diff --git a/package.json b/package.json index 040a9a4..c2a8f7f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/assets/core.scss b/src/assets/core.scss index d19f631..6645207 100644 --- a/src/assets/core.scss +++ b/src/assets/core.scss @@ -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; + } } \ No newline at end of file diff --git a/src/assets/images/cover.png b/src/assets/images/cover.png new file mode 100644 index 0000000..c80d38d Binary files /dev/null and b/src/assets/images/cover.png differ diff --git a/src/assets/libs.scss b/src/assets/libs.scss new file mode 100644 index 0000000..891a092 --- /dev/null +++ b/src/assets/libs.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/src/components/article/article.module.scss b/src/components/article/article.module.scss index 349a72a..1a06d04 100644 --- a/src/components/article/article.module.scss +++ b/src/components/article/article.module.scss @@ -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; } } diff --git a/src/components/article/block.tsx b/src/components/article/block.tsx index 65882e6..32db078 100644 --- a/src/components/article/block.tsx +++ b/src/components/article/block.tsx @@ -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
-
+
- {blocks.map((it, idx) => { - const isFirstTextBlock = index == 0 && it.type ==='text' && firstTextBlockIndex == idx - return (
-
- { - it.type === 'text' - ? handleBlockChange(idx, block)} data={it} - editable={editable}/> - : - } - {editable &&
- {isFirstTextBlock?: - 请确认删除此{it.type === 'text' ? '文本' : '图片'}? -
} - onConfirm={() => handleBlockRemove(idx)} - okText="删除" - cancelText="取消" - > - - - - } -
- handleAddBlock('text', idx + 1)} - className="article-action-icon" title="新增文本"> - handleAddBlock('image', idx + 1)} - className="article-action-icon mt-1" title="新增图片"> -
-
} -
- {isFirstTextBlock &&
该编辑框内容由数字人播报
} -
) - } - )} - {editable && blocks.length == 0 && -
-
- - -
-
- } +
+
+ handleBlockChange(0, block)} + data={blocks[0]} + isFirstBlock={index == 0} + editable={editable}/> +
+ {index == 0 &&
+
{errorMessage}
+
该编辑框内容由数字人播报
+
} +
+ {index > 0 && }
{editable &&
- - 请确认删除此删除此分组? -
} - onConfirm={onRemove} - okText="删除" - cancelText="取消" - > - - + { + index > 0 ? 请确认删除此分组?
} + onConfirm={onRemove} + okText="删除" + cancelText="取消" + > + + + + : + } } diff --git a/src/components/article/edit-modal.tsx b/src/components/article/edit-modal.tsx index 6a731b6..f607075 100644 --- a/src/components/article/edit-modal.tsx +++ b/src/components/article/edit-modal.tsx @@ -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([]); 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' ? '确定' : '重新生成'} >
@@ -59,19 +80,28 @@ export default function ArticleEditModal(props: Props) { *
- { + { setTitle(e.target.value) + setState({msgTitle: e.target.value ? '' : '请输入标题内容'}) }} placeholder={'请输入文章标题'}/>
+
{state.msgTitle}
-
+
正文 *
- setGroups(() => list)}/> + { + setGroups(() => list) + setState({msgGroup: (list.length == 0 || list[0].length == 0 || !list[0][0].content) ? '请输入正文文本内容' : ''}); + }} + />
+ {state.error &&
{state.error}
}
); } \ No newline at end of file diff --git a/src/components/article/group.tsx b/src/components/article/group.tsx index 20963f8..92fcb9f 100644 --- a/src/components/article/group.tsx +++ b/src/components/article/group.tsx @@ -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 && - onChange?.([blocks])} index={0} blocks={[{type:'text',content:''}]}/>} + onChange?.([blocks])} index={0} + blocks={[{type: 'text', content: ''}]}/>}
} \ No newline at end of file diff --git a/src/components/article/item.tsx b/src/components/article/item.tsx index 963cfbf..a1f367d 100644 --- a/src/components/article/item.tsx +++ b/src/components/article/item.tsx @@ -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(-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
- {editable ?
+ {editable ?
+ {!onlyUpload && 请确认删除此删除此图片?
} + onConfirm={onRemove} + okText="删除" + cancelText="取消" + > + + } = 0} percent={loading == 0 ? 'auto' : loading}> 更换图片
:
- +
+ +
上传图片
+
}
@@ -84,13 +101,14 @@ export function BlockImage({data, editable, onChange}: Props) { export function BlockText({data, editable, onChange, isFirstBlock}: Props) { return
-
+
{editable ?
{ 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"}/>
:

{data.content}

}
diff --git a/src/components/article/list.tsx b/src/components/article/list.tsx new file mode 100644 index 0000000..2a0bb6f --- /dev/null +++ b/src/components/article/list.tsx @@ -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 (
+ {props.blocks.map((it, index) => ( + it.type === 'image' ? handleUpdate(index, data)} + onRemove={() => handleRemove(index)} + /> : null + ))} + {props.editable && + } +
) +} + diff --git a/src/components/button-batch.tsx b/src/components/button-batch.tsx new file mode 100644 index 0000000..2f55bfb --- /dev/null +++ b/src/components/button-batch.tsx @@ -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 + 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 ( + + ) +} \ No newline at end of file diff --git a/src/components/message.ts b/src/components/message.ts index 00d304f..e37404b 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -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); } } diff --git a/src/components/video/video-list-item.tsx b/src/components/video/video-list-item.tsx index 3a4a9fa..b74522f 100644 --- a/src/components/video/video-list-item.tsx +++ b/src/components/video/video-list-item.tsx @@ -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
- {index && index > 0 &&
-
{id}
-
} + {/*{index && index > 0 &&
*/} + {/*
{index}
*/} + {/*
}*/}
-
{video.title}
-
- {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' : ''}`}> +
{video.title || video.video_title}
+
+ {video.video_title}/
- {editable && -
- {!active ? : } - {onPlay && - } +
+ {sortable && (!active ? : )} + {onPlay && + } + {editable && <> {onEdit && } @@ -71,15 +73,14 @@ export const VideoListItem = ( } }}> {onRemove && 请确认删除此视频?
} + title={
请确认删除此视频?
} onConfirm={onRemove} okText="删除" cancelText="取消" > } -
- } + } +
} \ No newline at end of file diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx deleted file mode 100644 index bb2f43b..0000000 --- a/src/pages/create/index.tsx +++ /dev/null @@ -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([]) - - useEffect(() => { - getList({}).then((ret) => { - setVideoData(ret.list) - }) - }, []) - - const [modal, contextHolder] = Modal.useModal() - const videoRef = useRef(null) - const [state, setState] = useSetState({ - checkedAll: false - }) - const [checkedIdArray, setCheckedIdArray] = useState([]) - 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 (
- {contextHolder} -
-
-
-
- 视频时长: 00:00:29 -
-
- 批量删除 - -
-
- { - 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); - } - }) - } - }}> - - {videoData.map((v, index) => ( - { - 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 - />))} - - -
- -
-
-
-
预览视频
-
-
- -
-
-
-
- -
) -} \ No newline at end of file diff --git a/src/pages/library/components/search-form.tsx b/src/pages/library/components/search-form.tsx index c3ddf40..5835daf 100644 --- a/src/pages/library/components/search-form.tsx +++ b/src/pages/library/components/search-form.tsx @@ -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) { {/**/} {/* */} {/**/} - {/**/} - {/* */} - {/* */} - {/* */} - {/* */} - {/**/} + + + + +
diff --git a/src/pages/library/index.tsx b/src/pages/library/index.tsx index 71f202c..f2515c2 100644 --- a/src/pages/library/index.tsx +++ b/src/pages/library/index.tsx @@ -10,7 +10,7 @@ import {getList} from "@/service/api/video.ts"; export default function LibraryIndex() { const [modal, contextHolder] = Modal.useModal(); const [checkedIdArray, setCheckedIdArray] = useState([]) - const {data} = useRequest(()=>getList({}),{ + const {data} = useRequest(()=>getList(),{ }) const handleRemove = (video: VideoInfo) => { diff --git a/src/pages/live/index.tsx b/src/pages/live/index.tsx index 9b0504e..4521939 100644 --- a/src/pages/live/index.tsx +++ b/src/pages/live/index.tsx @@ -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() + const videoRef = useRef(null) + const [videoData, setVideoData] = useState([]) const [modal, contextHolder] = Modal.useModal() const [checkedIdArray, setCheckedIdArray] = useState([]) - const [editable,setEditable] = useState(false) - const processDeleteVideo = async (_idArray: number[]) => { - message.info('删除成功!!!' + _idArray.join('')); + const [editable, setEditable] = useState(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 (
{contextHolder} @@ -37,67 +186,88 @@ export default function LiveIndex() {
数字人直播间
-
- +
+ + {state.muted && state.activeIndex != -1 &&
+ +
}
+
+ 视频时长: {formatDuration(totalDuration)} +
-
-
- {editable ?<> +
+
+ {editable ? <>
- +
-
- 批量删除 -
- :
- + :
+
} - + {!editable &&
+ 批量删除 +
} +
+
+
+
+ {videoData.map((v, index) => ( +
+
{index + 1}
+
+ ))} +
+
+ { + 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); + }); + } + }}> + + {videoData.map((v, index) => ( + { + setCheckedIdArray(idArray => { + return checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id); + }) + }} + onRemove={() => processDeleteVideo([v.id])} + editable={!editable} + sortable={editable} + />))} + + +
+
- { - 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]) - } - }) - } - }}> - - {videoData.map((v, index) => ( - { - setCheckedIdArray(idArray => { - return checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id); - }) - }} - onRemove={() => processDeleteVideo([v.id])} - editable={editable} - />))} - -
diff --git a/src/pages/live/style.module.scss b/src/pages/live/style.module.scss new file mode 100644 index 0000000..5ba3fb7 --- /dev/null +++ b/src/pages/live/style.module.scss @@ -0,0 +1,3 @@ +.videoListContainer{ + +} \ No newline at end of file diff --git a/src/pages/news/components/button-news-download.tsx b/src/pages/news/components/button-news-download.tsx index 72f7d87..bbfa8ac 100644 --- a/src/pages/news/components/button-news-download.tsx +++ b/src/pages/news/components/button-news-download.tsx @@ -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((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 ` + +${news.title} + + + +
+

${news.title}

+
+ ${news.media_name} + ${news.publish_time} +
+
${news.content}
+
+ +` +} + +/** + * 将新闻数据包装成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`, ` - -${id} - - -
-

title ${id}

-

content ${id}

-
- -`) - }) - 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 ( diff --git a/src/pages/news/components/button-push2video.tsx b/src/pages/news/components/button-push2video.tsx index c391851..42904e8 100644 --- a/src/pages/news/components/button-push2video.tsx +++ b/src/pages/news/components/button-push2video.tsx @@ -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 }) diff --git a/src/pages/news/edit.tsx b/src/pages/news/edit.tsx index 4baf032..924ff8b 100644 --- a/src/pages/news/edit.tsx +++ b/src/pages/news/edit.tsx @@ -14,12 +14,9 @@ export default function NewEdit() { const [editId, setEditId] = useState(-1) const [selectedRowKeys, setSelectedRowKeys] = useState([]) const [params, setParams] = useState({ - 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 = [ { @@ -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} }))} /> - +
}
- setEditId(-1)}/> + { + setEditId(-1) + if (saved) refresh() + }}/>
) } \ No newline at end of file diff --git a/src/pages/news/index.tsx b/src/pages/news/index.tsx index 8a8720c..cb3e321 100644 --- a/src/pages/news/index.tsx +++ b/src/pages/news/index.tsx @@ -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() {
{ handleViewNewsDetail(item.id) - }}>{item.id}{item.title}
+ }}>{item.title}
{item.internal_article_id > 0 &&
已加入编辑界面
}
diff --git a/src/pages/test.tsx b/src/pages/test.tsx new file mode 100644 index 0000000..80d3c0a --- /dev/null +++ b/src/pages/test.tsx @@ -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(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 (
+
+ +
+ +
) +} \ No newline at end of file diff --git a/src/pages/video/components/button-push2room.tsx b/src/pages/video/components/button-push2room.tsx new file mode 100644 index 0000000..ff069fe --- /dev/null +++ b/src/pages/video/components/button-push2room.tsx @@ -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 ( + + ) +} \ No newline at end of file diff --git a/src/pages/video/index.tsx b/src/pages/video/index.tsx new file mode 100644 index 0000000..cdbbda4 --- /dev/null +++ b/src/pages/video/index.tsx @@ -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([]) + const [modal, contextHolder] = Modal.useModal() + const videoRef = useRef(null) + const [state, setState] = useSetState({ + checkedAll: false, + playingIndex: -1, + }) + const [checkedIdArray, setCheckedIdArray] = useState([]) + + // 加载列表 + 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 (
+ {contextHolder} +
+
+
+
+ 视频时长: {formatDuration(totalDuration)} +
+
+ { + showToast('删除成功!','success') + loadList() + }} + >批量删除 + + +
+
+
+
+ {videoData.length == 0 ?
: <> +
+ {videoData.map((v, index) => ( +
+
{index + 1}
+
+ ))} +
+
+ { + 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); + } + }) + } + }}> + + {videoData.map((v, index) => ( + { + 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} + />))} + + +
+ } +
+
+
+ 一键推流 + {/**/} +
+
+
+
预览视频
+
+
+ +
+
+
+
+ setEditId(-1)}/> +
) +} \ No newline at end of file diff --git a/src/routes/layout/dashboard-navigation.tsx b/src/routes/layout/dashboard-navigation.tsx index ab764a0..610bf9d 100644 --- a/src/routes/layout/dashboard-navigation.tsx +++ b/src/routes/layout/dashboard-navigation.tsx @@ -36,7 +36,7 @@ const NavItems = [ ] export function DashboardNavigation() { - return (
+ return (
{NavItems.map((it, idx) => ( {it.name} diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index d2a2dc2..96f125f 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -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: , }, + { + path: '/test', + element: , + }, { path: '/', element: , diff --git a/src/service/api/article.ts b/src/service/api/article.ts index 7fb948c..3baff95 100644 --- a/src/service/api/article.ts +++ b/src/service/api/article.ts @@ -21,13 +21,14 @@ export function getById(id: Id) { return post({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}) } \ No newline at end of file diff --git a/src/service/api/live.ts b/src/service/api/live.ts new file mode 100644 index 0000000..23d14a3 --- /dev/null +++ b/src/service/api/live.ts @@ -0,0 +1,17 @@ +import {post} from "@/service/request.ts"; + +export function playState() { + return post({url: '/room/playing'}) +} + +export function getList() { + return post>('/room/list') +} + +export function modifyOrder(ids: Id[]) { + return post('/room/order', {ids}) +} + +export function deleteByIds(ids: Id[]) { + return post('/room/remove', {ids}) +} \ No newline at end of file diff --git a/src/service/api/news.ts b/src/service/api/news.ts index 03e3977..c795893 100644 --- a/src/service/api/news.ts +++ b/src/service/api/news.ts @@ -1,7 +1,7 @@ import {post} from "@/service/request.ts"; export function getList(data: ApiArticleSearchParams & ApiRequestPageParams) { - return post>({url: '/article/search', data}) + return post>({url: '/spider/search', data}) } export function getById(id: Id) { diff --git a/src/service/api/video.ts b/src/service/api/video.ts index f8273a7..d85dad3 100644 --- a/src/service/api/video.ts +++ b/src/service/api/video.ts @@ -1,10 +1,7 @@ import {post} from "@/service/request.ts"; -export function getList(data: { - title?: string, - time_flag?: number; -}) { - return post>({url: '/video/list', data}) +export function getList() { + return post>('/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({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}) } \ No newline at end of file diff --git a/src/service/request.ts b/src/service/request.ts index 676248f..77bba4a 100644 --- a/src/service/request.ts +++ b/src/service/request.ts @@ -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(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(options: RequestOption) { } -export function post(params: RequestOption) { +export function post(params: RequestOption | string, _data?: AllType) { + const options = typeof params === 'string' ? {url: params} : params; + if (_data) { + options.data = _data + } return request({ - ...params, + ...options, method: 'post' }) } diff --git a/src/types/api.d.ts b/src/types/api.d.ts index ce4ca4b..3658646 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -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; +} diff --git a/src/util/strings.ts b/src/util/strings.ts index 508d563..69cc62a 100644 --- a/src/util/strings.ts +++ b/src/util/strings.ts @@ -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}` } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 7634d58..0f76378 100644 --- a/yarn.lock +++ b/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"