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 &&
-
-
- handleAddBlock('text')}>添加文本
- handleAddBlock('image')}>添加图片
-
-
- }
+
+
+ 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' ? '确定' : '重新生成'}
>
-
+
正文
*
-
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 (
+ {children}
+ )
+}
\ 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 &&
}
+ {/*{index && index > 0 &&
*/}
+ {/*
{index}
*/}
+ {/*
}*/}
-
{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}
+
+
- {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 &&
+ {
+ setState({muted: false})
+ videoRef.current!.muted= false;
+ }}>开启声音
+
}
+
+ 视频时长: {formatDuration(totalDuration)}
+
-
-
- {editable ?<>
+
+
+ {editable ? <>
确定
- setEditable(false)}>退出
+ 退出
-
- 批量删除
-
- >:
-
setEditable(true)}>编辑
+ > :
+ setEditable(true)}>重新排序
}
-
+ {!editable &&
+ 批量删除
+
}
+
+
+
+
+ {videoData.map((v, index) => (
+
+ ))}
+
+
+ {
+ 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 (
onDownloadClick(props.ids)}>下载
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 (
+
+
+
+
load {index > -1 ?
+ {index} {list[index].video_title}
+ :''}
+
)
+}
\ 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) => (
+
+ ))}
+
+
+ {
+ 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"