Compare commits

..

20 Commits

Author SHA1 Message Date
e1bcc4c13f update 2024-12-17 20:54:28 +08:00
661b6f38da feat: 历史视频详情查看 2024-12-17 20:52:24 +08:00
a2770765d8 feat: 优化播放器;添加历史视频 2024-12-17 19:57:49 +08:00
8e6d7fe702 feat: 调整视频播放组件(使用腾讯直播sdk) 2024-12-17 16:51:38 +08:00
e1a4005e27 fixed: 新闻素材来源修改多选为单选;修改视频时长统计(视频时长单位为毫秒);
update: 更新导航栏文字(AI视频->视频生成)
2024-12-17 11:43:46 +08:00
1acdc2a99d fixed: ts build error 2024-12-16 22:17:22 +08:00
晏成
4d5449364a Accept Merge Request #354: (dev/main -> main)
Merge Request: create preview

Created By: @晏成
Accepted By: @晏成
URL: https://starbite.coding.net/p/aixiaodui/d/fengmang-backend/git/merge/354?initial=true
2024-12-16 22:00:03 +08:00
99d7787b04 fixed: 直播状态更新 2024-12-16 21:56:57 +08:00
74b52562f4 feat: 添加自动播放 2024-12-16 21:48:48 +08:00
983b35f914 feat: 添加自动播放 2024-12-16 19:50:17 +08:00
58ace4514b feat: 新闻编辑 2024-12-16 19:14:39 +08:00
0592d97e39 feat: update 2024-12-16 16:54:06 +08:00
39f254c99b feat: update 2024-12-15 18:45:28 +08:00
be22fc387a feat: 添加批量操作按钮;优化视频展示; 2024-12-15 18:00:48 +08:00
b07f336bd5 调整并优化新闻批量下载 2024-12-15 16:37:13 +08:00
2525358eb9 💄 update video item active 2024-12-15 13:33:01 +08:00
950bc59847 💄 update ui 2024-12-14 22:38:22 +08:00
ddda905608 merge new editor 2024-12-14 22:11:24 +08:00
c63b0c088e feat: 添加视频相关数字对接 2024-12-14 22:10:11 +08:00
97d9200217 feat: 编辑样式调整为新的样式 2024-12-14 16:28:01 +08:00
51 changed files with 1756 additions and 863 deletions

25
README.md Normal file
View File

@ -0,0 +1,25 @@
## 数值人直播间
### Start
```shell
git clone git@e.coding.net:starbite/aixiaodui/fengmang-backend.git
npm install
npm run dev
```
打开 [http://localhost:10021/](http://localhost:10021/) 查看效果
### Deploy
**直接部署**
> 需要配置 /mgmt 及 /api 反向代理
```shell
; 如果需要指定前缀(CDN)或者需要运行在相对路径中,不需要则跳过此命令
export PUBLIC_PATH=xxxxxxxx(相应路径)
npm run build
```
生成的资源在dist目录中将此目录中所有文件放置在待部署web目录即可。
**使用docker**
[x] TODO
```shell
dockercompose up -d
```

View File

@ -6,6 +6,7 @@
"description": "数字人直播间",
"scripts": {
"dev": "vite --host",
"dev-test": "vite --host --mode=test",
"build": "tsc && vite build",
"build-test": "tsc && vite build --mode=test",
"build-relative": "tsc && vite build --mode=relative",
@ -24,13 +25,15 @@
"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",
"react-dom": "^18.3.1",
"react-player": "^2.16.0",
"react-router-dom": "^6.28.0",
"sass": "^1.81.0"
"sass": "^1.81.0",
"tcplayer.js": "^5.2.0"
},
"devDependencies": {
"@types/file-saver": "^2.0.7",

View File

@ -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,48 @@
}
}
.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;
}
.video-player{
.video-js{
@apply w-full h-full;
max-width: 100%;
max-height: 100%;
background:#fff; // hsl(210, 100%, 48%)
}
}
.app-main-navigation {
@include media-breakpoint-down(md) {
display: none;
}
}

BIN
src/assets/images/cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

22
src/assets/libs.scss Normal file
View File

@ -0,0 +1,22 @@
@mixin media-breakpoint-down($name) {
@if $name == sm {
@media (max-width: 767px) {
@content;
}
}
@if $name == md {
@media (max-width: 991px) {
@content;
}
}
@if $name == lg {
@media (max-width: 1199px) {
@content;
}
}
@if $name == xl {
@media (max-width: 1399px) {
@content;
}
}
}

View File

@ -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;
}
}

View File

@ -1,40 +1,56 @@
import React from "react";
import clsx from "clsx";
import {Popconfirm} 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) {
const handleBlockRemove = (index: number) => {
// 删除当前项
onChange?.(blocks.filter((_, idx) => index !== idx))
function rebuildBlockArray(blocks: BlockContent[]) {
const textBlock: BlockContent = {
type: 'text',
content: ''
}
const firstTextBlockIndex = blocks.findIndex(it => it.type === 'text')
// 新增
const handleAddBlock = (type: 'text' | 'image', insertIndex: number = -1) => {
const newBlock: BlockContent = type === 'text' ? {type: 'text', content: ''} : {type: 'image', content: ''};
const _blocks = [...blocks]
if (insertIndex == -1 || insertIndex >= blocks.length) { // -1或者越界表示新增
_blocks.push(newBlock)
const _blocks: BlockContent[] = [textBlock];
const textArray: string[] = []
blocks.forEach(it => {
if (it.type == 'text') {
textArray.push(it.content)
} else {
_blocks.splice(insertIndex, 0, newBlock)
_blocks.push(it)
}
onChange?.(_blocks)
}
})
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 handleBlockChange = (index: number, block: BlockContent) => {
const _blocks = [...blocks]
@ -43,72 +59,38 @@ export default function ArticleBlock({className, blocks, editable, onRemove, onA
}
return <div className={styles.blockContainer}>
<div className={clsx(className || '', styles.block,' hover:bg-blue-10')}>
<div className={clsx(className || '', styles.block, index == 0 ? styles.blockFist : '', ' hover:bg-blue-10')}>
<div className={styles.blockBody}>
{blocks.map((it, idx) => {
const isFirstTextBlock = index == 0 && it.type ==='text' && firstTextBlockIndex == idx
return (<div key={idx}>
<div className={clsx(isFirstTextBlock?'':styles.blockItem, 'flex')}>
{
it.type === 'text'
? <BlockText isFirstBlock={isFirstTextBlock} onChange={(block) => handleBlockChange(idx, block)} data={it}
editable={editable}/>
: <BlockImage data={it} editable={editable}/>
}
{editable && <div className="create-container ml-2 flex flex-col justify-between">
{isFirstTextBlock?<span></span>:<Popconfirm
title="提示"
description={<div style={{minWidth: 150}}>
<span>{it.type === 'text' ? '文本' : '图片'}?</span>
</div>}
onConfirm={() => handleBlockRemove(idx)}
okText="删除"
cancelText="取消"
>
<span
className="article-action-icon"
title={`删除此${it.type === 'text' ? '文本' : '图片'}`}>
<IconDelete style={{fontSize: 18}}/>
</span>
</Popconfirm>}
<div>
<span onClick={() => handleAddBlock('text', idx + 1)}
className="article-action-icon" title="新增文本"><IconAddText
style={{fontSize: 18}}/></span>
<span onClick={() => handleAddBlock('image', idx + 1)}
className="article-action-icon mt-1" title="新增图片"><IconAddImage
style={{fontSize: 16}}/></span>
</div>
</div>}
</div>
{isFirstTextBlock && <div className={'text-red-500 text-right pr-6 mt-1 text-sm'}></div>}
</div>)
}
)}
{editable && blocks.length == 0 &&
<div style={{minHeight: 80}} className="flex items-center justify-center">
<div className="flex gap-5">
<Button onClick={() => handleAddBlock('text')}></Button>
<Button onClick={() => handleAddBlock('image')}></Button>
</div>
</div>
}
<div>
<div className={clsx(index == 0 ? '' : styles.blockItem, 'flex')}>
<BlockText
onChange={(block) => handleBlockChange(0, block)}
data={blocks[0]}
isFirstBlock={index == 0}
editable={editable}/>
</div>
{index == 0 && <div className="flex items-center text-red-500 justify-between text-sm mt-1">
<div>{errorMessage}</div>
<div></div>
</div>}
</div>
{index > 0 && <ImageList blocks={blocks} editable={editable} onChange={onChange}/>}
</div>
</div>
{editable && <div className="ml-2 flex flex-col justify-between ">
<Popconfirm
title="提示"
description={<div style={{minWidth: 150}}>
<span>?</span>
</div>}
onConfirm={onRemove}
okText="删除"
cancelText="取消"
>
<span className="article-action-icon" title="删除此分组"><IconDelete
style={{fontSize: 24}}/></span>
</Popconfirm>
{
index > 0 ? <Popconfirm
title={<div style={{minWidth: 150}}><span>?</span></div>}
onConfirm={onRemove}
okText="删除"
cancelText="取消"
>
<span className="article-action-icon" title="删除此分组">
<IconDelete style={{fontSize: 24}}/>
</span>
</Popconfirm> : <span></span>
}
<span onClick={onAdd} className="article-action-icon" title="新增分组"><IconAdd
style={{fontSize: 24}}/></span>
</div>}

View File

@ -2,41 +2,61 @@ import {Input, Modal} from "antd";
import ArticleGroup from "@/components/article/group.tsx";
import {useEffect, useState} from "react";
import {useSetState} from "ahooks";
import {getById} from "@/service/api/article.ts";
import * as article from "@/service/api/article.ts";
import {regenerate} from "@/service/api/video.ts";
type Props = {
id?: number;
onClose?: () => void;
type: 'news' | 'video';
onClose?: (saved?: boolean) => void;
}
const DEFAULT_STATE = {
loading: false,
open: false,
msgTitle: '',
msgGroup: '',
error:''
}
export default function ArticleEditModal(props: Props) {
const [groups, setGroups] = useState<BlockContent[][]>([]);
const [title, setTitle] = useState('')
const [state, setState] = useSetState({
loading: false,
open: false
...DEFAULT_STATE
})
// 保存数据
const handleSave = () => {
props.onClose?.()
// if (props.onSave) {
// setState({loading: true})
// props.onSave?.().then(() => {
// setState({loading: false, open: false})
// })
// } else {
// console.log(groups)
// }
setState({error: ''})
if (!title) {
// setState({msgTitle: '请输入标题内容'});
return;
}
if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) {
// setState({msgGroup: '请输入正文文本内容'});
return;
}
const save = props.type == 'news' ? article.save : regenerate
setState({loading: true})
save(title, groups, props.id && 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('')
}
@ -45,13 +65,14 @@ export default function ArticleEditModal(props: Props) {
return (<Modal
title={'编辑文章'}
open={props.id >= 0}
open={!!props.id && props.id >= 0}
maskClosable={false}
keyboard={false}
width={800}
onCancel={props.onClose}
onCancel={()=>props.onClose?.()}
okButtonProps={{loading: state.loading}}
onOk={handleSave}
okText={props.type == 'news' ? '确定' : '重新生成'}
>
<div className="article-title mt-5">
<div className="title">
@ -59,19 +80,28 @@ export default function ArticleEditModal(props: Props) {
<span className="require ml-1 font-bold text-red-500">*</span>
</div>
<div className="box mt-1">
<Input value={title} onChange={e => {
<Input rootClassName={state.msgTitle ? 'border-red-500' : ''} value={title} onChange={e => {
setTitle(e.target.value)
setState({msgTitle: e.target.value ? '' : '请输入标题内容'})
}} placeholder={'请输入文章标题'}/>
</div>
<div className="text-red-500">{state.msgTitle}</div>
</div>
<div className="aricle-body mt-2">
<div className="aricle-body mt-3">
<div className="title">
<span className="text text-base"></span>
<span className="require ml-1 font-bold text-red-500">*</span>
</div>
<div className="box mt-1">
<ArticleGroup editable groups={groups} onChange={list => setGroups(() => list)}/>
<ArticleGroup
errorMessage={state.msgGroup} editable groups={groups}
onChange={list => {
setGroups(() => list)
setState({msgGroup: (list.length == 0 || list[0].length == 0 || !list[0][0].content) ? '请输入正文文本内容' : ''});
}}
/>
</div>
{state.error && <div className="text-red-500">{state.error}</div>}
</div>
</Modal>);
}

View File

@ -1,22 +1,74 @@
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').length == 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;
blocks.sort((a) => a.type == 'text' ? -1 : 1)
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 +86,7 @@ export default function ArticleGroup({groups, editable, onChange}: Props) {
groups[index] = blocks
onChange?.([...groups])
}}
errorMessage={errorMessage}
index={index}
onAdd={() => {
handleAddGroup?.(index + 1)
@ -48,6 +101,7 @@ export default function ArticleGroup({groups, editable, onChange}: Props) {
/>
))}
{groups.length == 0 && editable &&
<ArticleBlock editable onChange={blocks => onChange?.([blocks])} index={0} blocks={[{type:'text',content:''}]}/>}
<ArticleBlock editable onChange={blocks => onChange?.([blocks])} index={0}
blocks={[{type: 'text', content: ''}]}/>}
</div>
}

View File

@ -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} from "@ant-design/icons";
import {clsx} from "clsx";
import styles from './article.module.scss'
import {getOssPolicy} from "@/service/api/common.ts";
import {showToast} from "@/components/message.ts";
import {clsx} from "clsx";
import {IconAddImage} from "@/components/icons";
type Props = {
children?: React.ReactNode;
@ -14,11 +16,15 @@ type Props = {
onChange?: (data: BlockContent) => void;
isFirstBlock?: boolean;
}
type ImageProps = {
onRemove?: () => void;
onlyUpload?: boolean;
} & Props;
const MimeTypes = ['image/jpeg', 'image/png', 'image/jpg']
const Data: { uploadConfig?: TOSSPolicy } = {}
export function BlockImage({data, editable, onChange}: Props) {
export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: ImageProps) {
const [loading, setLoading] = useState<number>(-1)
// oss上传文件所需的数据
@ -48,7 +54,7 @@ export function BlockImage({data, editable, onChange}: Props) {
console.log('onChange', file);
if (file.status == 'done') {
setLoading(-1)
onChange?.({type: 'image', content: Data.uploadConfig?.host + file.url})
onChange?.({type: 'image', content: Data.uploadConfig?.host + '/' + file.url})
} else if (file.status == 'error') {
setLoading(-1)
showToast('上传图片失败,请重试', 'warning')
@ -58,7 +64,15 @@ export function BlockImage({data, editable, onChange}: Props) {
}
//
return <div className={styles.image}>
{editable ? <div>
{editable ? <div className={'relative'}>
{!onlyUpload && <Popconfirm
title={<div style={{minWidth: 150}}><span>?</span></div>}
onConfirm={onRemove}
okText="删除"
cancelText="取消"
>
<span className={styles.imageDelete}><CloseOutlined/></span>
</Popconfirm>}
<Spin spinning={loading >= 0} percent={loading == 0 ? 'auto' : loading}>
<Upload
multiple={false} maxCount={1} data={getUploadData}
@ -73,7 +87,10 @@ export function BlockImage({data, editable, onChange}: Props) {
<span></span>
</div>
</> : <div className={styles.imagePlaceholder}>
<Button></Button>
<div className={'text-center'}>
<IconAddImage className={"text-4xl inline-block"} />
<div className={'text-sm'}></div>
</div>
</div>}
</div>
</Upload>
@ -84,13 +101,14 @@ export function BlockImage({data, editable, onChange}: Props) {
export function BlockText({data, editable, onChange, isFirstBlock}: Props) {
return <div className='flex-1'>
<div className={clsx(styles.text, isFirstBlock?'border-red-400 hover:border-red-500 focus-within:border-red-500':'')}>
<div
className={clsx(styles.text, isFirstBlock ? 'border-red-400 hover:border-red-500 focus-within:border-red-500' : '')}>
{editable ? <div className="relative">
<Input.TextArea
onChange={e => {
onChange?.({type: 'text', content: e.target.value})
}}
placeholder={'请输入文本'} value={data.content} autoSize={{minRows: 3, maxRows: 8}}
placeholder={'请输入文本内容'} value={data.content} autoSize={{minRows: 3, maxRows: 8}}
variant={"borderless"}/>
</div> : <p className="p-2">{data.content}</p>}
</div>

View File

@ -0,0 +1,40 @@
import {BlockImage} from "@/components/article/item.tsx";
import styles from './article.module.scss'
export default function ImageList(props: {
blocks: BlockContent[];
editable?: boolean;
onChange?: (blocks: BlockContent[]) => void;
}) {
// 处理删除
const handleRemove = (index: number) => {
props.onChange?.(props.blocks.filter((_, idx) => index !== idx))
const newBlocks = [...props.blocks]
newBlocks.splice(index, 1)
props.onChange?.(newBlocks)
}
// 处理新增
const handleAdd = (data: BlockContent) => {
props.onChange?.([...props.blocks, data])
}
// 处理更新
const handleUpdate = (index: number, data: BlockContent) => {
props.onChange?.(props.blocks.map((it, idx) => idx === index ? data : it))
}
return (<div className={styles.imageList}>
{props.blocks.map((it, index) => (
it.type === 'image' ? <BlockImage
key={index} data={it} editable={props.editable}
onChange={(data) => handleUpdate(index, data)}
onRemove={() => handleRemove(index)}
/> : null
))}
{props.editable &&
<BlockImage onlyUpload onChange={handleAdd} data={{type: 'image', content: ''}} editable={true}/>}
</div>)
}

View File

@ -0,0 +1,57 @@
import React, {useState} from "react";
import {Button, Modal} from "antd";
import {ButtonType} from "antd/es/button";
import {showErrorToast, showToast} from "@/components/message.ts";
import {BizError} from "@/service/types.ts";
type Props = {
selected: any[],
type?: ButtonType;
emptyMessage: string,
confirmMessage: React.ReactNode,
onProcess: (ids: Id[]) => Promise<any|void>
successMessage?: string;
onSuccess?: () => void;
children?: React.ReactNode
}
/**
*
*/
export default function ButtonBatch(
{
selected, emptyMessage, successMessage, children,
type, confirmMessage, onProcess,onSuccess
}: Props) {
const [loading, setLoading] = useState(false)
const onBatchProcess = async () => {
setLoading(true)
try {
await onProcess(selected)
if (successMessage) showToast(successMessage, 'success')
if (onSuccess) {
onSuccess()
}
} catch (e) {
showErrorToast(e as unknown as BizError)
} finally {
setLoading(false)
}
}
const handleBtnClick = () => {
if (selected.length == 0) {
showToast(emptyMessage, 'warning')
return;
}
Modal.confirm({
title: '操作提示',
centered: true,
content: confirmMessage,
onOk: onBatchProcess
})
}
return (
<Button loading={loading} type={type} onClick={handleBtnClick}>{children}</Button>
)
}

View File

@ -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);
}
}

View File

@ -1,18 +1,35 @@
import ReactPlayer from 'react-player'
import { PauseOutlined, PlayCircleOutlined, FullscreenOutlined, FullscreenExitOutlined } from "@ant-design/icons"
import { Progress } from "antd";
import {useState} from "react";
// import ReactPlayer from 'react-player'
// import {PauseOutlined, PlayCircleOutlined, FullscreenOutlined, FullscreenExitOutlined} from "@ant-design/icons"
// import {Progress} from "antd";
import React, {useEffect, useState} from "react";
import TCPlayer from 'tcplayer.js';
import 'tcplayer.js/dist/tcplayer.min.css';
type State = {
playing: boolean
muted: boolean
end?: boolean
error?: boolean
fullscreen: boolean
progress: number
playedSeconds: number
duration: number
}
type StateUpdate = Partial<State> | ((prev: State) => Partial<State>)
export function Player({ url, cover, simple, showControls }: { url: string; cover?: string; simple?: boolean; showControls?: boolean }) {
type Props = {
url?: string; cover?: string; showControls?: boolean; className?: string;
poster?: string;
onChange?: (state: State) => void;
muted?: boolean;
}
export type PlayerInstance = {
play: (url: string, currentTime: number) => void;
getState: () => State;
}
export const Player = React.forwardRef<PlayerInstance, Props>((props, ref) => {
const [tcPlayer, setTcPlayer] = useState<TCPlayer | null>(null)
const [prevUrl, setPrevUrl] = useState<string | undefined>();
const [state, _setState] = useState<State>({
playing: false,
muted: false,
@ -22,51 +39,82 @@ export function Player({ url, cover, simple, showControls }: { url: string; cove
playedSeconds: 0,
duration: 0
})
const setState = (data: StateUpdate) => {
console.log('playstate change', data)
_setState(prev => {
if (typeof(data) === 'function') return { ...prev, ...data(prev) }
return { ...prev, ...data }
const _state = typeof (data) === 'function' ? {...prev, ...data(prev)} : {...prev, ...data}
props.onChange?.(_state)
return _state
})
}
return <div className="video-player">
{simple ? <div>
<video style={{width:400,height:400}} preload={'metadata'} src={url} poster={cover} controls={showControls}></video>
</div> : <>
<ReactPlayer
url={url}
controls={true}
light={cover}
width="100%"
height="250px"
onPlay={() => setState({ playing: true })}
onEnded={() => setState({ playing: false })}
onPause={() => setState({ playing: false })}
onReady={(_player) => {
setState({duration: _player.getDuration() })
}}
onProgress={(_) => {
setState(_prev=>({
playedSeconds: _.playedSeconds,
progress: Math.floor(_.playedSeconds / _prev.duration * 100)
}))
}}
/>
<div className="video-control p-2 flex items-center gap-2">
<button>
{state.playing ? <PauseOutlined /> : <PlayCircleOutlined />}
</button>
<div className="whitespace-nowrap flex items-center text-sm">
<span>00:00</span>
<span>/</span>
<span>00:00</span>
</div>
<div className="flex-1">
<Progress size="small" percent={state.progress} showInfo={false} />
</div>
<button>
{state.fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
</button>
</div>
</>}
useEffect(()=>{
if(props.url && tcPlayer){
tcPlayer.src(props.url)
}
},[props.url, tcPlayer])
useEffect(() => {
const playerVideo = document.createElement('video');
const playerId = `player-container-${Date.now().toString(16)}`;
playerVideo.setAttribute('id', playerId)
playerVideo.setAttribute('preload', 'auto')
playerVideo.setAttribute('playsInline', 'true')
playerVideo.setAttribute('webkit-playsinline', 'true')
if(props.className) playerVideo.setAttribute('className', props.className)
document.querySelector('.video-player-container-inner')!.appendChild(playerVideo)
const player = TCPlayer(playerId, {
//sources: [{src: props.url}],
controls: props.showControls,
// muted:props.muted,
poster: props.poster,
autoplay: true,
licenseUrl: 'https://license.vod2.myqcloud.com/license/v2/1328581896_1/v_cube.license'
}
)
player.on('pause', () => {
setState({playing: false, end: false, error: false})
})
player.on('playing', () => {
setState({playing: true, end: false, error: false})
})
player.on('ended', () => {
setState({end: true, playing: false, error: false})
})
player.on('error', () => {
setState({end: false, playing: false, error: true})
})
setTcPlayer(() => player)
return () => {
if (tcPlayer) {
tcPlayer.pause()
tcPlayer.unload()
}
}
}, [])
React.useImperativeHandle(ref, () => {
return {
play: (url, currentTime = 0) => {
console.log('play', url, currentTime)
if (!tcPlayer) return;
const player = tcPlayer
if (prevUrl == url) {
player.currentTime(0)
} else {
player.src(url)
}
player.play()
setPrevUrl(url)
if (currentTime > 0) {
player.currentTime(currentTime)
}
},
getState: () => state
}
})
return <div className={`video-player relative ${props.className} video-player-container-inner`}>
</div>
}
})

View File

@ -2,13 +2,15 @@ import {useSortable} from "@dnd-kit/sortable";
import {useSetState} from "ahooks";
import React, {useEffect} from "react";
import {clsx} from "clsx";
import {CheckCircleFilled, MenuOutlined, MinusCircleFilled} from "@ant-design/icons";
import {IconEdit, IconPlay} from "@/components/icons";
import {Popconfirm} from "antd";
import {CheckCircleFilled, MenuOutlined, MinusCircleFilled,LoadingOutlined} from "@ant-design/icons";
import ImageCover from '@/assets/images/cover.png'
import {IconEdit, IconPlay} from "@/components/icons";
import {VideoStatus} from "@/service/api/video.ts";
type Props = {
video: VideoInfo,
video: VideoInfo | LiveVideoInfo,
editable?: boolean;
sortable?: boolean;
index?: number;
@ -19,13 +21,15 @@ type Props = {
onEdit?: () => void;
onRemove?: () => void;
id: number;
className?: string;
type?:'live'|'create'
}
export const VideoListItem = (
{
index, id, video, onPlay, onRemove, checked,
id, video, onPlay, onRemove, checked,
onCheckedChange, onEdit, active, editable,
className, sortable,type
}: Props) => {
const {
attributes, listeners,
@ -39,27 +43,34 @@ export const VideoListItem = (
}, [checked])
return <div
className={'video-item flex items-center gap-3 mb-5'}
className={`video-item flex items-center gap-3 ${className}`}
ref={setNodeRef} style={{transform: `translateY(${transform?.y || 0}px)`,}}>
{index && index > 0 && <div className="flex items-center px-2">
<div
className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{id}</div>
</div>}
{/*{index && index > 0 && <div className="flex items-center px-2">*/}
{/* <div className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{index}</div>*/}
{/*</div>}*/}
<div
className={`video-item-info flex gap-2 flex-1 bg-gray-100 min-h-[40px] rounded-lg p-3 shadow-blue-500 ${active ? 'video-item-shadow' : ''}`}>
<div className={'video-title leading-7 flex-1'}>{video.title}</div>
<div className={'video-item-cover'}>
<img className="w-[100px] rounded-md" src={video.cover || ''} alt={video.title}/>
className={`video-item-info flex gap-2 flex-1 bg-gray-100 h-[80px] overflow-hidden rounded-lg p-3 shadow-blue-500 ${active ? 'video-item-shadow' : ''}`}>
<div className={'video-title leading-7 flex-1'}>{video.title || video.video_title}</div>
<div className={'video-item-cover bg-white rounded-md overflow-hidden'}>
<img className="w-[100px] h-[56px] object-cover" src={video.cover || ImageCover} />
</div>
</div>
{editable &&
<div className="operation flex items-center ml-2 gap-3 text-lg text-gray-400">
{!active ? <button className="hover:text-blue-500 cursor-move" {...attributes} {...listeners}>
<MenuOutlined/>
</button> : <button disabled className="cursor-not-allowed"><MenuOutlined/></button>}
{onPlay &&
<button className="hover:text-blue-500" onClick={onPlay} style={{fontSize: '1.3em'}}><IconPlay/>
</button>}
<div className="operation flex items-center ml-2 gap-3 text-lg text-gray-400">
{sortable && (!active ? <button className="hover:text-blue-500 cursor-move" {...attributes} {...listeners}>
<MenuOutlined/>
</button> : <button disabled className="cursor-not-allowed"><MenuOutlined/></button>)}
{onPlay &&
<>
{(
type == 'create' && video.status == VideoStatus.Generating
) ? <button title="视频生成中" className="flex items-center justify-center">
<LoadingOutlined className="block text-gray-500" style={{fontSize: '0.85em'}}/>
</button>: <button className="hover:text-blue-500" onClick={onPlay} style={{fontSize: '1.3em'}}><IconPlay/>
</button>
}
</>}
{editable && <>
{onEdit &&
<button className="hover:text-blue-500" onClick={onEdit} style={{fontSize: '1.1em'}}><IconEdit/>
</button>}
@ -71,15 +82,14 @@ export const VideoListItem = (
}
}}><CheckCircleFilled className={clsx({'text-blue-500': state.checked})}/></button>
{onRemove && <Popconfirm
title="提示"
description={<div style={{minWidth: 150}}><span>?</span></div>}
title={<div style={{minWidth: 150}}><span>?</span></div>}
onConfirm={onRemove}
okText="删除"
cancelText="取消"
>
<button className="hover:text-blue-500"><MinusCircleFilled/></button>
</Popconfirm>}
</div>
}
</>}
</div>
</div>
}

View File

@ -2,19 +2,10 @@ import React, {createContext, useEffect, useReducer} from "react";
import Loader from "@/components/loader";
import {getAuthToken, setAuthToken} from "@/hooks/useAuth.ts";
import {auth, getUserInfo} from "@/service/api/user.ts";
import {auth} from "@/service/api/user.ts";
const UserRoleStorageKey = 'user-current-role';
function getCurrentRole() {
return (localStorage.getItem(UserRoleStorageKey)) as UserRole
}
export function setCurrentRole(role: UserRole) {
localStorage.setItem(UserRoleStorageKey, role)
}
function removeRoleStorage() {
localStorage.removeItem(UserRoleStorageKey)
}
@ -87,7 +78,7 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
}
// 登出
const logout = async () => {
setAuthToken(null)
setAuthToken(null,null,-1)
removeRoleStorage()
dispatch({
action: 'logout',

View File

@ -18,7 +18,7 @@ const clearAuth = () => {
localStorage.removeItem(AppConfig.AUTH_TOKEN_KEY);
localStorage.removeItem(AppConfig.AUTHED_PERSON_DATA_KEY);
}
export const setAuthToken = (token: string | null,profileData:UserProfile, expiry_time = -1) => {
export const setAuthToken = (token: string | null,profileData:UserProfile|null, expiry_time = -1) => {
if (!token) {
clearAuth();
return;

View File

@ -1,4 +1,3 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'

View File

@ -1,139 +0,0 @@
import {Button, message, Modal} from "antd";
import React, {useEffect, useRef, useState} from "react";
import {ArticleGroupList, MockVideoDataList} from "@/_local/mock-data";
import {DndContext} from "@dnd-kit/core";
import {arrayMove, SortableContext} from "@dnd-kit/sortable";
import {VideoListItem} from "@/components/video/video-list-item.tsx";
import ArticleEditModal from "@/components/article/edit-modal.tsx";
import {useSetState} from "ahooks";
import {CheckCircleFilled} from "@ant-design/icons";
import {clsx} from "clsx";
import {getList} from "@/service/api/video.ts";
export default function CreateIndex() {
const [editNews, setEditNews] = useSetState<{
title?: string;
groups?: ArticleContentGroup[];
}>({})
const [videoData, setVideoData] = useState<VideoInfo[]>([])
useEffect(() => {
getList({}).then((ret) => {
setVideoData(ret.list)
})
}, [])
const [modal, contextHolder] = Modal.useModal()
const videoRef = useRef<HTMLVideoElement | null>(null)
const [state, setState] = useSetState({
checkedAll: false
})
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
const processDeleteVideo = async (_idArray: number[]) => {
message.info('删除成功!!!' + _idArray.join(''));
}
const handleDeleteBatch = () => {
modal.confirm({
title: '提示',
content: '是否要删除选择的视频?',
onOk: () => processDeleteVideo(checkedIdArray)
})
}
const playVideo = (video: VideoInfo) => {
console.log('play', video)
if (videoRef.current) {
videoRef.current!.src = video.play_url
}
}
const handleAllCheckedChange = () => {
// setVideoData(list=>{
// list.map(s=>{
// s.checked = !state.checkedAll
// })
// return list
// })
setCheckedIdArray(state.checkedAll ? [] : videoData.map(v => v.id))
setState({
checkedAll: !state.checkedAll
})
}
return (<div className="container py-10 page-live">
{contextHolder}
<div className="flex">
<div className="video-list-container bg-white p-10 rounded flex-1">
<div className="live-control flex justify-between mb-8">
<div className="pl-[70px]">
<span>视频时长: 00:00:29</span>
</div>
<div className="flex gap-2">
<span className="cursor-pointer" onClick={handleDeleteBatch}></span>
<button className="ml-5 hover:text-blue-300 text-gray-400 text-lg"
onClick={handleAllCheckedChange}>
<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>
</button>
</div>
</div>
<DndContext onDragEnd={(e) => {
const {active, over} = e;
if (over && active.id !== over.id) {
let oldIndex = -1, newIndex = -1;
const originArr = [...videoData]
setVideoData((items) => {
oldIndex = items.findIndex(s => s.id == active.id);
newIndex = items.findIndex(s => s.id == over.id);
return arrayMove(items, oldIndex, newIndex);
});
modal.confirm({
title: '提示',
content: '是否要移动到指定位置',
onCancel: () => {
setVideoData(originArr);
}
})
}
}}>
<SortableContext items={videoData}>
{videoData.map((v, index) => (
<VideoListItem
video={v}
index={index + 1}
id={v.id}
key={index}
checked={checkedIdArray.includes(v.id)}
onCheckedChange={(checked) => {
setCheckedIdArray(idArray => {
const newArr = checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
setState({checkedAll: newArr.length == videoData.length})
return newArr;
})
}}
onPlay={() => playVideo(v)}
onEdit={() => {
setEditNews({title: v.title, groups: [...ArticleGroupList]})
}}
editable
/>))}
</SortableContext>
</DndContext>
<div className="text-right mt-10">
<Button type="primary"></Button>
</div>
</div>
<div className="video-player-container ml-8 w-[400px] flex flex-col">
<div className="text-center text-base mt-10"></div>
<div className="video-player flex items-center justify-center flex-1">
<div className=" rounded overflow-hidden">
<video ref={videoRef} controls autoPlay className="w-full bg-white min-w-[360px]"></video>
</div>
</div>
</div>
</div>
<ArticleEditModal title={editNews.title} groups={editNews.groups}/>
</div>)
}

View File

@ -3,44 +3,33 @@ import {useSetState} from "ahooks";
import {PlayCircleOutlined} from "@ant-design/icons";
import {SearchListTimes} from "@/pages/news/components/news-source.ts";
type SearchParams = {
keywords?: string;
date?: string;
}
type Props = {
onSearch?: (params: SearchParams) => Promise<void>;
onSearch?: (params: VideoSearchParams) => void;
onBtnStartClick?: () => Promise<void>;
loading?:boolean;
}
export default function SearchForm({onSearch, onBtnStartClick}: Props) {
export default function SearchForm({onSearch, onBtnStartClick,loading}: Props) {
const [state, setState] = useSetState<{
timeRange: string;
keywords: string;
searching: boolean;
time: string;
}>({
keywords: "", searching: false, timeRange: "", time: '-1'
})
const onFinish = (values: any) => {
setState({searching: true})
pushing?: boolean;
}>({})
const onFinish = (values) => {
onSearch?.({
keywords: values.keywords,
date: values.timeRange.join('-'),
}).finally(() => {
setState({searching: false})
...values,
pagination: {page: 1, limit: 10}
})
//console.log(values)
}
return (<div className={'search-panel'}>
<div className="flex justify-between items-center">
<div className="search-form">
<Form className={""} layout="inline" onFinish={onFinish}>
<Form.Item name="keywords">
<Input className="w-[200px]" placeholder={'请输入搜索信息'}/>
<Form<VideoSearchParams> className={""} layout="inline" onFinish={onFinish} initialValues={{title:'',time_flag:0}}>
<Form.Item name="title">
<Input className="w-[200px]" allowClear placeholder={'请输入搜索信息'}/>
</Form.Item>
<Form.Item label={'更新时间'} name="date" className="w-[250px]">
<Form.Item label={'更新时间'} name="time_flag" className="w-[250px]">
<Select
defaultValue={state.time} options={SearchListTimes}
options={SearchListTimes}
optionRender={(option) => (
<div className="flex items-center">
<span role="icon" className={`radio-icon`}></span>
@ -49,20 +38,16 @@ export default function SearchForm({onSearch, onBtnStartClick}: Props) {
)}
/>
</Form.Item>
{/*<Form.Item label={'更新时间'} name="timeRange">*/}
{/* <DatePicker.RangePicker />*/}
{/*</Form.Item>*/}
{/*<Form.Item>*/}
{/* <Space size={10}>*/}
{/* <Button type={'primary'} htmlType={'submit'}>搜索</Button>*/}
{/* <Button htmlType={'reset'}>重置</Button>*/}
{/* </Space>*/}
{/*</Form.Item>*/}
<Form.Item>
<Space size={10}>
<Button loading={loading} type={'primary'} htmlType={'submit'}></Button>
</Space>
</Form.Item>
</Form>
</div>
<Space size={10}>
<Button
loading={state.searching} type={'primary'}
loading={state.pushing} type={'primary'}
onClick={onBtnStartClick} icon={<PlayCircleOutlined/>}
></Button>
</Space>

View File

@ -1,43 +1,88 @@
import {Button, Modal} from "antd";
import {Button, Input, Modal} from "antd";
import {saveAs} from "file-saver";
import {useEffect, useState} from "react";
import {useSetState} from "ahooks";
import {Player} from "@/components/video/player.tsx";
import { ArticleGroupList } from "@/_local/mock-data";
import ArticleGroup from "@/components/article/group";
import * as article from "@/service/api/article.ts";
import {push2room} from "@/service/api/video.ts";
import {showErrorToast, showToast} from "@/components/message.ts";
type Props = {
video?: VideoInfo;
onClose?: () => void
}
export default function VideoDetail({video, onClose}: Props) {
const startStream = () => {
const [groups, setGroups] = useState<BlockContent[][]>([]);
const [state, setState] = useSetState({
exporting: false,
pushing: false,
})
// 将视频推送到数字人直播间
const pushToRoom = () => {
if (video) {
if (state.pushing) return
setState({pushing: true})
push2room([video.id]).then(() => {
showToast('一键推流成功,已推流至数字人直播间,请查看!', 'success')
}).catch(showErrorToast).finally(() => {
setState({pushing: false})
})
}
}
// 下载视频
const downloadVideo = () => {
if (video?.oss_video_url) {
const filename = video.oss_video_url.split('/').pop() || `${video.id}.flv`
saveAs(video.oss_video_url, filename)
}
}
useEffect(() => {
if (video) {
if (video.id > 0) {
article.getById(video.id).then(res => {
setGroups(res.content_group)
})
}
}
}, [video])
return (<>
<Modal open={!!video} title="新闻视频详情" width={1000} footer={null} onCancel={onClose}>
<div className="flex gap-2 my-5">
<div className="news-video w-[350px]">
<div className="video-container bg-gray-100 rounded overflow-hidden h-[400px]">
<Player url={'https://file.wx.wm-app.xyz/os/media/ymca.mp4'} simple showControls />
<div className="video-container bg-gray-100 rounded overflow-hidden h-[640px]">
<Player url={video?.oss_video_url} poster={video?.cover} showControls={true}
className="w-[360px] h-[640px] bg-white"/>
</div>
<div className="video-info text-right text-sm text-gray-600 mt-3">
<span>创建时间: 5小时前</span>
</div>
</div>
<div className="detail flex-1 ml-5">
<div className="title">
<span>标题: xxxxxxxx</span>
<div className="text-lg"></div>
<div className="article-title mt-5 items-center flex">
<span className="text text-base"></span>
<span className="ml-4 flex-1">
<Input value={video?.title}/>
</span>
</div>
<div className="content-container max-h-[500px] overflow-auto pr-1 mt-4">
<ArticleGroup groups={ArticleGroupList}/>
<div className="aricle-body mt-3">
<div className="title">
<span className="text text-base"></span>
</div>
<div className="box mt-1">
<ArticleGroup groups={groups}/>
</div>
</div>
</div>
</div>
<div className="footer flex justify-between">
<div className="action flex gap-2">
<Button type="primary" onClick={startStream}></Button>
<Button loading={state.pushing} type="primary" onClick={pushToRoom}></Button>
<Button onClick={downloadVideo}></Button>
</div>
<div className="close">

View File

@ -4,6 +4,8 @@ import {useState} from "react";
import ImageCover from './cover.png'
import {formatDuration, timeFromNow} from "@/util/strings.ts";
import dayjs from "dayjs";
type VideoItemProps = {
videoInfo: VideoInfo;
@ -32,13 +34,13 @@ export default function VideoItem(props: VideoItemProps) {
<Image className={'w-full cursor-pointer'} preview={false} src={ImageCover}/>
</div>
<div className="text-sm py-2 px-3">
<div className="title my-1 cursor-pointer" onClick={props.onClick}></div>
<div className="title my-1 cursor-pointer" onClick={props.onClick}>{props.videoInfo.title}</div>
<div className="info flex justify-between gap-2 text-sm">
<div className="video-time-info text-gray-500">
<span>时长: 2年半</span>
<span className="ml-1">16</span>
<span>: {formatDuration(Math.ceil(props.videoInfo.duration / 1000))}</span>
<span className="ml-1">{timeFromNow(props.videoInfo.publish_time)}</span>
</div>
{props.onLive && <div className="live-info">
{props.videoInfo.status == 3 && <div className="live-info">
<Tag color="processing" className="mr-0"></Tag>
</div>}
</div>

View File

@ -1,17 +1,24 @@
import {useState} from "react";
import {Modal, Pagination} from "antd";
import {Empty, Modal, Pagination} from "antd";
import {useRequest} from "ahooks";
import VideoItem from "@/pages/library/components/video-item.tsx";
import SearchForm from "@/pages/library/components/search-form.tsx";
import VideoDetail from "@/pages/library/components/video-detail.tsx";
import {getList} from "@/service/api/video.ts";
import {search} from "@/service/api/video.ts";
export default function LibraryIndex() {
const [modal, contextHolder] = Modal.useModal();
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
const {data} = useRequest(()=>getList({}),{
const [params, setParams] = useState<VideoSearchParams>({
time_flag: 0,
pagination: {
page: 1,
limit: 10
}
})
const {data,loading} = useRequest(() => search(params), {
refreshDeps: [params]
})
const handleRemove = (video: VideoInfo) => {
modal.confirm({
@ -39,9 +46,9 @@ export default function LibraryIndex() {
{contextHolder}
<div className="search-form-container mb-5">
<SearchForm
onSearch={async () => {
}}
onSearch={setParams}
onBtnStartClick={handleLive}
loading={loading}
/>
</div>
<div className="bg-white rounded p-5">
@ -61,10 +68,24 @@ export default function LibraryIndex() {
))}
</div>
<div className="video-page-container flex justify-center mt-5">
<Pagination defaultCurrent={1} total={50}/>
{data?.pagination && data?.pagination.total > 0 ? <div className="flex justify-center mt-10">
<Pagination
current={params.pagination.page}
total={data?.pagination.total}
pageSize={data?.pagination.limit}
showSizeChanger={false}
simple={true}
rootClassName={'simple-pagination'}
onChange={(page) => setParams(prev=>({...prev,pagination: {page, limit: 10}}))}
/>
</div> : <div className="py-10">
<Empty />
</div>
}
{/*<Pagination defaultCurrent={1} total={50}/>*/}
</div>
</div>
</div>
<VideoDetail video={detailVideo} onClose={() => setDetailVideo(undefined)}/>
{detailVideo && <VideoDetail video={detailVideo} onClose={() => setDetailVideo(undefined)}/>}
</>)
}

View File

@ -1,122 +0,0 @@
import React, {useState} from 'react';
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
useSortable, arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
type TestData = {
id: number;
}
type SortableItemProps = {
data: TestData;
active?: boolean;
id: number;
}
function SortableItem(props: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
resizeObserverConfig: {},
id: props.data.id
});
return (
<div ref={setNodeRef} style={{
transform: `translateY(${transform ? transform?.y:0}px)`,
transition,
// marginTop:10,
// marginBottom:10
}} className={props.active ? 'drop-shadow shadow-blue-400 drop-shadow-md' : ''}>
<div className="h-[100px] mb-5 border p-5 rounded bg-white flex justify-between items-center">
<div className="flex-1">
<div>
{JSON.stringify(props.data)}
</div>
<div>{JSON.stringify(transform)}</div>
</div>
<button {...attributes} {...listeners} className="cursor-move">Move</button>
</div>
</div>
);
}
export function SortDemo() {
const [items, setItems] = useState<TestData[]>([
{id: 1},
{id: 2},
{id: 3},
{id: 4},
{id: 5},
]);
const [activeId, setActiveId] = useState<number>();
function handleDragEnd(event) {
const {active, over} = event;
setActiveId(undefined)
console.log(JSON.stringify({
items,
active: active.id,
over: over.id,
}))
if (active.id !== over.id) {
setItems((items) => {
const oldIndex = items.findIndex(s=>s.id == active.id);
const newIndex = items.findIndex(s=>s.id == over.id);
const _newArr = arrayMove(items, oldIndex, newIndex);
console.log(JSON.stringify({
_newArr,
items,
oldIndex,
newIndex
}))
return _newArr;
});
}
}
// const sensors = useSensors(
// useSensor(PointerSensor),
// useSensor(KeyboardSensor, {
// coordinateGetter: sortableKeyboardCoordinates,
// })
// );
return (
<div>
<div>{JSON.stringify(items)}</div>
<DndContext
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
onDragStart={e => {
if (e.active && e.active.id) {
setActiveId(Number(e.active.id))
}
}}
>
<SortableContext
items={items}
>
{items.map(it => <SortableItem active={it.id == activeId} key={it.id} data={it} id={it.id}/>)}
</SortableContext>
</DndContext>
</div>
);
}

View File

@ -1,35 +1,161 @@
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";
import {Player, PlayerInstance} from "@/components/video/player.tsx";
const cache: { flvPlayer?: FlvJs.Player, timerPlayNext?: any, timerLoadState?: any, prevUrl?: string } = {}
export default function LiveIndex() {
const [videoData, setVideoData] = useState<VideoInfo[]>()
const videoRef = useRef<HTMLVideoElement | null>(null)
const player = useRef<PlayerInstance | null>(null)
const [videoData, setVideoData] = useState<LiveVideoInfo[]>([])
const [modal, contextHolder] = Modal.useModal()
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
const [editable,setEditable] = useState<boolean>(false)
const processDeleteVideo = async (_idArray: number[]) => {
message.info('删除成功!!!' + _idArray.join(''));
const [editable, setEditable] = useState<boolean>(false)
const [state, setState] = useSetState({
activeIndex: -1,
muted: true,
})
const activeIndex = useRef(state.activeIndex)
useEffect(() => {
activeIndex.current = state.activeIndex
}, [state.activeIndex])
const showVideoItem = (index: number) => {
// 找到对应video item 并显示在视图可见区域
const container = document.querySelector('.live-video-list-sort-container')
const item = document.querySelector(`.list-item-${index}`)
if (item && container) {
// 获取容器数据
const containerRect = container.getBoundingClientRect()
// 获取对应item的数据
const rect = item.getBoundingClientRect()
// 计算对应item需要在容器中滚动的距离
const scrollDistance = rect.top - containerRect.top
// 设置滚动高度
container.scrollTo({
top: index == 0 ? 0 : container.scrollTop + scrollDistance - 10,
behavior: 'smooth'
})
}
}
const handleDeleteBatch = () => {
modal.confirm({
title: '提示',
content: '是否要删除选择的视频?',
onOk: () => processDeleteVideo(checkedIdArray)
})
const activeToNext = (index?: number) => {
const endToFirst = index != undefined && index > -1 ? false : activeIndex.current >= videoData.length - 1
const _activeIndex = index != undefined && index > -1 ? index : (endToFirst ? 0 : activeIndex.current + 1)
setState({activeIndex: _activeIndex})
if (endToFirst) {
showToast('即将播放第一条视频');
}
// 找到对应video item 并显示在视图可见区域
showVideoItem(_activeIndex)
return _activeIndex;
}
const playVideo = (video: LiveVideoInfo, liveState: LiveState) => {
if (player.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;
}
player.current?.play(video.video_oss_url, playedTime)
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: Id[]) => {
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 + Math.ceil(v.video_duration / 1000), 0);
}, [videoData])
return (<div className="container py-10 page-live">
{contextHolder}
@ -37,67 +163,82 @@ export default function LiveIndex() {
<div className="video-player-container mr-8 flex flex-col">
<div className="text-center text-base"></div>
<div className="video-player flex justify-center flex-1 mt-5">
<div className=" rounded overflow-hidden w-[360px] h-[700px]">
<iframe src="https://fm.gachafun.com/" className="border-0 w-full h-full max-h-full"></iframe>
<div className="live-player relative rounded overflow-hidden w-[360px] h-[636px]"
style={{backgroundColor: 'hsl(210, 100%, 48%)'}}>
<Player ref={player} className="w-[360px] h-[636px] bg-white" muted={true}/>
</div>
</div>
<div className="mt-4 text-center text-sm">
<span>: {formatDuration(totalDuration)}</span>
</div>
</div>
<div className="video-list-container flex-1">
<div className=" bg-white py-8 px-6 rounded py-1">
<div className="live-control flex justify-between mb-8">
{editable ?<>
<div className=" bg-white py-8 px-6 rounded">
<div className="live-control flex justify-between mb-4">
{editable ? <>
<div className="flex gap-2">
<Button type="primary" onClick={handleConfirm}></Button>
<Button onClick={()=>setEditable(false)}>退</Button>
<Button onClick={handleCancelConfirm}>退</Button>
</div>
<div>
<span className="cursor-pointer" onClick={handleDeleteBatch}></span>
</div>
</>: <div>
<Button type="primary" onClick={()=>setEditable(true)}></Button>
</> : <div>
<Button type="primary" onClick={() => setEditable(true)}></Button>
</div>}
{!editable && <div>
<ButtonBatch
selected={checkedIdArray}
emptyMessage={`请选择要删除的新闻视频`}
confirmMessage={`是否删除当前的${checkedIdArray.length}个新闻视频?`}
onSuccess={loadList}
onProcess={processDeleteVideo}
></ButtonBatch>
</div>}
</div>
<div className="live-video-list-sort-container">
<div className="flex">
<div className="sort-number-container mr-2">
{videoData.map((v, index) => (
<div key={index} className="flex items-center px-2 h-[80px] mt-3 mb-2">
<div
className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{index + 1}</div>
</div>
))}
</div>
<div className="sort-list-container flex-1">
<DndContext onDragEnd={(e) => {
const {active, over} = e;
if (over && active.id !== over.id) {
let oldIndex = -1, newIndex = -1;
setVideoData((items) => {
oldIndex = items.findIndex(s => s.id == active.id);
newIndex = items.findIndex(s => s.id == over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
}}>
<SortableContext items={videoData}>
{videoData.map((v, index) => (
<VideoListItem
video={v}
index={index + 1}
id={v.id}
key={index}
active={state.activeIndex == index}
className={`list-item-${index} mt-3 mb-2`}
checked={checkedIdArray.includes(v.id)}
onCheckedChange={(checked) => {
setCheckedIdArray(idArray => {
return checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
})
}}
onRemove={() => processDeleteVideo([v.id])}
editable={!editable}
sortable={editable}
/>))}
</SortableContext>
</DndContext>
</div>
</div>
</div>
<DndContext onDragEnd={(e) => {
const {active, over} = e;
if (over && active.id !== over.id) {
let oldIndex = -1, newIndex = -1;
const originArr = [...videoData]
setVideoData((items) => {
oldIndex = items.findIndex(s => s.id == active.id);
newIndex = items.findIndex(s => s.id == over.id);
return arrayMove(items, oldIndex, newIndex);
});
modal.confirm({
title: '提示',
content: '是否要移动到指定位置',
onCancel: () => {
setVideoData(originArr);
},
onOk: () => {
setVideoData([...videoData])
}
})
}
}}>
<SortableContext items={videoData}>
{videoData.map((v, index) => (
<VideoListItem
video={v}
index={index + 1}
id={v.id}
active={index == 0}
key={index}
onCheckedChange={(checked) => {
setCheckedIdArray(idArray => {
return checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
})
}}
onRemove={() => processDeleteVideo([v.id])}
editable={editable}
/>))}
</SortableContext>
</DndContext>
</div>
</div>
</div>

View File

@ -0,0 +1,3 @@
.videoListContainer{
}

View File

@ -4,63 +4,63 @@ import React, {useEffect, useMemo} from "react";
const prevSelectValues: Id[][] = [];
function buildValues(options: OptionItem[], selectedValues: Id[][], allValue = -1) {
const values: Id[][] = []
selectedValues.forEach(item => {
if (item.length === 0) {
return;
}
if (item.length == 1) {
if (item[0] == allValue) {
values.push([allValue]);
return;
}
// 只有1个值 表示选择了一级分类下的所有二级分类
const op = options.find(option => option.value === item[0]);
if (!op || !op.children || op.children.length === 0) {
// 没有找到或者没有二级分类
return;
}
// 只有一级分类
op.children.forEach(child => {
values.push([item[0], child.value]);
})
return;
}
values.push(item)
})
return values
}
// function buildValues(options: OptionItem[], selectedValues: Id[][], allValue = -1) {
// const values: Id[][] = []
// selectedValues.forEach(item => {
// if (item.length === 0) {
// return;
// }
// if (item.length == 1) {
// if (item[0] == allValue) {
// values.push([allValue]);
// return;
// }
// // 只有1个值 表示选择了一级分类下的所有二级分类
// const op = options.find(option => option.value === item[0]);
// if (!op || !op.children || op.children.length === 0) {
// // 没有找到或者没有二级分类
// return;
// }
// // 只有一级分类
// op.children.forEach(child => {
// values.push([item[0], child.value]);
// })
// return;
// }
// values.push(item)
// })
// return values
// }
// 获取两个数组的差集
function getValuesDiff(values: Id[][], prevValues: Id[][]) {
if (values.length != prevValues.length) {
const moreItems = values.length > prevValues.length ? values : prevValues;
const lessItems = values.length > prevValues.length ? prevValues : values;
const lessItemsKeys = lessItems.map(s => s.join('-'));
for (let i = 0; i < moreItems.length; i++) {
const item = moreItems[i], index = lessItemsKeys.indexOf(item.join('-'));
if (index === -1) {
return item;
}
}
}
return null;
}
function getAllValue(options: OptionItem[]) {
const values: Id[][] = []
options.forEach(option => {
if (option.children && option.children.length > 0) {
option.children.forEach(child => {
values.push([option.value, child.value]);
})
} else {
values.push([option.value]);
}
})
return values
}
// function getValuesDiff(values: Id[][], prevValues: Id[][]) {
// if (values.length != prevValues.length) {
// const moreItems = values.length > prevValues.length ? values : prevValues;
// const lessItems = values.length > prevValues.length ? prevValues : values;
// const lessItemsKeys = lessItems.map(s => s.join('-'));
// for (let i = 0; i < moreItems.length; i++) {
// const item = moreItems[i], index = lessItemsKeys.indexOf(item.join('-'));
// if (index === -1) {
// return item;
// }
// }
// }
// return null;
// }
//
// function getAllValue(options: OptionItem[]) {
// const values: Id[][] = []
// options.forEach(option => {
// if (option.children && option.children.length > 0) {
// option.children.forEach(child => {
// values.push([option.value, child.value]);
// })
// } else {
// values.push([option.value]);
// }
// })
// return values
// }
export default function ArticleCascader(props: {
options: OptionItem[];
@ -71,9 +71,9 @@ export default function ArticleCascader(props: {
// 清除上一次的选中值
prevSelectValues.length = 0;
}, [])
const allOptionValue = useMemo(() => {
return getAllValue(props.options)
}, [props.options])
// const allOptionValue = useMemo(() => {
// return getAllValue(props.options)
// }, [props.options])
const setSelectValues = (value: Id[][]) => {
_setSelectValues(value)

View File

@ -2,36 +2,86 @@ import {Button} from "antd";
import JSZip from "jszip"
import {saveAs} from "file-saver";
import {useState} from "react";
import {getById} from "@/service/api/news.ts";
import {showToast} from "@/components/message.ts";
/**
*
* @param ids
*/
function getAllNewsContent(ids: Id[]) {
return new Promise<NewsInfo[]>((resolve, reject) => {
const request = ids.map((id) => getById(id))
Promise.all(request).then(res => {
resolve(res)
}).catch(err => {
reject(err)
})
})
}
/**
* html
* @param news
*/
function getNewsHtml(news: NewsInfo) {
return `<html>
<head>
<title>${news.title}</title>
<meta charset="UTF-8">
</head>
<body>
<div style="max-width: 80%;width:1000px;margin:30px auto;">
<h1>${news.title}</h1>
<div style="margin: 5px 0;font-size: 13px;">
<span style="color: #766DF4">${news.media_name}</span>
<span style="color: #999;margin-left: 10px;">${news.publish_time}</span>
</div>
<div>${news.content}</div>
</div>
</body>
</html>`
}
/**
* html并打包下载
* @param list
*/
async function downloadAsZip(list: NewsInfo[]) {
const zip = new JSZip();
list.forEach(news => {
zip.file(`${news.title}.html`, getNewsHtml(news))
})
const content = await zip.generateAsync({type: "blob"});
saveAs(content, "news.zip");
// .then(function (content) {
//
// }).finally(() => {
// setLoading(false)
// });
}
export default function ButtonNewsDownload(props: { ids: Id[] }) {
const [loading, setLoading] = useState(false)
const onDownloadClick = (ids: Id[]) => {
const onDownloadClick = async (ids: Id[]) => {
if (props.ids.length === 0) {
showToast('请选择要推送的新闻', 'warning')
showToast('请选择要下载的新闻', 'warning')
return
}
setLoading(true)
const zip = new JSZip();
ids.forEach(id => {
zip.file(`${id}.html`, `<html>
<head>
<title>${id}</title>
</head>
<body>
<div style="max-width: 90%;width:1000px;margin:30px auto;">
<h1>title ${id}</h1>
<p>content ${id}</p>
</div>
</body>
</html>`)
})
zip.generateAsync({type: "blob"}).then(function (content) {
saveAs(content, "news.zip");
}).finally(() => {
try {
const list = await getAllNewsContent(ids)
await downloadAsZip(list)
} catch (e) {
showToast('下载新闻失败,请重试!', 'error')
} finally {
setLoading(false)
});
}
}
return (
<Button loading={loading} onClick={() => onDownloadClick(props.ids)}></Button>

View File

@ -1,26 +1,27 @@
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[];onSuccess?:()=>void; }) {
const [loading, setLoading] = useState(false)
const handlePush = () => {
setLoading(true)
push2article(props.ids).then(()=>{
push2video(props.ids).then(() => {
showToast('一键推流成功,已成功推入数字人视频生成,请前往数字人视频生成页面查看!', 'success')
}).finally(()=>{
props.onSuccess?.()
}).catch(showErrorToast).finally(() => {
setLoading(false)
})
}
const onPushClick = ()=>{
const onPushClick = () => {
if (props.ids.length === 0) {
showToast('请选择要开播的新闻', 'warning')
return
}
Modal.confirm({
title:'操作提示',
title: '操作提示',
content: '是否确定一键开播选中新闻?',
onOk: handlePush
})

View File

@ -11,6 +11,11 @@ type SearchPanelProps = {
const pagination = {
limit: 10, page: 1
}
const DEFAULT_STATE = {
tag_level_1_id: -1,
tag_level_2_id: -1,
subOptions: []
}
export default function SearchPanel({onSearch}: SearchPanelProps) {
const tags = useArticleTags();
const [params, setParams] = useSetState<ApiArticleSearchParams>({
@ -18,36 +23,26 @@ export default function SearchPanel({onSearch}: SearchPanelProps) {
});
const [state, setState] = useSetState<{
source: string | number;
tag_level_1_id: number;
tag_level_2_id: number;
subOptions: (string | number)[]
}>({
source: -1,
subOptions: []
})
}>({...DEFAULT_STATE})
// 二级分类
const [subOptions, setSubOptions] = useState<OptionItem[]>([])
const onFinish = () => {
if(state.source != -1){
params.tags = [];
state.subOptions.forEach(level2 => {
params.tags!.push({
level1: state.source,
level2
})
})
}else{
params.tags = undefined;
}
onSearch?.({
...params
...params,
tag_level_1_id: state.tag_level_1_id > 0?state.tag_level_1_id:undefined,
tag_level_2_id: state.tag_level_2_id > 0?state.tag_level_2_id:undefined,
pagination
})
}
// 重置
const onReset = () => {
setParams({pagination, title: ''})
setState({source: -1,subOptions: []})
setState({...DEFAULT_STATE})
setSubOptions([])
onSearch?.({pagination})
}
@ -84,37 +79,31 @@ export default function SearchPanel({onSearch}: SearchPanelProps) {
<div className="list-container flex-1">
<div className="news-source-lv-1 flex flex-wrap">
<div
className={`filter-item whitespace-nowrap px-2 py-1 mt-1 text-sm mr-1 cursor-pointer rounded ${state.source == -1 ? 'bg-blue-500 text-white' : 'hover:bg-gray-100'}`}
className={`filter-item whitespace-nowrap px-2 py-1 mt-1 text-sm mr-1 cursor-pointer rounded ${state.tag_level_1_id == -1 ? 'bg-blue-500 text-white' : 'hover:bg-gray-100'}`}
onClick={() => {
setState({source: -1, subOptions: []})
setState({...DEFAULT_STATE})
setSubOptions([])
}}></div>
{
tags.filter(s=>s.value !== 999999).map(it => (
<div
className={`filter-item whitespace-nowrap px-2 py-1 mt-1 text-sm mr-1 cursor-pointer rounded ${state.source == it.value ? 'bg-blue-500 text-white' : 'hover:bg-gray-100'}`}
className={`filter-item whitespace-nowrap px-2 py-1 mt-1 text-sm mr-1 cursor-pointer rounded ${state.tag_level_1_id == it.value ? 'bg-blue-500 text-white' : 'hover:bg-gray-100'}`}
key={it.value}
onClick={() => {
setState({source: it.value, subOptions: []})
setState({tag_level_1_id: Number(it.value),tag_level_2_id:-1})
setSubOptions(it.children || [])
}}>{it.label}</div>)
)
}
</div>
{state.source != -1 && subOptions.length > 0 && <div className="news-source-lv-2 bg-gray-100 p-2 rounded mt-2 flex flex-wrap">
{state.tag_level_1_id != -1 && subOptions.length > 0 && <div className="news-source-lv-2 bg-gray-100 p-2 rounded mt-2 flex flex-wrap">
{
subOptions.map(it => (
<div
className={`filter-item whitespace-nowrap px-2 py-1 mt-1 text-sm mr-1 cursor-pointer rounded ${state.subOptions.includes(it.value) ? 'bg-blue-500 text-white' : 'hover:bg-gray-100'}`}
className={`filter-item whitespace-nowrap px-2 py-1 mt-1 text-sm mr-1 cursor-pointer rounded ${state.tag_level_2_id == it.value ? 'bg-blue-500 text-white' : 'hover:bg-gray-100'}`}
key={it.value}
onClick={() => {
const options = [...state.subOptions]
if (options.includes(it.value)) {
options.splice(options.indexOf(it.value), 1)
} else {
options.push(it.value)
}
setState({subOptions: options})
setState({tag_level_2_id: Number(it.value)})
}}>{it.label}</div>)
)
}

View File

@ -1,25 +1,23 @@
import {Button, Pagination, Table, TableColumnsType, TableProps, Typography} from "antd";
import {Card} from "@/components/card";
import React, {useEffect, useState} from "react";
import React, {useState} from "react";
import {useRequest} from "ahooks";
import {formatTime} from "@/util/strings.ts";
import ArticleEditModal from "@/components/article/edit-modal.tsx";
import {getList} from "@/service/api/article.ts";
import EditSearchForm from "@/pages/news/components/edit-search-form.tsx";
import ButtonPush2Video from "@/pages/news/components/button-push2video.tsx";
import {Key} from "antd/es/table/interface";
export default function NewEdit() {
const [editId, setEditId] = useState(-1)
const [selectedRowKeys, setSelectedRowKeys] = useState<Id[]>([])
const [params, setParams] = useState<ApiArticleSearchParams>({
pagination: {
page: 1,
limit: 10
}
pagination: {page: 1, limit: 10}
})
const {data} = useRequest(() => getList(params), {refreshDeps: [params]})
const {data, refresh} = useRequest(() => getList(params), {refreshDeps: [params]})
const columns: TableColumnsType<ListArticleItem> = [
{
@ -58,8 +56,8 @@ export default function NewEdit() {
];
const rowSelection: TableProps<ListArticleItem>['rowSelection'] = {
onChange: (selectedRowKeys: Id[]) => {
setSelectedRowKeys(selectedRowKeys)
onChange: (selectedRowKeys: Key[]) => {
setSelectedRowKeys(selectedRowKeys as Id[])
},
};
@ -78,7 +76,7 @@ export default function NewEdit() {
bordered
pagination={false}
/>
{data?.pagination.total > 0 && <div className="footer flex justify-between items-center mt-5">
{data?.pagination && data?.pagination.total > 0 && <div className="footer flex justify-between items-center mt-5">
<Pagination
current={params.pagination.page}
total={data?.pagination.total}
@ -86,15 +84,20 @@ export default function NewEdit() {
showSizeChanger={false}
simple={true}
rootClassName={'simple-pagination'}
onChange={(page) => setParams(prev=>({
onChange={(page) => setParams(prev => ({
...prev,
pagination: {page, limit: 10}
}))}
/>
<ButtonPush2Video ids={selectedRowKeys} />
<ButtonPush2Video ids={selectedRowKeys} onSuccess={refresh}/>
</div>}
</div>
<ArticleEditModal id={editId} onClose={() => setEditId(-1)}/>
<ArticleEditModal
type="news" id={editId}
onClose={(saved) => {
setEditId(-1)
if (saved) refresh()
}}/>
</Card>
</div>)
}

View File

@ -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";
@ -21,7 +20,7 @@ export default function NewsIndex() {
limit: 10
}
})
const [checkedId, setCheckedId] = useState<number[]>([])
const [checkedId, setCheckedId] = useState<Id[]>([])
const [activeNews, setActiveNews] = useState<NewsInfo>()
const [state, setState] = useState<{
@ -67,7 +66,7 @@ export default function NewsIndex() {
<Checkbox checked={state.checkAll} onChange={e => {
setState({checkAll: e.target.checked})
if (e.target.checked) {
setCheckedId(data.list.map(item => item.id))
setCheckedId(data?.list?.map(item => item.id) || [])
} else {
setCheckedId([])
}
@ -95,7 +94,7 @@ export default function NewsIndex() {
<div className="flex items-center justify-between">
<div className="title text-lg cursor-pointer" onClick={() => {
handleViewNewsDetail(item.id)
}}>{item.id}{item.title}</div>
}}>{item.title}</div>
{item.internal_article_id > 0 &&
<div className="text-sm text-blue-500"></div>}
</div>
@ -112,14 +111,14 @@ export default function NewsIndex() {
<div className="info text-gray-300 flex items-center justify-between gap-3 text-sm">
<div>: <span>{item.media_name}</span></div>
{/*<Divider type="vertical" />*/}
<div>: <span>{item.publish_time}</span></div>
<div>: <span>{formatTime(item.publish_time)}</span></div>
</div>
</div>
</div>
))}
</div>
{data?.pagination.total > 0 ? <div className="flex justify-center mt-10">
{data?.pagination && data?.pagination.total > 0 ? <div className="flex justify-center mt-10">
<Pagination
current={params.pagination.page}
total={data?.pagination.total}

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -2,7 +2,7 @@ import {useState} from "react";
import {useNavigate, useSearchParams} from "react-router-dom";
import type {FormProps} from 'antd';
import {LockOutlined, UserOutlined} from '@ant-design/icons';
import {Button, Checkbox, Flex, Form, Input} from 'antd';
import {Button, Checkbox, Divider, Flex, Form, Input} from 'antd';
import {clsx} from "clsx";
import useAuth from "@/hooks/useAuth.ts";
@ -37,7 +37,7 @@ export default function FormLogin() {
};
return (<div className="form">
<h1 className={'text-center text-2xl pb-10 pt-4'}></h1>
<Divider className=" pb-8 pt-4"><div className={'text-center text-2xl'}></div></Divider>
<Form<FieldType>
name="basic"
style={{maxWidth: 600}}
@ -72,20 +72,13 @@ export default function FormLogin() {
</div>
</Form.Item>
<Form.Item>
<div className="absolute text-red-500 text-center inset-x-0" style={{top: -20}}>{error}</div>
<Flex justify="space-between" align="center">
<Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
<a href=""></a>
</Flex>
</Form.Item>
<Form.Item>
<Form.Item className={"mt-14"}>
<div className="absolute text-red-500 text-center inset-x-0" style={{top: -30}}>{error}</div>
<Button disabled={disabled || loading} loading={loading} type="primary" size={'large'} htmlType="submit"
block shape={'round'}>
{login ? '登录中' : '立即登录'}
{loading ? '登录中...' : '立即登录'}
</Button>
</Form.Item>
</Form>

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -1,9 +1,11 @@
import styles from './style.module.scss'
import FormLogin from "./components/form-login.tsx";
import useAuth from "@/hooks/useAuth.ts";
import {useNavigate, useSearchParams} from "react-router-dom";
import {useEffect} from "react";
import useAuth from "@/hooks/useAuth.ts";
import MainBgImage from './components/bg.png'
import styles from './style.module.scss'
import FormLogin from "./components/form-login.tsx";
export default function UserIndex(){
const {user} = useAuth();
const navigate = useNavigate() ;
@ -14,6 +16,10 @@ export default function UserIndex(){
}
}, [user])
return (<div className={styles.main}>
<div className={"flex-1 ml-[15%]"}>
{/*<h2 className="text-4xl mb-10 text-white/90">数字人直播间</h2>*/}
<img className="w-[450px]" src={MainBgImage} alt=""/>
</div>
<div className={styles.boxLogin}>
<FormLogin />
</div>

View File

@ -1,6 +1,6 @@
.main {
@apply py-10;
background-image: url(https://lf-webcast-platform.bytetos.com/obj/webcast-platform-cdn/ies/webcast_union_platform/static/image/bg.71a36267.png);
background-image: url(components/main-bg.jpg);
background-size: 100% 100%;
height: 100vh;
min-height: 500px;

View File

@ -0,0 +1,37 @@
import {Button, Modal} from "antd";
import React, {useState} from "react";
import {showErrorToast, showToast} from "@/components/message.ts";
import {push2room, VideoStatus} from "@/service/api/video.ts";
export default function ButtonPush2Room(props: { ids: Id[]; list: VideoInfo[] }) {
const [loading, setLoading] = useState(false)
const handlePush = () => {
setLoading(true)
// 只需要已经生成视频的数据id
const vids = props.list.filter(v => v.status == VideoStatus.Generated && props.ids.includes(v.id)).map(v => v.id)
push2room(vids).then(() => {
if(props.ids.length == vids.length){
showToast('一键推流成功,已推流至数字人直播间,请前往数字人直播间页面查看!', 'success')
}else{
showToast('选择视频中有部分视频还在生成中无法推送,推流成功视频前往数字人直播间页面查看!', 'success')
}
}).catch(showErrorToast).finally(() => {
setLoading(false)
})
}
const onPushClick = () => {
if (props.ids.length === 0) {
showToast('请选择要推流的新闻', 'warning')
return
}
Modal.confirm({
title: '操作提示',
content: '是否确定一键推流选中新闻视频??',
onOk: handlePush
})
}
return (
<Button type="primary" loading={loading} onClick={onPushClick}></Button>
)
}

195
src/pages/video/index.tsx Normal file
View File

@ -0,0 +1,195 @@
import {Empty, Modal} from "antd";
import React, {useEffect, useMemo, useRef, useState} from "react";
import {DndContext} from "@dnd-kit/core";
import {arrayMove, SortableContext} from "@dnd-kit/sortable";
import {useSetState} from "ahooks";
import {CheckCircleFilled} from "@ant-design/icons";
import {clsx} from "clsx";
import {VideoListItem} from "@/components/video/video-list-item.tsx";
import ArticleEditModal from "@/components/article/edit-modal.tsx";
import {deleteByIds, getList, modifyOrder, VideoStatus} 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 {Player, PlayerInstance} from "@/components/video/player.tsx";
import ButtonPush2Room from "@/pages/video/components/button-push2room.tsx";
export default function VideoIndex() {
const [editId, setEditId] = useState(-1)
const [videoData, setVideoData] = useState<VideoInfo[]>([])
const [modal, contextHolder] = Modal.useModal()
const player = useRef<PlayerInstance|null>(null)
const [state, setState] = useSetState({
checkedAll: false,
playingIndex: -1,
})
const [checkedIdArray, setCheckedIdArray] = useState<Id[]>([])
// 加载列表
const loadList = (needReset = true) => {
getList().then((ret) => {
const list = ret.list || []
setVideoData(list)
if (needReset) {
setCheckedIdArray([])
setState({checkedAll: false, playingIndex: -1})
}
// 判断是否有生成中的视频
if (list.filter(s => s.status == VideoStatus.Generating).length > 0) {
// 每5s重新获取一次最新数据
setTimeout(() => loadList(false), 5000)
}
})
}
// 播放视频
const playVideo = (video: VideoInfo, playingIndex: number) => {
if (video.oss_video_url && video.status !== 1) {
setState({playingIndex})
player.current?.play(video.oss_video_url, 0)
}
}
// 处理全选
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 + Math.ceil(v.duration / 1000), 0);
}, [videoData])
return (<div className="container py-10 page-live">
{contextHolder}
<div className="flex">
<div className="video-list-container bg-white p-10 rounded flex flex-col flex-1">
<div className="live-control flex justify-between mb-5">
<div className="pl-[70px]">
<span>: {formatDuration(totalDuration)}</span>
</div>
<div className="flex gap-2 items-center pr-[10px]">
<ButtonBatch
onProcess={deleteByIds}
selected={checkedIdArray}
emptyMessage={`请选择要删除的新闻视频`}
confirmMessage={`是否删除当前的${checkedIdArray.length}个新闻视频?`}
onSuccess={() => {
showToast('删除成功!', 'success')
loadList()
}}
></ButtonBatch>
<button className="ml-5 hover:text-blue-300 text-gray-400 text-lg"
onClick={handleAllCheckedChange}>
<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>
</button>
</div>
</div>
<div className={'video-list-sort-container flex-1'}>
<div className="flex my-2">
{videoData.length == 0 ? <div className="m-auto"><Empty/></div> : <>
<div className="sort-number-container mr-2">
{videoData.map((v, index) => (
<div key={index} className="flex items-center px-2 h-[80px] mt-3 mb-2">
<div
className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{index + 1}</div>
</div>
))}
</div>
<div className="sort-list-container flex-1">
<DndContext onDragEnd={(e) => {
const {active, over} = e;
if (over && active.id !== over.id) {
let oldIndex = -1, newIndex = -1;
const originArr = [...videoData]
console.log(originArr.map(s => s.id))
setVideoData((items) => {
oldIndex = items.findIndex(s => s.id == active.id);
newIndex = items.findIndex(s => s.id == over.id);
return arrayMove(items, oldIndex, newIndex);
});
modal.confirm({
title: '提示',
content: '是否要移动到指定位置',
onOk: handleModifySort,
onCancel: () => {
setVideoData(originArr);
}
})
}
}}>
<SortableContext items={videoData}>
{videoData.map((v, index) => (
<VideoListItem
video={v}
index={index + 1}
id={v.id}
key={index}
type={'create'}
active={state.playingIndex == index}
checked={checkedIdArray.includes(v.id)}
className={`list-item-${index} mt-3 mb-2`}
onCheckedChange={(checked) => {
setCheckedIdArray(idArray => {
const newArr = checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
setState({checkedAll: newArr.length == videoData.length})
return newArr;
})
}}
onPlay={() => playVideo(v, index)}
onEdit={() => {
setEditId(v.article_id)
}}
editable={true}
sortable={true}
/>))}
</SortableContext>
</DndContext>
</div>
</>}
</div>
</div>
<div className="text-right mt-5">
{/*<ButtonBatch*/}
{/* type={'primary'}*/}
{/* onProcess={push2room}*/}
{/* selected={checkedIdArray}*/}
{/* emptyMessage={`请选择要推流的新闻`}*/}
{/* confirmMessage={`是否确定一键推流选中新闻视频?`}*/}
{/* onSuccess={loadList}*/}
{/*>一键推流</ButtonBatch>*/}
<ButtonPush2Room ids={checkedIdArray} list={videoData}/>
</div>
</div>
<div className="video-player-container ml-16 w-[360px] flex flex-col">
<div className="text-center text-base"></div>
<div className="video-player flex items-center mt-2">
<div className=" w-[360px] h-[640px] rounded overflow-hidden">
{/*<video ref={videoRef} poster={videoData[state.playingIndex]?.cover} preload="auto" playsinline webkit-playsinline className="w-full bg-white w-[360px] h-[640px]"></video>*/}
<Player
ref={player} url={videoData[state.playingIndex]?.oss_video_url}
onChange={(state) => {
if (state.end || state.end) setState({playingIndex: -1})
}}
className="w-[360px] h-[640px] bg-white"/>
</div>
</div>
</div>
</div>
<ArticleEditModal type={'video'} id={editId} onClose={() => setEditId(-1)}/>
</div>)
}

View File

@ -17,7 +17,7 @@ const NavItems = [
},
{
key: 'create',
name: 'AI视频',
name: '视频生成',
icon: 'ai',
path:'/create'
},
@ -36,7 +36,7 @@ const NavItems = [
]
export function DashboardNavigation() {
return (<div className={'flex'}>
return (<div className={'flex app-main-navigation'}>
{NavItems.map((it, idx) => (
<NavLink to={it.path} key={idx} className={clsx('nav-item cursor-pointer items-center')}>
<span className="menu-text ml-1">{it.name}</span>

View File

@ -1,12 +1,16 @@
import {RouteObject} from "react-router-dom";
import ErrorBoundary from "@/routes/error.tsx";
import UserAuth from "@/pages/user";
import CreateIndex from "@/pages/create";
import LibraryIndex from "@/pages/library";
import LiveIndex from "@/pages/live";
import NewsIndex from "@/pages/news";
import NewsEdit from "@/pages/news/edit.tsx";
;
import DashboardLayout from "@/routes/layout/dashboard-layout.tsx";
import React from "react";
const UserAuth = React.lazy(() => import("@/pages/user"))
const CreateVideoIndex = React.lazy(() => import("@/pages/video"))
const LibraryIndex = React.lazy(() => import("@/pages/library"))
const LiveIndex = React.lazy(() => import("@/pages/live"))
const NewsIndex = React.lazy(() => import("@/pages/news"))
const NewsEdit = React.lazy(() => import("@/pages/news/edit.tsx"))
const routes: RouteObject[] = [
{
@ -28,7 +32,7 @@ const routes: RouteObject[] = [
},
{
path: 'create',
element: <CreateIndex/>
element: <CreateVideoIndex/>
},
{
path: 'library',

View File

@ -21,13 +21,14 @@ export function getById(id: Id) {
return post<ArticleDetail>({url: '/article/detail/' + id})
}
export function save(title: string, content_group: BlockContent[][], id: number) {
return post<{ content: string }>({
url: '/spider/article',
data: {
title,
content_group,
id
}
export function save(title: string, content_group: BlockContent[][], id?: number) {
return post<{ content: string }>(id && id > 0 ? '/article/modify' : '/article/create/new', {
title,
content_group,
id
})
}
export function push2video(article_ids: Id[]) {
return post('/article/push2video', {article_ids})
}

17
src/service/api/live.ts Normal file
View File

@ -0,0 +1,17 @@
import {post} from "@/service/request.ts";
export function playState() {
return post<LiveState>({url: '/room/playing'})
}
export function getList() {
return post<DataList<LiveVideoInfo>>('/room/list')
}
export function modifyOrder(ids: Id[]) {
return post('/room/order', {ids})
}
export function deleteByIds(ids: Id[]) {
return post('/room/remove', {ids})
}

View File

@ -1,7 +1,7 @@
import {post} from "@/service/request.ts";
export function getList(data: ApiArticleSearchParams & ApiRequestPageParams) {
return post<DataList<ListCrawlerNewsItem>>({url: '/article/search', data})
return post<DataList<ListCrawlerNewsItem>>({url: '/spider/search', data})
}
export function getById(id: Id) {

View File

@ -1,19 +1,18 @@
import {post} from "@/service/request.ts";
export function getList(data: {
title?: string,
time_flag?: number;
}) {
return post<DataList<VideoInfo>>({url: '/video/list', data})
export function getList() {
return post<DataList<VideoInfo>>('/video/list')
}
export function search(params:VideoSearchParams) {
return post<DataList<VideoInfo>>('/video/search',params)
}
/**
*
* @param title
* @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 +27,22 @@ export function getById(id: Id) {
return post<VideoInfo>({url: '/video/detail/' + id})
}
export function deleteById(id: Id) {
return post({url: '/video/detail/' + id})
export function deleteByIds(ids: Id[]) {
return post('/video/remove', {ids})
}
export function modifyOrder(ids: Id[]) {
return post({url: ' /video/modifyorder',data:{ids}})
return post('/video/modifyorder', {ids})
}
export function push2room(ids: Id[]) {
return post({url: ' /video/push2room',data:{ids}})
export function push2room(video_ids: Id[]) {
return post('/video/push2room', {video_ids})
}
export enum VideoStatus {
// 生成中
Generating = 1,
// 已生成
Generated = 2,
}

View File

@ -23,6 +23,7 @@ Axios.interceptors.request.use(config => {
}
return config
}, err => {
console.log('请求拦截器报错',err)
return Promise.reject(err)
})
@ -46,11 +47,11 @@ export function request<T>(options: RequestOption) {
return;
}
// const
const {code, message, data, request_id} = res.data
const {code, msg, data, trace_id} = res.data
if (code == 0) {
resolve(data as unknown as T)
} else {
reject(new BizError(message, code, request_id, data as unknown as AllType))
reject(new BizError(msg, code, trace_id, data as unknown as AllType))
}
}).catch(e => {
reject(new BizError(e.message, 500))
@ -59,9 +60,13 @@ export function request<T>(options: RequestOption) {
}
export function post<T>(params: RequestOption) {
export function post<T>(params: RequestOption | string, _data?: AllType) {
const options = typeof params === 'string' ? {url: params} : params;
if (_data) {
options.data = _data
}
return request<T>({
...params,
...options,
method: 'post'
})
}

34
src/types/api.d.ts vendored
View File

@ -6,10 +6,10 @@ declare interface ApiRequestPageParams {
}
declare interface ApiArticleSearchParams extends ApiRequestPageParams{
// // 1级标签id
// tag_level_1_id?: number;
// // 2级标签id 没有则为0
// tag_level_2_id?: number;
// 1级标签id
tag_level_1_id?: number;
// 2级标签id 没有则为0
tag_level_2_id?: number;
tags?: {
level1: Id;
level2: Id;
@ -80,12 +80,36 @@ declare interface ListCrawlerNewsItem extends BasicArticleInfo {
// 内部文章关联id
internal_article_id: number;
}
declare interface VideoSearchParams extends ApiRequestPageParams{
// 标题
title?: string;
time_flag?: number;
}
declare interface VideoInfo {
id: number;
video_title: string;
title: string;
cover?: string;
cover: string;
oss_video_url: string;
duration: number;
article_id: number;
status: number;
publish_time?: number|string;
}
// room live
declare interface LiveVideoInfo {
id: number;
title: string;
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;
}

View File

@ -1,5 +1,6 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"
import {padStart} from "lodash";
dayjs.extend(relativeTime)
@ -60,6 +61,7 @@ export function formatTime(time:any,template = 'YYYY-MM-DD HH:mm:ss') {
}
export function timeFromNow(time: any) {
if(!time) return '';
return getDayjs(time).fromNow();
}
@ -79,4 +81,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}`
}

View File

@ -21,7 +21,9 @@
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedLocals": false,
"noImplicitAny": false,
"noUnusedParameters": false,
"allowSyntheticDefaultImports": true
},
"include": ["src"],

View File

@ -4,44 +4,50 @@ import {resolve} from "path";
// https://vitejs.dev/config/
export default defineConfig(({mode}) => {
return {
plugins: [react()],
base: process.env.PUBLIC_PATH || (mode == 'relative' ? './' : '/'),
define: {
AppConfig: JSON.stringify({
SITE_URL: process.env.APP_SITE_URL || null,
API_PREFIX: process.env.APP_API_PREFIX || '/mgmt/v1/metahuman',
AUTH_TOKEN_KEY: process.env.AUTH_TOKEN_KEY || 'digital-person-token',
AUTHED_PERSON_DATA_KEY: process.env.AUTHED_PERSON_DATA_KEY || 'digital-person-user-info',
}),
AppMode: JSON.stringify(mode)
},
resolve: {
alias: {
'@': resolve(__dirname, './src')
}
},
css:{
preprocessorOptions:{
scss:{
api:'modern'
}
}
},
server: {
port: 10021,
proxy: {
'/mgmt': {
target: 'http://192.168.0.231:9999',
changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api/, '')
},
'/api': {
target: 'http://192.168.0.231:9999',
changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}
const devServerHost = mode == 'test' ? '124.220.14.192' : '192.168.0.231:9999'
const AUTH_TOKEN_KEY = mode == 'production' ? 'digital-person-token' : `digital-person-token_${mode}`
if (mode !== 'production') {
console.log('dev server is', devServerHost,mode)
}
return {
plugins: [react()],
base: process.env.PUBLIC_PATH || (mode == 'relative' ? './' : '/'),
define: {
AppConfig: JSON.stringify({
SITE_URL: process.env.APP_SITE_URL || null,
API_PREFIX: process.env.APP_API_PREFIX || '/mgmt/v1/metahuman',
AUTH_TOKEN_KEY: process.env.AUTH_TOKEN_KEY || AUTH_TOKEN_KEY,
AUTHED_PERSON_DATA_KEY: process.env.AUTHED_PERSON_DATA_KEY || 'digital-person-user-info',
}),
AppMode: JSON.stringify(mode)
},
resolve: {
alias: {
'@': resolve(__dirname, './src')
}
},
css: {
preprocessorOptions: {
scss: {
api: 'modern'
}
}
},
server: {
port: 10021,
proxy: {
'/mgmt': {
target: `http://${devServerHost}`, // http://124.220.14.192/ 192.168.0.231:9999
changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api/, '')
},
'/api': {
target: `http://${devServerHost}`,
changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}
})

232
yarn.lock
View File

@ -1137,6 +1137,14 @@ axios@^1.7.7:
form-data "^4.0.0"
proxy-from-env "^1.1.0"
babel-runtime@^6.9.2:
version "6.26.0"
resolved "https://registry.npmmirror.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
integrity sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==
dependencies:
core-js "^2.4.0"
regenerator-runtime "^0.11.0"
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
@ -1147,6 +1155,11 @@ binary-extensions@^2.0.0:
resolved "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"
integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==
blueimp-md5@^2.10.0:
version "2.19.0"
resolved "https://registry.npmmirror.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0"
integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@ -1213,6 +1226,11 @@ chalk@^4.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
charenc@0.0.2:
version "0.0.2"
resolved "https://registry.npmmirror.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
integrity sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==
chokidar@^3.6.0:
version "3.6.0"
resolved "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
@ -1291,6 +1309,11 @@ copy-to-clipboard@^3.3.3:
dependencies:
toggle-selection "^1.0.6"
core-js@^2.4.0:
version "2.6.12"
resolved "https://registry.npmmirror.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
core-util-is@~1.0.0:
version "1.0.3"
resolved "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
@ -1305,6 +1328,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2:
shebang-command "^2.0.0"
which "^2.0.1"
crypt@0.0.2:
version "0.0.2"
resolved "https://registry.npmmirror.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
@ -1327,6 +1355,11 @@ debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4:
dependencies:
ms "^2.1.3"
decode-uri-component@^0.2.0:
version "0.2.2"
resolved "https://registry.npmmirror.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9"
integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
@ -1380,6 +1413,11 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
dom-walk@^0.1.0:
version "0.1.2"
resolved "https://registry.npmmirror.com/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84"
integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==
eastasianwidth@^0.2.0:
version "0.2.0"
resolved "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
@ -1412,6 +1450,16 @@ es-errors@^1.3.0:
resolved "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
es5-shim@^4.5.1:
version "4.6.7"
resolved "https://registry.npmmirror.com/es5-shim/-/es5-shim-4.6.7.tgz#bc67ae0fc3dd520636e0a1601cc73b450ad3e955"
integrity sha512-jg21/dmlrNQI7JyyA2w7n+yifSxBng0ZralnSfVZjoCawgNTCnS+yBCyVM9DL5itm7SUnDGgv7hcq2XCZX4iRQ==
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 +1673,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"
@ -1721,6 +1777,22 @@ glob@^7.1.3:
once "^1.3.0"
path-is-absolute "^1.0.0"
global@4.3.2, global@~4.3.0:
version "4.3.2"
resolved "https://registry.npmmirror.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f"
integrity sha512-/4AybdwIDU4HkCUbJkZdWpe4P6vuw/CUtu+0I1YlLIPe7OlUO7KNJ+q/rO70CW2/NW6Jc6I62++Hzsf5Alu6rQ==
dependencies:
min-document "^2.19.0"
process "~0.5.1"
global@^4.3.1:
version "4.4.0"
resolved "https://registry.npmmirror.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406"
integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==
dependencies:
min-document "^2.19.0"
process "^0.11.10"
globals@^11.1.0:
version "11.12.0"
resolved "https://registry.npmmirror.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
@ -1814,6 +1886,11 @@ imurmurhash@^0.1.4:
resolved "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==
individual@^2.0.0:
version "2.0.0"
resolved "https://registry.npmmirror.com/individual/-/individual-2.0.0.tgz#833b097dad23294e76117a98fb38e0d9ad61bb97"
integrity sha512-pWt8hBCqJsUWI/HtcfWod7+N9SgAqyPEaF7JQjwzjn5vGrpg6aQ5qeAFQ7dx//UH4J1O+7xqew+gCeeFt6xN/g==
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
@ -1839,6 +1916,11 @@ is-binary-path@~2.1.0:
dependencies:
binary-extensions "^2.0.0"
is-buffer@~1.1.6:
version "1.1.6"
resolved "https://registry.npmmirror.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
is-core-module@^2.13.0:
version "2.15.1"
resolved "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.15.1.tgz#a7363a25bee942fefab0de13bf6aa372c82dcc37"
@ -1856,6 +1938,11 @@ is-fullwidth-code-point@^3.0.0:
resolved "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
is-function@^1.0.1:
version "1.0.2"
resolved "https://registry.npmmirror.com/is-function/-/is-function-1.0.2.tgz#4f097f30abf6efadac9833b17ca5dc03f8144e08"
integrity sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
@ -1914,6 +2001,11 @@ js-yaml@^4.1.0:
dependencies:
argparse "^2.0.1"
jsencrypt@^3.2.0:
version "3.3.2"
resolved "https://registry.npmmirror.com/jsencrypt/-/jsencrypt-3.3.2.tgz#b0f1a2278810c7ba1cb8957af11195354622df7c"
integrity sha512-arQR1R1ESGdAxY7ZheWr12wCaF2yF47v5qpB76TtV64H1pyGudk9Hvw8Y9tb/FiTIaaTRUyaSnm5T/Y53Ghm/A==
jsesc@^3.0.2:
version "3.0.2"
resolved "https://registry.npmmirror.com/jsesc/-/jsesc-3.0.2.tgz#bb8b09a6597ba426425f2e4a07245c3d00b9343e"
@ -2034,6 +2126,15 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
md5@^2.3.0:
version "2.3.0"
resolved "https://registry.npmmirror.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f"
integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==
dependencies:
charenc "0.0.2"
crypt "0.0.2"
is-buffer "~1.1.6"
memoize-one@^5.1.1:
version "5.2.1"
resolved "https://registry.npmmirror.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
@ -2064,6 +2165,13 @@ mime-types@^2.1.12:
dependencies:
mime-db "1.52.0"
min-document@^2.19.0:
version "2.19.0"
resolved "https://registry.npmmirror.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685"
integrity sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==
dependencies:
dom-walk "^0.1.0"
minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
@ -2127,7 +2235,7 @@ normalize-range@^0.1.2:
resolved "https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==
object-assign@^4.0.1, object-assign@^4.1.1:
object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
@ -2192,6 +2300,11 @@ parent-module@^1.0.0:
dependencies:
callsites "^3.0.0"
parse-headers@^2.0.0:
version "2.0.5"
resolved "https://registry.npmmirror.com/parse-headers/-/parse-headers-2.0.5.tgz#069793f9356a54008571eb7f9761153e6c770da9"
integrity sha512-ft3iAoLOB/MlwbNXgzy43SWGP6sQki2jQvAyBg/zDFAgr9bfNWZIUj42Kw2eJIl8kEi4PbgE6U1Zau/HwI75HA==
path-exists@^4.0.0:
version "4.0.0"
resolved "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
@ -2308,6 +2421,16 @@ process-nextick-args@~2.0.0:
resolved "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
process@^0.11.10:
version "0.11.10"
resolved "https://registry.npmmirror.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
process@~0.5.1:
version "0.5.2"
resolved "https://registry.npmmirror.com/process/-/process-0.5.2.tgz#1638d8a8e34c2f440a91db95ab9aeb677fc185cf"
integrity sha512-oNpcutj+nYX2FjdEW7PGltWhXulAnFlM0My/k48L90hARCOJtvBbQXc/6itV2jDvU5xAAtonP+r6wmQgCcbAUA==
prop-types@^15.7.2:
version "15.8.1"
resolved "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
@ -2334,6 +2457,15 @@ qs@^6.12.1:
dependencies:
side-channel "^1.0.6"
query-string@^5.0.1:
version "5.1.1"
resolved "https://registry.npmmirror.com/query-string/-/query-string-5.1.1.tgz#a78c012b71c17e05f2e3fa2319dd330682efb3cb"
integrity sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==
dependencies:
decode-uri-component "^0.2.0"
object-assign "^4.1.0"
strict-uri-encode "^1.0.0"
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
@ -2782,6 +2914,11 @@ readdirp@~3.6.0:
dependencies:
picomatch "^2.2.1"
regenerator-runtime@^0.11.0:
version "0.11.1"
resolved "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
regenerator-runtime@^0.14.0:
version "0.14.1"
resolved "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f"
@ -2852,11 +2989,25 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
rust-result@^1.0.0:
version "1.0.0"
resolved "https://registry.npmmirror.com/rust-result/-/rust-result-1.0.0.tgz#34c75b2e6dc39fe5875e5bdec85b5e0f91536f72"
integrity sha512-6cJzSBU+J/RJCF063onnQf0cDUOHs9uZI1oroSGnHOph+CQTIJ5Pp2hK5kEQq1+7yE/EEWfulSNXAQ2jikPthA==
dependencies:
individual "^2.0.0"
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
safe-json-parse@4.0.0:
version "4.0.0"
resolved "https://registry.npmmirror.com/safe-json-parse/-/safe-json-parse-4.0.0.tgz#7c0f578cfccd12d33a71c0e05413e2eca171eaac"
integrity sha512-RjZPPHugjK0TOzFrLZ8inw44s9bKox99/0AZW9o/BEQVrJfhI+fIHMErnPyRa89/yRXUUr93q+tiN6zhoVV4wQ==
dependencies:
rust-result "^1.0.0"
sass@^1.81.0:
version "1.81.0"
resolved "https://registry.npmmirror.com/sass/-/sass-1.81.0.tgz#a9010c0599867909dfdbad057e4a6fbdd5eec941"
@ -2951,6 +3102,21 @@ slash@^3.0.0:
resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
store2@^2.7.1:
version "2.14.3"
resolved "https://registry.npmmirror.com/store2/-/store2-2.14.3.tgz#24077d7ba110711864e4f691d2af941ec533deb5"
integrity sha512-4QcZ+yx7nzEFiV4BMLnr/pRa5HYzNITX2ri0Zh6sT9EyQHbBHacC6YigllUPU9X3D0f/22QCgfokpKs52YRrUg==
store@^2.0.12:
version "2.0.12"
resolved "https://registry.npmmirror.com/store/-/store-2.0.12.tgz#8c534e2a0b831f72b75fc5f1119857c44ef5d593"
integrity sha512-eO9xlzDpXLiMr9W1nQ3Nfp9EzZieIQc10zPPMP5jsVV7bLOziSFFBP0XoDXACEIFtdI+rIz0NwWVA/QVJ8zJtw==
strict-uri-encode@^1.0.0:
version "1.1.0"
resolved "https://registry.npmmirror.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
integrity sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==
string-convert@^0.2.0:
version "0.2.1"
resolved "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz#6982cc3049fbb4cd85f8b24568b9d9bf39eeff97"
@ -3059,6 +3225,26 @@ tailwindcss@^3.4.7:
resolve "^1.22.8"
sucrase "^3.35.0"
tcplayer.js@^5.2.0:
version "5.2.0"
resolved "https://registry.npmmirror.com/tcplayer.js/-/tcplayer.js-5.2.0.tgz#95a1cf8e5548a831d1eb945a3585cfd7e9f4480b"
integrity sha512-rXxHhS9ajp07maE81OXNrsOQuPbvrW/GVn+Z+aS9Oxpl8yC/pIg8wthUZUK/C1Va3GNZt6bE2oFoQTPut3bRww==
dependencies:
babel-runtime "^6.9.2"
blueimp-md5 "^2.10.0"
global "4.3.2"
jsencrypt "^3.2.0"
md5 "^2.3.0"
query-string "^5.0.1"
safe-json-parse "4.0.0"
store "^2.0.12"
store2 "^2.7.1"
tsml "1.0.1"
videojs-font "2.1.0"
videojs-ie8 "1.1.2"
videojs-vtt.js "0.12.4"
xhr "2.4.0"
text-table@^0.2.0:
version "0.2.0"
resolved "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
@ -3110,6 +3296,11 @@ tslib@^2.0.0, tslib@^2.4.1:
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tsml@1.0.1:
version "1.0.1"
resolved "https://registry.npmmirror.com/tsml/-/tsml-1.0.1.tgz#89f8218b9d9e257f47d7f6b56d01c5a4d2c68fc3"
integrity sha512-3KmepnH9SUsoOVtg013CRrL7c+AK7ECaquAsJdvu4288EDJuraqBlP4PDXT/rLEJ9YDn4jqLAzRJsnFPx+V6lg==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@ -3152,6 +3343,25 @@ util-deprecate@^1.0.2, util-deprecate@~1.0.1:
resolved "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
videojs-font@2.1.0:
version "2.1.0"
resolved "https://registry.npmmirror.com/videojs-font/-/videojs-font-2.1.0.tgz#a25930a67f6c9cfbf2bb88dacb8c6b451f093379"
integrity sha512-zFqWpLrXf1q8NtYx5qtZhMC6SLUFScDmR6j+UGPogobxR21lvXShhnzcNNMdOxJUuFLiToJ/BPpFUQwX4xhpvA==
videojs-ie8@1.1.2:
version "1.1.2"
resolved "https://registry.npmmirror.com/videojs-ie8/-/videojs-ie8-1.1.2.tgz#a23d3d8608ad7192b69c6077fc4eb848998d35d9"
integrity sha512-0Zb2T4MLkpfZbeGMK/Z93b8Lrepr+rLFoHgQV1CoDeFqXvH7b+Vsd/VHoILGxQrgCSHFQ7mAODR6oyMjuiD4/g==
dependencies:
es5-shim "^4.5.1"
videojs-vtt.js@0.12.4:
version "0.12.4"
resolved "https://registry.npmmirror.com/videojs-vtt.js/-/videojs-vtt.js-0.12.4.tgz#38f2499e31efb3fa93590ddad4cb663275a4b161"
integrity sha512-JQ5eozH5SLOL5xI8ALb1aWf9HjcewQmOytf1gPIsFBTQlSgtSdJ8E8x0GO0ZEXVtFCaPDFiYWAhrjuTI125tBQ==
dependencies:
global "^4.3.1"
vite@^5.2.0:
version "5.4.11"
resolved "https://registry.npmmirror.com/vite/-/vite-5.4.11.tgz#3b415cd4aed781a356c1de5a9ebafb837715f6e5"
@ -3163,6 +3373,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"
@ -3198,6 +3413,21 @@ wrappy@1:
resolved "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
xhr@2.4.0:
version "2.4.0"
resolved "https://registry.npmmirror.com/xhr/-/xhr-2.4.0.tgz#e16e66a45f869861eeefab416d5eff722dc40993"
integrity sha512-TUbBsdAuJbX8olk9hsDwGK8P1ri1XlV+PdEWkYw+HQQbpkiBR8PLgD1F3kQDPBs9l4Px34hP9rCYAZOCCAENbw==
dependencies:
global "~4.3.0"
is-function "^1.0.1"
parse-headers "^2.0.0"
xtend "^4.0.0"
xtend@^4.0.0:
version "4.0.2"
resolved "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
yallist@^3.0.2:
version "3.1.1"
resolved "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"