Accept Merge Request #354: (dev/main -> main)
Merge Request: create preview Created By: @晏成 Accepted By: @晏成 URL: https://starbite.coding.net/p/aixiaodui/d/fengmang-backend/git/merge/354?initial=true
This commit is contained in:
commit
4d5449364a
@ -24,6 +24,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"file-saver": "^2.0.5",
|
||||
"flv.js": "^1.6.2",
|
||||
"jszip": "^3.10.1",
|
||||
"qs": "^6.12.1",
|
||||
"react": "^18.3.1",
|
||||
|
@ -1,3 +1,5 @@
|
||||
@use "./libs" as *;
|
||||
|
||||
:root {
|
||||
font-family: -apple-system, "PingFang SC", 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.5;
|
||||
@ -19,22 +21,41 @@
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
.btn {
|
||||
@apply px-5 py-2 rounded-md bg-white border text-sm;
|
||||
&:hover {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
@apply bg-blue-500 text-white border-blue-500;
|
||||
&:hover {
|
||||
@apply bg-blue-600;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
|
||||
&:hover {
|
||||
background: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg p-5 my-10;
|
||||
@layer base {
|
||||
.btn {
|
||||
@apply px-5 py-2 rounded-md bg-white border text-sm;
|
||||
&:hover {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
@apply bg-blue-500 text-white border-blue-500;
|
||||
&:hover {
|
||||
@apply bg-blue-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg p-5 my-10;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.radio-icon, .checkbox-icon {
|
||||
@ -106,7 +127,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
.page-live {
|
||||
.live-player {
|
||||
max-height: calc(100vh - var(--app-header-header) - 130px);
|
||||
overflow: hidden;
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-item-shadow {
|
||||
box-shadow: 0 0 6px 0 var(--tw-shadow-color);
|
||||
//filter: drop-shadow(0 0 6px var(--tw-shadow-color));
|
||||
}
|
||||
|
||||
.video-list-sort-container {
|
||||
min-height: 300px;
|
||||
max-height: calc(100vh - var(--app-header-header) - 300px);
|
||||
overflow: auto;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.live-video-list-sort-container {
|
||||
min-height: 300px;
|
||||
padding-right: 10px;
|
||||
max-height: calc(100vh - var(--app-header-header) - 200px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-main-navigation {
|
||||
@include media-breakpoint-down(md) {
|
||||
display: none;
|
||||
}
|
||||
}
|
BIN
src/assets/images/cover.png
Normal file
BIN
src/assets/images/cover.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 64 KiB |
22
src/assets/libs.scss
Normal file
22
src/assets/libs.scss
Normal file
@ -0,0 +1,22 @@
|
||||
@mixin media-breakpoint-down($name) {
|
||||
@if $name == sm {
|
||||
@media (max-width: 767px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
@if $name == md {
|
||||
@media (max-width: 991px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
@if $name == lg {
|
||||
@media (max-width: 1199px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
@if $name == xl {
|
||||
@media (max-width: 1399px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
.blockContainer {
|
||||
@apply flex mb-5;
|
||||
@apply flex mb-5;
|
||||
}
|
||||
|
||||
.block {
|
||||
@ -14,6 +14,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.blockFist {
|
||||
@apply p-0 border-0 !important;
|
||||
}
|
||||
|
||||
.blockItem {
|
||||
|
||||
}
|
||||
@ -21,40 +25,61 @@
|
||||
.group {
|
||||
}
|
||||
|
||||
.image {
|
||||
@apply border border-blue-200 p-2 flex-1 rounded focus:border-blue-200;
|
||||
min-height: 100px;
|
||||
&:hover{
|
||||
@apply border-blue-500;
|
||||
}
|
||||
:global{
|
||||
.ant-upload-wrapper{
|
||||
.imageList {
|
||||
@apply grid grid-cols-4 gap-4 p-3 border border-blue-200;
|
||||
:global {
|
||||
.ant-upload-wrapper {
|
||||
display: block;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.ant-upload{
|
||||
|
||||
.ant-upload {
|
||||
display: block;
|
||||
}
|
||||
|
||||
img {
|
||||
@apply block m-0;
|
||||
max-width: 100%;
|
||||
height: 100px;
|
||||
object-fit: contain;
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
@apply rounded bg-gray-100;
|
||||
height: 100px;
|
||||
|
||||
&:hover {
|
||||
@apply border-blue-500;
|
||||
}
|
||||
}
|
||||
.imageDelete{
|
||||
@apply absolute flex items-center justify-center p-0.5 w-[22px] h-[22px] rounded-full border border-red-500 text-red-500 cursor-pointer z-10;
|
||||
right:-10px;
|
||||
top:-10px;
|
||||
font-size: 14px;
|
||||
&:hover{
|
||||
@apply text-white bg-red-500;
|
||||
}
|
||||
}
|
||||
.uploadImage {
|
||||
@apply flex justify-center items-center relative;
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
@apply flex justify-center items-center relative h-[100px] text-gray-400;
|
||||
|
||||
.uploadTips {
|
||||
@apply absolute inset-0 cursor-pointer opacity-0 transition rounded flex items-center justify-center bg-black/20 text-white;
|
||||
}
|
||||
.uploadTips{
|
||||
@apply absolute inset-0 cursor-pointer opacity-0 rounded flex items-center justify-center bg-black/50 text-white;
|
||||
}
|
||||
.imagePlaceholder{
|
||||
|
||||
.imagePlaceholder {
|
||||
@apply flex items-center justify-center;
|
||||
height: 100px;
|
||||
}
|
||||
&:hover{
|
||||
.uploadTips{
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-100 cursor-pointer rounded text-blue-500;
|
||||
.uploadTips {
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
@ -62,10 +87,11 @@
|
||||
|
||||
.text {
|
||||
@apply border border-blue-200 overflow-hidden flex-1 rounded focus:border-blue-200 transition;
|
||||
&:hover{
|
||||
&:hover {
|
||||
@apply border-blue-500;
|
||||
}
|
||||
&:focus-within{
|
||||
|
||||
&:focus-within {
|
||||
@apply border-blue-500 shadow-md;
|
||||
}
|
||||
}
|
||||
|
@ -1,24 +1,56 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import {Popconfirm, Space} from "antd";
|
||||
|
||||
import {IconAdd, IconAddImage, IconAddText, IconDelete} from "@/components/icons";
|
||||
import {BlockImage, BlockText} from "./item.tsx";
|
||||
import {IconAdd, IconDelete} from "@/components/icons";
|
||||
import ImageList from "@/components/article/list.tsx";
|
||||
|
||||
import { BlockText} from "./item.tsx";
|
||||
import styles from './article.module.scss'
|
||||
import {Button, Popconfirm} from "antd";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
index?:number;
|
||||
index?: number;
|
||||
className?: string;
|
||||
blocks: BlockContent[];
|
||||
editable?: boolean;
|
||||
onChange?: (blocks: BlockContent[]) => void;
|
||||
onRemove?: () => void;
|
||||
onAdd?: () => void;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export default function ArticleBlock({className, blocks, editable, onRemove, onAdd, onChange,index}: Props) {
|
||||
function rebuildBlockArray(blocks: BlockContent[]) {
|
||||
const textBlock: BlockContent = {
|
||||
type: 'text',
|
||||
content: ''
|
||||
}
|
||||
const _blocks: BlockContent[] = [textBlock];
|
||||
const textArray: string[] = []
|
||||
blocks.forEach(it => {
|
||||
if (it.type == 'text') {
|
||||
textArray.push(it.content)
|
||||
} else {
|
||||
_blocks.push(it)
|
||||
}
|
||||
})
|
||||
textBlock.content = textArray.join('\n')
|
||||
return _blocks
|
||||
}
|
||||
|
||||
|
||||
export default function ArticleBlock(
|
||||
{
|
||||
className,
|
||||
blocks: defaultBlocks,
|
||||
editable,
|
||||
onRemove,
|
||||
onAdd,
|
||||
onChange,
|
||||
index,
|
||||
errorMessage
|
||||
}: Props) {
|
||||
const blocks = rebuildBlockArray(defaultBlocks)
|
||||
const handleBlockRemove = (index: number) => {
|
||||
// 删除当前项
|
||||
onChange?.(blocks.filter((_, idx) => index !== idx))
|
||||
@ -43,72 +75,38 @@ export default function ArticleBlock({className, blocks, editable, onRemove, onA
|
||||
}
|
||||
|
||||
return <div className={styles.blockContainer}>
|
||||
<div className={clsx(className || '', styles.block,' hover:bg-blue-10')}>
|
||||
<div className={clsx(className || '', styles.block, index == 0 ? styles.blockFist : '', ' hover:bg-blue-10')}>
|
||||
<div className={styles.blockBody}>
|
||||
{blocks.map((it, idx) => {
|
||||
const isFirstTextBlock = index == 0 && it.type ==='text' && firstTextBlockIndex == idx
|
||||
return (<div key={idx}>
|
||||
<div className={clsx(isFirstTextBlock?'':styles.blockItem, 'flex')}>
|
||||
{
|
||||
it.type === 'text'
|
||||
? <BlockText isFirstBlock={isFirstTextBlock} onChange={(block) => handleBlockChange(idx, block)} data={it}
|
||||
editable={editable}/>
|
||||
: <BlockImage data={it} editable={editable}/>
|
||||
}
|
||||
{editable && <div className="create-container ml-2 flex flex-col justify-between">
|
||||
{isFirstTextBlock?<span></span>:<Popconfirm
|
||||
title="提示"
|
||||
description={<div style={{minWidth: 150}}>
|
||||
<span>请确认删除此{it.type === 'text' ? '文本' : '图片'}?</span>
|
||||
</div>}
|
||||
onConfirm={() => handleBlockRemove(idx)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<span
|
||||
className="article-action-icon"
|
||||
title={`删除此${it.type === 'text' ? '文本' : '图片'}`}>
|
||||
<IconDelete style={{fontSize: 18}}/>
|
||||
</span>
|
||||
</Popconfirm>}
|
||||
<div>
|
||||
<span onClick={() => handleAddBlock('text', idx + 1)}
|
||||
className="article-action-icon" title="新增文本"><IconAddText
|
||||
style={{fontSize: 18}}/></span>
|
||||
<span onClick={() => handleAddBlock('image', idx + 1)}
|
||||
className="article-action-icon mt-1" title="新增图片"><IconAddImage
|
||||
style={{fontSize: 16}}/></span>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
{isFirstTextBlock && <div className={'text-red-500 text-right pr-6 mt-1 text-sm'}>该编辑框内容由数字人播报</div>}
|
||||
</div>)
|
||||
}
|
||||
)}
|
||||
{editable && blocks.length == 0 &&
|
||||
<div style={{minHeight: 80}} className="flex items-center justify-center">
|
||||
<div className="flex gap-5">
|
||||
<Button onClick={() => handleAddBlock('text')}>添加文本</Button>
|
||||
<Button onClick={() => handleAddBlock('image')}>添加图片</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<div className={clsx(index == 0 ? '' : styles.blockItem, 'flex')}>
|
||||
<BlockText
|
||||
onChange={(block) => handleBlockChange(0, block)}
|
||||
data={blocks[0]}
|
||||
isFirstBlock={index == 0}
|
||||
editable={editable}/>
|
||||
</div>
|
||||
{index == 0 && <div className="flex items-center text-red-500 justify-between text-sm mt-1">
|
||||
<div>{errorMessage}</div>
|
||||
<div>该编辑框内容由数字人播报</div>
|
||||
</div>}
|
||||
</div>
|
||||
{index > 0 && <ImageList blocks={blocks} editable={editable} onChange={onChange}/>}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{editable && <div className="ml-2 flex flex-col justify-between ">
|
||||
<Popconfirm
|
||||
title="提示"
|
||||
description={<div style={{minWidth: 150}}>
|
||||
<span>请确认删除此删除此分组?</span>
|
||||
</div>}
|
||||
onConfirm={onRemove}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<span className="article-action-icon" title="删除此分组"><IconDelete
|
||||
style={{fontSize: 24}}/></span>
|
||||
</Popconfirm>
|
||||
{
|
||||
index > 0 ? <Popconfirm
|
||||
title={<div style={{minWidth: 150}}><span>请确认删除此分组?</span></div>}
|
||||
onConfirm={onRemove}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<span className="article-action-icon" title="删除此分组">
|
||||
<IconDelete style={{fontSize: 24}}/>
|
||||
</span>
|
||||
</Popconfirm> : <span></span>
|
||||
}
|
||||
<span onClick={onAdd} className="article-action-icon" title="新增分组"><IconAdd
|
||||
style={{fontSize: 24}}/></span>
|
||||
</div>}
|
||||
|
@ -2,41 +2,61 @@ import {Input, Modal} from "antd";
|
||||
import ArticleGroup from "@/components/article/group.tsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useSetState} from "ahooks";
|
||||
import {getById} from "@/service/api/article.ts";
|
||||
import * as article from "@/service/api/article.ts";
|
||||
import {regenerate} from "@/service/api/video.ts";
|
||||
|
||||
type Props = {
|
||||
id?: number;
|
||||
onClose?: () => void;
|
||||
type: 'news' | 'video';
|
||||
onClose?: (saved?: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
loading: false,
|
||||
open: false,
|
||||
msgTitle: '',
|
||||
msgGroup: '',
|
||||
error:''
|
||||
}
|
||||
export default function ArticleEditModal(props: Props) {
|
||||
|
||||
const [groups, setGroups] = useState<BlockContent[][]>([]);
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
const [state, setState] = useSetState({
|
||||
loading: false,
|
||||
open: false
|
||||
...DEFAULT_STATE
|
||||
})
|
||||
// 保存数据
|
||||
const handleSave = () => {
|
||||
props.onClose?.()
|
||||
// if (props.onSave) {
|
||||
// setState({loading: true})
|
||||
// props.onSave?.().then(() => {
|
||||
// setState({loading: false, open: false})
|
||||
// })
|
||||
// } else {
|
||||
// console.log(groups)
|
||||
// }
|
||||
setState({error: ''})
|
||||
if (!title) {
|
||||
// setState({msgTitle: '请输入标题内容'});
|
||||
return;
|
||||
}
|
||||
if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) {
|
||||
// setState({msgGroup: '请输入正文文本内容'});
|
||||
return;
|
||||
}
|
||||
const save = props.type == 'news' ? article.save : regenerate
|
||||
setState({loading: true})
|
||||
save(title, groups, props.id > 0 ? props.id : undefined).then(() => {
|
||||
props.onClose?.(true)
|
||||
}).catch(e=>{
|
||||
setState({error: e.data || '保存失败,请重试!'})
|
||||
}).finally(() => {
|
||||
setState({loading: false})
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
if(props.id){
|
||||
if(props.id > 0){
|
||||
getById(props.id).then(res => {
|
||||
setState({...DEFAULT_STATE})
|
||||
if (props.id) {
|
||||
if (props.id > 0) {
|
||||
article.getById(props.id).then(res => {
|
||||
setGroups(res.content_group)
|
||||
setTitle(res.title)
|
||||
})
|
||||
}else{
|
||||
} else {
|
||||
// 新增
|
||||
setGroups([])
|
||||
setTitle('')
|
||||
}
|
||||
@ -49,9 +69,10 @@ export default function ArticleEditModal(props: Props) {
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
width={800}
|
||||
onCancel={props.onClose}
|
||||
onCancel={()=>props.onClose?.()}
|
||||
okButtonProps={{loading: state.loading}}
|
||||
onOk={handleSave}
|
||||
okText={props.type == 'news' ? '确定' : '重新生成'}
|
||||
>
|
||||
<div className="article-title mt-5">
|
||||
<div className="title">
|
||||
@ -59,19 +80,28 @@ export default function ArticleEditModal(props: Props) {
|
||||
<span className="require ml-1 font-bold text-red-500">*</span>
|
||||
</div>
|
||||
<div className="box mt-1">
|
||||
<Input value={title} onChange={e => {
|
||||
<Input rootClassName={state.msgTitle ? 'border-red-500' : ''} value={title} onChange={e => {
|
||||
setTitle(e.target.value)
|
||||
setState({msgTitle: e.target.value ? '' : '请输入标题内容'})
|
||||
}} placeholder={'请输入文章标题'}/>
|
||||
</div>
|
||||
<div className="text-red-500">{state.msgTitle}</div>
|
||||
</div>
|
||||
<div className="aricle-body mt-2">
|
||||
<div className="aricle-body mt-3">
|
||||
<div className="title">
|
||||
<span className="text text-base">正文</span>
|
||||
<span className="require ml-1 font-bold text-red-500">*</span>
|
||||
</div>
|
||||
<div className="box mt-1">
|
||||
<ArticleGroup editable groups={groups} onChange={list => setGroups(() => list)}/>
|
||||
<ArticleGroup
|
||||
errorMessage={state.msgGroup} editable groups={groups}
|
||||
onChange={list => {
|
||||
setGroups(() => list)
|
||||
setState({msgGroup: (list.length == 0 || list[0].length == 0 || !list[0][0].content) ? '请输入正文文本内容' : ''});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{state.error && <div className="text-red-500">{state.error}</div>}
|
||||
</div>
|
||||
</Modal>);
|
||||
}
|
@ -1,22 +1,73 @@
|
||||
|
||||
import {message} from "antd"
|
||||
import ArticleBlock from "@/components/article/block.tsx";
|
||||
|
||||
import styles from './article.module.scss'
|
||||
import {showToast} from "@/components/message.ts";
|
||||
|
||||
type Props = {
|
||||
groups: BlockContent[][];
|
||||
editable?: boolean;
|
||||
onChange?: (groups: BlockContent[][]) => void;
|
||||
errorMessage?: string;
|
||||
}
|
||||
export default function ArticleGroup({groups, editable, onChange}: Props) {
|
||||
|
||||
|
||||
function pushBlocksToGroup(blocks: BlockContent[],groups: BlockContent[][]){
|
||||
const lastGroup = groups[groups.length - 1]
|
||||
if (lastGroup && lastGroup.filter(s=>s.type == 'text') == 0) {
|
||||
// 如果上一个group中没有文本则直接合并
|
||||
lastGroup.push(...blocks)
|
||||
} else {
|
||||
groups.push(blocks)
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildGroups(groups: BlockContent[][]) {
|
||||
const _groups: BlockContent[][] = [];
|
||||
if (!groups || groups.length == 0) return _groups;
|
||||
groups.forEach((blocks,index) => {
|
||||
if(!blocks) return;
|
||||
if (blocks.length == 1) {
|
||||
if(index == 0) _groups.push(blocks)
|
||||
else pushBlocksToGroup(blocks,_groups)
|
||||
} else {
|
||||
if(index == 0){
|
||||
_groups.push([blocks[0]])
|
||||
_groups.push(blocks.slice(1))
|
||||
}else{
|
||||
pushBlocksToGroup(blocks,_groups)
|
||||
}
|
||||
}
|
||||
});
|
||||
if (_groups.length < 2) {
|
||||
Array(2 - _groups.length).fill([{type: 'text', content: ''}]).forEach((it) => {
|
||||
_groups.push(it)
|
||||
})
|
||||
}
|
||||
console.log('rebuildGroups', _groups)
|
||||
return _groups;
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default function ArticleGroup({groups: _groups, editable, onChange, errorMessage}: Props) {
|
||||
const groups = rebuildGroups(_groups)
|
||||
/**
|
||||
* 添加一个组
|
||||
* @param insertIndex 插入的位置,-1表示插入到末尾
|
||||
*/
|
||||
const handleAddGroup = ( insertIndex: number = -1) => {
|
||||
const newGroup: BlockContent[] = []
|
||||
const _groups = [...groups]
|
||||
const handleAddGroup = (insertIndex: number = -1) => {
|
||||
if (insertIndex !== -1 && insertIndex !== 1) {
|
||||
const triggerGroup = insertIndex == -1 || insertIndex >= groups.length ? groups[groups.length - 1] : groups[insertIndex - 1];
|
||||
// 判断
|
||||
if (triggerGroup.length == 0 || triggerGroup.some(s => !s.content)) {
|
||||
showToast('请先添加内容')
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newGroup: BlockContent[] = [{type: 'text', content: ''}]
|
||||
const _groups = [...groups];
|
||||
if (insertIndex == -1 || insertIndex >= groups.length) { // -1或者越界表示新增
|
||||
_groups.push(newGroup)
|
||||
} else {
|
||||
@ -34,6 +85,7 @@ export default function ArticleGroup({groups, editable, onChange}: Props) {
|
||||
groups[index] = blocks
|
||||
onChange?.([...groups])
|
||||
}}
|
||||
errorMessage={errorMessage}
|
||||
index={index}
|
||||
onAdd={() => {
|
||||
handleAddGroup?.(index + 1)
|
||||
@ -48,6 +100,7 @@ export default function ArticleGroup({groups, editable, onChange}: Props) {
|
||||
/>
|
||||
))}
|
||||
{groups.length == 0 && editable &&
|
||||
<ArticleBlock editable onChange={blocks => onChange?.([blocks])} index={0} blocks={[{type:'text',content:''}]}/>}
|
||||
<ArticleBlock editable onChange={blocks => onChange?.([blocks])} index={0}
|
||||
blocks={[{type: 'text', content: ''}]}/>}
|
||||
</div>
|
||||
}
|
@ -1,10 +1,12 @@
|
||||
import React, {useState} from "react";
|
||||
import {Button, Input, Spin, Upload, UploadProps} from "antd";
|
||||
import {Input, Popconfirm, Spin, Upload, UploadProps} from "antd";
|
||||
import {CloseOutlined,CloudUploadOutlined} from "@ant-design/icons";
|
||||
import {clsx} from "clsx";
|
||||
|
||||
import styles from './article.module.scss'
|
||||
import {getOssPolicy} from "@/service/api/common.ts";
|
||||
import {showToast} from "@/components/message.ts";
|
||||
import {clsx} from "clsx";
|
||||
import {IconAddImage} from "@/components/icons";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@ -14,11 +16,15 @@ type Props = {
|
||||
onChange?: (data: BlockContent) => void;
|
||||
isFirstBlock?: boolean;
|
||||
}
|
||||
type ImageProps = {
|
||||
onRemove?: () => void;
|
||||
onlyUpload?: boolean;
|
||||
} & Props;
|
||||
|
||||
const MimeTypes = ['image/jpeg', 'image/png', 'image/jpg']
|
||||
const Data: { uploadConfig?: TOSSPolicy } = {}
|
||||
|
||||
export function BlockImage({data, editable, onChange}: Props) {
|
||||
export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: ImageProps) {
|
||||
|
||||
const [loading, setLoading] = useState<number>(-1)
|
||||
// oss上传文件所需的数据
|
||||
@ -48,7 +54,7 @@ export function BlockImage({data, editable, onChange}: Props) {
|
||||
console.log('onChange', file);
|
||||
if (file.status == 'done') {
|
||||
setLoading(-1)
|
||||
onChange?.({type: 'image', content: Data.uploadConfig?.host + file.url})
|
||||
onChange?.({type: 'image', content: Data.uploadConfig?.host + '/' + file.url})
|
||||
} else if (file.status == 'error') {
|
||||
setLoading(-1)
|
||||
showToast('上传图片失败,请重试', 'warning')
|
||||
@ -58,7 +64,15 @@ export function BlockImage({data, editable, onChange}: Props) {
|
||||
}
|
||||
//
|
||||
return <div className={styles.image}>
|
||||
{editable ? <div>
|
||||
{editable ? <div className={'relative'}>
|
||||
{!onlyUpload && <Popconfirm
|
||||
title={<div style={{minWidth: 150}}><span>请确认删除此删除此图片?</span></div>}
|
||||
onConfirm={onRemove}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<span className={styles.imageDelete}><CloseOutlined/></span>
|
||||
</Popconfirm>}
|
||||
<Spin spinning={loading >= 0} percent={loading == 0 ? 'auto' : loading}>
|
||||
<Upload
|
||||
multiple={false} maxCount={1} data={getUploadData}
|
||||
@ -73,7 +87,10 @@ export function BlockImage({data, editable, onChange}: Props) {
|
||||
<span>更换图片</span>
|
||||
</div>
|
||||
</> : <div className={styles.imagePlaceholder}>
|
||||
<Button>选择图片</Button>
|
||||
<div className={'text-center'}>
|
||||
<IconAddImage className={"text-4xl inline-block"} />
|
||||
<div className={'text-sm'}>上传图片</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</Upload>
|
||||
@ -84,13 +101,14 @@ export function BlockImage({data, editable, onChange}: Props) {
|
||||
|
||||
export function BlockText({data, editable, onChange, isFirstBlock}: Props) {
|
||||
return <div className='flex-1'>
|
||||
<div className={clsx(styles.text, isFirstBlock?'border-red-400 hover:border-red-500 focus-within:border-red-500':'')}>
|
||||
<div
|
||||
className={clsx(styles.text, isFirstBlock ? 'border-red-400 hover:border-red-500 focus-within:border-red-500' : '')}>
|
||||
{editable ? <div className="relative">
|
||||
<Input.TextArea
|
||||
onChange={e => {
|
||||
onChange?.({type: 'text', content: e.target.value})
|
||||
}}
|
||||
placeholder={'请输入文本'} value={data.content} autoSize={{minRows: 3, maxRows: 8}}
|
||||
placeholder={'请输入文本内容'} value={data.content} autoSize={{minRows: 3, maxRows: 8}}
|
||||
variant={"borderless"}/>
|
||||
</div> : <p className="p-2">{data.content}</p>}
|
||||
</div>
|
||||
|
42
src/components/article/list.tsx
Normal file
42
src/components/article/list.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
import {BlockImage} from "@/components/article/item.tsx";
|
||||
|
||||
import styles from './article.module.scss'
|
||||
|
||||
export default function ImageList(props: {
|
||||
blocks: BlockContent[];
|
||||
editable?: boolean;
|
||||
onChange?: (blocks: BlockContent[]) => void;
|
||||
}) {
|
||||
|
||||
// 处理删除
|
||||
const handleRemove = (index: number) => {
|
||||
props.onChange?.(props.blocks.filter((_, idx) => index !== idx))
|
||||
const newBlocks = [...props.blocks]
|
||||
newBlocks.splice(index, 1)
|
||||
props.onChange?.(newBlocks)
|
||||
}
|
||||
// 处理新增
|
||||
const handleAdd = (data: BlockContent) => {
|
||||
props.onChange?.([...props.blocks, data])
|
||||
}
|
||||
// 处理更新
|
||||
const handleUpdate = (index: number, data: BlockContent) => {
|
||||
props.onChange?.(props.blocks.map((it, idx) => idx === index ? data : it))
|
||||
}
|
||||
|
||||
|
||||
return (<div className={styles.imageList}>
|
||||
{props.blocks.map((it, index) => (
|
||||
it.type === 'image' ? <BlockImage
|
||||
key={index} data={it} editable={props.editable}
|
||||
onChange={(data) => handleUpdate(index, data)}
|
||||
onRemove={() => handleRemove(index)}
|
||||
/> : null
|
||||
))}
|
||||
{props.editable &&
|
||||
<BlockImage onlyUpload onChange={handleAdd} data={{type: 'image', content: ''}} editable={true}/>}
|
||||
</div>)
|
||||
}
|
||||
|
56
src/components/button-batch.tsx
Normal file
56
src/components/button-batch.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import React, {useState} from "react";
|
||||
import {Button, Modal} from "antd";
|
||||
import {ButtonType} from "antd/es/button";
|
||||
import {showErrorToast, showToast} from "@/components/message.ts";
|
||||
|
||||
type Props = {
|
||||
selected: any[],
|
||||
type?: ButtonType;
|
||||
emptyMessage: string,
|
||||
confirmMessage: React.ReactNode,
|
||||
onProcess: (ids: Id[]) => Promise<void>
|
||||
successMessage?: string;
|
||||
onSuccess?: () => void;
|
||||
children?: React.ReactNode
|
||||
|
||||
}
|
||||
/**
|
||||
* 统一批量操作按钮
|
||||
*/
|
||||
export default function ButtonBatch(
|
||||
{
|
||||
selected, emptyMessage, successMessage, children,
|
||||
type, confirmMessage, onProcess,onSuccess
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const onBatchProcess = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await onProcess(selected)
|
||||
if (successMessage) showToast(successMessage, 'success')
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const handleBtnClick = () => {
|
||||
if (selected.length == 0) {
|
||||
showToast(emptyMessage, 'warning')
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '操作提示',
|
||||
centered: true,
|
||||
content: confirmMessage,
|
||||
onOk: onBatchProcess
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button loading={loading} type={type} onClick={handleBtnClick}>{children}</Button>
|
||||
)
|
||||
}
|
@ -1,14 +1,21 @@
|
||||
import {message} from "antd";
|
||||
import {BizError} from "@/service/types.ts";
|
||||
|
||||
export function showToast(content: string, type?: 'success' | 'info' | 'warning' | 'error') {
|
||||
export function showToast(content: string, type?: 'success' | 'info' | 'warning' | 'error', duration?: number) {
|
||||
|
||||
message.open({
|
||||
type,
|
||||
content,
|
||||
duration,
|
||||
className: 'aui-toast'
|
||||
}).then();
|
||||
}
|
||||
|
||||
export function showErrorToast(e: Error | BizError) {
|
||||
showToast(String(((e instanceof BizError) ? e.data : '') || e.message), 'error')
|
||||
}
|
||||
|
||||
|
||||
export function showLoading(content = 'Loading...') {
|
||||
const key = 'globalLoading_' + (new Date().getTime());
|
||||
message.open({
|
||||
@ -17,14 +24,14 @@ export function showLoading(content = 'Loading...') {
|
||||
content,
|
||||
}).then();
|
||||
return {
|
||||
update(content: string,type?: 'success' | 'info' | 'warning' | 'error'){
|
||||
update(content: string, type?: 'success' | 'info' | 'warning' | 'error') {
|
||||
message.open({
|
||||
key,
|
||||
content,
|
||||
type
|
||||
}).then();
|
||||
},
|
||||
close(){
|
||||
close() {
|
||||
message.destroy(key);
|
||||
}
|
||||
}
|
||||
|
@ -2,13 +2,14 @@ import {useSortable} from "@dnd-kit/sortable";
|
||||
import {useSetState} from "ahooks";
|
||||
import React, {useEffect} from "react";
|
||||
import {clsx} from "clsx";
|
||||
import {Image, Popconfirm} from "antd";
|
||||
import {CheckCircleFilled, MenuOutlined, MinusCircleFilled} from "@ant-design/icons";
|
||||
|
||||
import ImageCover from '@/assets/images/cover.png'
|
||||
import {IconEdit, IconPlay} from "@/components/icons";
|
||||
import {Popconfirm} from "antd";
|
||||
|
||||
type Props = {
|
||||
video: VideoInfo,
|
||||
video: VideoInfo | LiveVideoInfo,
|
||||
editable?: boolean;
|
||||
sortable?: boolean;
|
||||
index?: number;
|
||||
@ -19,13 +20,15 @@ type Props = {
|
||||
onEdit?: () => void;
|
||||
onRemove?: () => void;
|
||||
id: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const VideoListItem = (
|
||||
{
|
||||
index, id, video, onPlay, onRemove, checked,
|
||||
// index,
|
||||
id, video, onPlay, onRemove, checked,
|
||||
onCheckedChange, onEdit, active, editable,
|
||||
|
||||
className, sortable
|
||||
}: Props) => {
|
||||
const {
|
||||
attributes, listeners,
|
||||
@ -39,27 +42,26 @@ export const VideoListItem = (
|
||||
}, [checked])
|
||||
|
||||
return <div
|
||||
className={'video-item flex items-center gap-3 mb-5'}
|
||||
className={`video-item flex items-center gap-3 ${className}`}
|
||||
ref={setNodeRef} style={{transform: `translateY(${transform?.y || 0}px)`,}}>
|
||||
{index && index > 0 && <div className="flex items-center px-2">
|
||||
<div
|
||||
className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{id}</div>
|
||||
</div>}
|
||||
{/*{index && index > 0 && <div className="flex items-center px-2">*/}
|
||||
{/* <div className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{index}</div>*/}
|
||||
{/*</div>}*/}
|
||||
<div
|
||||
className={`video-item-info flex gap-2 flex-1 bg-gray-100 min-h-[40px] rounded-lg p-3 shadow-blue-500 ${active ? 'video-item-shadow' : ''}`}>
|
||||
<div className={'video-title leading-7 flex-1'}>{video.title}</div>
|
||||
<div className={'video-item-cover'}>
|
||||
<img className="w-[100px] rounded-md" src={video.cover || ''} alt={video.title}/>
|
||||
className={`video-item-info flex gap-2 flex-1 bg-gray-100 h-[80px] overflow-hidden rounded-lg p-3 shadow-blue-500 ${active ? 'video-item-shadow' : ''}`}>
|
||||
<div className={'video-title leading-7 flex-1'}>{video.title || video.video_title}</div>
|
||||
<div className={'video-item-cover bg-white rounded-md overflow-hidden'}>
|
||||
<img className="w-[100px] h-[56px] object-cover" src={video.cover || ImageCover} alt={video.video_title}/>
|
||||
</div>
|
||||
</div>
|
||||
{editable &&
|
||||
<div className="operation flex items-center ml-2 gap-3 text-lg text-gray-400">
|
||||
{!active ? <button className="hover:text-blue-500 cursor-move" {...attributes} {...listeners}>
|
||||
<MenuOutlined/>
|
||||
</button> : <button disabled className="cursor-not-allowed"><MenuOutlined/></button>}
|
||||
{onPlay &&
|
||||
<button className="hover:text-blue-500" onClick={onPlay} style={{fontSize: '1.3em'}}><IconPlay/>
|
||||
</button>}
|
||||
<div className="operation flex items-center ml-2 gap-3 text-lg text-gray-400">
|
||||
{sortable && (!active ? <button className="hover:text-blue-500 cursor-move" {...attributes} {...listeners}>
|
||||
<MenuOutlined/>
|
||||
</button> : <button disabled className="cursor-not-allowed"><MenuOutlined/></button>)}
|
||||
{onPlay &&
|
||||
<button className="hover:text-blue-500" onClick={onPlay} style={{fontSize: '1.3em'}}><IconPlay/>
|
||||
</button>}
|
||||
{editable && <>
|
||||
{onEdit &&
|
||||
<button className="hover:text-blue-500" onClick={onEdit} style={{fontSize: '1.1em'}}><IconEdit/>
|
||||
</button>}
|
||||
@ -71,15 +73,14 @@ export const VideoListItem = (
|
||||
}
|
||||
}}><CheckCircleFilled className={clsx({'text-blue-500': state.checked})}/></button>
|
||||
{onRemove && <Popconfirm
|
||||
title="提示"
|
||||
description={<div style={{minWidth: 150}}><span>请确认删除此视频?</span></div>}
|
||||
title={<div style={{minWidth: 150}}><span>请确认删除此视频?</span></div>}
|
||||
onConfirm={onRemove}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button className="hover:text-blue-500"><MinusCircleFilled/></button>
|
||||
</Popconfirm>}
|
||||
</div>
|
||||
}
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
@ -1,139 +0,0 @@
|
||||
import {Button, message, Modal} from "antd";
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
|
||||
import {ArticleGroupList, MockVideoDataList} from "@/_local/mock-data";
|
||||
import {DndContext} from "@dnd-kit/core";
|
||||
import {arrayMove, SortableContext} from "@dnd-kit/sortable";
|
||||
import {VideoListItem} from "@/components/video/video-list-item.tsx";
|
||||
import ArticleEditModal from "@/components/article/edit-modal.tsx";
|
||||
import {useSetState} from "ahooks";
|
||||
import {CheckCircleFilled} from "@ant-design/icons";
|
||||
import {clsx} from "clsx";
|
||||
import {getList} from "@/service/api/video.ts";
|
||||
|
||||
|
||||
export default function CreateIndex() {
|
||||
const [editNews, setEditNews] = useSetState<{
|
||||
title?: string;
|
||||
groups?: ArticleContentGroup[];
|
||||
}>({})
|
||||
|
||||
const [videoData, setVideoData] = useState<VideoInfo[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
getList({}).then((ret) => {
|
||||
setVideoData(ret.list)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const [modal, contextHolder] = Modal.useModal()
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const [state, setState] = useSetState({
|
||||
checkedAll: false
|
||||
})
|
||||
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
|
||||
const processDeleteVideo = async (_idArray: number[]) => {
|
||||
message.info('删除成功!!!' + _idArray.join(''));
|
||||
}
|
||||
|
||||
const handleDeleteBatch = () => {
|
||||
modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否要删除选择的视频?',
|
||||
onOk: () => processDeleteVideo(checkedIdArray)
|
||||
})
|
||||
}
|
||||
|
||||
const playVideo = (video: VideoInfo) => {
|
||||
console.log('play', video)
|
||||
if (videoRef.current) {
|
||||
videoRef.current!.src = video.play_url
|
||||
}
|
||||
}
|
||||
const handleAllCheckedChange = () => {
|
||||
// setVideoData(list=>{
|
||||
// list.map(s=>{
|
||||
// s.checked = !state.checkedAll
|
||||
// })
|
||||
// return list
|
||||
// })
|
||||
setCheckedIdArray(state.checkedAll ? [] : videoData.map(v => v.id))
|
||||
setState({
|
||||
checkedAll: !state.checkedAll
|
||||
})
|
||||
}
|
||||
|
||||
return (<div className="container py-10 page-live">
|
||||
{contextHolder}
|
||||
<div className="flex">
|
||||
<div className="video-list-container bg-white p-10 rounded flex-1">
|
||||
<div className="live-control flex justify-between mb-8">
|
||||
<div className="pl-[70px]">
|
||||
<span>视频时长: 00:00:29</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="cursor-pointer" onClick={handleDeleteBatch}>批量删除</span>
|
||||
<button className="ml-5 hover:text-blue-300 text-gray-400 text-lg"
|
||||
onClick={handleAllCheckedChange}>
|
||||
<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DndContext onDragEnd={(e) => {
|
||||
const {active, over} = e;
|
||||
if (over && active.id !== over.id) {
|
||||
let oldIndex = -1, newIndex = -1;
|
||||
const originArr = [...videoData]
|
||||
setVideoData((items) => {
|
||||
oldIndex = items.findIndex(s => s.id == active.id);
|
||||
newIndex = items.findIndex(s => s.id == over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否要移动到指定位置',
|
||||
onCancel: () => {
|
||||
setVideoData(originArr);
|
||||
}
|
||||
})
|
||||
}
|
||||
}}>
|
||||
<SortableContext items={videoData}>
|
||||
{videoData.map((v, index) => (
|
||||
<VideoListItem
|
||||
video={v}
|
||||
index={index + 1}
|
||||
id={v.id}
|
||||
key={index}
|
||||
checked={checkedIdArray.includes(v.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setCheckedIdArray(idArray => {
|
||||
const newArr = checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
|
||||
setState({checkedAll: newArr.length == videoData.length})
|
||||
return newArr;
|
||||
})
|
||||
}}
|
||||
onPlay={() => playVideo(v)}
|
||||
onEdit={() => {
|
||||
setEditNews({title: v.title, groups: [...ArticleGroupList]})
|
||||
}}
|
||||
editable
|
||||
/>))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<div className="text-right mt-10">
|
||||
<Button type="primary">一键推流</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-player-container ml-8 w-[400px] flex flex-col">
|
||||
<div className="text-center text-base mt-10">预览视频</div>
|
||||
<div className="video-player flex items-center justify-center flex-1">
|
||||
<div className=" rounded overflow-hidden">
|
||||
<video ref={videoRef} controls autoPlay className="w-full bg-white min-w-[360px]"></video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArticleEditModal title={editNews.title} groups={editNews.groups}/>
|
||||
</div>)
|
||||
}
|
@ -17,9 +17,9 @@ export default function SearchForm({onSearch, onBtnStartClick}: Props) {
|
||||
timeRange: string;
|
||||
keywords: string;
|
||||
searching: boolean;
|
||||
time: string;
|
||||
time: number;
|
||||
}>({
|
||||
keywords: "", searching: false, timeRange: "", time: '-1'
|
||||
keywords: "", searching: false, timeRange: "", time: 0
|
||||
})
|
||||
const onFinish = (values: any) => {
|
||||
setState({searching: true})
|
||||
@ -52,12 +52,11 @@ export default function SearchForm({onSearch, onBtnStartClick}: Props) {
|
||||
{/*<Form.Item label={'更新时间'} name="timeRange">*/}
|
||||
{/* <DatePicker.RangePicker />*/}
|
||||
{/*</Form.Item>*/}
|
||||
{/*<Form.Item>*/}
|
||||
{/* <Space size={10}>*/}
|
||||
{/* <Button type={'primary'} htmlType={'submit'}>搜索</Button>*/}
|
||||
{/* <Button htmlType={'reset'}>重置</Button>*/}
|
||||
{/* </Space>*/}
|
||||
{/*</Form.Item>*/}
|
||||
<Form.Item>
|
||||
<Space size={10}>
|
||||
<Button type={'primary'} htmlType={'submit'}>搜索</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
<Space size={10}>
|
||||
|
@ -10,7 +10,7 @@ import {getList} from "@/service/api/video.ts";
|
||||
export default function LibraryIndex() {
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
|
||||
const {data} = useRequest(()=>getList({}),{
|
||||
const {data} = useRequest(()=>getList(),{
|
||||
|
||||
})
|
||||
const handleRemove = (video: VideoInfo) => {
|
||||
|
@ -1,35 +1,184 @@
|
||||
import React, {useState} from "react";
|
||||
import {Button, message, Modal} from "antd";
|
||||
import React, {useEffect, useMemo, useRef, useState} from "react";
|
||||
import {Button, Modal} from "antd";
|
||||
import {SortableContext, arrayMove} from '@dnd-kit/sortable';
|
||||
import {DndContext} from "@dnd-kit/core";
|
||||
|
||||
import {VideoListItem} from "@/components/video/video-list-item.tsx";
|
||||
import {deleteByIds, getList, modifyOrder, playState} from "@/service/api/live.ts";
|
||||
|
||||
import {showErrorToast, showToast} from "@/components/message.ts";
|
||||
import ButtonBatch from "@/components/button-batch.tsx";
|
||||
import FlvJs from "flv.js";
|
||||
import {formatDuration} from "@/util/strings.ts";
|
||||
import {useSetState} from "ahooks";
|
||||
|
||||
const cache: { flvPlayer?: FlvJs.Player,timerPlayNext?:any,timerLoadState?:any,prevUrl?:string } = {}
|
||||
export default function LiveIndex() {
|
||||
const [videoData, setVideoData] = useState<VideoInfo[]>()
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const [videoData, setVideoData] = useState<LiveVideoInfo[]>([])
|
||||
const [modal, contextHolder] = Modal.useModal()
|
||||
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
|
||||
const [editable,setEditable] = useState<boolean>(false)
|
||||
const processDeleteVideo = async (_idArray: number[]) => {
|
||||
message.info('删除成功!!!' + _idArray.join(''));
|
||||
const [editable, setEditable] = useState<boolean>(false)
|
||||
|
||||
const [state, setState] = useSetState({
|
||||
activeIndex: -1,
|
||||
muted: true,
|
||||
})
|
||||
const activeIndex = useRef(state.activeIndex)
|
||||
useEffect(()=>{
|
||||
activeIndex.current = state.activeIndex
|
||||
},[state.activeIndex])
|
||||
|
||||
const showVideoItem = (index: number) => {
|
||||
// 找到对应video item 并显示在视图可见区域
|
||||
const container = document.querySelector('.live-video-list-sort-container')
|
||||
const item = document.querySelector(`.list-item-${index}`)
|
||||
if (item && container) {
|
||||
// 获取容器数据
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
// 获取对应item的数据
|
||||
const rect = item.getBoundingClientRect()
|
||||
// 计算对应item需要在容器中滚动的距离
|
||||
const scrollDistance = rect.top - containerRect.top
|
||||
// 设置滚动高度
|
||||
container.scrollTo({
|
||||
top: index == 0 ? 0 : container.scrollTop + scrollDistance - 10,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
const handleDeleteBatch = () => {
|
||||
modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否要删除选择的视频?',
|
||||
onOk: () => processDeleteVideo(checkedIdArray)
|
||||
})
|
||||
|
||||
const activeToNext = (index?: number) => {
|
||||
const endToFirst = index != undefined && index > -1 ? false : activeIndex.current >= videoData.length - 1
|
||||
const _activeIndex = index != undefined && index > -1 ? index : (endToFirst ? 0 : activeIndex.current + 1)
|
||||
setState({activeIndex:_activeIndex})
|
||||
if (endToFirst) {
|
||||
showToast('即将播放第一条视频');
|
||||
}
|
||||
// 找到对应video item 并显示在视图可见区域
|
||||
showVideoItem(_activeIndex)
|
||||
return _activeIndex;
|
||||
}
|
||||
const playVideo = (video: LiveVideoInfo, liveState: LiveState) => {
|
||||
if (videoRef.current && video.video_oss_url) {
|
||||
if(cache.timerPlayNext) clearTimeout(cache.timerPlayNext)
|
||||
const duration = Math.ceil(video.video_duration / 1000)
|
||||
const playedTime =( Date.now() / 1000 >> 0) - liveState.live_start_time
|
||||
if (playedTime < 0 || playedTime > duration) { // 已播放时间大于总时长了
|
||||
//initPlayingState() // 重新获取播放状态
|
||||
return;
|
||||
}
|
||||
if (/mp4$/i.test(video.video_oss_url)) {
|
||||
videoRef.current!.src = video.video_oss_url
|
||||
if(liveState.live_start_time > 0 && playedTime > 0) videoRef.current!.currentTime = playedTime
|
||||
videoRef.current!.play()
|
||||
return;
|
||||
}
|
||||
if (FlvJs.isSupported()) {
|
||||
if(cache.prevUrl !== video.video_oss_url) {
|
||||
// 已经有播放实例 则销毁
|
||||
if (cache.flvPlayer) {
|
||||
cache.flvPlayer.pause()
|
||||
cache.flvPlayer.unload()
|
||||
}
|
||||
cache.prevUrl = video.video_oss_url
|
||||
cache.flvPlayer = FlvJs.createPlayer({
|
||||
type: 'flv',
|
||||
url: video.video_oss_url
|
||||
})
|
||||
|
||||
cache.flvPlayer.attachMediaElement(videoRef.current!)
|
||||
cache.flvPlayer.load()
|
||||
}
|
||||
|
||||
if(liveState.live_start_time > 0 && playedTime > 0) videoRef.current!.currentTime = playedTime
|
||||
|
||||
cache.flvPlayer!.play()
|
||||
|
||||
cache.timerPlayNext = setTimeout(()=>{
|
||||
const index = activeToNext(),nextVideo = videoData[index]
|
||||
playVideo(nextVideo,{live_start_time:(Date.now() / 1000 >> 0),id:nextVideo.id})
|
||||
},(duration - playedTime) * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
const initPlayingState = () => {
|
||||
if(cache.timerLoadState) clearTimeout(cache.timerLoadState)
|
||||
if(videoData.length == 0) {
|
||||
cache.timerLoadState = setTimeout(initPlayingState, 1000)
|
||||
return;
|
||||
}
|
||||
playState().then(liveState => {
|
||||
const video = videoData.find(v => v.id === liveState.id)
|
||||
if (video) {
|
||||
activeToNext(videoData.findIndex(v => v.id === liveState.id))
|
||||
playVideo(video, liveState)
|
||||
} else {
|
||||
setState({activeIndex: -1})
|
||||
cache.timerLoadState = setTimeout(initPlayingState, 5000)
|
||||
}
|
||||
});
|
||||
}
|
||||
const clearAllTimer = ()=>{
|
||||
if(cache.timerPlayNext) clearTimeout(cache.timerPlayNext)
|
||||
if(cache.timerLoadState) clearTimeout(cache.timerLoadState)
|
||||
}
|
||||
|
||||
const loadList = () => {
|
||||
clearAllTimer();
|
||||
getList().then(res => {
|
||||
// console.log('origin list', res.list.map(s => s.id))
|
||||
setVideoData(()=>(res.list || []))
|
||||
setCheckedIdArray([])
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(initPlayingState,[videoData])
|
||||
useEffect(()=>{
|
||||
loadList()
|
||||
return clearAllTimer;
|
||||
}, [])
|
||||
|
||||
const processDeleteVideo = async (ids: number[]) => {
|
||||
deleteByIds(ids).then(() => {
|
||||
showToast('删除成功!', 'success')
|
||||
loadList()
|
||||
}).catch(showErrorToast)
|
||||
}
|
||||
const handleConfirm = () => {
|
||||
modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否采纳全部编辑操作?',
|
||||
content: '是否采纳移动视频位置操作?',
|
||||
onOk: () => {
|
||||
message.info('编辑成功!!!');
|
||||
//showToast('编辑成功!!!', 'info');
|
||||
modifyOrder(videoData.map(s => s.id)).then(() => {
|
||||
setEditable(false)
|
||||
loadList()
|
||||
}).catch(() => {
|
||||
showToast('调整视频顺序失败,请重试!')
|
||||
})
|
||||
// showToast('编辑成功!!!', 'info');
|
||||
// console.log('origin list', videoData.map(s => s.id))
|
||||
}
|
||||
})
|
||||
}
|
||||
const handleCancelConfirm = () => {
|
||||
modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否取消移动视频位置操作?',
|
||||
onOk: () => {
|
||||
showToast('退出并清除移动视频位置操作!', 'info');
|
||||
loadList()
|
||||
setEditable(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const totalDuration = useMemo(() => {
|
||||
if (!videoData || videoData.length == 0) return 0;
|
||||
// 计算总时长
|
||||
return videoData.reduce((sum, v) => sum + v.video_duration, 0);
|
||||
}, [videoData])
|
||||
|
||||
return (<div className="container py-10 page-live">
|
||||
{contextHolder}
|
||||
@ -37,67 +186,88 @@ export default function LiveIndex() {
|
||||
<div className="video-player-container mr-8 flex flex-col">
|
||||
<div className="text-center text-base">数字人直播间</div>
|
||||
<div className="video-player flex justify-center flex-1 mt-5">
|
||||
<div className=" rounded overflow-hidden w-[360px] h-[700px]">
|
||||
<iframe src="https://fm.gachafun.com/" className="border-0 w-full h-full max-h-full"></iframe>
|
||||
<div className="live-player relative rounded overflow-hidden w-[360px] h-[636px]" style={{backgroundColor:'hsl(210, 100%, 48%)'}}>
|
||||
<video ref={videoRef} autoPlay muted={state.muted}
|
||||
className="w-[360px] rounded overflow-hidden h-full object-contain"></video>
|
||||
{state.muted && state.activeIndex != -1 && <div className="absolute inset-0 flex items-center justify-center">
|
||||
<Button onClick={()=>{
|
||||
setState({muted: false})
|
||||
videoRef.current!.muted= false;
|
||||
}}>开启声音</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<span>视频时长: {formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-list-container flex-1">
|
||||
<div className=" bg-white py-8 px-6 rounded py-1">
|
||||
<div className="live-control flex justify-between mb-8">
|
||||
{editable ?<>
|
||||
<div className=" bg-white py-8 px-6 rounded">
|
||||
<div className="live-control flex justify-between mb-4">
|
||||
{editable ? <>
|
||||
<div className="flex gap-2">
|
||||
<Button type="primary" onClick={handleConfirm}>确定</Button>
|
||||
<Button onClick={()=>setEditable(false)}>退出</Button>
|
||||
<Button onClick={handleCancelConfirm}>退出</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="cursor-pointer" onClick={handleDeleteBatch}>批量删除</span>
|
||||
</div>
|
||||
</>: <div>
|
||||
<Button type="primary" onClick={()=>setEditable(true)}>编辑</Button>
|
||||
</> : <div>
|
||||
<Button type="primary" onClick={() => setEditable(true)}>重新排序</Button>
|
||||
</div>}
|
||||
|
||||
{!editable && <div>
|
||||
<ButtonBatch
|
||||
selected={checkedIdArray}
|
||||
emptyMessage={`请选择要删除的新闻视频`}
|
||||
confirmMessage={`是否删除当前的${checkedIdArray.length}个新闻视频?`}
|
||||
onSuccess={loadList}
|
||||
onProcess={processDeleteVideo}
|
||||
>批量删除</ButtonBatch>
|
||||
</div>}
|
||||
</div>
|
||||
<div className="live-video-list-sort-container">
|
||||
<div className="flex">
|
||||
<div className="sort-number-container mr-2">
|
||||
{videoData.map((v, index) => (
|
||||
<div key={index} className="flex items-center px-2 h-[80px] mt-3 mb-2">
|
||||
<div
|
||||
className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{index + 1}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="sort-list-container flex-1">
|
||||
<DndContext onDragEnd={(e) => {
|
||||
const {active, over} = e;
|
||||
if (over && active.id !== over.id) {
|
||||
let oldIndex = -1, newIndex = -1;
|
||||
setVideoData((items) => {
|
||||
oldIndex = items.findIndex(s => s.id == active.id);
|
||||
newIndex = items.findIndex(s => s.id == over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<SortableContext items={videoData}>
|
||||
{videoData.map((v, index) => (
|
||||
<VideoListItem
|
||||
video={v}
|
||||
index={index + 1}
|
||||
id={v.id}
|
||||
key={index}
|
||||
active={state.activeIndex == index}
|
||||
className={`list-item-${index} mt-3 mb-2`}
|
||||
checked={checkedIdArray.includes(v.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setCheckedIdArray(idArray => {
|
||||
return checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
|
||||
})
|
||||
}}
|
||||
onRemove={() => processDeleteVideo([v.id])}
|
||||
editable={!editable}
|
||||
sortable={editable}
|
||||
/>))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DndContext onDragEnd={(e) => {
|
||||
const {active, over} = e;
|
||||
if (over && active.id !== over.id) {
|
||||
let oldIndex = -1, newIndex = -1;
|
||||
const originArr = [...videoData]
|
||||
setVideoData((items) => {
|
||||
oldIndex = items.findIndex(s => s.id == active.id);
|
||||
newIndex = items.findIndex(s => s.id == over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否要移动到指定位置',
|
||||
onCancel: () => {
|
||||
setVideoData(originArr);
|
||||
},
|
||||
onOk: () => {
|
||||
setVideoData([...videoData])
|
||||
}
|
||||
})
|
||||
}
|
||||
}}>
|
||||
<SortableContext items={videoData}>
|
||||
{videoData.map((v, index) => (
|
||||
<VideoListItem
|
||||
video={v}
|
||||
index={index + 1}
|
||||
id={v.id}
|
||||
active={index == 0}
|
||||
key={index}
|
||||
onCheckedChange={(checked) => {
|
||||
setCheckedIdArray(idArray => {
|
||||
return checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
|
||||
})
|
||||
}}
|
||||
onRemove={() => processDeleteVideo([v.id])}
|
||||
editable={editable}
|
||||
/>))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
3
src/pages/live/style.module.scss
Normal file
3
src/pages/live/style.module.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.videoListContainer{
|
||||
|
||||
}
|
@ -2,36 +2,86 @@ import {Button} from "antd";
|
||||
import JSZip from "jszip"
|
||||
import {saveAs} from "file-saver";
|
||||
import {useState} from "react";
|
||||
|
||||
import {getById} from "@/service/api/news.ts";
|
||||
|
||||
import {showToast} from "@/components/message.ts";
|
||||
|
||||
|
||||
/**
|
||||
* 批量获取新闻内容
|
||||
* @param ids
|
||||
*/
|
||||
function getAllNewsContent(ids: Id[]) {
|
||||
return new Promise<NewsInfo[]>((resolve, reject) => {
|
||||
const request = ids.map((id) => getById(id))
|
||||
Promise.all(request).then(res => {
|
||||
resolve(res)
|
||||
}).catch(err => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取新闻html
|
||||
* @param news
|
||||
*/
|
||||
function getNewsHtml(news: NewsInfo) {
|
||||
return `<html>
|
||||
<head>
|
||||
<title>${news.title}</title>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
<div style="max-width: 80%;width:1000px;margin:30px auto;">
|
||||
<h1>${news.title}</h1>
|
||||
<div style="margin: 5px 0;font-size: 13px;">
|
||||
<span style="color: #766DF4">${news.media_name}</span>
|
||||
<span style="color: #999;margin-left: 10px;">${news.publish_time}</span>
|
||||
</div>
|
||||
<div>${news.content}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
/**
|
||||
* 将新闻数据包装成html并打包下载
|
||||
* @param list
|
||||
*/
|
||||
async function downloadAsZip(list: NewsInfo[]) {
|
||||
const zip = new JSZip();
|
||||
|
||||
list.forEach(news => {
|
||||
zip.file(`${news.title}.html`, getNewsHtml(news))
|
||||
})
|
||||
const content = await zip.generateAsync({type: "blob"});
|
||||
saveAs(content, "news.zip");
|
||||
// .then(function (content) {
|
||||
//
|
||||
// }).finally(() => {
|
||||
// setLoading(false)
|
||||
// });
|
||||
}
|
||||
|
||||
export default function ButtonNewsDownload(props: { ids: Id[] }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const onDownloadClick = (ids: Id[]) => {
|
||||
const onDownloadClick = async (ids: Id[]) => {
|
||||
if (props.ids.length === 0) {
|
||||
showToast('请选择要推送的新闻', 'warning')
|
||||
showToast('请选择要下载的新闻', 'warning')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
const zip = new JSZip();
|
||||
ids.forEach(id => {
|
||||
zip.file(`${id}.html`, `<html>
|
||||
<head>
|
||||
<title>${id}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div style="max-width: 90%;width:1000px;margin:30px auto;">
|
||||
<h1>title ${id}</h1>
|
||||
<p>content ${id}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`)
|
||||
})
|
||||
zip.generateAsync({type: "blob"}).then(function (content) {
|
||||
saveAs(content, "news.zip");
|
||||
}).finally(() => {
|
||||
try {
|
||||
const list = await getAllNewsContent(ids)
|
||||
await downloadAsZip(list)
|
||||
} catch (e) {
|
||||
showToast('下载新闻失败,请重试!', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
return (
|
||||
<Button loading={loading} onClick={() => onDownloadClick(props.ids)}>下载</Button>
|
||||
|
@ -1,26 +1,26 @@
|
||||
import {Button, Modal} from "antd";
|
||||
import React, {useState} from "react";
|
||||
import {showToast} from "@/components/message.ts";
|
||||
import {push2article} from "@/service/api/news.ts";
|
||||
import {showErrorToast, showToast} from "@/components/message.ts";
|
||||
import {push2video} from "@/service/api/article.ts";
|
||||
|
||||
|
||||
export default function ButtonPush2Video(props: { ids: Id[]}){
|
||||
const [loading,setLoading] = useState(false)
|
||||
const handlePush = ()=>{
|
||||
export default function ButtonPush2Video(props: { ids: Id[] }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const handlePush = () => {
|
||||
setLoading(true)
|
||||
push2article(props.ids).then(()=>{
|
||||
push2video(props.ids).then(() => {
|
||||
showToast('一键推流成功,已成功推入数字人视频生成,请前往数字人视频生成页面查看!', 'success')
|
||||
}).finally(()=>{
|
||||
}).catch(showErrorToast).finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
const onPushClick = ()=>{
|
||||
const onPushClick = () => {
|
||||
if (props.ids.length === 0) {
|
||||
showToast('请选择要开播的新闻', 'warning')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title:'操作提示',
|
||||
title: '操作提示',
|
||||
content: '是否确定一键开播选中新闻?',
|
||||
onOk: handlePush
|
||||
})
|
||||
|
@ -14,12 +14,9 @@ export default function NewEdit() {
|
||||
const [editId, setEditId] = useState(-1)
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Id[]>([])
|
||||
const [params, setParams] = useState<ApiArticleSearchParams>({
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 10
|
||||
}
|
||||
pagination: {page: 1, limit: 10}
|
||||
})
|
||||
const {data} = useRequest(() => getList(params), {refreshDeps: [params]})
|
||||
const {data, refresh} = useRequest(() => getList(params), {refreshDeps: [params]})
|
||||
|
||||
const columns: TableColumnsType<ListArticleItem> = [
|
||||
{
|
||||
@ -86,15 +83,20 @@ export default function NewEdit() {
|
||||
showSizeChanger={false}
|
||||
simple={true}
|
||||
rootClassName={'simple-pagination'}
|
||||
onChange={(page) => setParams(prev=>({
|
||||
onChange={(page) => setParams(prev => ({
|
||||
...prev,
|
||||
pagination: {page, limit: 10}
|
||||
}))}
|
||||
/>
|
||||
<ButtonPush2Video ids={selectedRowKeys} />
|
||||
<ButtonPush2Video ids={selectedRowKeys}/>
|
||||
</div>}
|
||||
</div>
|
||||
<ArticleEditModal id={editId} onClose={() => setEditId(-1)}/>
|
||||
<ArticleEditModal
|
||||
type="news" id={editId}
|
||||
onClose={(saved) => {
|
||||
setEditId(-1)
|
||||
if (saved) refresh()
|
||||
}}/>
|
||||
</Card>
|
||||
</div>)
|
||||
}
|
@ -1,13 +1,12 @@
|
||||
import {useState} from "react";
|
||||
import {Checkbox, Empty, Modal, Pagination, Space} from "antd";
|
||||
import {useRequest, useSetState} from "ahooks";
|
||||
import {useRequest} from "ahooks";
|
||||
|
||||
import {Card} from "@/components/card";
|
||||
import {getList} from "@/service/api/article.ts";
|
||||
|
||||
import SearchPanel from "@/pages/news/components/search-panel.tsx";
|
||||
import styles from './style.module.scss'
|
||||
import {getById} from "@/service/api/news.ts";
|
||||
import {getById,getList} from "@/service/api/news.ts";
|
||||
import {showLoading} from "@/components/message.ts";
|
||||
import {formatTime} from "@/util/strings.ts";
|
||||
import ButtonPushNews2Article from "@/pages/news/components/button-push-news2article.tsx";
|
||||
@ -95,7 +94,7 @@ export default function NewsIndex() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="title text-lg cursor-pointer" onClick={() => {
|
||||
handleViewNewsDetail(item.id)
|
||||
}}>{item.id}{item.title}</div>
|
||||
}}>{item.title}</div>
|
||||
{item.internal_article_id > 0 &&
|
||||
<div className="text-sm text-blue-500">已加入编辑界面</div>}
|
||||
</div>
|
||||
|
90
src/pages/test.tsx
Normal file
90
src/pages/test.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import {useRef, useState} from "react";
|
||||
import {Button} from "antd";
|
||||
import FlvJs from "flv.js";
|
||||
|
||||
const list = [
|
||||
{
|
||||
"id": 10,
|
||||
"cover_url": "",
|
||||
"video_id": 51,
|
||||
"video_title": "以军称在加沙地带打死一名哈马斯高级官员",
|
||||
"video_duration": 31910,
|
||||
"video_oss_url": "https://staticplus.gachafun.com/ai-collect/composite_video/2024-12-14/1185251497659736064.flv",
|
||||
"status": 4,
|
||||
"order_no": ""
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"cover_url": "",
|
||||
"video_id": 43,
|
||||
"video_title": "历时12天史上第三人 尹锡悦总统弹劾案获通过 一文梳理韩国政坛众生相",
|
||||
"video_duration": 728840,
|
||||
"video_oss_url": "https://staticplus.gachafun.com/ai-collect/composite_video/2024-12-14/1185229869001351168.flv",
|
||||
"status": 4,
|
||||
"order_no": ""
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"cover_url": "",
|
||||
"video_id": 44,
|
||||
"video_title": "推动房地产市场止跌回稳,发力重点在哪里?",
|
||||
"video_duration": 57500,
|
||||
"video_oss_url": "https://staticplus.gachafun.com/ai-collect/composite_video/2024-12-14/1185229857764810752.flv",
|
||||
"status": 4,
|
||||
"order_no": ""
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"cover_url": "",
|
||||
"video_id": 52,
|
||||
"video_title": "以军称在加沙地带打死一名哈马斯高级官员",
|
||||
"video_duration": 37980,
|
||||
"video_oss_url": "https://staticplus.gachafun.com/ai-collect/composite_video/2024-12-14/1185251495390617600.flv",
|
||||
"status": 4,
|
||||
"order_no": ""
|
||||
}
|
||||
]
|
||||
|
||||
const cache:{
|
||||
flvPlayer?: FlvJs.Player
|
||||
} = {
|
||||
|
||||
}
|
||||
export default function Test() {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const [index, setIndex] = useState(-1)
|
||||
const load = (url: string) => {
|
||||
if (FlvJs.isSupported()) {
|
||||
if(cache.flvPlayer){
|
||||
cache.flvPlayer.pause()
|
||||
cache.flvPlayer.unload()
|
||||
}
|
||||
cache.flvPlayer = FlvJs.createPlayer({
|
||||
type: 'flv',
|
||||
url: url
|
||||
})
|
||||
|
||||
cache.flvPlayer.attachMediaElement(videoRef.current!)
|
||||
cache.flvPlayer.load()
|
||||
cache.flvPlayer.play()
|
||||
}
|
||||
// const url = 'https://staticplus.gachafun.com/ai-collect/composite_video/2024-12-14/1185229869001351168.flv'
|
||||
// if (videoRef.current) {
|
||||
// videoRef.current!.src = url
|
||||
// videoRef.current?.play()
|
||||
// }
|
||||
}
|
||||
const play = () => {
|
||||
const next = index >= list.length - 1 ? 0 : index + 1
|
||||
load(list[next].video_oss_url)
|
||||
setIndex(next)
|
||||
}
|
||||
return (<div className="test container m-auto">
|
||||
<div className="my-10">
|
||||
<video controls className="border w-[400px] max-h-[600px]" ref={videoRef}></video>
|
||||
</div>
|
||||
<Button onClick={play}>load {index > -1 ? <span>
|
||||
{index} {list[index].video_title}
|
||||
</span>:''}</Button>
|
||||
</div>)
|
||||
}
|
31
src/pages/video/components/button-push2room.tsx
Normal file
31
src/pages/video/components/button-push2room.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import {Button, Modal} from "antd";
|
||||
import React, {useState} from "react";
|
||||
import {showErrorToast, showToast} from "@/components/message.ts";
|
||||
import {push2room} from "@/service/api/video.ts";
|
||||
|
||||
|
||||
export default function ButtonPush2Room(props: { ids: Id[]}){
|
||||
const [loading,setLoading] = useState(false)
|
||||
const handlePush = ()=>{
|
||||
setLoading(true)
|
||||
push2room(props.ids).then(()=>{
|
||||
showToast('一键推流成功,已推流至数字人直播间,请前往数字人直播间页面查看!', 'success')
|
||||
}).catch(showErrorToast).finally(()=>{
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
const onPushClick = ()=>{
|
||||
if (props.ids.length === 0) {
|
||||
showToast('请选择要推流的新闻', 'warning')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title:'操作提示',
|
||||
content: '是否确定一键推流选中新闻视频??',
|
||||
onOk: handlePush
|
||||
})
|
||||
}
|
||||
return (
|
||||
<Button type="primary" loading={loading} onClick={onPushClick}>一键推流</Button>
|
||||
)
|
||||
}
|
195
src/pages/video/index.tsx
Normal file
195
src/pages/video/index.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import {Empty, Modal} from "antd";
|
||||
import React, {useEffect, useMemo, useRef, useState} from "react";
|
||||
import {DndContext} from "@dnd-kit/core";
|
||||
import {arrayMove, SortableContext} from "@dnd-kit/sortable";
|
||||
import {useSetState} from "ahooks";
|
||||
import {CheckCircleFilled} from "@ant-design/icons";
|
||||
import {clsx} from "clsx";
|
||||
|
||||
import {VideoListItem} from "@/components/video/video-list-item.tsx";
|
||||
import ArticleEditModal from "@/components/article/edit-modal.tsx";
|
||||
import {deleteByIds, getList, modifyOrder, push2room} from "@/service/api/video.ts";
|
||||
import {formatDuration} from "@/util/strings.ts";
|
||||
import ButtonBatch from "@/components/button-batch.tsx";
|
||||
import {showToast} from "@/components/message.ts";
|
||||
import FlvJs from "flv.js";
|
||||
|
||||
const cache:{flvPlayer?: FlvJs.Player} = {}
|
||||
export default function VideoIndex() {
|
||||
const [editId, setEditId] = useState(-1)
|
||||
const [videoData, setVideoData] = useState<VideoInfo[]>([])
|
||||
const [modal, contextHolder] = Modal.useModal()
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const [state, setState] = useSetState({
|
||||
checkedAll: false,
|
||||
playingIndex: -1,
|
||||
})
|
||||
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
|
||||
|
||||
// 加载列表
|
||||
const loadList = () => {
|
||||
getList().then((ret) => {
|
||||
setCheckedIdArray([])
|
||||
setVideoData(ret.list || [])
|
||||
setState({checkedAll: false, playingIndex: -1})
|
||||
})
|
||||
}
|
||||
|
||||
// 播放视频
|
||||
const playVideo = (video: VideoInfo, playingIndex: number) => {
|
||||
if (videoRef.current && video.oss_video_url) {
|
||||
setState({playingIndex})
|
||||
if (FlvJs.isSupported()) {
|
||||
// 已经有播放实例 则销毁
|
||||
if(cache.flvPlayer){
|
||||
cache.flvPlayer.pause()
|
||||
cache.flvPlayer.unload()
|
||||
}
|
||||
cache.flvPlayer = FlvJs.createPlayer({
|
||||
type: 'flv',
|
||||
url: video.oss_video_url
|
||||
})
|
||||
|
||||
cache.flvPlayer.attachMediaElement(videoRef.current!)
|
||||
cache.flvPlayer.load()
|
||||
cache.flvPlayer.play()
|
||||
}
|
||||
videoRef.current!.src = video.oss_video_url
|
||||
}
|
||||
}
|
||||
// 处理全选
|
||||
const handleAllCheckedChange = () => {
|
||||
setCheckedIdArray(state.checkedAll ? [] : videoData.map(v => v.id))
|
||||
setState({
|
||||
checkedAll: !state.checkedAll
|
||||
})
|
||||
}
|
||||
const handleModifySort = () => {
|
||||
setVideoData((items) => {
|
||||
modifyOrder(items.map(s => s.id)).catch(() => {
|
||||
showToast('调整视频顺序失败,请重试!')
|
||||
}).finally(loadList)
|
||||
return items;
|
||||
})
|
||||
}
|
||||
//
|
||||
useEffect(loadList, [])
|
||||
const totalDuration = useMemo(() => {
|
||||
if (!videoData || videoData.length == 0) return 0;
|
||||
// 计算总时长
|
||||
return videoData.reduce((sum, v) => sum + v.duration, 0);
|
||||
}, [videoData])
|
||||
|
||||
return (<div className="container py-10 page-live">
|
||||
{contextHolder}
|
||||
<div className="flex">
|
||||
<div className="video-list-container bg-white p-10 rounded flex-1">
|
||||
<div className="live-control flex justify-between mb-5">
|
||||
<div className="pl-[70px]">
|
||||
<span>视频时长: {formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center pr-[10px]">
|
||||
<ButtonBatch
|
||||
onProcess={deleteByIds}
|
||||
selected={checkedIdArray}
|
||||
emptyMessage={`请选择要删除的新闻视频`}
|
||||
confirmMessage={`是否删除当前的${checkedIdArray.length}个新闻视频?`}
|
||||
onSuccess={()=>{
|
||||
showToast('删除成功!','success')
|
||||
loadList()
|
||||
}}
|
||||
>批量删除</ButtonBatch>
|
||||
|
||||
<button className="ml-5 hover:text-blue-300 text-gray-400 text-lg"
|
||||
onClick={handleAllCheckedChange}>
|
||||
<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'video-list-sort-container'}>
|
||||
<div className="flex my-2">
|
||||
{videoData.length == 0 ? <div className="m-auto"><Empty/></div> : <>
|
||||
<div className="sort-number-container mr-2">
|
||||
{videoData.map((v, index) => (
|
||||
<div key={index} className="flex items-center px-2 h-[80px] mt-3 mb-2">
|
||||
<div
|
||||
className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{index + 1}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="sort-list-container flex-1">
|
||||
<DndContext onDragEnd={(e) => {
|
||||
const {active, over} = e;
|
||||
if (over && active.id !== over.id) {
|
||||
let oldIndex = -1, newIndex = -1;
|
||||
const originArr = [...videoData]
|
||||
console.log(originArr.map(s => s.id))
|
||||
setVideoData((items) => {
|
||||
oldIndex = items.findIndex(s => s.id == active.id);
|
||||
newIndex = items.findIndex(s => s.id == over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否要移动到指定位置',
|
||||
onOk: handleModifySort,
|
||||
onCancel: () => {
|
||||
setVideoData(originArr);
|
||||
}
|
||||
})
|
||||
}
|
||||
}}>
|
||||
<SortableContext items={videoData}>
|
||||
{videoData.map((v, index) => (
|
||||
<VideoListItem
|
||||
video={v}
|
||||
index={index + 1}
|
||||
id={v.id}
|
||||
key={index}
|
||||
active={state.playingIndex == index}
|
||||
checked={checkedIdArray.includes(v.id)}
|
||||
className={`list-item-${index} mt-3 mb-2`}
|
||||
onCheckedChange={(checked) => {
|
||||
setCheckedIdArray(idArray => {
|
||||
const newArr = checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
|
||||
setState({checkedAll: newArr.length == videoData.length})
|
||||
return newArr;
|
||||
})
|
||||
}}
|
||||
onPlay={() => playVideo(v, index)}
|
||||
onEdit={() => {
|
||||
setEditId(v.article_id)
|
||||
}}
|
||||
editable={true}
|
||||
sortable={true}
|
||||
/>))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right mt-5">
|
||||
<ButtonBatch
|
||||
type={'primary'}
|
||||
onProcess={push2room}
|
||||
selected={checkedIdArray}
|
||||
emptyMessage={`请选择要推流的新闻`}
|
||||
confirmMessage={`是否确定一键推流选中新闻视频?`}
|
||||
onSuccess={loadList}
|
||||
>一键推流</ButtonBatch>
|
||||
{/*<ButtonPush2Room ids={checkedIdArray}/>*/}
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-player-container ml-16 min-w-[360px] flex flex-col">
|
||||
<div className="text-center text-base mt-10">预览视频</div>
|
||||
<div className="video-player flex items-center mt-20">
|
||||
<div className=" rounded overflow-hidden">
|
||||
<video ref={videoRef} controls autoPlay className="w-full bg-white min-w-[360px]"></video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArticleEditModal type={'video'} id={editId} onClose={() => setEditId(-1)}/>
|
||||
</div>)
|
||||
}
|
@ -36,7 +36,7 @@ const NavItems = [
|
||||
]
|
||||
|
||||
export function DashboardNavigation() {
|
||||
return (<div className={'flex'}>
|
||||
return (<div className={'flex app-main-navigation'}>
|
||||
{NavItems.map((it, idx) => (
|
||||
<NavLink to={it.path} key={idx} className={clsx('nav-item cursor-pointer items-center')}>
|
||||
<span className="menu-text ml-1">{it.name}</span>
|
||||
|
@ -1,7 +1,8 @@
|
||||
import {RouteObject} from "react-router-dom";
|
||||
import ErrorBoundary from "@/routes/error.tsx";
|
||||
import UserAuth from "@/pages/user";
|
||||
import CreateIndex from "@/pages/create";
|
||||
import Test from "@/pages/test";
|
||||
import CreateIndex from "../pages/video";
|
||||
import LibraryIndex from "@/pages/library";
|
||||
import LiveIndex from "@/pages/live";
|
||||
import NewsIndex from "@/pages/news";
|
||||
@ -13,6 +14,10 @@ const routes: RouteObject[] = [
|
||||
path: '/user',
|
||||
element: <UserAuth/>,
|
||||
},
|
||||
{
|
||||
path: '/test',
|
||||
element: <Test/>,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <DashboardLayout/>,
|
||||
|
@ -21,13 +21,14 @@ export function getById(id: Id) {
|
||||
return post<ArticleDetail>({url: '/article/detail/' + id})
|
||||
}
|
||||
|
||||
export function save(title: string, content_group: BlockContent[][], id: number) {
|
||||
return post<{ content: string }>({
|
||||
url: '/spider/article',
|
||||
data: {
|
||||
title,
|
||||
content_group,
|
||||
id
|
||||
}
|
||||
export function save(title: string, content_group: BlockContent[][], id?: number) {
|
||||
return post<{ content: string }>(id && id > 0 ? '/article/modify' : '/article/create/new', {
|
||||
title,
|
||||
content_group,
|
||||
id
|
||||
})
|
||||
}
|
||||
|
||||
export function push2video(article_ids: Id[]) {
|
||||
return post('/article/push2video', {article_ids})
|
||||
}
|
17
src/service/api/live.ts
Normal file
17
src/service/api/live.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import {post} from "@/service/request.ts";
|
||||
|
||||
export function playState() {
|
||||
return post<LiveState>({url: '/room/playing'})
|
||||
}
|
||||
|
||||
export function getList() {
|
||||
return post<DataList<LiveVideoInfo>>('/room/list')
|
||||
}
|
||||
|
||||
export function modifyOrder(ids: Id[]) {
|
||||
return post('/room/order', {ids})
|
||||
}
|
||||
|
||||
export function deleteByIds(ids: Id[]) {
|
||||
return post('/room/remove', {ids})
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import {post} from "@/service/request.ts";
|
||||
|
||||
export function getList(data: ApiArticleSearchParams & ApiRequestPageParams) {
|
||||
return post<DataList<ListCrawlerNewsItem>>({url: '/article/search', data})
|
||||
return post<DataList<ListCrawlerNewsItem>>({url: '/spider/search', data})
|
||||
}
|
||||
|
||||
export function getById(id: Id) {
|
||||
|
@ -1,10 +1,7 @@
|
||||
import {post} from "@/service/request.ts";
|
||||
|
||||
export function getList(data: {
|
||||
title?: string,
|
||||
time_flag?: number;
|
||||
}) {
|
||||
return post<DataList<VideoInfo>>({url: '/video/list', data})
|
||||
export function getList() {
|
||||
return post<DataList<VideoInfo>>('/video/list')
|
||||
}
|
||||
|
||||
/**
|
||||
@ -13,7 +10,7 @@ export function getList(data: {
|
||||
* @param content_group
|
||||
* @param article_id
|
||||
*/
|
||||
export function regenerate(title: string, content_group: BlockContent[][], article_id: number) {
|
||||
export function regenerate(title: string, content_group: BlockContent[][], article_id?: Id) {
|
||||
return post<{ content: string }>({
|
||||
url: '/video/regenerate',
|
||||
data: {
|
||||
@ -28,12 +25,15 @@ export function getById(id: Id) {
|
||||
return post<VideoInfo>({url: '/video/detail/' + id})
|
||||
}
|
||||
|
||||
export function deleteById(id: Id) {
|
||||
return post({url: '/video/detail/' + id})
|
||||
export function deleteByIds(ids: Id[]) {
|
||||
return post('/video/remove', {ids})
|
||||
}
|
||||
|
||||
|
||||
export function modifyOrder(ids: Id[]) {
|
||||
return post({url: ' /video/modifyorder',data:{ids}})
|
||||
return post('/video/modifyorder', {ids})
|
||||
}
|
||||
export function push2room(ids: Id[]) {
|
||||
return post({url: ' /video/push2room',data:{ids}})
|
||||
|
||||
export function push2room(video_ids: Id[]) {
|
||||
return post('/video/push2room', {video_ids})
|
||||
}
|
@ -2,6 +2,7 @@ import axios from 'axios';
|
||||
import {stringify} from 'qs'
|
||||
import {BizError} from './types';
|
||||
import {getAuthToken} from "@/hooks/useAuth.ts";
|
||||
import {showToast} from "@/components/message.ts";
|
||||
|
||||
const JSON_FORMAT: string = 'application/json';
|
||||
const REQUEST_TIMEOUT = 300000; // 超时时长5min
|
||||
@ -23,6 +24,7 @@ Axios.interceptors.request.use(config => {
|
||||
}
|
||||
return config
|
||||
}, err => {
|
||||
console.log('请求拦截器报错',err)
|
||||
return Promise.reject(err)
|
||||
})
|
||||
|
||||
@ -46,11 +48,11 @@ export function request<T>(options: RequestOption) {
|
||||
return;
|
||||
}
|
||||
// const
|
||||
const {code, message, data, request_id} = res.data
|
||||
const {code, msg, data, trace_id} = res.data
|
||||
if (code == 0) {
|
||||
resolve(data as unknown as T)
|
||||
} else {
|
||||
reject(new BizError(message, code, request_id, data as unknown as AllType))
|
||||
reject(new BizError(msg, code, trace_id, data as unknown as AllType))
|
||||
}
|
||||
}).catch(e => {
|
||||
reject(new BizError(e.message, 500))
|
||||
@ -59,9 +61,13 @@ export function request<T>(options: RequestOption) {
|
||||
}
|
||||
|
||||
|
||||
export function post<T>(params: RequestOption) {
|
||||
export function post<T>(params: RequestOption | string, _data?: AllType) {
|
||||
const options = typeof params === 'string' ? {url: params} : params;
|
||||
if (_data) {
|
||||
options.data = _data
|
||||
}
|
||||
return request<T>({
|
||||
...params,
|
||||
...options,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
18
src/types/api.d.ts
vendored
18
src/types/api.d.ts
vendored
@ -83,9 +83,25 @@ declare interface ListCrawlerNewsItem extends BasicArticleInfo {
|
||||
declare interface VideoInfo {
|
||||
id: number;
|
||||
title: string;
|
||||
cover?: string;
|
||||
cover: string;
|
||||
oss_video_url: string;
|
||||
duration: number;
|
||||
article_id: number;
|
||||
status: number;
|
||||
}
|
||||
// room live
|
||||
declare interface LiveVideoInfo {
|
||||
id: number;
|
||||
video_id: number;
|
||||
video_title: string;
|
||||
cover: string;
|
||||
video_duration: number;
|
||||
video_oss_url: string;
|
||||
status: number;
|
||||
order_no: string;
|
||||
}
|
||||
|
||||
declare interface LiveState{
|
||||
id: number;
|
||||
live_start_time?: number;
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import {padStart} from "lodash";
|
||||
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
@ -79,4 +80,14 @@ export function calcContentLengthLikeWord(str:string) {
|
||||
} catch (e) {
|
||||
return str.length
|
||||
}
|
||||
}
|
||||
|
||||
// 将时长转换成 时:分:秒
|
||||
export function formatDuration(duration: number) {
|
||||
const hour = Math.floor(duration / 3600);
|
||||
const minute = Math.floor((duration - hour * 3600) / 60);
|
||||
const second = duration - hour * 3600 - minute * 60;
|
||||
// 需要补0
|
||||
return padStart(hour.toString(), 2, '0') + ':' + padStart(minute.toString(), 2, '0') + ':' + padStart(second.toString(), 2, '0')
|
||||
// return `${hour}:${minute}:${second}`
|
||||
}
|
18
yarn.lock
18
yarn.lock
@ -1412,6 +1412,11 @@ es-errors@^1.3.0:
|
||||
resolved "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
|
||||
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
|
||||
|
||||
es6-promise@^4.2.8:
|
||||
version "4.2.8"
|
||||
resolved "https://registry.npmmirror.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
|
||||
integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
|
||||
|
||||
esbuild@^0.21.3:
|
||||
version "0.21.5"
|
||||
resolved "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d"
|
||||
@ -1625,6 +1630,14 @@ flatted@^3.2.9:
|
||||
resolved "https://registry.npmmirror.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a"
|
||||
integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==
|
||||
|
||||
flv.js@^1.6.2:
|
||||
version "1.6.2"
|
||||
resolved "https://registry.npmmirror.com/flv.js/-/flv.js-1.6.2.tgz#fa3340fe3f7ee01d3977f7876aee66b8436e5922"
|
||||
integrity sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==
|
||||
dependencies:
|
||||
es6-promise "^4.2.8"
|
||||
webworkify-webpack "^2.1.5"
|
||||
|
||||
follow-redirects@^1.15.6:
|
||||
version "1.15.9"
|
||||
resolved "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1"
|
||||
@ -3163,6 +3176,11 @@ vite@^5.2.0:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.3"
|
||||
|
||||
webworkify-webpack@^2.1.5:
|
||||
version "2.1.5"
|
||||
resolved "https://registry.npmmirror.com/webworkify-webpack/-/webworkify-webpack-2.1.5.tgz#bf4336624c0626cbe85cf1ffde157f7aa90b1d1c"
|
||||
integrity sha512-2akF8FIyUvbiBBdD+RoHpoTbHMQF2HwjcxfDvgztAX5YwbZNyrtfUMgvfgFVsgDhDPVTlkbb5vyasqDHfIDPQw==
|
||||
|
||||
which@^2.0.1:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmmirror.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
|
||||
|
Loading…
x
Reference in New Issue
Block a user