Compare commits

...

28 Commits

Author SHA1 Message Date
0a4bb5426e fix: 限制最长新闻标签最多为4个字符 2025-04-24 21:42:18 +08:00
6fc064fbc8 feat: 标签及模板接口联调完成 2025-04-24 17:09:22 +08:00
611a00a550 feat: 保留腾讯播放器 2025-04-24 16:38:05 +08:00
0ccbfb5f5a feat: update video player 2025-04-22 16:14:41 +08:00
74f37055bc feat: ️更新依赖版本 2025-04-22 07:59:13 +08:00
d270d615a2 feat: 新闻编辑UI新增背景选择 2025-04-21 17:13:57 +08:00
b7b15e7471 fixed: 移除回收站页面中已推送条数 2025-04-16 18:45:20 +08:00
a2b5df22f8 feat: ️ 新增图片上传类型校验及错误提示 2025-04-16 18:32:03 +08:00
116c171249 fixed: 回收站时间宽度修复 2025-04-16 16:36:15 +08:00
c8e5d8a6ab fixed: 重置筛选或全部删除时已选数据 2025-04-16 16:18:12 +08:00
99323df02b feat: ️ 新增订单/回收站导航
fix:直播间回退
style: 📚️调整相关UI
2025-04-16 11:29:54 +08:00
42e2d3fcc0 feat: ️ 移除订单分页在单页时的隐藏逻辑 2025-04-15 15:29:51 +08:00
cbd476d1e2 fixed: 订单效果 2025-04-15 15:03:08 +08:00
1be407d34e fixed: 订单分页效果 2025-04-14 21:34:46 +08:00
605a769b89 feat: 添加余额提醒 2025-04-14 14:10:45 +08:00
4e23bb623f feat: 统一样式 2025-04-14 10:19:53 +08:00
cea77ea231 merge 2025-04-11 19:20:06 +08:00
be34a8bc9b feat: ️ 新增MP4视频下载支持
- 优化视频列表项下载逻辑及状态判断
2025-04-09 12:31:28 +08:00
fdb125c7ba feat: ️ 将热点填写统一调整为"自定义" 2025-04-09 11:26:49 +08:00
bcbdac6673 feat: ️ 优化文章编辑模态框标题布局,完善素材区空内容提示文案 2025-04-08 23:06:58 +08:00
0520cb8e1d feat: ️ 优化数字人面板高度及布局,调整热点新闻编辑模式切换逻辑及国际化文案 2025-04-08 23:00:32 +08:00
4dee84a459 feat: ️ 直播间页调整锁定相关状态及逻辑
- 新增直播视频回滚功能
- 优化编辑模式操作流程
2025-04-08 22:39:00 +08:00
3d47964580 fix: 🪲️ 修复视频删除接口路径错误,将/outside更正为/remove 2025-04-08 16:15:25 +08:00
17c9fa6c10 feat: ️ 新增视频下载功能
- 支持从OSS直接下载已生成视频
2025-04-07 23:39:32 +08:00
500c849140 feat: ️ 新增推送视频状态控制及国际化支持,优化热点新闻数据加载逻辑 2025-04-07 23:24:53 +08:00
64ee960846 feat: ️ 完成接口联调;去除生成视频判断 2025-04-07 11:56:31 +08:00
e61bfcc26c feat: ️ 新增新闻热点功能,支持手动/自动填充热点内容并完善相关验证逻辑 2025-04-06 20:26:24 +08:00
de7088f642 fix: 🪲️修复空素材编辑验证 2025-04-06 16:00:36 +08:00
63 changed files with 3014 additions and 1673 deletions

12
.prettierignore Normal file
View File

@ -0,0 +1,12 @@
/node_modules
package*.json
.gitignore
*.local
*_local
__test__
.ide
.vscode
.idea
test
dist
public

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"useTabs": true,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100
}

View File

@ -19,9 +19,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^10.0.0",
"ahooks": "^3.8.1", "ahooks": "^3.8.1",
"antd": "^5.22.5", "antd": "^5.22.5",
"axios": "^1.7.7", "axios": "^1.7.7",
@ -30,7 +30,7 @@
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"flv.js": "^1.6.2", "flv.js": "^1.6.2",
"i18next": "^24.2.1", "i18next": "^25.0.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"qs": "^6.12.1", "qs": "^6.12.1",
"react": "^18.3.1", "react": "^18.3.1",
@ -51,15 +51,15 @@
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0", "@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6", "eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.40", "postcss": "^8.5.3",
"tailwindcss": "^3.4.7", "tailwindcss": "^3.4.7",
"typescript": "^5.2.2", "typescript": "^5.8.3",
"vite": "^5.2.0" "vite": "^6.3.2"
}, },
"packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72" "packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72"
} }

View File

@ -1,10 +1,13 @@
import AppRouter from "@/routes"; import AppRouter from "@/routes";
import {ConfigProvider} from "@/contexts/config"; import {ConfigProvider} from "@/contexts/config";
import {AuthProvider} from "@/contexts/auth"; import {AuthProvider} from "@/contexts/auth";
import LivePlayer from "@/pages/live-player"; import React from 'react';
// import LivePlayer from "@/pages/live-player";
console.log(`APP-BUILD-AT: ${AppBuildVersion}`) console.log(`APP-BUILD-AT: ${AppBuildVersion}`)
const LivePlayer = React.lazy(() => import('@/pages/live-player'));
function App() { function App() {
return ( return (
<ConfigProvider> <ConfigProvider>

View File

@ -170,7 +170,7 @@
@apply text-sm; @apply text-sm;
background: none; background: none;
.col{ .col{
@apply text-sm text-gray-800; @apply text-base text-gray-800;
height: 42px; height: 42px;
} }
} }
@ -180,7 +180,7 @@
} }
.col { .col {
@apply flex items-center relative pl-4 text-center justify-center; @apply flex items-center relative pl-4 text-center justify-center text-sm;
height: 60px; height: 60px;
&:after { &:after {
@ -209,7 +209,7 @@
} }
.title { .title {
@apply flex-1 text-base; @apply flex-1;
} }
.generated-time { .generated-time {
@ -218,7 +218,7 @@
.operation { .operation {
@apply flex items-center ml-2 text-lg text-gray-400 justify-center; @apply flex items-center ml-2 text-lg text-gray-400 justify-center;
width: 150px; width: 180px;
padding: 0; padding: 0;
} }
} }
@ -230,34 +230,7 @@
//max-height: calc(100vh - var(--app-header-header) - 200px); //max-height: calc(100vh - var(--app-header-header) - 200px);
//overflow: auto; //overflow: auto;
} }
.root-modal-confirm{
z-index: calc(var(--header-z-index) + 1) !important;
background: rgba(0, 0, 0, 0.05);
//anticon anticon-exclamation-circle
.ant-modal-confirm-title{
font-size: 20px;
margin-top: -2px;
}
.ant-modal-confirm-content{
margin-top: 10px;
margin-left: -30px;
}
.icon-warning{
}
.ant-modal-confirm-btns{
@apply mt-8;
button{
@apply rounded-2xl py-4 px-8;
}
.ant-btn-default{
@apply bg-white shadow-none text-popconfirm-btn-cancel border border-popconfirm-btn-cancel hover:border-popconfirm-btn-cancel hover:text-popconfirm-btn-cancel hover:bg-white hover:bg-popconfirm-btn-cancel/10;
}
.ant-btn-primary{
@apply bg-white shadow-none text-popconfirm-bg border border-popconfirm-bg hover:text-popconfirm-bg hover:bg-white hover:bg-popconfirm-btn-primary-hover/10;
}
}
}
.video-player { .video-player {
.video-js { .video-js {
@ -290,7 +263,9 @@
} }
} }
} }
.w-min-60px{
min-width: 60px;
}
.list-scroller-container { .list-scroller-container {
overflow: auto; overflow: auto;
margin-right: -20px; margin-right: -20px;
@ -342,35 +317,74 @@
} }
.popconfirm-main{ .popconfirm-main{
.ant-popover-inner{ .ant-popover-inner{
@apply bg-white px-6 py-6 rounded-xl; border-radius: 4px;
padding: 20px 24px;
min-width: 360px; min-width: 360px;
background-color: #f2f2f2;
box-shadow: 0 0 10px rgba(25, 25, 25, 0.1); box-shadow: 0 0 10px rgba(25, 25, 25, 0.1);
} }
.icon-warning{ .icon-warning{
@apply text-red-500; @apply text-red-500;
font-size: 20px; font-size: 20px;
transform: translateY(5px); transform: translateY(5px);
margin-right: 10px; margin-right: 20px;
} }
.ant-popconfirm-message{ .ant-popconfirm-message{
.ant-popconfirm-title{ .ant-popconfirm-title{
@apply text-xl font-bold; @apply text-xl;
font-weight: 400;
} }
.ant-popconfirm-description{ .ant-popconfirm-description{
@apply mt-4 text-gray-400 text-sm; @apply mt-2 text-gray-600 text-sm;
margin-left: -30px; margin-left: 0px;
} }
} }
.ant-popconfirm-buttons{ .ant-popconfirm-buttons{
@apply mt-8; @apply mt-6;
button{ button{
@apply rounded-2xl py-4 px-8; font-size: 14px;
line-height: 1.5714285714285714;
height: 32px;
padding: 4px 15px;
border-radius: 4px;
min-width: 88px;
} }
.ant-btn-default{ //.ant-btn-default{
@apply bg-white shadow-none text-popconfirm-btn-cancel border border-popconfirm-btn-cancel hover:border-popconfirm-btn-cancel hover:text-popconfirm-btn-cancel hover:bg-white hover:bg-popconfirm-btn-cancel/10; // @apply bg-white shadow-none text-popconfirm-btn-cancel border border-popconfirm-btn-cancel hover:border-popconfirm-btn-cancel hover:text-popconfirm-btn-cancel hover:bg-white hover:bg-popconfirm-btn-cancel/10;
} //}
.ant-btn-primary{ //.ant-btn-primary{
@apply bg-white shadow-none text-popconfirm-bg border border-popconfirm-bg hover:text-popconfirm-bg hover:bg-white hover:bg-popconfirm-btn-primary-hover/10; // @apply bg-white shadow-none text-popconfirm-bg border border-popconfirm-bg hover:text-popconfirm-bg hover:bg-white hover:bg-popconfirm-btn-primary-hover/10;
//}
}
}
.root-modal-confirm{
z-index: calc(var(--header-z-index) + 1) !important;
background: rgba(0, 0, 0, 0.05);
//anticon anticon-exclamation-circle
.icon-warning{
@apply text-red-500;
font-size: 20px;
//transform: translateY(5px);
margin-right: 20px;
}
.ant-modal-confirm-title{
font-size: 1rem;
line-height: 1.5rem;
font-weight: 400;
}
.ant-modal-confirm-content{
margin-top: 10px;
margin-left: 0px;
}
.ant-modal-confirm-btns{
@apply mt-6;
button{
font-size: 14px;
line-height: 1.5714285714285714;
height: 32px;
padding: 4px 15px;
border-radius: 4px;
min-width: 88px;
} }
} }
} }
@ -391,7 +405,11 @@
.ant-message { .ant-message {
z-index: var(--message-z-index); z-index: var(--message-z-index);
} }
.background-template-popover{
.ant-popover-inner{
background-color: #E9E9E9;
}
}
.ant-modal-root { .ant-modal-root {
.ant-modal-mask { .ant-modal-mask {
@apply bg-black/20; @apply bg-black/20;
@ -412,7 +430,7 @@
} }
.ant-modal-confirm-btns { .ant-modal-confirm-btns {
margin-top: 40px; @apply mt-6;
} }
} }
} }
@ -429,24 +447,48 @@
} }
.article-title { .article-title {
@apply px-6 pt-10 pb-6; border-bottom: 1px solid rgba(0,0,0,0.09);
} }
.article-body { .article-body {
@apply p-6 @apply p-6 pt-1;
} }
.modal-control-footer { .modal-control-footer {
@apply p-6 @apply p-6
} }
.hot-news-list{
@apply focus-within:bg-[#e6ebf1] focus-within:border-gray-100;
}
.input-box { .input-box {
// focus-within:shadow // focus-within:shadow
@apply bg-[#f8f8f8] border border-transparent w-full px-4 py-2 focus-within:bg-[#f0f0f0] focus-within:border-gray-300; @apply text-base bg-[#f8f8f8] border border-transparent w-full px-3 focus-within:bg-[#f3f3f3] focus-within:border-gray-100;
border-radius: 8px; border-radius: 8px;
color:#3d3d3d;
}
.main-human-text{
@apply focus-within:bg-[#e6ebf1] focus-within:border-gray-100;
}
.main-human-text-input{
// focus-within:shadow
@apply text-base bg-[#f8f8f8] border border-transparent w-full p-2;
min-height: 100%;
} }
} }
.icon-language{
@apply relative text-gray-500 p-1.5 hover:bg-[#e3eeff] hover:text-gray-600 rounded cursor-pointer text-xl;
}
@keyframes animation_loading {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.icon-generating{
animation: animation_loading 6s linear infinite;
}
// 全局按钮 // 全局按钮
.page-action { .page-action {
@apply fixed right-10 bottom-10 flex flex-col gap-4 z-10; @apply fixed right-10 bottom-10 flex flex-col gap-4 z-10;
@ -456,6 +498,7 @@
flex: 1; flex: 1;
text-align: left; text-align: left;
padding-left: 15px; padding-left: 15px;
margin-right: 10px;
} }
&:disabled { &:disabled {

View File

@ -0,0 +1,4 @@
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3726" width="256" height="256">
<path d="M896 213.333333v281.173334l-97.706667-98.133334c-16.64-16.64-43.946667-16.64-60.586666 0L597.333333 537.173333 456.96 396.8a42.496 42.496 0 0 0-60.16 0L256 537.173333 128 408.746667V213.333333c0-46.933333 38.4-85.333333 85.333333-85.333333h597.333334c46.933333 0 85.333333 38.4 85.333333 85.333333z m-128 273.92l128 128.426667V810.666667c0 46.933333-38.4 85.333333-85.333333 85.333333H213.333333c-46.933333 0-85.333333-38.4-85.333333-85.333333v-280.746667l97.706667 97.706667c16.64 16.64 43.52 16.64 60.16 0l140.8-140.8 140.373333 140.373333c16.64 16.64 43.52 16.64 60.16 0l140.8-139.946667z"
fill="#e6e6e6" />
</svg>

After

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,61 @@
import styles from './article.module.scss'
import {useTranslation} from "react-i18next";
import {Input, Switch} from "antd";
import {useMemo} from "react";
type HotNewsProps = {
news: string[];
mode: string;
onValueChange: (values: {
news: string[],
mode: string
}) => void;
}
function HotNews({news, mode, onValueChange}: HotNewsProps) {
const {t,i18n} = useTranslation()
const demoPlaceholderList = useMemo(()=>{
return i18n.language == 'zh-CN' ? [
'例:韩正会见英国汇丰集团主席',
'例: 丁薛祥出席全国高校毕业生等青年就业创业工作视频...',
'例:俄称乌方再度袭击俄能源设施 乌称击退俄军进攻',
] : [
'eg.China\'s tax policies invigorate private economy',
'eg.China\'s central bank conducts reverse repos Wednesday',
'eg.China intensifies law enforcement in cyberspace',
]
},[i18n.language])
const handleValueChange = (value: string, index: number) => {
const values = [...news]
values[index] = value
onValueChange({news: values, mode})
}
return (
<div className={`${styles.hotNews} mt-3`}>
<div className="flex justify-between">
<div className="area-title">
<span className="title">{t("modal.hot_news.title")}</span>
</div>
<div className="mode">
<span className="mr-2">{mode == 'auto' ? t("modal.hot_news.edit_auto") : t("modal.hot_news.edit_manual")}</span>
<Switch size="small" checked={mode != 'auto'} onChange={checked => {
onValueChange({news, mode: checked ? 'manual' : 'auto'})
}}/>
</div>
</div>
{mode != 'auto' && <div className="hot-news-list panel-body p-3 ">
{news.map((item, index) => <div key={index} className={`hot-news-item bg-gray-50 ${index == 0?'':'mt-3'} rounded-xl`}>
<Input
variant={"borderless"}
readOnly={mode == 'auto'}
placeholder={mode != 'auto' ? demoPlaceholderList[index] : ''}
value={item}
onChange={e => handleValueChange(e.target.value, index)}/>
</div>)}
</div>}
</div>
)
}
export default HotNews

View File

@ -46,7 +46,7 @@
@apply flex gap-4; @apply flex gap-4;
:global{ :global{
.area-title{ .area-title{
@apply text-gray-400 text-sm text-gray-800; @apply text-gray-400 text-base text-gray-800;
} }
.digital-person{ .digital-person{
width: 450px; width: 450px;
@ -133,3 +133,9 @@
.textarea { .textarea {
@apply border-0 @apply border-0
} }
// hot news
.hotNews{
.title{}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -8,6 +8,7 @@ import ImageList from "@/components/article/list.tsx";
import { BlockText} from "./item.tsx"; import { BlockText} from "./item.tsx";
import styles from './article.module.scss' import styles from './article.module.scss'
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
@ -86,8 +87,9 @@ export default function ArticleBlock(
rootClassName={'popconfirm-main'} rootClassName={'popconfirm-main'}
placement={'left'} placement={'left'}
arrow={false} arrow={false}
icon={<IconWarningCircle/>} icon={<ModalWarning.Icon />}
title={<div style={{minWidth: 150}}><span>{t('news.edit_delete_group_confirm')}</span></div>} title={<ModalWarning.Title />}
description={<div style={{minWidth: 150}}><span>{t('news.edit_delete_group_confirm')}</span></div>}
onConfirm={onRemove} onConfirm={onRemove}
okText={t('delete')} okText={t('delete')}
cancelText={t('cancel')} cancelText={t('cancel')}

View File

@ -1,17 +1,25 @@
import {Modal} from "antd"; import { Modal, App, Radio, Popover } from 'antd';
import ArticleGroup from "@/components/article/group.tsx"; import React, { useEffect, useState } from 'react';
import {useEffect, useState} from "react"; import { useSetState } from 'ahooks';
import {useSetState} from "ahooks"; import { useTranslation } from 'react-i18next';
import * as article from "@/service/api/article.ts"; import { TFunction } from 'i18next';
import {regenerate} from "@/service/api/video.ts";
import {push2video} from "@/service/api/article.ts"; import * as article from '@/service/api/article.ts';
import {showErrorToast, showToast} from "@/components/message.ts"; import { regenerate } from '@/service/api/video.ts';
import {useTranslation} from "react-i18next"; import { push2video } from '@/service/api/article.ts';
import { showErrorToast, showToast } from '@/components/message.ts';
import ArticleGroup, { HotNewsData } from '@/components/article/group.tsx';
import type { HookAPI as ModalHookAPI } from 'antd/es/modal/useModal';
import { IconWarningCircle } from '@/components/icons';
import Bg1 from './assets/bg1.jpg'
import Bg2 from './assets/bg2.jpg'
type Props = { type Props = {
id?: number; id?: number;
type: 'news' | 'video'; type: 'news' | 'video';
onClose?: (saved?: boolean) => void; onClose?: (saved?: boolean) => void;
onRefresh?: () => void
} }
const DEFAULT_STATE = { const DEFAULT_STATE = {
@ -20,15 +28,15 @@ const DEFAULT_STATE = {
msgTitle: '', msgTitle: '',
msgGroup: '', msgGroup: '',
error: '' error: ''
} };
function pushBlocksToGroup(blocks: BlockContent[], groups: BlockContent[][]) { function pushBlocksToGroup(blocks: BlockContent[], groups: BlockContent[][]) {
const lastGroup = groups[groups.length - 1] const lastGroup = groups[groups.length - 1];
if (lastGroup && lastGroup.filter(s => s.type == 'text').length == 0) { if (lastGroup && lastGroup.filter(s => s.type == 'text').length == 0) {
// 如果上一个group中没有文本则直接合并 // 如果上一个group中没有文本则直接合并
lastGroup.push(...blocks) lastGroup.push(...blocks);
} else { } else {
groups.push(blocks) groups.push(blocks);
} }
} }
@ -39,132 +47,217 @@ function rebuildGroups(groups: BlockContent[][]) {
if (!blocks) return; if (!blocks) return;
blocks = blocks.filter(s => !!s).sort((a, b) => { blocks = blocks.filter(s => !!s).sort((a, b) => {
if (a.type == 'text' && b.type == 'text') return 1; if (a.type == 'text' && b.type == 'text') return 1;
return a.type == 'text' ? -1 : 1 return a.type == 'text' ? -1 : 1;
}) });
if (blocks.length == 1) { if (blocks.length == 1) {
if (index == 0) _groups.push(blocks) if (index == 0) _groups.push(blocks);
else pushBlocksToGroup(blocks, _groups) else pushBlocksToGroup(blocks, _groups);
} else { } else {
if (index == 0) { if (index == 0) {
_groups.push([blocks[0]]) _groups.push([blocks[0]]);
_groups.push(blocks.slice(1)) _groups.push(blocks.slice(1));
} else { } else {
pushBlocksToGroup(blocks, _groups) pushBlocksToGroup(blocks, _groups);
} }
} }
}); });
if (_groups.length < 2) { if (_groups.length < 2) {
Array(2 - _groups.length).fill([{type: 'text', content: ''}]).forEach((it) => { Array(2 - _groups.length).fill([{ type: 'text', content: '' }]).forEach((it) => {
_groups.push(it) _groups.push(it);
}) });
} }
// console.log('rebuildGroups', _groups) // console.log('rebuildGroups', _groups)
return _groups; return _groups;
} }
function groupHasImageAndText(blocks: BlockContent[]) { function groupHasImageAndText(blocks: BlockContent[]) {
return blocks.some(s=>s.type == 'image' && s.content.trim().length > 0) && blocks.some(s=>s.type == 'text' && s.content.trim().length > 0) return blocks.some(s => s.type == 'image' && s.content.trim().length > 0) && blocks.some(s => s.type == 'text' && s.content.trim().length > 0);
} }
// 验证分组数据是否合法 // 验证分组数据是否合法
function checkGroupsValid(groups: BlockContent[][]) { function checkGroupsValid(_groups: BlockContent[][]) {
const groups = _groups.filter((_, index) => {
if (index == 0) return true;
return _.length > 1 || (_.length == 1 && _[0].content.trim().length > 0);
});
if (groups.length == 1) return true; if (groups.length == 1) return true;
for (let index = 1;index< groups.length; index ++) { for (let index = 1; index < groups.length; index++) {
if(!groupHasImageAndText(groups[index])) return false; if (!groupHasImageAndText(groups[index])) return false;
} }
return true; return true;
} }
export default function ArticleEditModal(props: Props) { function checkHotNewsValid(hotNews: HotNewsData, modal: ModalHookAPI, t: TFunction<'translation', undefined>) {
const {t} = useTranslation() return new Promise<boolean>((resolve) => {
const [groups, setGroups] = useState<BlockContent[][]>([]);
const [title, setTitle] = useState('')
// 验证热点新闻数据是否正确
if (hotNews.mode == 'manual' && hotNews.list.filter(s => s.trim().length > 0).length < 3) {
modal.warning({
wrapClassName: 'root-modal-confirm',
icon: <span className="anticon anticon-exclamation-circle"><IconWarningCircle /></span>,
title: t('modal.hot_news.empty_notice_title'),
content: <span dangerouslySetInnerHTML={{ __html: t('modal.hot_news.empty_notice_message') }}></span>,
centered: true,
onOk: () => {
resolve(false);
},
onCancel: () => {
resolve(false);
}
});
return;
}
resolve(true);
});
}
export default function ArticleEditModal(props: Props) {
const { t, i18n } = useTranslation();
const { modal } = App.useApp();
const [groups, setGroups] = useState<BlockContent[][]>([]);
const [title, setTitle] = useState('');
const [tag, setTag] = useState('');
const [articleTemplateInfo, setArticleTemplateInfo] = useState<ArticleTemplateInfo>({
select:'',
options:[]
});
const [hotNews, setHotNews] = useState<HotNewsData>({
list: ['', '', ''],
mode: 'auto'
});
const [state, setState] = useSetState({ const [state, setState] = useSetState({
...DEFAULT_STATE, ...DEFAULT_STATE,
generating:false generating: false,
}) pushed: false
});
// 保存数据 // 保存数据
const handleSave = () => { const handleSave = async () => {
setState({error: ''}) setState({ error: '' });
if (!title) { if (!title) {
// setState({msgTitle: '请输入标题内容'}); // setState({msgTitle: '请输入标题内容'});
return; return;
} }
if(i18n.language == 'zh-CN' && tag.length > 4 ){
// 获取图文设置不正确的数据
setState({ msgGroup: t('news.edit.tag_length_error') });
return;
}
if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) { if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) {
setState({msgGroup: t('news.edit_empty_human_content')}); setState({ msgGroup: t('news.edit_empty_human_content') });
return; return;
} }
// 验证图文都存在时,文图是否匹配 // 验证图文都存在时,文图是否匹配
if(!checkGroupsValid(groups)) { if (!checkGroupsValid(groups)) {
// 获取图文设置不正确的数据 // 获取图文设置不正确的数据
setState({msgGroup: t('news.edit_empty_group_content')}); setState({ msgGroup: t('news.edit_empty_group_content') });
return; return;
} }
const hotNewsValid = await checkHotNewsValid(hotNews, modal, t);
if (!hotNewsValid) return;
// if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) { // if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) {
// // setState({msgGroup: '请输入正文文本内容'}); // // setState({msgGroup: '请输入正文文本内容'});
// return; // return;
// } // }
const save = props.type == 'news' ? article.save : regenerate const save = props.type == 'news' ? article.save : regenerate;
setState({loading: true}) setState({ loading: true });
save(title, groups[0][0].content, groups.slice(1), props.id && props.id > 0 ? props.id : undefined).then(() => { save({
props.onClose?.(true) title,
metahuman_text: groups[0][0].content,
content_group: groups.slice(1),
hot_news: hotNews.mode == 'auto' ? [''] : hotNews.list,
video_tag:tag,
background: articleTemplateInfo.select,
id: props.id && props.id > 0 ? props.id : undefined
}).then(() => {
props.onClose?.(true);
}).catch(e => { }).catch(e => {
setState({error: e.message || t('news.edit_save_failed')}) setState({ error: e.message || t('news.edit_save_failed') });
}).finally(() => { }).finally(() => {
setState({loading: false}) setState({ loading: false });
}); });
} };
const handlePush2Video = async () =>{ const handlePush2Video = async () => {
if (state.pushed) return;
if (!title) { if (!title) {
// setState({msgTitle: '请输入标题内容'}); // setState({msgTitle: '请输入标题内容'});
return; return;
} }
if(i18n.language == 'zh-CN' && tag.length > 4 ){
// 获取图文设置不正确的数据
setState({ msgGroup: t('news.edit.tag_length_error') });
return;
}
if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) { if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) {
setState({msgGroup: t('news.edit_empty_human_content')}); setState({ msgGroup: t('news.edit_empty_human_content') });
return; return;
} }
// 验证图文都存在时,文图是否匹配 // 验证图文都存在时,文图是否匹配
if(!checkGroupsValid(groups)) { if (!checkGroupsValid(groups)) {
// 获取图文设置不正确的数据 // 获取图文设置不正确的数据
setState({msgGroup: t('news.edit_empty_group_content')}); setState({ msgGroup: t('news.edit_empty_group_content') });
return; return;
} }
if(!props.id || state.generating) return; if (!props.id || state.generating) return;
setState({generating:true}) const hotNewsValid = await checkHotNewsValid(hotNews, modal, t);
await article.save(title, groups[0][0].content, groups.slice(1), props.id) if (!hotNewsValid) return;
setState({ generating: true });
await article.save({
title,
metahuman_text: groups[0][0].content,
content_group: groups.slice(1),
hot_news: hotNews.mode == 'auto' ? [''] : hotNews.list,
video_tag:tag,
background: articleTemplateInfo.select,
id: props.id
});
push2video([props.id]).then(() => { push2video([props.id]).then(() => {
showToast(t('news.push_stream_success'), 'success') showToast(t('news.push_stream_success'), 'success');
setState({ pushed: true });
props.onClose?.(true);
// props.onRefresh?.();
// navigate('/create?state=push-success',{ // navigate('/create?state=push-success',{
// state: 'push-success' // state: 'push-success'
// }) // })
// props.onSuccess?.() // props.onSuccess?.()
}).catch(showErrorToast).finally(()=>{ }).catch(showErrorToast).finally(() => {
setState({generating:false}) setState({ generating: false });
}) });
} };
useEffect(() => { useEffect(() => {
setState({...DEFAULT_STATE}) setState({ ...DEFAULT_STATE });
if (typeof (props.id) != 'undefined') { if (typeof (props.id) != 'undefined') {
// 如果传入了id则获取数据 // 如果传入了id则获取数据
if (props.id > 0) { if (props.id > 0) {
article.getById(props.id).then(res => { article.getById(props.id).then(res => {
setGroups(rebuildGroups([[{content: res.metahuman_text, type: "text"}], ...res.content_group])) if (res.hot_news) {
setTitle(res.title) const len = res.hot_news.length;
}) const list = len >= 3 ? res.hot_news : res.hot_news.concat(Array(3 - len).fill(''));
const mode = res.hot_news && res.hot_news.filter(s => s.length > 0).length == 3 ? 'manual' : 'auto';
setHotNews({
list,
mode
});
}
setGroups(rebuildGroups([[{ content: res.metahuman_text, type: 'text' }], ...res.content_group]));
setTitle(res.title);
setTag(res.video_tag)
setArticleTemplateInfo(res.template_info)
});
} else { } else {
// 新增 // 新增
setGroups([]) setGroups([]);
setTitle('') setTitle('');
} }
} }
}, [props.id]) }, [props.id]);
return (<Modal return (<Modal
title={null} title={null}
centered={true} centered={true}
rootClassName={"article-edit-modal"} rootClassName={'article-edit-modal'}
open={props.id != undefined && props.id >= 0} open={props.id != undefined && props.id >= 0}
maskClosable={false} maskClosable={false}
keyboard={false} keyboard={false}
@ -172,35 +265,80 @@ export default function ArticleEditModal(props: Props) {
footer={null} footer={null}
closeIcon={null} closeIcon={null}
onCancel={() => props.onClose?.()} onCancel={() => props.onClose?.()}
okButtonProps={{loading: state.loading}} okButtonProps={{ loading: state.loading }}
onOk={handleSave} onOk={handleSave}
okText={props.type == 'news' ? t('confirm_text') : t('news.edit_generate_video_again')} okText={props.type == 'news' ? t('confirm_text') : t('news.edit_generate_video_again')}
> >
<div className="article-title mt-5"> <div className="mt-5 px-6 pt-10">
<input className={'input-box text-lg'} value={title} onChange={e => { <div className="flex items-center pb-3 article-title">
setTitle(e.target.value) <span className="mr-2 text-lg">{t('news.title')}</span>
setState({msgTitle: e.target.value ? '' : t('news.edit_notice_enter_article_title1')}) <input className={'input-box text-lg flex-1 py-2'} value={title} onChange={e => {
}} placeholder={t('news.edit_notice_enter_article_title')}/> setTitle(e.target.value);
<div className="text-red-500">{state.msgTitle}</div> setState({ msgTitle: e.target.value ? '' : t('news.edit_notice_enter_article_title1') });
}} placeholder={t('news.edit_notice_enter_article_title')} />
</div>
<div className="text-red-500 mt-2">{state.msgTitle}</div>
</div> </div>
<div className="article-body"> <div className="article-body">
<div className="box"> <div className="box text-base">
<ArticleGroup <ArticleGroup
errorMessage={state.msgGroup} editable groups={groups} errorMessage={state.msgGroup}
onChange={list => { editable
setGroups(() => list) groups={groups}
setState({msgGroup: (list.length == 0 || list[0].length == 0 || !list[0][0].content) ? t('news.edit_empty_human_content') : ''}); hotNews={hotNews}
onChange={(list, hotNews) => {
setHotNews(hotNews);
setGroups(() => list);
setState({ msgGroup: (list.length == 0 || list[0].length == 0 || !list[0][0].content) ? t('news.edit_empty_human_content') : '' });
}} }}
leftPanelHeader={<div>
<div className="row tag flex items-center mt-2">
<span className="mr-2">{t('news.edit.tag')}</span>
<input className={'input-box flex-1 py-1.5'} value={tag} onChange={e => {
setTag(e.target.value);
}} placeholder={t('news.edit.tag_placeholder')} />
</div>
<div className="row bg flex items-center my-3">
<span className="mr-2">{t('news.edit.bg')}</span>
<div className="bg-radio-container">
<Radio.Group
value={articleTemplateInfo.select}
onChange={e=>{
setArticleTemplateInfo(prev=>(
{
...prev,
select: e.target.value
}
))
}}>
{articleTemplateInfo.options.map((opt,idx)=>(
<Popover
rootClassName="background-template-popover"
key={idx} placement="bottomLeft" arrow={false}
content={<img className="w-[150px] rounded" src={opt.background} />}
>
<Radio value={opt.template_id}>{t('news.edit.bg')}{idx + 1}</Radio></Popover>
))}
</Radio.Group>
</div>
</div>
</div>}
/> />
<div className="text-red-500">{state.msgGroup}</div> <div className="text-red-500 mt-2">{state.msgGroup}</div>
</div> </div>
{state.error && <div className="text-red-500">{state.error}</div>} {state.error && <div className="text-red-500 mt-2">{state.error}</div>}
</div> </div>
<div className="modal-control-footer flex justify-end"> <div className="modal-control-footer flex justify-end">
<div className="flex gap-10 "> <div className="flex gap-10 ">
{props.type == 'news' && props.id ? <button className="text-gray-400 hover:text-gray-800" onClick={handlePush2Video}>{t('news.edit_generate_video')}{state.generating?'推送中...':''}</button> : null} {props.type == 'news' && props.id ? <button
className="text-gray-400 hover:text-gray-800"
onClick={handlePush2Video}
>
{t('news.edit_generate_video')}{state.pushed ? `${i18n.language == 'zh-CN' ? '中' : ''}...` : (state.generating ? `${i18n.language == 'zh-CN' ? '推送中' : 'Pushing'}...` : '')}
</button> : null}
<button className="text-gray-400 hover:text-gray-800" onClick={() => props.onClose?.()}>{t('cancel')}</button> <button className="text-gray-400 hover:text-gray-800" onClick={() => props.onClose?.()}>{t('cancel')}</button>
<button onClick={handleSave} className="text-gray-800 hover:text-blue-500">{props.type == 'news' ? t('confirm_text') : t('news.edit_generate_again')}</button> <button onClick={handleSave}
className="text-gray-800 hover:text-blue-500">{props.type == 'news' ? t('news.save_text') : t('news.edit_generate_again')}</button>
</div> </div>
</div> </div>
</Modal>); </Modal>);

View File

@ -6,23 +6,30 @@ import {showToast} from "@/components/message.ts";
import React from "react"; import React from "react";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {IconAdd} from "@/components/icons"; import {IconAdd} from "@/components/icons";
import HotNews from "@/components/article/HotNews.tsx";
export type HotNewsData = {
list: string[];
mode: string
}
type Props = { type Props = {
groups: BlockContent[][]; groups: BlockContent[][];
editable?: boolean; editable?: boolean;
onChange?: (groups: BlockContent[][]) => void; onChange?: (groups: BlockContent[][], hotNews: HotNewsData) => void;
errorMessage?: string; errorMessage?: string;
hotNews: HotNewsData;
leftPanelHeader?: React.ReactNode;
} }
export default function ArticleGroup({groups, editable, onChange, errorMessage}: Props) { export default function ArticleGroup({groups, editable, onChange, errorMessage, hotNews, leftPanelHeader}: Props) {
const {t,i18n} = useTranslation() const {t, i18n} = useTranslation()
// const groups = rebuildGroups(_groups) // const groups = rebuildGroups(_groups)
/** /**
* *
* @param insertIndex -1 * @param insertIndex -1
*/ */
const handleAddGroup = (insertIndex: number,checkId:number) => { const handleAddGroup = (insertIndex: number, checkId: number) => {
// && insertIndex !== 1 // && insertIndex !== 1
if (checkId > 0 && checkId < groups.length) { if (checkId > 0 && checkId < groups.length) {
//const index = insertIndex == -1 || insertIndex >= groups.length ? groups.length - 1 : insertIndex - 1 //const index = insertIndex == -1 || insertIndex >= groups.length ? groups.length - 1 : insertIndex - 1
@ -41,36 +48,49 @@ export default function ArticleGroup({groups, editable, onChange, errorMessage}:
} else { } else {
_groups.splice(insertIndex, 0, newGroup) _groups.splice(insertIndex, 0, newGroup)
} }
onChange?.(_groups) onChange?.(_groups, hotNews)
} }
const handleDigitalPersonContentChange = (content:string) => { const handleDigitalPersonContentChange = (content: string) => {
groups[0] = [{type: 'text', content}] groups[0] = [{type: 'text', content}]
onChange?.([...groups]) onChange?.([...groups], hotNews)
} }
return <div className={styles.group}> return <div className={styles.group}>
<div className={'panel digital-person'}> <div className={'panel digital-person h-[544px]'}>
{leftPanelHeader}
<div className="area-title"> <div className="area-title">
<span className="">{t('news.edit_digital_text')}</span> <span className="">{t('news.edit_digital_text')}</span>
{i18n.language == 'zh-CN' && <span className="text-gray-400"></span>} {i18n.language == 'zh-CN' && <span className="text-gray-400"></span>}
</div> </div>
<div className="panel-body p-3"> <div className="panel-body p-3 flex-1 main-human-text">
{/* value={groups || groups[0][0].content}*/} {/* value={groups || groups[0][0].content}*/}
<div className="h-[486px] pt-2 rounded-xl overflow-hidden bg-gray-50"> <div className={`h-full rounded-xl overflow-hidden bg-gray-50`}>
{editable ? <div className="relative"> <div className="human-tts h-full">
<Input.TextArea {editable ? <div className="relative h-full">
placeholder={t('news.edit_notice_enter_text')} <Input.TextArea
value={groups && groups.length > 0 ? groups[0][0].content : ''} placeholder={t('news.edit_notice_enter_text')}
autoSize={{minRows: 20, maxRows: 21}} className="main-human-text-input"
variant={"borderless"} value={groups && groups.length > 0 ? groups[0][0].content : ''}
onChange={e => { autoSize={{maxRows: hotNews.mode == 'auto'?15:8}}
handleDigitalPersonContentChange(e.target.value) variant={"borderless"}
}} onChange={e => {
/> handleDigitalPersonContentChange(e.target.value)
</div> : <p className="p-2">{groups && groups.length > 0 ? groups[0][0].content : ''}</p>} }}
/>
</div> : <p className="p-2">{groups && groups.length > 0 ? groups[0][0].content : ''}</p>}
</div>
</div> </div>
</div> </div>
<div className="hot-news-container">
<HotNews
news={hotNews.list} mode={hotNews.mode}
onValueChange={(hotNews) => {
onChange?.([...groups], {
list:hotNews.news,mode: hotNews.mode
})
}}/>
</div>
</div> </div>
<div className={"panel groups-list flex-1"}> <div className={"panel groups-list flex-1"}>
<div className={"area-title"}> <div className={"area-title"}>
@ -81,9 +101,12 @@ export default function ArticleGroup({groups, editable, onChange, errorMessage}:
<div className="panel-body py-3"> <div className="panel-body py-3">
<div className="max-h-[485px] overflow-auto py-4"> <div className="max-h-[485px] overflow-auto py-4">
{editable && groups.length == 1 && <div className={`${styles.blockContainer} group`}><div className={'divider-container before'}><Divider> {editable && groups.length == 1 && <div className={`${styles.blockContainer} group`}>
<span onClick={()=>handleAddGroup?.(1,1)} className="article-action-add" title={t('news.materials.add_group')}><IconAdd style={{fontSize: 24}}/></span> <div className={'divider-container before'}><Divider>
</Divider></div></div> } <span onClick={() => handleAddGroup?.(1, 1)} className="article-action-add"
title={t('news.materials.add_group')}><IconAdd style={{fontSize: 24}}/></span>
</Divider></div>
</div>}
{groups.map((g, index) => ( {groups.map((g, index) => (
index == 0 ? null : <ArticleBlock index == 0 ? null : <ArticleBlock
@ -92,20 +115,20 @@ export default function ArticleGroup({groups, editable, onChange, errorMessage}:
blocks={g} blocks={g}
onChange={(blocks) => { onChange={(blocks) => {
groups[index] = blocks groups[index] = blocks
onChange?.([...groups]) onChange?.([...groups], hotNews)
}} }}
errorMessage={errorMessage} errorMessage={errorMessage}
index={index} index={index}
onAdd={(_index,checkIndex) => { onAdd={(_index, checkIndex) => {
handleAddGroup?.(_index ? _index :index + 1,checkIndex) handleAddGroup?.(_index ? _index : index + 1, checkIndex)
}} }}
disableRemoveMessage={groups.length <= 1?t('news.edit_notice_keep_1'):''} disableRemoveMessage={groups.length <= 1 ? t('news.edit_notice_keep_1') : ''}
onRemove={async () => { onRemove={async () => {
if (groups.length <= 1) { if (groups.length <= 1) {
message.warning(t('news.edit_notice_keep_1')) message.warning(t('news.edit_notice_keep_1'))
return; return;
} }
onChange?.(groups.filter((_, idx) => index !== idx)) onChange?.(groups.filter((_, idx) => index !== idx), hotNews)
}} }}
/> />
))} ))}
@ -113,7 +136,7 @@ export default function ArticleGroup({groups, editable, onChange, errorMessage}:
</div> </div>
</div> </div>
{groups.length == 0 && editable && {groups.length == 0 && editable &&
<ArticleBlock editable onChange={blocks => onChange?.([blocks])} index={0} <ArticleBlock editable onChange={blocks => onChange?.([blocks],hotNews)} index={0}
blocks={[{type: 'text', content: ''}]}/>} blocks={[{type: 'text', content: ''}]}/>}
</div> </div>
} }

View File

@ -2,12 +2,14 @@ import React, {useState} from "react";
import {Input, Popconfirm, Spin, Upload, UploadProps} from "antd"; import {Input, Popconfirm, Spin, Upload, UploadProps} from "antd";
import {CloseOutlined} from "@ant-design/icons"; import {CloseOutlined} from "@ant-design/icons";
import {clsx} from "clsx"; import {clsx} from "clsx";
import {useTranslation} from "react-i18next";
import styles from './article.module.scss' import styles from './article.module.scss'
import {getOssPolicy} from "@/service/api/common.ts"; import {getOssPolicy} from "@/service/api/common.ts";
import {showToast} from "@/components/message.ts"; import {showToast} from "@/components/message.ts";
import {IconAddImage, IconWarningCircle} from "@/components/icons"; import {IconAddImage} from "@/components/icons";
import {useTranslation} from "react-i18next"; import {ModalWarningIcon, ModalWarningTitle} from "@/components/icons/ModalWarning.tsx";
import { BizError } from '@/service/types.ts';
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
@ -37,6 +39,10 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
}); });
const beforeUpload = async (file: any) => { const beforeUpload = async (file: any) => {
try { try {
// 判断文件类型
if (!MimeTypes.includes(file.type)) {
throw new Error('upload_file_type_error')
}
// 因为有超时问题,所以每次上传都重新获取参数 // 因为有超时问题,所以每次上传都重新获取参数
Data.uploadConfig = await getOssPolicy(); Data.uploadConfig = await getOssPolicy();
const suffix = file.name.slice(file.name.lastIndexOf('.')); const suffix = file.name.slice(file.name.lastIndexOf('.'));
@ -52,17 +58,22 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
const onUploadChange = async (info) => { const onUploadChange = async (info) => {
if (info.fileList.length == 0) return; if (info.fileList.length == 0) return;
const file = info.fileList[0]; const file = info.fileList[0];
console.log('onChange', file); console.log('onUploadChange', file);
if (file.status == 'done') { if (file.status == 'done') {
setLoading(-1) 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') { } else if (file.status == 'error') {
setLoading(-1)
showToast(t('upload.upload_failed'), 'warning') if (!MimeTypes.includes(file.type)) {
showToast(t('upload.upload_file_type_error'), 'warning');
return;
}
setLoading(-1);
showToast(t('upload.upload_failed'), 'warning');
} else if (file.status == 'uploading') { } else if (file.status == 'uploading') {
setLoading(file.percent) setLoading(file.percent);
} }
} };
// //
return <div className={styles.image}> return <div className={styles.image}>
{editable && onlyUpload ? <div className={'relative'}> {editable && onlyUpload ? <div className={'relative'}>
@ -104,8 +115,9 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
rootClassName={'popconfirm-main'} rootClassName={'popconfirm-main'}
placement={'right'} placement={'right'}
arrow={false} arrow={false}
icon={<IconWarningCircle/>} icon={<ModalWarningIcon/>}
title={<div style={{minWidth: 150}}><span>{t('upload.delete_confirm')}</span></div>} title={<ModalWarningTitle/>}
description={<div style={{minWidth: 150}}><span>{t('upload.delete_confirm')}</span></div>}
onConfirm={onRemove} onConfirm={onRemove}
okText={t('delete')} okText={t('delete')}
cancelText={t('cancel')} cancelText={t('cancel')}

View File

@ -7,6 +7,7 @@ import {BizError} from "@/service/types.ts";
import {IconWarningCircle} from "@/components/icons"; import {IconWarningCircle} from "@/components/icons";
import {LoadingOutlined} from "@ant-design/icons"; import {LoadingOutlined} from "@ant-design/icons";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
type Props = { type Props = {
selected: any[], selected: any[],
@ -57,10 +58,12 @@ export default function ButtonBatch(
if(confirmMessage){ if(confirmMessage){
modal.confirm({ modal.confirm({
wrapClassName: 'root-modal-confirm', wrapClassName: 'root-modal-confirm',
title: <span dangerouslySetInnerHTML={{__html:title || t('confirm.title')}}></span>, title: <ModalWarning.Title />,
centered: true, centered: true,
icon: <span className="anticon anticon-exclamation-circle"><IconWarningCircle/></span>, icon: <ModalWarning.Icon />,
content: confirmMessage, content: <div>
<div>{confirmMessage}</div>
</div>,
onOk: onBatchProcess onOk: onBatchProcess
}) })
}else{ }else{

View File

@ -0,0 +1,17 @@
import {IconWarningCircle} from "@/components/icons/index.tsx";
import React from "react";
import {useTranslation} from "react-i18next";
export function ModalWarningIcon({size=24}:{size?:number}) {
return <IconWarningCircle
style={{fontSize: size, color: 'rgba(250, 173, 20, 1)'}}/>
}
export function ModalWarningTitle(){
const {t} = useTranslation()
return <span className="text-base">{t('modal.warning')}</span>
}
const ModalWarning = {
Icon: ModalWarningIcon,
Title: ModalWarningTitle
}
export default ModalWarning

View File

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import RecycleIndex from "@/pages/recycle";
type IconProps = { style?: React.CSSProperties; className?: string; } type IconProps = { style?: React.CSSProperties; className?: string; }
@ -59,6 +60,43 @@ export const IconDownload = ({style, className}: IconProps) => (
</svg> </svg>
) )
export const IconOrderFill = ({style, className}: IconProps) => (
<svg
className={`svg-icon ${className || ''} icon-download`} style={style}
xmlns="http://www.w3.org/2000/svg"
fill="none" width="1em" height="1em" viewBox="0 0 22 22"
>
<path
d="M1.15485 4.02687C0.839494 4.03116 0.539152 4.16202 0.322478 4.39157C0.105803 4.61897 -0.0100427 4.92575 0.000683739 5.2411C-0.00146155 5.88898 0.506973 6.42531 1.15485 6.45534H3.46533C3.78069 6.45105 4.08103 6.32019 4.29771 6.09064C4.51438 5.86324 4.63023 5.55646 4.6195 5.2411C4.62165 4.59323 4.11321 4.0569 3.46533 4.02687H1.15485ZM1.15485 9.98006C0.839494 9.98435 0.539152 10.1152 0.322478 10.3448C0.105803 10.5722 -0.0100427 10.8789 0.000683739 11.1943C-0.00146155 11.8422 0.506973 12.3785 1.15485 12.4085H3.46533C3.78069 12.4042 4.08103 12.2734 4.29771 12.0438C4.51438 11.8164 4.63023 11.5097 4.6195 11.1943C4.62165 10.5464 4.11321 10.0101 3.46533 9.98006H1.15485ZM1.15485 15.9912C0.532717 16.0555 0.0628972 16.5811 0.0628973 17.2054C0.0628973 17.8297 0.534862 18.3531 1.15485 18.4196H3.46533C4.08747 18.3531 4.55729 17.8297 4.55729 17.2054C4.55729 16.5811 4.08532 16.0577 3.46533 15.9912H1.15485ZM20.8186 0.0216038H3.40741C3.09205 0.0258944 2.79171 0.156757 2.57504 0.386304C2.35836 0.613705 2.24252 0.920482 2.25324 1.23584V2.81263H3.40741C4.69244 2.84481 5.71789 3.896 5.71789 5.18104C5.72862 5.80317 5.49049 6.40171 5.05714 6.84578C4.62379 7.28986 4.02955 7.543 3.40956 7.54944H2.25539V8.76368H3.40956C4.69459 8.79585 5.72004 9.84705 5.72004 11.1321C5.73076 11.7542 5.49264 12.3528 5.05929 12.7968C4.62594 13.2409 4.03169 13.494 3.4117 13.5005H2.25753V14.7147H3.4117C4.69244 14.749 5.71789 15.8002 5.71789 17.0853C5.72862 17.7074 5.49049 18.3059 5.05714 18.75C4.62379 19.1941 4.02955 19.4472 3.40956 19.4537H2.25539V20.7752C2.25324 21.4231 2.76168 21.9594 3.40956 21.9894H20.8208C21.1361 21.9851 21.4365 21.8543 21.6531 21.6247C21.8698 21.3973 21.9857 21.0905 21.9749 20.7752V1.06636C21.9234 0.467825 21.4172 0.0130226 20.8186 0.0216038ZM17.7015 8.87738C18.1799 8.87738 18.5682 9.26567 18.5682 9.74407C18.5682 10.2225 18.1799 10.6108 17.7015 10.6108H15.1014V11.6512H17.6994C18.1778 11.6512 18.5661 12.0395 18.5661 12.5179C18.5661 12.9963 18.1778 13.3846 17.6994 13.3846H15.1014V16.2743C15.1143 16.5919 14.9512 16.89 14.6788 17.0509C14.4063 17.214 14.0652 17.214 13.7928 17.0509C13.5203 16.8879 13.3573 16.5897 13.3702 16.2743V13.3954H10.7701C10.2917 13.3954 9.90336 13.0071 9.90336 12.5287C9.90336 12.0503 10.2917 11.662 10.7701 11.662H13.368V10.6215H10.7701C10.2917 10.6215 9.90336 10.2332 9.90336 9.7548C9.90336 9.2764 10.2917 8.8881 10.7701 8.8881H12.0401L10.1372 6.98094C9.90765 6.76641 9.81111 6.44461 9.88834 6.13998C9.96557 5.83535 10.2037 5.59722 10.5083 5.51999C10.813 5.44276 11.1348 5.53715 11.3493 5.7667L14.0052 8.42472L14.0631 8.48264H14.4106L14.4685 8.42472L17.1244 5.7667C17.4634 5.45349 17.989 5.46207 18.3151 5.7903C18.6411 6.11638 18.6497 6.64198 18.3365 6.98094L16.4336 8.87738H17.7015Z"
fill="currentColor"
/>
</svg>
)
export const IconRecycleFill = ({style, className}: IconProps) => (
<svg
className={`svg-icon ${className || ''} icon-download`} style={style}
xmlns="http://www.w3.org/2000/svg"
fill="none" width="1em" height="1em" viewBox="0 0 22 22"
>
<path
d="M21.1832 3.92852H17.262V2.35739C17.2638 1.73447 17.019 1.1363 16.5814 0.694283C16.1437 0.252265 15.549 0.00255176 14.9279 0H7.08268C6.45926 0.000364354 5.86146 0.248804 5.42051 0.690782C4.97956 1.13276 4.73149 1.73216 4.73076 2.35739V3.92852H0.816386C0.675283 3.92266 0.535222 3.95514 0.411003 4.02252C0.286784 4.0899 0.183019 4.18968 0.110673 4.31132C0.0382409 4.43311 0 4.57228 0 4.71409C0 4.8559 0.0382409 4.99507 0.110673 5.11686C0.183019 5.23849 0.286784 5.33828 0.411003 5.40566C0.535222 5.47304 0.675283 5.50551 0.816386 5.49966H21.1832C21.2888 5.50409 21.3943 5.48706 21.4932 5.44958C21.5921 5.4121 21.6824 5.35495 21.7587 5.28157C21.835 5.20818 21.8957 5.12008 21.9371 5.02256C21.9786 4.92504 22 4.82011 22 4.71409C22 4.60807 21.9786 4.50314 21.9371 4.40562C21.8957 4.3081 21.835 4.21999 21.7587 4.14661C21.6824 4.07323 21.5921 4.01608 21.4932 3.9786C21.3943 3.94112 21.2888 3.92409 21.1832 3.92852ZM18.0542 6.87801H3.95091C3.74286 6.87801 3.54331 6.9608 3.39607 7.10822C3.24883 7.25563 3.16593 7.45561 3.16556 7.66427V19.644C3.16665 20.2687 3.41469 20.8676 3.85531 21.3092C4.29592 21.7509 4.89317 21.9993 5.51611 22H16.4849C17.1078 21.9993 17.7051 21.7509 18.1457 21.3092C18.5863 20.8676 18.8343 20.2687 18.8354 19.644V7.69456C18.8366 7.4827 18.7554 7.27873 18.609 7.12599C18.4626 6.97326 18.2626 6.8838 18.0514 6.87664L18.0542 6.87801ZM5.66576 16.0845C5.60496 15.9767 5.56272 15.8594 5.54082 15.7375C5.53845 15.6251 5.56797 15.5143 5.62595 15.418C5.62595 15.418 6.26439 14.3082 6.27262 14.2972C6.28086 14.2862 5.66988 13.9309 5.66988 13.9309L7.75682 13.4614L8.65338 15.6976L8.06299 15.3561L7.20351 16.7468C7.04274 17.0229 6.94126 17.3297 6.90557 17.6474C6.87595 17.9301 6.9234 18.2157 7.04287 18.4736L5.66576 16.0845ZM7.70465 18.9073C7.60718 18.8801 7.51453 18.8378 7.43005 18.782C7.30763 18.6826 7.21302 18.5531 7.15545 18.4061C7.04694 18.1357 7.00915 17.8419 7.04568 17.5527C7.0822 17.2635 7.19183 16.9885 7.36415 16.7537H10.1307V18.9114H7.70465V18.9073ZM9.61309 12.8307L7.7527 11.7525L8.96642 9.63886C9.03647 9.56856 9.11766 9.5104 9.20669 9.46673C9.35308 9.41038 9.51138 9.39237 9.66664 9.41441C9.98643 9.46441 10.287 9.59945 10.5371 9.80547C10.7304 9.97913 10.8864 10.1904 10.9957 10.4265L9.61309 12.8307ZM11.0836 10.2805C10.9264 10.0033 10.7126 9.76262 10.4561 9.57414C10.2286 9.40715 9.96083 9.30401 9.68037 9.27533H12.4263C12.5495 9.2769 12.6716 9.29924 12.7874 9.34143C12.8859 9.39611 12.9669 9.47778 13.0208 9.57689C13.0208 9.57689 13.6593 10.6867 13.6634 10.7005C13.6634 10.6922 14.2799 10.359 14.2799 10.359L13.6346 12.4093L11.2524 12.0651L11.8469 11.7236L11.0836 10.2805ZM15.0295 18.5286C14.9669 18.6352 14.8871 18.7306 14.7934 18.8109C14.6971 18.8696 14.5861 18.8992 14.4735 18.8963H13.1829C13.1677 18.8963 13.1691 19.6027 13.1691 19.6027L11.7151 18.0205L13.2007 16.1203V16.8088L14.8304 16.8597C15.1488 16.8615 15.4641 16.7963 15.7558 16.6683C16.0132 16.554 16.2351 16.3723 16.3984 16.1423L15.0254 18.5273L15.0295 18.5286ZM16.2844 16.0666C16.0817 16.3194 15.8146 16.5126 15.5114 16.6256C15.2657 16.707 15.0058 16.737 14.7481 16.7138L13.3627 14.3137L15.2217 13.2341L16.4354 15.3464C16.4721 15.47 16.477 15.6008 16.4497 15.7267C16.4223 15.8527 16.3636 15.9696 16.2789 16.0666H16.2844Z"
fill="currentColor"
/>
</svg>
)
export const IconDownloadOutline = ({style, className}: IconProps)=>(
<svg
className={`svg-icon ${className || ''} icon-download`} style={style} xmlns="http://www.w3.org/2000/svg"
fill="none" width="1em" height="1em" viewBox="0 0 22 22"
>
<path
d="M9.94889 12.052V1.05935C9.94889 0.778394 10.0596 0.508944 10.2568 0.310277C10.4539 0.11161 10.7212 0 11 0C11.2788 0 11.5461 0.11161 11.7432 0.310277C11.9404 0.508944 12.0511 0.778394 12.0511 1.05935V12.052L13.9236 10.1648C14.0204 10.0634 14.1364 9.98239 14.2648 9.92663C14.3931 9.87087 14.5311 9.84146 14.6708 9.84011C14.8106 9.83876 14.9492 9.8655 15.0785 9.91876C15.2079 9.97203 15.3254 10.0508 15.4242 10.1503C15.523 10.2499 15.6011 10.3683 15.6539 10.4987C15.7068 10.6291 15.7333 10.7687 15.732 10.9096C15.7306 11.0504 15.7015 11.1895 15.6461 11.3188C15.5908 11.4482 15.5105 11.5651 15.4098 11.6627L11.7431 15.3581C11.546 15.5567 11.2787 15.6683 11 15.6683C10.7213 15.6683 10.454 15.5567 10.2569 15.3581L6.59022 11.6627C6.48954 11.5651 6.40919 11.4482 6.35387 11.3188C6.29854 11.1895 6.26936 11.0504 6.26802 10.9096C6.26668 10.7687 6.29321 10.6291 6.34606 10.4987C6.39892 10.3683 6.47703 10.2499 6.57583 10.1503C6.67464 10.0508 6.79215 9.97203 6.9215 9.91876C7.05085 9.8655 7.18944 9.83876 7.32916 9.84011C7.46888 9.84146 7.60694 9.87087 7.73525 9.92663C7.86356 9.98239 7.97955 10.0634 8.07644 10.1648L9.94889 12.052ZM17.5181 3.52296C17.194 3.52291 16.8832 3.39311 16.654 3.1621C16.4248 2.9311 16.2961 2.61782 16.2961 2.29115C16.2961 1.96449 16.4248 1.65121 16.654 1.4202C16.8832 1.1892 17.194 1.0594 17.5181 1.05935H19.5556C20.2039 1.05935 20.8256 1.31891 21.284 1.78092C21.7425 2.24294 22 2.86957 22 3.52296V19.5364C22 20.1898 21.7425 20.8164 21.284 21.2784C20.8256 21.7404 20.2039 22 19.5556 22H2.44444C1.79614 22 1.17438 21.7404 0.715961 21.2784C0.257539 20.8164 0 20.1898 0 19.5364V3.52296C0 2.86957 0.257539 2.24294 0.715961 1.78092C1.17438 1.31891 1.79614 1.05935 2.44444 1.05935H4.48189C4.64241 1.05933 4.80136 1.09117 4.94967 1.15306C5.09798 1.21495 5.23274 1.30568 5.34625 1.42007C5.45977 1.53446 5.54981 1.67026 5.61124 1.81972C5.67268 1.96918 5.7043 2.12937 5.7043 2.29115C5.7043 2.45293 5.67268 2.61313 5.61124 2.76259C5.54981 2.91205 5.45977 3.04785 5.34625 3.16224C5.23274 3.27662 5.09798 3.36735 4.94967 3.42925C4.80136 3.49114 4.64241 3.52298 4.48189 3.52296H2.44444V19.5364H19.5556V3.52296H17.5181Z"
fill="currentColor"/>
</svg>
)
export const IconPin = ({style, className}: IconProps) => ( export const IconPin = ({style, className}: IconProps) => (
<svg className={`svg-icon ${className || ''} icon-download`} style={style} xmlns="http://www.w3.org/2000/svg" <svg className={`svg-icon ${className || ''} icon-download`} style={style} xmlns="http://www.w3.org/2000/svg"
fill="none" version="1.1" width="0.6em" height="1em" viewBox="0 0 12 21"> fill="none" version="1.1" width="0.6em" height="1em" viewBox="0 0 12 21">
@ -170,26 +208,26 @@ export const IconUnlock = ({style, className}: IconProps) => (
) )
export const IconPlaying = ({style, className}: IconProps) => ( export const IconPlaying = ({style, className}: IconProps) => (
<svg className={`svg-icon ${className || ''} icon-delete`} style={style} xmlns="http://www.w3.org/2000/svg" <svg className={`svg-icon ${className || ''} icon-playing`} style={style} xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em" viewBox="0 0 32 30" version="1.1"> width="1em" height="1em" viewBox="0 0 32 30" version="1.1">
<path d="M1 11.7057V18.2943M7 6.76424V23.2358M13 1V29M19 7.22275V22.7772M25 11.1114V18.8886M31 13.3528V16.6472" <path d="M1 11.7057V18.2943M7 6.76424V23.2358M13 1V29M19 7.22275V22.7772M25 11.1114V18.8886M31 13.3528V16.6472"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg> </svg>
) )
export const IconGenerating = ({style, className}: IconProps) => ( export const IconGenerating = ({style, className}: IconProps) => (
<svg className={`svg-icon ${className || ''} icon-delete`} style={style} xmlns="http://www.w3.org/2000/svg" <svg className={`svg-icon ${className || ''} icon-generating`} style={style} xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em" viewBox="0 0 20 20" version="1.1"> width="1em" height="1em" viewBox="0 0 20 20" version="1.1">
<path d="M3.463 2.43301C5.27751 0.860592 7.59897 -0.00342947 10 1.02307e-05C15.523 1.02307e-05 20 4.47701 20 10C20 12.136 19.33 14.116 18.19 15.74L15 10H18C18.0001 8.43163 17.5392 6.89781 16.6747 5.58927C15.8101 4.28072 14.5799 3.25517 13.1372 2.64013C11.6944 2.0251 10.1027 1.84771 8.55996 2.13003C7.0172 2.41234 5.59145 3.14191 4.46 4.22801L3.463 2.43301ZM16.537 17.567C14.7225 19.1394 12.401 20.0034 10 20C4.477 20 0 15.523 0 10C0 7.86401 0.67 5.88401 1.81 4.26001L5 10H2C1.99987 11.5684 2.46075 13.1022 3.32534 14.4108C4.18992 15.7193 5.42007 16.7449 6.86282 17.3599C8.30557 17.9749 9.89729 18.1523 11.44 17.87C12.9828 17.5877 14.4085 16.8581 15.54 15.772L16.537 17.567Z" fill="white"/> <path d="M3.463 2.43301C5.27751 0.860592 7.59897 -0.00342947 10 1.02307e-05C15.523 1.02307e-05 20 4.47701 20 10C20 12.136 19.33 14.116 18.19 15.74L15 10H18C18.0001 8.43163 17.5392 6.89781 16.6747 5.58927C15.8101 4.28072 14.5799 3.25517 13.1372 2.64013C11.6944 2.0251 10.1027 1.84771 8.55996 2.13003C7.0172 2.41234 5.59145 3.14191 4.46 4.22801L3.463 2.43301ZM16.537 17.567C14.7225 19.1394 12.401 20.0034 10 20C4.477 20 0 15.523 0 10C0 7.86401 0.67 5.88401 1.81 4.26001L5 10H2C1.99987 11.5684 2.46075 13.1022 3.32534 14.4108C4.18992 15.7193 5.42007 16.7449 6.86282 17.3599C8.30557 17.9749 9.89729 18.1523 11.44 17.87C12.9828 17.5877 14.4085 16.8581 15.54 15.772L16.537 17.567Z" fill="white"/>
</svg> </svg>
) )
export const IconGenerateFailed = ({style, className}: IconProps) => ( export const IconGenerateFailed = ({style, className}: IconProps) => (
<svg className={`svg-icon ${className || ''} icon-delete`} style={style} xmlns="http://www.w3.org/2000/svg" <svg className={`svg-icon ${className || ''} icon-generate-fail`} style={style} xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em" viewBox="0 0 20 20" version="1.1"> width="1em" height="1em" viewBox="0 0 20 20" version="1.1">
<path d="M18 0H2C0.9 0 0.00999999 0.9 0.00999999 2L0 20L4 16H18C19.1 16 20 15.1 20 14V2C20 0.9 19.1 0 18 0ZM11 12H9V10H11V12ZM11 8H9V4H11V8Z" fill="#FFA800"/> <path d="M18 0H2C0.9 0 0.00999999 0.9 0.00999999 2L0 20L4 16H18C19.1 16 20 15.1 20 14V2C20 0.9 19.1 0 18 0ZM11 12H9V10H11V12ZM11 8H9V4H11V8Z" fill="#FFA800"/>
</svg> </svg>
) )
export const IconRegenerate = ({style, className}: IconProps) => ( export const IconRegenerate = ({style, className}: IconProps) => (
<svg className={`svg-icon ${className || ''} icon-delete`} style={style} xmlns="http://www.w3.org/2000/svg" <svg className={`svg-icon ${className || ''} icon-regenerate`} style={style} xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em" viewBox="0 0 24 24" version="1.1"> width="1em" height="1em" viewBox="0 0 24 24" version="1.1">
<path d="M20.4728 3.525C19.3618 2.4074 18.0406 1.52056 16.5851 0.915578C15.1297 0.310592 13.5688 -0.000577199 11.9925 8.03759e-07C5.35835 8.03759e-07 0 5.37 0 12C0 18.63 5.35835 24 11.9925 24C17.591 24 22.2589 20.175 23.5947 15H20.4728C19.8545 16.7543 18.7067 18.2736 17.1878 19.3483C15.6688 20.4229 13.8536 21.0001 11.9925 21C7.02439 21 2.98687 16.965 2.98687 12C2.98687 7.035 7.02439 3 11.9925 3C14.4841 3 16.7054 4.035 18.3265 5.67L13.4934 10.5H24V8.03759e-07L20.4728 3.525Z" fill="currentColor"/> <path d="M20.4728 3.525C19.3618 2.4074 18.0406 1.52056 16.5851 0.915578C15.1297 0.310592 13.5688 -0.000577199 11.9925 8.03759e-07C5.35835 8.03759e-07 0 5.37 0 12C0 18.63 5.35835 24 11.9925 24C17.591 24 22.2589 20.175 23.5947 15H20.4728C19.8545 16.7543 18.7067 18.2736 17.1878 19.3483C15.6688 20.4229 13.8536 21.0001 11.9925 21C7.02439 21 2.98687 16.965 2.98687 12C2.98687 7.035 7.02439 3 11.9925 3C14.4841 3 16.7054 4.035 18.3265 5.67L13.4934 10.5H24V8.03759e-07L20.4728 3.525Z" fill="currentColor"/>
</svg> </svg>

View File

@ -0,0 +1,32 @@
import useGlobalConfig from "@/hooks/useGlobalConfig.ts";
import {useTranslation} from "react-i18next";
import React from "react";
import {useSearchParams} from "react-router-dom";
function LanguageSwitcher() {
const {i18n} = useTranslation();
const [params] = useSearchParams();
const {globalConfig} = useGlobalConfig()
const handleChangeLang = async () => {
const key = i18n.language == 'zh-CN' ? 'en-US' : 'zh-CN'
await i18n.changeLanguage(key)
globalConfig.i18n = key
localStorage.setItem('ai-human-lang',key)
}
return (
(params.get('lang') == 'yes' || AppConfig.APP_LANG == 'multiple') ?
<div className="icon-language" onClick={handleChangeLang}>
<span className="hover:bg-gray-200">
<svg
className="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em">
<path
d="M757.205333 473.173333c5.333333 0 10.453333 2.090667 14.250667 5.717334a19.029333 19.029333 0 0 1 5.888 13.738666v58.154667h141.184c11.093333 0 20.138667 8.704 20.138667 19.413333v232.704a19.797333 19.797333 0 0 1-20.138667 19.413334h-141.184v96.981333a19.754667 19.754667 0 0 1-20.138667 19.370667H716.8a20.565333 20.565333 0 0 1-14.250667-5.674667 19.029333 19.029333 0 0 1-5.888-13.696v-96.981333h-141.141333a20.565333 20.565333 0 0 1-14.250667-5.674667 19.029333 19.029333 0 0 1-5.930666-13.738667v-232.704c0-5.12 2.133333-10.112 5.930666-13.738666a20.565333 20.565333 0 0 1 14.250667-5.674667h141.141333v-58.154667c0-5.162667 2.133333-10.112 5.888-13.738666a20.565333 20.565333 0 0 1 14.250667-5.674667h40.362667zM192.597333 628.394667c22.272 0 40.32 17.365333 40.32 38.826666v38.741334c0 40.618667 32.512 74.368 74.624 77.397333l6.058667 0.213333h80.64c21.930667 0.469333 39.424 17.706667 39.424 38.784 0 21.077333-17.493333 38.314667-39.424 38.784H313.6c-89.088 0-161.28-69.461333-161.28-155.178666v-38.741334c0-21.461333 18.005333-38.826667 40.277333-38.826666z m504.106667 0h-80.64v116.394666h80.64v-116.394666z m161.28 0h-80.64v116.394666h80.64v-116.394666zM320.170667 85.333333c8.234667 0 15.658667 4.778667 18.773333 12.202667H338.773333l161.322667 387.84c2.517333 5.973333 1.706667 12.8-2.005333 18.090667a20.394667 20.394667 0 0 1-16.725334 8.533333h-43.52a20.181333 20.181333 0 0 1-18.688-12.202667L375.850667 395.648H210.901333l-43.264 104.149333A20.181333 20.181333 0 0 1 148.906667 512H105.514667a20.394667 20.394667 0 0 1-16.725334-8.533333 18.773333 18.773333 0 0 1-2.005333-18.090667l161.28-387.84A20.181333 20.181333 0 0 1 266.88 85.333333h53.290667zM716.8 162.901333c42.794667 0 83.84 16.341333 114.090667 45.44a152.234667 152.234667 0 0 1 47.232 109.738667v38.741333c-0.469333 21.077333-18.389333 37.930667-40.32 37.930667s-39.808-16.853333-40.32-37.930667v-38.741333c0-20.608-8.490667-40.32-23.637334-54.869333a82.304 82.304 0 0 0-57.045333-22.741334h-80.64c-21.888-0.469333-39.424-17.706667-39.424-38.784 0-21.077333 17.493333-38.314667 39.424-38.784h80.64z m-423.424 34.304L243.2 318.037333h100.48L293.418667 197.205333z"
fill="currentColor"/>
</svg>
</span>
</div> : null
)
}
export default LanguageSwitcher

View File

@ -0,0 +1,21 @@
import React from "react";
import {Popconfirm} from "antd";
import {useTranslation} from "react-i18next";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
export function DeleteItemPopoverConfirm({children,description,onConfirm}: {
onConfirm: ((e?: React.MouseEvent<HTMLElement>) => void) | undefined
description?: React.ReactNode;
children?: React.ReactNode;
}){
const {t} = useTranslation()
return <Popconfirm
rootClassName={'popconfirm-main'}
placement={'left'}
arrow={false}
icon={<ModalWarning.Icon />}
title={<ModalWarning.Title />}
description={<div dangerouslySetInnerHTML={{__html:description || t('modal.delete_item_confirm')}}></div>}
onConfirm={onConfirm}
>{children}</Popconfirm>
}

View File

@ -2,6 +2,7 @@ import React, {CSSProperties, useCallback, useEffect, useImperativeHandle, useRe
import {useInViewport, useScroll} from "ahooks"; import {useInViewport, useScroll} from "ahooks";
import { LoadingOutlined } from '@ant-design/icons'; import { LoadingOutlined } from '@ant-design/icons';
import {Spin} from "antd"; import {Spin} from "antd";
import {t} from "i18next";
export type InfiniteScrollerRef = { export type InfiniteScrollerRef = {
scrollToPosition: (top: number) => void scrollToPosition: (top: number) => void
@ -65,7 +66,7 @@ const InfiniteScroller = React.forwardRef<InfiniteScrollerRef, InfiniteScrollerP
{props.loading && <div style={{minHeight:'30vh'}}></div>} {props.loading && <div style={{minHeight:'30vh'}}></div>}
{props?.pagination && props.pagination.total > props.pagination.limit * props.pagination.page && (props.loadingPlaceholder || {props?.pagination && props.pagination.total > props.pagination.limit * props.pagination.page && (props.loadingPlaceholder ||
<div className="data-load-control-element py-10 text-center"> <div className="data-load-control-element py-10 text-center">
<div className="loading-text">...</div> <div className="loading-text">{t('loading')}</div>
</div>)} </div>)}
{props?.empty && !props.loading && props.pagination?.total == 0 && <div className="flex justify-center text-center pt-20"> {props?.empty && !props.loading && props.pagination?.total == 0 && <div className="flex justify-center text-center pt-20">
<div className="rounded-lg px-4 py-10"> <div className="rounded-lg px-4 py-10">

View File

@ -0,0 +1,135 @@
import React, {useEffect, useState} from "react";
// import TCPlayer from 'tcplayer.js';
import flvPlayer, { VideoPlayer } from '@/components/video/VideoPlayer.ts';
import {PlayerInstance} from "@/hooks/useCache.ts";
// 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>)
type Props = {
url?: string; cover?: string; showControls?: boolean; className?: string;
poster?: string;
onChange?: (state: State) => void;
onProgress?: (current:number,duration:number) => void;
onPause?: () => void;
onPlay?: () => void;
muted?: boolean;
autoPlay?: boolean;
}
export type PlayerInstance = {
play: (url: string, currentTime: number) => void;
pause: () => void;
getState: () => State;
}
export const Mp4Player = React.forwardRef<PlayerInstance, Props>((props, ref) => {
const [tcPlayer, setTcPlayer] = useState<VideoPlayer | null>(null)
const [prevUrl, setPrevUrl] = useState<string | undefined>();
const [state, _setState] = useState<State>({
playing: false,
muted: false,
// 是否全屏
fullscreen: false,
progress: 0,
playedSeconds: 0,
duration: 0
})
const setState = (data: StateUpdate) => {
_setState(prev => {
const _state = typeof (data) === 'function' ? {...prev, ...data(prev)} : {...prev, ...data}
props.onChange?.(_state)
return _state
})
}
useEffect(()=>{
if(props.url && tcPlayer){
tcPlayer.src(props.url)
}
},[props.url, tcPlayer])
useEffect(() => {
const player = flvPlayer.newInstance({className:props.className});
document.querySelector('.video-player-container-inner')!.appendChild(player.getVideo())
// const player = TCPlayer(playerVideo, {
// controls: props.showControls,
// poster: props.poster,
// autoplay: typeof(props.autoPlay) != 'undefined' ? props.autoPlay : true,
// licenseUrl: AppConfig.TCPlayerLicense
// }
// ) as TCPlayerInstance;
player.on('pause', () => {
setState({playing: false, end: false, error: false})
props.onPause?.()
})
player.on('playing', () => {
setState({playing: true, end: false, error: false})
props.onPlay?.()
})
player.on('ended', () => {
setState({end: true, playing: false, error: false})
})
player.on('timeupdate', () => {
props.onProgress?.(player.currentTime(), player.duration())
})
player.on('error', () => {
setState({end: false, playing: false, error: true})
})
setTcPlayer(() => player)
return () => {
console.log('destroy video')
try{
// player.unload();
player.dispose();
setTcPlayer(() => null)
//Array.from(document.querySelectorAll('video')).forEach(v => v.pause())
}catch (e){
console.log(e)
}
//playerVideo.parentElement?.removeChild(playerVideo)
}
}, [])
React.useImperativeHandle(ref, () => {
return {
pause(){
if (!tcPlayer) return;
tcPlayer.pause()
},
play: (url, currentTime = 0) => {
// console.log('play', url, currentTime)
url = url.replace('.flv','.mp4');
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

@ -0,0 +1,165 @@
type VideoPlayerEvents =
'playing'
| 'pause'
| 'ended'
| 'timeupdate'
| 'error'
| 'canplay'
| 'durationchange'
| 'progress'
type PlayerOptions = {
enableLog?: boolean;
className?: string;
}
enum PlayState {
playing = 'playing',
pause = 'pause',
ended = 'ended',
error = 'error',
}
export class VideoPlayer {
private video?: HTMLVideoElement;
private State: PlayState = PlayState.pause;
private enable_log = true;
private currentDuration = 0;
newInstance(options?: PlayerOptions) {
const { className, enableLog=true } = options || {};
this.enable_log = enableLog || false;
if (this.video) {
this.video.remove();
}
if (this.video) {
this.video.pause();
}
// Create video element
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 (className) playerVideo.setAttribute('className', className);
playerVideo.classList.add('digital-video-player');
playerVideo.addEventListener('durationchange', (e) => {
this.currentDuration = Math.floor(this.video?.duration || 0);
this.log('video duration change',e)
});
playerVideo.addEventListener('error', (e) => {
this.log('video error:',e)
});
this.video = playerVideo;
return this;
}
/**
*
* @param args
*/
log(...args: any[]) {
if (this.enable_log) {
console.log(...args);
}
}
getVideo() {
return this.video!;
}
// 暂停视频
pause() {
this.video?.pause();
// 更新状态
this.State = PlayState.pause;
}
/**
*
* @param event
* @param callback
*/
on(event: VideoPlayerEvents, callback: () => void) {
this.video?.addEventListener(event, callback);
}
off(event: VideoPlayerEvents, callback: () => void) {
this.video?.removeEventListener(event, callback);
}
/**
*
* @param time
*/
currentTime(time?: number) {
if (!this.video) return 0;
if (typeof time === 'number' && this.video.currentTime !== time) {
try {
this.video.currentTime = time;
} catch (err) {
this.log('Failed to set currentTime', err);
}
}
return this.video.currentTime || 0;
}
src(url: string) {
if (!this.video) return;
this.video.src = url;
this.video.load();
}
/**
*
*/
play() {
if (!this.video) return;
this.video.play().then(() => {
this.State = PlayState.playing;
}).catch((err) => {
this.State = PlayState.error;
this.log('play error', err);
});
}
duration() {
return this.currentDuration || this.video?.duration || 0;
}
dispose(){
if(!this.video){
return;
}
// 暂停
this.pause();
// 移除
this.video.parentNode?.removeChild(this.video);
this.video = undefined;
}
}
/**
* 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('timeupdate', () => {
* props.onProgress?.(player.currentTime(), player.duration())
* })
* player.on('error', () => {
* setState({end: false, playing: false, error: true})
* })
*/
const videoPlayer = new VideoPlayer();
export default videoPlayer;

View File

@ -1,11 +1,10 @@
// import ReactPlayer from 'react-player' import React, { useEffect, useState } from 'react';
// import {PauseOutlined, PlayCircleOutlined, FullscreenOutlined, FullscreenExitOutlined} from "@ant-design/icons"
// import {Progress} from "antd";
import React, {useEffect, useState} from "react";
import {PlayerInstance} from "@/hooks/useCache.ts";
import TCPlayer from 'tcplayer.js'; import TCPlayer from 'tcplayer.js';
import 'tcplayer.js/dist/tcplayer.min.css'; import 'tcplayer.js/dist/tcplayer.min.css';
import { PlayerInstance } from '@/hooks/useCache.ts';
import videoPlayer from '@/components/video/VideoPlayer.ts';
type State = { type State = {
playing: boolean playing: boolean
muted: boolean muted: boolean
@ -22,7 +21,7 @@ type Props = {
url?: string; cover?: string; showControls?: boolean; className?: string; url?: string; cover?: string; showControls?: boolean; className?: string;
poster?: string; poster?: string;
onChange?: (state: State) => void; onChange?: (state: State) => void;
onProgress?: (current:number,duration:number) => void; onProgress?: (current: number, duration: number) => void;
muted?: boolean; muted?: boolean;
autoPlay?: boolean; autoPlay?: boolean;
} }
@ -32,8 +31,9 @@ export type PlayerInstance = {
getState: () => State; getState: () => State;
} }
export const Player = React.forwardRef<PlayerInstance, Props>((props, ref) => { export const Player = React.forwardRef<PlayerInstance, Props>((props, ref) => {
const [tcPlayer, setTcPlayer] = useState<TCPlayer | null>(null)
const [prevUrl, setPrevUrl] = useState<string | undefined>(); const [prevUrl, setPrevUrl] = useState<string | undefined>();
const [tcPlayer, setTcPlayer] = useState<TCPlayerInstance | null>(null);
const [state, _setState] = useState<State>({ const [state, _setState] = useState<State>({
playing: false, playing: false,
muted: false, muted: false,
@ -42,105 +42,85 @@ export const Player = React.forwardRef<PlayerInstance, Props>((props, ref) => {
progress: 0, progress: 0,
playedSeconds: 0, playedSeconds: 0,
duration: 0 duration: 0
}) });
const setState = (data: StateUpdate) => { const setState = (data: StateUpdate) => {
console.log('playstate change', data) console.log('playstate change', data);
_setState(prev => { _setState(prev => {
const _state = typeof (data) === 'function' ? {...prev, ...data(prev)} : {...prev, ...data} const _state = typeof (data) === 'function' ? { ...prev, ...data(prev) } : { ...prev, ...data };
props.onChange?.(_state) props.onChange?.(_state);
return _state return _state;
}) });
} };
useEffect(()=>{
if(props.url && tcPlayer){
tcPlayer.src(props.url)
}
},[props.url, tcPlayer])
useEffect(() => { useEffect(() => {
if(PlayerInstance.length != 0){ if (props.url && tcPlayer) {
PlayerInstance.forEach(player => player.pause()) tcPlayer.src(props.url);
PlayerInstance.length = 0
} }
const playerVideo = document.createElement('video'); }, [props.url, tcPlayer]);
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)
playerVideo.classList.add('digital-video-player')
PlayerInstance.push(playerVideo)
document.querySelector('.video-player-container-inner')!.appendChild(playerVideo)
const player = TCPlayer(playerId, { useEffect(() => {
const playerVideo = videoPlayer.newInstance().getVideo();
document.querySelector('.video-player-container-inner')!.appendChild(playerVideo);
const flvPlayer = TCPlayer(playerVideo, {
//sources: [{src: props.url}], //sources: [{src: props.url}],
controls: props.showControls, controls: props.showControls,
// muted:props.muted, // muted:props.muted,
poster: props.poster, poster: props.poster,
autoplay: typeof(props.autoPlay) != 'undefined' ? props.autoPlay : true, autoplay: typeof (props.autoPlay) != 'undefined' ? props.autoPlay : true,
licenseUrl: 'https://license.vod2.myqcloud.com/license/v2/1328581896_1/v_cube.license' licenseUrl: 'https://license.vod2.myqcloud.com/license/v2/1328581896_1/v_cube.license'
} }
) );
player.on('pause', () => {
setState({playing: false, end: false, error: false}) flvPlayer.on('pause', () => {
}) setState({ playing: false, end: false, error: false });
player.on('playing', () => { });
setState({playing: true, end: false, error: false}) flvPlayer.on('playing', () => {
}) setState({ playing: true, end: false, error: false });
player.on('ended', () => { });
setState({end: true, playing: false, error: false}) flvPlayer.on('ended', () => {
}) setState({ end: true, playing: false, error: false });
player.on('timeupdate', () => { });
props.onProgress?.(player.currentTime(), player.duration()) flvPlayer.on('timeupdate', () => {
}) props.onProgress?.(flvPlayer.currentTime(), flvPlayer.duration());
player.on('error', () => { });
setState({end: false, playing: false, error: true}) flvPlayer.on('error', () => {
}) setState({ end: false, playing: false, error: true });
setTcPlayer(() => player) });
setTcPlayer(() => flvPlayer);
return () => { return () => {
// if (tcPlayer) { tcPlayer?.pause();
// tcPlayer.pause() console.log('destroy video');
// tcPlayer.unload() tcPlayer?.dispose();
// }else{ };
// playerVideo.pause() }, []);
// }
console.log('destroy video')
try{
Array.from(document.querySelectorAll('video')).forEach(v => v.pause())
}catch (e){
console.log(e)
}
playerVideo.parentElement?.removeChild(playerVideo)
}
}, [])
React.useImperativeHandle(ref, () => { React.useImperativeHandle(ref, () => {
return { return {
pause(){ pause() {
if (!tcPlayer) return; if (!tcPlayer) return;
tcPlayer.pause() tcPlayer.pause();
}, },
play: (url, currentTime = 0) => { play: (url, currentTime = 0) => {
console.log('play', url, currentTime) console.log('play', url, currentTime);
if (!tcPlayer) return; if (!tcPlayer) return;
const player = tcPlayer const player = tcPlayer;
if (prevUrl == url) { if (prevUrl == url) {
player.currentTime(0) player.currentTime(0);
} else { } else {
player.src(url) player.src(url);
} }
player.play() player.play();
setPrevUrl(url) setPrevUrl(url);
if (currentTime > 0) { if (currentTime > 0) {
player.currentTime(currentTime) player.currentTime(currentTime);
} }
}, },
getState: () => state getState: () => state
} };
}) });
return <div className={`video-player relative ${props.className} video-player-container-inner`}> return <div className={`video-player relative ${props.className} video-player-container-inner`}>
</div> </div>;
}) });

View File

@ -5,7 +5,7 @@ import {Checkbox, Popconfirm} from "antd";
import ImageCover from '@/assets/images/cover.png' import ImageCover from '@/assets/images/cover.png'
import { import {
IconDelete, IconDelete, IconDownloadOutline,
IconEdit, IconEdit,
IconGenerateFailed, IconGenerateFailed,
IconGenerating, IconGenerating,
@ -15,11 +15,16 @@ import {
import {VideoStatus} from "@/service/api/video.ts"; import {VideoStatus} from "@/service/api/video.ts";
import {formatTime} from "@/util/strings.ts"; import {formatTime} from "@/util/strings.ts";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {saveAs} from "file-saver";
import {DeleteItemPopoverConfirm} from "@/components/message/confirm.tsx";
import {showLoading, showToast} from "@/components/message.ts";
type Props = { type Props = {
video: VideoInfo | LiveVideoInfo, video: VideoInfo | LiveVideoInfo,
additionOperation?: React.ReactNode; additionOperationBefore?: React.ReactNode;
additionOperationAfter?: React.ReactNode;
editable?: boolean; editable?: boolean;
downloadUrl?: string;
sortable?: boolean; sortable?: boolean;
index?: number; index?: number;
checked?: boolean; checked?: boolean;
@ -31,7 +36,7 @@ type Props = {
onRegenerate?: () => void; onRegenerate?: () => void;
hideCheckBox?: boolean; hideCheckBox?: boolean;
onItemClick?: () => void; onItemClick?: () => void;
onRemove?: (action?:'delete' | 'rollback') => void; onRemove?: (action?: 'delete' | 'rollback') => void;
removeIcon?: React.ReactNode; removeIcon?: React.ReactNode;
id: number; id: number;
className?: string; className?: string;
@ -40,67 +45,83 @@ type Props = {
export const VideoListItem = ( export const VideoListItem = (
{ {
id, video, onRemove,removeIcon, checked,playing, id, video, onRemove, removeIcon, checked, playing,
onCheckedChange, onEdit, active, editable, onCheckedChange, onEdit, active, editable, downloadUrl,
className, sortable, type, index,onItemClick, className, sortable, type, index, onItemClick,
additionOperation,onRegenerate,hideCheckBox additionOperationAfter, additionOperationBefore, onRegenerate, hideCheckBox
}: Props) => { }: Props) => {
const { const {
attributes, listeners, attributes, listeners,
setNodeRef, transform setNodeRef, transform
} = useSortable({resizeObserverConfig: {}, id}) } = useSortable({resizeObserverConfig: {}, id})
const {t} = useTranslation() const {t,i18n} = useTranslation()
const [state, setState] = useSetState<{ checked?: boolean }>({}) const [state, setState] = useSetState<{ checked?: boolean }>({})
useEffect(() => { useEffect(() => {
setState({checked}) setState({checked})
}, [checked]) }, [checked])
const generating = (type == 'create' && video.status == VideoStatus.Generating) const generating = (type == 'create' && video.status == VideoStatus.Generating)
const failed = (type == 'create' && (video.status != VideoStatus.Generating && video.status != VideoStatus.Generated) ) const failed = (type == 'create' && (video.status != VideoStatus.Generating && video.status != VideoStatus.Generated))
const handleDownloadVideo = () => {
if (downloadUrl && video.status == VideoStatus.Generated) {
const ext = downloadUrl.substring(downloadUrl.lastIndexOf('.'))
const loading = showLoading(t('downloading'))
try{
saveAs(downloadUrl, `${video.title || video.video_title}${ext}`)
loading.close()
}catch (e){
loading.update(t('download_failed'),'error')
}
}
}
return <div return <div
className={`video-item ${className}`} className={`video-item ${className}`}
ref={setNodeRef} style={{transform: `translateY(${transform?.y || 0}px)`,}} ref={setNodeRef} style={{transform: `translateY(${transform?.y || 0}px)`,}}
> >
<div className={`list-row ${generating ? ' status-generating' : ''} ${failed ? 'status-generate-failed' : ''} ${active?'playing':''}`}> <div
className={`list-row ${generating ? ' status-generating' : ''} ${failed ? 'status-generate-failed' : ''} ${active ? 'playing' : ''}`}>
<div <div
className="col number" className="col number"
{... (sortable && !generating?listeners:{})} {...(sortable && !generating ? listeners : {})}
{... (sortable && !generating?attributes:{})} {...(sortable && !generating ? attributes : {})}
>{index}</div> >{index}</div>
<div className="col cover cursor-pointer" onClick={onItemClick}> <div className="col cover cursor-pointer" onClick={onItemClick}>
<div className="relative"> <div className="relative">
<img className="w-[100px] h-[56px] object-cover" src={video.cover || ImageCover}/> <img className="w-[100px] h-[56px] object-cover border border-gray-200" src={video.cover || ImageCover}/>
{generating && {generating &&
<div className={'absolute rounded inset-0 bg-black/40 backdrop-blur-[1px] text-white flex items-center justify-center'}> <div
<div className="text-center"> className={'absolute rounded inset-0 bg-black/40 backdrop-blur-[1px] text-white flex items-center justify-center'}>
<IconGenerating className="inline-block text-xl" /> <div className="text-center">
<div className="text-xs">{t('video.generating')}</div> <IconGenerating className="inline-block text-xl"/>
</div> <div className="text-xs">{t('video.generating')}</div>
</div> </div>
</div>
} }
{failed && {failed &&
<div className={'absolute rounded inset-0 bg-black/40 backdrop-blur-[1px] text-white flex items-center justify-center'}> <div
<div className="text-center"> className={'absolute rounded inset-0 bg-black/40 backdrop-blur-[1px] text-white flex items-center justify-center'}>
<IconGenerateFailed className="inline-block text-xl" /> <div className="text-center">
<div className="text-xs">{t('video.generate_failed')}</div> <IconGenerateFailed className="inline-block text-xl"/>
</div> <div className="text-xs">{t('video.generate_failed')}</div>
</div> </div>
</div>
} }
{/* && active*/} {/* && active*/}
{!generating && !failed && playing && <div className={'absolute rounded inset-0 backdrop-blur-[1px] bg-black/40 text-white flex items-center justify-center'}> {!generating && !failed && playing && <div
<div className="text-center"> className={'absolute rounded inset-0 backdrop-blur-[1px] bg-black/40 text-white flex items-center justify-center'}>
<IconPlaying className="inline-block text-xl" /> <div className="text-center">
<div className="text-xs">{t('video.playing')}</div> <IconPlaying className="inline-block text-xl"/>
</div> <div className="text-xs">{t('video.playing')}</div>
</div>} </div>
</div>}
</div> </div>
</div> </div>
<div <div
className="col title" className="col title"
{... (sortable && !generating?listeners:{})} {...(sortable && !generating ? listeners : {})}
{... (sortable && !generating?attributes:{})} {...(sortable && !generating ? attributes : {})}
> >
<div className="line-clamp-2"> <div className="line-clamp-2">
{video.title || video.video_title} {video.title || video.video_title}
@ -108,51 +129,63 @@ export const VideoListItem = (
</div> </div>
<div <div
className="col generated-time" className="col generated-time"
{... (sortable && !generating?listeners:{})} {...(sortable && !generating ? listeners : {})}
{... (sortable && !generating?attributes:{})} {...(sortable && !generating ? attributes : {})}
>{video.ctime ? formatTime(video.ctime,'min') : '-'}</div> >{video.ctime ? formatTime(video.ctime, 'min') : '-'}</div>
<div className="col operation"> <div className="col operation">
{/*{sortable && !generating && (!active ?*/} {/*{sortable && !generating && (!active ?*/}
{/* <button className="hover:text-blue-500 cursor-move">*/} {/* <button className="hover:text-blue-500 cursor-move">*/}
{/* <MenuOutlined/>*/} {/* <MenuOutlined/>*/}
{/* </button> : <button disabled className="cursor-not-allowed"><MenuOutlined/></button>)}*/} {/* </button> : <button disabled className="cursor-not-allowed"><MenuOutlined/></button>)}*/}
<div className={"flex items-center justify-start gap-6"}> <div className={"flex items-center justify-center gap-5"}>
{downloadUrl && video.status == VideoStatus.Generated &&
<button className="hover:text-blue-500" onClick={e => {
e.preventDefault()
e.stopPropagation()
handleDownloadVideo?.()
}} style={{fontSize: '1.1em'}} title={i18n.language == 'zh-CN'?'下载':'Download'}>
<IconDownloadOutline/>
</button>}
{additionOperationBefore}
{editable && !generating && <> {editable && !generating && <>
{onEdit && {onEdit && <button
<button className="hover:text-blue-500" onClick={e=>{ className="hover:text-blue-500" onClick={e => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
onEdit?.() onEdit?.()
}} style={{fontSize: '1.1em'}}> }} style={{fontSize: '1.1em'}} title={i18n.language == 'zh-CN'?'修改':'Modify'}>
<IconEdit/> <IconEdit/>
</button>} </button>}
{onRegenerate && {onRegenerate && <button
<button className="text-red-400 hover:text-blue-500" onClick={e=>{ className="text-red-400 hover:text-blue-500" onClick={e => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
onRegenerate?.() onRegenerate?.()
}} style={{fontSize: '1.1em'}}> }} style={{fontSize: '1.1em'}} title={i18n.language == 'zh-CN'?'重新生成':'Regenerate'}>
<IconRegenerate/> <IconRegenerate/>
</button>} </button>}
{onRemove && <Popconfirm {onRemove && !failed && <DeleteItemPopoverConfirm
rootClassName={'popconfirm-main'} description={failed ? t('video.rollback_confirm_title') : undefined}
placement={'left'} onConfirm={() => onRemove(failed ? 'rollback' : 'delete')}>
arrow={false} <button className="hover:text-blue-500" title={
icon={<IconWarningCircle/>} failed ? (i18n.language == 'zh-CN'?'重新生成':'Regenerate') : i18n.language == 'zh-CN'?'删除':'Delete'
title={t('video.delete_confirm_title')} } style={{fontSize:20}}>
// description={`删除后需从重新${type == 'create' ? '生成' : '推流'}`} {removeIcon ? removeIcon : (failed ?
onConfirm={() => onRemove(failed ? 'rollback' : 'delete')} <IconRollbackCircle/> :
><button className="hover:text-blue-500">{removeIcon?removeIcon:(failed?<IconRollbackCircle />:<IconDelete/>)}</button></Popconfirm>} <IconDelete/>)}
{hideCheckBox ? <span className={"inline-block w-[18px] h-1"}></span> : <Checkbox checked={state.checked} onChange={() => { </button>
if (onCheckedChange) { </DeleteItemPopoverConfirm>}
onCheckedChange(!state.checked) {hideCheckBox ? <></> :
} else { <Checkbox checked={state.checked} onChange={() => {
setState({checked: !state.checked}) if (onCheckedChange) {
} onCheckedChange(!state.checked)
}} />} } else {
</>} setState({checked: !state.checked})
{additionOperation} }
}}/>}
</>}
{additionOperationAfter}
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,6 +5,10 @@ import {getAuthToken, setAuthToken} from "@/hooks/useAuth.ts";
import {auth} from "@/service/api/user.ts"; import {auth} from "@/service/api/user.ts";
import {getAllCategory} from "@/service/api/article.ts"; import {getAllCategory} from "@/service/api/article.ts";
import {BizError} from "@/service/types.ts"; import {BizError} from "@/service/types.ts";
import {getRemainingDuration} from "@/service/api/order.ts";
import {Modal} from "antd";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
import {useTranslation} from "react-i18next";
const UserRoleStorageKey = 'user-current-role'; const UserRoleStorageKey = 'user-current-role';
@ -54,9 +58,9 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
token token
} }
}) })
}catch (e){ } catch (e) {
const err = e as BizError; const err = e as BizError;
if(err.code == 1001){ if (err.code == 1001) {
// token失效 // token失效
setAuthToken(null) setAuthToken(null)
dispatch({ dispatch({

View File

@ -1,15 +1,10 @@
{ {
"AppTitle": "AI Livesteam", "AppTitle": "AI Livesteam",
"go_to_home": "Go to Homepage" ,
"Hello": "Hello", "Hello": "Hello",
"cancel": "Cancel", "cancel": "Cancel",
"close": "Close", "close": "Close",
"service_error": "Service exception, please contact customer support.",
"error_401": "You do not have permission to access this page",
"error_403": "You do not have permission to access this page",
"error_404": "Page not found",
"error_500": "Service exception, please contact customer support.",
"confirm": { "confirm": {
"ok": "Confirm",
"push_title": "Push Notice", "push_title": "Push Notice",
"push_video": "Are you sure editing selected news?", "push_video": "Are you sure editing selected news?",
"title": "Notice" "title": "Notice"
@ -20,14 +15,21 @@
"delete_failed": "Delete failed", "delete_failed": "Delete failed",
"delete_success": "Delete success", "delete_success": "Delete success",
"download": "Download", "download": "Download",
"download_fail": "Download Failed",
"downloading": "Downloading...",
"error_401": "You do not have permission to access this page",
"error_403": "You do not have permission to access this page",
"error_404": "Page not found",
"error_500": "Service exception, please contact customer support.",
"generating": { "generating": {
"title": "Preview - Click the video to play" "title": "Preview - Click the video to play"
}, },
"go_to_home": "Go to Homepage",
"history": { "history": {
"delete_confirm": "Are you sure you want to delete this video?", "delete_confirm": "Are you sure you want to delete this video?",
"push_success": "Streaming success", "push_success": "Streaming success",
"search_key": "Please enter title keywords", "search_key": "Please enter title keywords",
"text": "Video history" "text": "Recycle Bin"
}, },
"history.pushed": "Streaming: {{count}}", "history.pushed": "Streaming: {{count}}",
"live": { "live": {
@ -38,6 +40,7 @@
"playlist_count": "{{count}} videos in total", "playlist_count": "{{count}} videos in total",
"title": "Livestream" "title": "Livestream"
}, },
"loading": "Loading...",
"login": { "login": {
"code_sending": "Sending...", "code_sending": "Sending...",
"invalid_username_or_pwd": "Invalid phone number or code", "invalid_username_or_pwd": "Invalid phone number or code",
@ -49,6 +52,32 @@
"username": "Please enter your phone number", "username": "Please enter your phone number",
"welcome": "Welcome" "welcome": "Welcome"
}, },
"message": {
"save_failed": "Save failed",
"save_success": "Save success"
},
"modal": {
"delete_item_confirm": "Are you sure you want to delete this item?",
"hot_news": {
"edit_auto": "Customize",
"edit_manual": "Customize",
"empty_notice_message": "Some live news items are incomplete. Please update them or disable the customization feature.",
"empty_notice_title": "Warning",
"title": "Live news"
},
"push_article": {
"action_all": "Still generating",
"action_cancel": "Cancel",
"action_skip": "Skip the news",
"content_error": "<span class=\"modal-count-normal\">{{count}}</span> news are selected, and <span class=\"modal-count-warning\">{{error_count}}</span> metahuman contents are too short in these news below. Do you want to transfer them to videos?",
"content_error_single": "<span class=\"modal-count-normal\">{{count}}</span> news is selected, and the metahuman content is too short in this news. Do you want to transfer it to a video?",
"content_normal": "<span class=\"modal-count-normal\">{{count}}</span> news are selected, Do you want to transfer them into videos?",
"content_normal_single": "<span class=\"modal-count-normal\">{{count}}</span> news is selected. Do you want to transfer it to a video?",
"empty_notice_title": "Warning",
"error_title": "Abnormal news"
},
"warning": "Warning"
},
"nav": { "nav": {
"editing": "Editing", "editing": "Editing",
"generating": "Generating", "generating": "Generating",
@ -64,11 +93,17 @@
"delete_the_picture": "Are you sure delete the picture?", "delete_the_picture": "Are you sure delete the picture?",
"download_empty": "Please select the news to download", "download_empty": "Please select the news to download",
"download_failed": "Download failed!", "download_failed": "Download failed!",
"edit": {
"bg": "Background",
"tag": "Tag",
"tag_length_error": "Video tag only limit 4 words ",
"tag_placeholder": "Example: Enterprise dynamics"
},
"edit_add_group": "Add Group", "edit_add_group": "Add Group",
"edit_delete_group": "Delete Group", "edit_delete_group": "Delete Group",
"edit_delete_group_confirm": "Are you sure you want to delete the group?", "edit_delete_group_confirm": "Are you sure you want to delete the group?",
"edit_digital_text": "Metahuman Material", "edit_digital_text": "Metahuman Material",
"edit_empty_group_content": "Please other media material", "edit_empty_group_content": "To generate a fully Metahuman video, ensure that no other media materials are included. For all other cases, both text and images must be provided in the media material section.",
"edit_empty_human_content": "Please enter meta human material", "edit_empty_human_content": "Please enter meta human material",
"edit_form_search": "Please enter title keywords", "edit_form_search": "Please enter title keywords",
"edit_generate_again": "Regenerate", "edit_generate_again": "Regenerate",
@ -89,8 +124,8 @@
"get_detail_error": "Get new details failed", "get_detail_error": "Get new details failed",
"image_count": "Images", "image_count": "Images",
"materials": { "materials": {
"title": "News Materials", "add_group": "Add Group",
"add_group": "Add Group" "title": "News Materials"
}, },
"news_all_source": "All", "news_all_source": "All",
"push_empty": "please select the news to edit", "push_empty": "please select the news to edit",
@ -101,15 +136,40 @@
"push_success": "Push success", "push_success": "Push success",
"push_to_edit": "Editing", "push_to_edit": "Editing",
"pushed": "Editing", "pushed": "Editing",
"save_text": "Save",
"search_key_title": "Please enter title keywords", "search_key_title": "Please enter title keywords",
"source": "Source", "source": "Source",
"title": "Content", "title": "Title",
"title_image_count": "No. of images", "title_image_count": "No. of images",
"title_operate": "", "title_operate": "",
"title_time": "Time stamp", "title_time": "Time stamp",
"title_word_count": "Word count", "title_word_count": "Word count",
"word_count": "Words" "word_count": "Words"
}, },
"order": {
"left_time": "Remaining time",
"list": {
"consume_time": "Duration",
"cover": "Cover",
"id": "No.",
"operator": "User",
"order_time": "Time stamp",
"title": "Title"
},
"remaining_duration_warning": "Unable to generate videos due to insufficient remaining time",
"text": "Orders"
},
"page": {
"size_10": "10 per page",
"size_20": "20 per page",
"size_30": "30 per page",
"total_item": "{{total}} videos in total"
},
"recycle": {
"remove_forever": "Remove Forever",
"restore_video": "Restore"
},
"save_operation": "Save",
"select": { "select": {
"pushed": "Pushed: {{count}}", "pushed": "Pushed: {{count}}",
"select_all": "Select all", "select_all": "Select all",
@ -118,6 +178,7 @@
"text": "Select", "text": "Select",
"total": "Total: {{count}}" "total": "Total: {{count}}"
}, },
"service_error": "Service exception, please contact customer support.",
"time_filter": { "time_filter": {
"all": "All", "all": "All",
"last_week": "Last week", "last_week": "Last week",
@ -126,9 +187,11 @@
"past_4_hour": "Past 4 hour", "past_4_hour": "Past 4 hour",
"past_hour": "Past 1 hour" "past_hour": "Past 1 hour"
}, },
"title": "Title",
"upload": { "upload": {
"delete_confirm": "Are you sure delete the picture?", "delete_confirm": "Are you sure delete the picture?",
"upload_failed": "Upload failed", "upload_failed": "Upload failed",
"upload_file_type_error": "Only support upload image",
"upload_image": "Upload Image" "upload_image": "Upload Image"
}, },
"user": { "user": {
@ -141,15 +204,21 @@
"delete_description": "Are you sure you want to delete the video?", "delete_description": "Are you sure you want to delete the video?",
"delete_description_count": "Are you sure you want to delete these {{count}} videos?", "delete_description_count": "Are you sure you want to delete these {{count}} videos?",
"delete_empty": "Select the video you want to delete", "delete_empty": "Select the video you want to delete",
"delete_forever_confirm": "Do you want to permutely delete it?",
"delete_forever_confirm_count": "Do you want to permutely delete these videos?",
"download": "Download", "download": "Download",
"generating": "Generating",
"generate_failed": "Generate Failed", "generate_failed": "Generate Failed",
"generating": "Generating",
"live_rollback_confirm_title": "Are you sure you want to rollback this video?",
"playing": "Playing", "playing": "Playing",
"push_confirm": "Are you sure you want to streaming these video?", "push_confirm": "Are you sure you want to streaming these video?",
"push_empty": "Select the video you want to streaming", "push_empty": "Select the video you want to streaming",
"push_failed": "some video streaming failed!", "push_failed": "some video streaming failed!",
"push_success": "Streaming success,please goto \"Streaming\"!", "push_success": "Streaming success,please goto \"Streaming\"!",
"push_to_live": "Streaming", "push_to_live": "Streaming",
"restore_confirm": "Do you want to restore it to the generating page?",
"restore_confirm_count": "Do you want to restore these videos to the generating page?",
"rollback_confirm_title": "Are you sure you want to revert this video?",
"sort_modify_confirm": "Are you change video sequence?", "sort_modify_confirm": "Are you change video sequence?",
"sort_modify_failed": "Video sequence change failed", "sort_modify_failed": "Video sequence change failed",
"sort_modify_live_success": "Video sequence changed", "sort_modify_live_success": "Video sequence changed",
@ -159,18 +228,5 @@
"title_generated_time": "Time stamp", "title_generated_time": "Time stamp",
"title_operation": "", "title_operation": "",
"title_thumb": "Cover" "title_thumb": "Cover"
},
"modal": {
"warning": "Warning",
"push_article": {
"content_normal": "<span class=\"modal-count-normal\">{{count}}</span> news are selected, Do you want to transfer them into videos?",
"content_normal_single": "<span class=\"modal-count-normal\">{{count}}</span> news is selected. Do you want to transfer it to a video?",
"content_error": "<span class=\"modal-count-normal\">{{count}}</span> news are selected, and <span class=\"modal-count-warning\">{{error_count}}</span> metahuman contents are too short in these news below. Do you want to transfer them to videos?",
"content_error_single": "<span class=\"modal-count-normal\">{{count}}</span> news is selected, and the metahuman content is too short in this news. Do you want to transfer it to a video?",
"error_title": "Abnormal news",
"action_cancel": "Cancel",
"action_skip": "Skip the news",
"action_all": "Still generating"
}
} }
} }

View File

@ -1,15 +1,10 @@
{ {
"AppTitle": "数字人直播", "AppTitle": "数字人直播",
"go_to_home": "返回首页" ,
"Hello": "你好", "Hello": "你好",
"cancel": "取消", "cancel": "取消",
"close": "关闭", "close": "关闭",
"service_error": "新闻异常,无法生成,请咨询客服",
"error_401": "您没有权限访问本页面",
"error_403": "您没有权限访问本页面",
"error_404": "访问的页面不存在",
"error_500": "服务异常,请咨询客服.",
"confirm": { "confirm": {
"ok": "确定",
"push_title": "推流提示", "push_title": "推流提示",
"push_video": "是否确定一键推流选中新闻视频?", "push_video": "是否确定一键推流选中新闻视频?",
"title": "提示" "title": "提示"
@ -20,14 +15,21 @@
"delete_failed": "删除失败", "delete_failed": "删除失败",
"delete_success": "删除成功", "delete_success": "删除成功",
"download": "下载", "download": "下载",
"download_fail": "下载失败",
"downloading": "下载中...",
"error_401": "您没有权限访问本页面",
"error_403": "您没有权限访问本页面",
"error_404": "访问的页面不存在",
"error_500": "服务异常,请咨询客服.",
"generating": { "generating": {
"title": "预览视频 - 点击视频列表播放" "title": "预览视频 - 点击视频列表播放"
}, },
"go_to_home": "返回首页",
"history": { "history": {
"delete_confirm": "是否要删除该视频", "delete_confirm": "是否要删除该视频",
"push_success": "一键推流成功,已推流至数字人直播间,请查看!", "push_success": "一键推流成功,已推流至数字人直播间,请查看!",
"search_key": "请输入视频标题关键字进行信息", "search_key": "请输入视频标题关键字进行信息",
"text": "历史视频" "text": "回收站"
}, },
"history.pushed": "已推送 {{count}} 条", "history.pushed": "已推送 {{count}} 条",
"live": { "live": {
@ -38,6 +40,7 @@
"playlist_count": "当前播放列表共 {{count}} 条", "playlist_count": "当前播放列表共 {{count}} 条",
"title": "直播界面" "title": "直播界面"
}, },
"loading": "加载中...",
"login": { "login": {
"code_sending": "发送中", "code_sending": "发送中",
"invalid_username_or_pwd": "账号或密码错误", "invalid_username_or_pwd": "账号或密码错误",
@ -49,6 +52,32 @@
"username": "请输入账号", "username": "请输入账号",
"welcome": "欢迎登录" "welcome": "欢迎登录"
}, },
"message": {
"save_failed": "保存失败",
"save_success": "保存成功"
},
"modal": {
"delete_item_confirm": "您确定要删除吗?",
"hot_news": {
"edit_auto": "自定义",
"edit_manual": "自定义",
"empty_notice_message": "自定义的“新闻热点”尚未填写完毕,<br/>请填写全部热点,或开启智能填充",
"empty_notice_title": "操作提示",
"title": "视频下方热点(跑马灯)"
},
"push_article": {
"action_all": "全部生成",
"action_cancel": "全部取消",
"action_skip": "跳过异常新闻",
"content_error": "已选中<span class=\"modal-count-normal\">{{count}}</span>条新闻,<span class=\"modal-count-warning\">{{error_count}}</span>条新闻数字人播报字数过少,是否生成全部<span class=\"modal-count-normal\">{{count}}</span>条视频?",
"content_error_single": "已选中<span class=\"modal-count-normal\">{{count}}</span>条新闻,<span class=\"modal-count-warning\">{{error_count}}</span>条新闻数字人播报字数过少,是否生成全部<span class=\"modal-count-normal\">{{count}}</span>条视频?",
"content_normal": "已选中<span class=\"modal-count-normal\">{{count}}</span>条新闻,是否全部生成?",
"content_normal_single": "已选中<span class=\"modal-count-normal\">{{count}}</span>条新闻,是否生成?",
"empty_notice_title": "操作提示",
"error_title": "异常新闻"
},
"warning": "操作提示"
},
"nav": { "nav": {
"editing": "新闻编辑", "editing": "新闻编辑",
"generating": "视频生成", "generating": "视频生成",
@ -56,7 +85,7 @@
"materials": "新闻素材" "materials": "新闻素材"
}, },
"news": { "news": {
"delete_confirm": "确定要删除吗?", "delete_confirm": "确定要删除吗?",
"delete_confirm_count": "你确定要删除选择的 {{count}} 条新闻吗?", "delete_confirm_count": "你确定要删除选择的 {{count}} 条新闻吗?",
"delete_description": "删除后需从新闻素材中重新选择", "delete_description": "删除后需从新闻素材中重新选择",
"delete_description_count": "删除后需从新闻素材中重新选择", "delete_description_count": "删除后需从新闻素材中重新选择",
@ -64,11 +93,17 @@
"delete_the_picture": "请确认删除此图片", "delete_the_picture": "请确认删除此图片",
"download_empty": "请选择要下载的新闻", "download_empty": "请选择要下载的新闻",
"download_failed": "下载新闻失败,请重试!", "download_failed": "下载新闻失败,请重试!",
"edit": {
"bg": "背景",
"tag": "标签",
"tag_length_error": "标签长度不能超过4个字",
"tag_placeholder": "例:企业动态"
},
"edit_add_group": "新增分组", "edit_add_group": "新增分组",
"edit_delete_group": "删除此分组", "edit_delete_group": "删除此分组",
"edit_delete_group_confirm": "请确认删除此分组?", "edit_delete_group_confirm": "请确认删除此分组?",
"edit_digital_text": "数字人主播台编辑区", "edit_digital_text": "数字人主播台编辑区",
"edit_empty_group_content": "素材区的文本和图片,均不得为空", "edit_empty_group_content": "如仅需数字人播报,请勿在素材融合区填写内容;如需展示图文信息,素材融合区的文本、图片均不得为空",
"edit_empty_human_content": "请先填写数字人播报内容", "edit_empty_human_content": "请先填写数字人播报内容",
"edit_form_search": "请输入新闻标题关键词进行搜索", "edit_form_search": "请输入新闻标题关键词进行搜索",
"edit_generate_again": "重新生成", "edit_generate_again": "重新生成",
@ -89,8 +124,8 @@
"get_detail_error": "获取新闻详情失败", "get_detail_error": "获取新闻详情失败",
"image_count": "图片数", "image_count": "图片数",
"materials": { "materials": {
"title": "新闻素材", "add_group": "新增分组",
"add_group": "新增分组" "title": "新闻素材"
}, },
"news_all_source": "全部来源", "news_all_source": "全部来源",
"push_empty": "请选择要推入编辑的新闻", "push_empty": "请选择要推入编辑的新闻",
@ -101,6 +136,7 @@
"push_success": "推送成功", "push_success": "推送成功",
"push_to_edit": "推入编辑", "push_to_edit": "推入编辑",
"pushed": "已推送", "pushed": "已推送",
"save_text": "保存",
"search_key_title": "请输入新闻标题关键词进行搜索", "search_key_title": "请输入新闻标题关键词进行搜索",
"source": "来源", "source": "来源",
"title": "标题", "title": "标题",
@ -110,6 +146,30 @@
"title_word_count": "字数", "title_word_count": "字数",
"word_count": "字数" "word_count": "字数"
}, },
"order": {
"left_time": "当前剩余时长",
"list": {
"consume_time": "消费时长",
"cover": "缩略图",
"id": "订单编号",
"operator": "操作人",
"order_time": "下单时间",
"title": "标题"
},
"remaining_duration_warning": "视频生成剩余时长为零,将无法生成视频,请尽快充值额度。",
"text": "订单记录"
},
"page": {
"size_10": "10条/页",
"size_20": "20条/页",
"size_30": "30条/页",
"total_item": "共计{{total}}条"
},
"recycle": {
"remove_forever": "彻底删除",
"restore_video": "还原视频"
},
"save_operation": "保存操作",
"select": { "select": {
"pushed": "已推送: {{count}} 条", "pushed": "已推送: {{count}} 条",
"select_all": "全选", "select_all": "全选",
@ -118,6 +178,7 @@
"text": "选择", "text": "选择",
"total": "总共 {{count}} 条" "total": "总共 {{count}} 条"
}, },
"service_error": "新闻异常,无法生成,请咨询客服",
"time_filter": { "time_filter": {
"all": "所有时间", "all": "所有时间",
"last_week": "近一周", "last_week": "近一周",
@ -126,9 +187,11 @@
"past_4_hour": "四小时内", "past_4_hour": "四小时内",
"past_hour": "一小时内" "past_hour": "一小时内"
}, },
"title": "标题",
"upload": { "upload": {
"delete_confirm": "请确认删除此图片?", "delete_confirm": "请确认删除此图片?",
"upload_failed": "上传图片失败,请重试", "upload_failed": "上传图片失败,请重试",
"upload_file_type_error": "仅支持上传图片",
"upload_image": "上传图片" "upload_image": "上传图片"
}, },
"user": { "user": {
@ -141,15 +204,21 @@
"delete_description": "已选择{{count}}条,确定要全部删除吗?", "delete_description": "已选择{{count}}条,确定要全部删除吗?",
"delete_description_count": "已选择{{count}}条,确定要全部删除吗?", "delete_description_count": "已选择{{count}}条,确定要全部删除吗?",
"delete_empty": "请选择要删除的视频", "delete_empty": "请选择要删除的视频",
"delete_forever_confirm": "是否彻底删除选中的视频? <br />这些视频将无法找回",
"delete_forever_confirm_count": "是否彻底删除选中的视频? <br />这些视频将无法找回!",
"download": "下载视频", "download": "下载视频",
"generating": "生成中",
"generate_failed": "生成失败", "generate_failed": "生成失败",
"generating": "生成中",
"live_rollback_confirm_title": "你确定要回退此视频吗 ",
"playing": "播放中", "playing": "播放中",
"push_confirm": "是否确定一键推流选中新闻视频?", "push_confirm": "是否确定一键推流选中新闻视频?",
"push_empty": "请选择要推流的新闻视频", "push_empty": "请选择要推流的新闻视频",
"push_failed": "选择视频中有部分视频还在生成中无法推送,推流成功视频前往数字人直播间页面查看!", "push_failed": "选择视频中有部分视频还在生成中无法推送,推流成功视频前往数字人直播间页面查看!",
"push_success": "一键推流成功,已推流至数字人直播间,请前往数字人直播间页面查看!", "push_success": "一键推流成功,已推流至数字人直播间,请前往数字人直播间页面查看!",
"push_to_live": "一键推流", "push_to_live": "一键推流",
"restore_confirm": "是否将选中视频,还原到视频生成页?",
"restore_confirm_count": "是否将选中视频,还原到视频生成页",
"rollback_confirm_title": "您确定要回退此视频吗?",
"sort_modify_confirm": "是否采纳移动视频位置操作?", "sort_modify_confirm": "是否采纳移动视频位置操作?",
"sort_modify_failed": "调整视频顺序失败,请重试!", "sort_modify_failed": "调整视频顺序失败,请重试!",
"sort_modify_live_success": "已完成直播队列的修改", "sort_modify_live_success": "已完成直播队列的修改",
@ -159,18 +228,5 @@
"title_generated_time": "生成时间", "title_generated_time": "生成时间",
"title_operation": "操作", "title_operation": "操作",
"title_thumb": "缩略图" "title_thumb": "缩略图"
},
"modal": {
"warning": "操作提示",
"push_article": {
"content_normal": "已选中<span class=\"modal-count-normal\">{{count}}</span>条新闻,是否全部生成?",
"content_normal_single": "已选中<span class=\"modal-count-normal\">{{count}}</span>条新闻,是否生成?",
"content_error": "已选中<span class=\"modal-count-normal\">{{count}}</span>条新闻,<span class=\"modal-count-warning\">{{error_count}}</span>条新闻数字人播报字数过少,是否生成全部<span class=\"modal-count-normal\">{{count}}</span>条视频?",
"content_error_single": "已选中<span class=\"modal-count-normal\">{{count}}</span>条新闻,<span class=\"modal-count-warning\">{{error_count}}</span>条新闻数字人播报字数过少,是否生成全部<span class=\"modal-count-normal\">{{count}}</span>条视频?",
"error_title": "异常新闻",
"action_cancel": "全部取消",
"action_skip": "跳过异常新闻",
"action_all": "全部生成"
}
} }
} }

View File

@ -1,6 +1,7 @@
import {useMount} from "ahooks"; import {useMount} from "ahooks";
import {getLiveUrl} from "@/service/api/live.ts";
import React, {useState} from "react"; import React, {useState} from "react";
import {getLiveUrl} from "@/service/api/live.ts";
import {Player} from "@/components/video/player.tsx"; import {Player} from "@/components/video/player.tsx";
import './style.scss' import './style.scss'

View File

@ -1,32 +1,36 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {Checkbox, Empty, Modal, Space} from "antd"; import {Checkbox, Empty, Popconfirm, Space} from "antd";
import {SortableContext, arrayMove} from '@dnd-kit/sortable'; import {SortableContext, arrayMove} from '@dnd-kit/sortable';
import {DndContext} from "@dnd-kit/core"; import {DndContext} from "@dnd-kit/core";
import FlvJs from "flv.js";
import {useTranslation} from "react-i18next";
import {useSetState} from "ahooks";
import {VideoListItem} from "@/components/video/video-list-item.tsx"; import {VideoListItem} from "@/components/video/video-list-item.tsx";
import {deleteByIds, getList, modifyOrder, playState} from "@/service/api/live.ts"; import {deleteByIds, getList, modifyOrder, playState, restoreByIds} from "@/service/api/live.ts";
import {showErrorToast, showToast} from "@/components/message.ts"; import {showErrorToast, showToast} from "@/components/message.ts";
import ButtonBatch from "@/components/button-batch.tsx"; import ButtonBatch from "@/components/button-batch.tsx";
import FlvJs from "flv.js";
import {formatDuration} from "@/util/strings.ts"; import {formatDuration} from "@/util/strings.ts";
import {useSetState} from "ahooks"; import {Mp4Player as Player, PlayerInstance} from "@/components/video/Mp4Player.tsx";
import {Player, PlayerInstance} from "@/components/video/player.tsx"; import {IconDelete, IconLocked, IconRollbackCircle} from "@/components/icons";
import {IconDelete, IconLocked, IconUnlock} from "@/components/icons";
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx"; import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
import ButtonToTop from "@/components/scoller/button-to-top.tsx"; import ButtonToTop from "@/components/scoller/button-to-top.tsx";
import {useTranslation} from "react-i18next"; import {ModalWarningIcon, ModalWarningTitle} from "@/components/icons/ModalWarning.tsx";
import styles from "./style.module.scss"
const cache: { flvPlayer?: FlvJs.Player, timerPlayNext?: any, timerLoadState?: any, prevUrl?: string } = {} const cache: { flvPlayer?: FlvJs.Player, timerPlayNext?: any, timerLoadState?: any, prevUrl?: string } = {}
export default function LiveIndex() { export default function LiveIndex() {
const {t} = useTranslation() const {t} = useTranslation()
const player = useRef<PlayerInstance | null>(null) const player = useRef<PlayerInstance | null>(null)
const [videoData, setVideoData] = useState<LiveVideoInfo[]>([]) const [videoData, setVideoData] = useState<LiveVideoInfo[]>([])
const [modal, contextHolder] = Modal.useModal()
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([]) const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
const [editable, setEditable] = useState<boolean>(false) const [editable, setEditable] = useState<boolean>(false)
const scrollerRef = useRef<InfiniteScrollerRef | null>(null) const scrollerRef = useRef<InfiniteScrollerRef | null>(null)
const [rollbackIds,setRollbackIds] = useState<Id[]>([])
const [delIds,setDelIds] = useState<Id[]>([])
const [state, setState] = useSetState({ const [state, setState] = useSetState({
playId:-1, playId:-1,
@ -83,6 +87,8 @@ export default function LiveIndex() {
const playedTime = (Date.now() / 1000 >> 0) - liveState.live_start_time const playedTime = (Date.now() / 1000 >> 0) - liveState.live_start_time
if (playedTime < 0 || playedTime > duration) { // 已播放时间大于总时长了 if (playedTime < 0 || playedTime > duration) { // 已播放时间大于总时长了
//initPlayingState() // 重新获取播放状态 //initPlayingState() // 重新获取播放状态
console.log('已播放时间大于总时长')
cache.timerLoadState = setTimeout(initPlayingState, 5000)
return; return;
} }
player.current?.play(video.video_oss_url, playedTime) player.current?.play(video.video_oss_url, playedTime)
@ -93,6 +99,7 @@ export default function LiveIndex() {
} }
} }
// 初始化播放状态 // 初始化播放状态
const initPlayingState = () => { const initPlayingState = () => {
player.current?.pause(); player.current?.pause();
@ -152,44 +159,59 @@ export default function LiveIndex() {
// 删除视频 // 删除视频
const processDeleteVideo = async (ids: Id[]) => { const processDeleteVideo = async (ids: Id[]) => {
deleteByIds(ids).then(() => { // 临时记录删除的id
showToast(t('delete_success'), 'success') setDelIds(_=>[...ids,..._])
loadList() // deleteByIds(ids).then(() => {
}).catch(showErrorToast) // showToast(t('delete_success'), 'success')
// loadList()
// }).catch(showErrorToast)
}
const resetState = (editable: boolean)=>{
setEditable(editable)
setCheckedIdArray([])
setRollbackIds(()=>[])
setDelIds(()=>[])
setState({checkedAll: false})
}
// 状态:锁定->解锁
const handleSetEditable = ()=>{
resetState(true)
} }
// //
const handleConfirm = () => { const handleCancel = ()=>{
resetState(false)
}
const handleRollback = (v:LiveVideoInfo)=>{
setRollbackIds(_=>[v.id,..._])
}
const handleConfirm = async () => {
if (!editable) { if (!editable) {
setEditable(true)
return; return;
} }
const newSort = videoData.map(s => s.id).join(',') const ids = videoData
if (newSort == state.originSort) { .filter(s=>!(delIds.includes(s.id) || rollbackIds.includes(s.id)))
setEditable(false) .map(s => s.id)
return; try{
} // 删除
modal.confirm({ if(delIds.length > 0) {
title: t('confirm.title'), await deleteByIds(delIds)
content: t('video.sort_modify_confirm'),
centered: true,
onOk: () => {
//showToast('编辑成功!!!', 'info');
modifyOrder(videoData.map(s => s.id)).then(() => {
showToast(t('video.sort_modify_live_success'), 'success')
setEditable(false)
}).catch(() => {
showToast(t('video.sort_modify_failed'), 'warning')
})
},
onCancel: () => {
showToast(t('video.sort_modify_rollback'), 'info');
loadList()
setEditable(false)
} }
}) if(rollbackIds.length > 0) {
await restoreByIds(rollbackIds)
}
// 调整排序
await modifyOrder(ids);
showToast(t('message.save_success'), 'success')
}catch (e){
console.log(e)
showToast(t('message.save_failed'), 'error')
}finally {
loadList()
resetState(false)
}
} }
const handleAllCheckedChange = () => { const handleAllCheckedChange = () => {
if(editable) return; if(!editable) return;
setCheckedIdArray(state.checkedAll ? [] : videoData.map(v => v.id)) setCheckedIdArray(state.checkedAll ? [] : videoData.map(v => v.id))
setState({ setState({
checkedAll: !state.checkedAll checkedAll: !state.checkedAll
@ -250,7 +272,7 @@ export default function LiveIndex() {
</div> </div>
</div> </div>
<div className="video-list-container video-list-sort-container flex flex-col flex-1 mt-2"> <div className="video-list-container video-list-sort-container flex flex-col flex-1 mt-2">
<div className="live-control flex justify-between mb-1"> <div className="live-control flex justify-between mb-1 h-[30px]">
<div> <div>
<Space> <Space>
{/*<span className={"text-blue-500"}>视频正在播放{state.activeIndex == -1 ? '' : `到 ${state.activeIndex + 1} 条`}</span>*/} {/*<span className={"text-blue-500"}>视频正在播放{state.activeIndex == -1 ? '' : `到 ${state.activeIndex + 1} 条`}</span>*/}
@ -259,12 +281,14 @@ export default function LiveIndex() {
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<div className={'flex items-center text-gray-400 cursor-pointer select-none'} <div className={'flex items-center text-gray-400 cursor-pointer select-none'}>
onClick={handleConfirm}> {editable ? (<Space size={15}>
<span>{editable ? t('live.edit_unlock') : t('live.edit_locked')}</span> <button className={styles.btnDefault} onClick={handleCancel}>{t('cancel')}</button>
<span className="ml-2 text-sm"> <button className={styles.btn} onClick={handleConfirm}>{t('save_operation')}</button>
{editable ? <IconUnlock/> : <IconLocked/>} </Space>):(<div className="flex items-center " onClick={handleSetEditable}>
</span> {t('live.edit_locked')}
<span className="ml-2 text-sm"><IconLocked/></span>
</div>)}
</div> </div>
<div className="check-all ml-10"> <div className="check-all ml-10">
<button disabled={editable} className={`${editable?'':'hover:text-blue-300'} text-gray-400`} <button disabled={editable} className={`${editable?'':'hover:text-blue-300'} text-gray-400`}
@ -272,7 +296,7 @@ export default function LiveIndex() {
<span className="text-sm mr-2 whitespace-nowrap">{t('select.select_all')}</span> <span className="text-sm mr-2 whitespace-nowrap">{t('select.select_all')}</span>
{/*<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>*/} {/*<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>*/}
</button> </button>
<Checkbox disabled={editable} checked={state.checkedAll} onChange={() => handleAllCheckedChange()}/> <Checkbox disabled={!editable} checked={state.checkedAll} onChange={() => handleAllCheckedChange()}/>
</div> </div>
</div> </div>
</div> </div>
@ -308,7 +332,7 @@ export default function LiveIndex() {
} }
}}> }}>
<SortableContext items={videoData}> <SortableContext items={videoData}>
{videoData.map((v, index) => ( {videoData.filter(v=>(!(delIds.includes(v.id) || rollbackIds.includes(v.id)))).map((v, index) => (
<VideoListItem <VideoListItem
video={v} video={v}
index={index + 1} index={index + 1}
@ -327,8 +351,19 @@ export default function LiveIndex() {
// }) // })
}} }}
onRemove={() => processDeleteVideo([v.id])} onRemove={() => processDeleteVideo([v.id])}
editable={!editable && state.playId != v.id} editable={editable && state.playId != v.id}
sortable={editable && state.playId != v.id} sortable={editable && state.playId != v.id}
additionOperationBefore={<>
{editable && state.playId != v.id && <Popconfirm
rootClassName={'popconfirm-main'}
placement={'left'}
arrow={false}
icon={<ModalWarningIcon/>}
title={<ModalWarningTitle />}
description={t('video.live_rollback_confirm_title')}
onConfirm={() => handleRollback(v)}
><button className="hover:text-blue-500"><IconRollbackCircle /></button></Popconfirm>}
</>}
/>))} />))}
</SortableContext> </SortableContext>
</DndContext> </DndContext>
@ -356,6 +391,5 @@ export default function LiveIndex() {
<IconDelete/> <IconDelete/>
</ButtonBatch>} </ButtonBatch>}
</div> </div>
{contextHolder}
</div>) </div>)
} }

View File

@ -1,3 +1,26 @@
.videoListContainer{ .videoListContainer{
} }
@mixin btnDefault{
border-radius: 20px;
padding: 2px 16px;
height: auto;
}
.btn{
@include btnDefault;
background: #4096FF;
color:#fff;
border: 1px solid #4096FF;
&:hover{
background: #337acc;
}
}
.btnDefault{
@include btnDefault;
color:#00000099;
border: 1px solid #00000099;
&:hover{
background: #00000011;
}
}

View File

@ -5,6 +5,7 @@ import {IconDelete, IconWarningCircle} from "@/components/icons";
import {deleteByIds} from "@/service/api/article.ts"; import {deleteByIds} from "@/service/api/article.ts";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {divide} from "lodash"; import {divide} from "lodash";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
export default function ButtonDeleteBatch(props: { ids: Id[];onSuccess?: () => void; }) { export default function ButtonDeleteBatch(props: { ids: Id[];onSuccess?: () => void; }) {
const {t} = useTranslation() const {t} = useTranslation()
@ -29,9 +30,12 @@ export default function ButtonDeleteBatch(props: { ids: Id[];onSuccess?: () => v
} }
modal.confirm({ modal.confirm({
wrapClassName:'root-modal-confirm', wrapClassName:'root-modal-confirm',
icon: <span className="anticon anticon-exclamation-circle"><IconWarningCircle/></span>, icon: <ModalWarning.Icon />,
title: t(props.ids.length == 1 ?'news.delete_confirm':'news.delete_confirm_count',{count:props.ids.length}), title: <ModalWarning.Title />,
content: <span dangerouslySetInnerHTML={{__html:props.ids.length == 1 ?t('news.delete_description') :t('news.delete_description_count')}}></span>, content: <div>
<div>{t(props.ids.length == 1 ?'news.delete_confirm':'news.delete_confirm_count',{count:props.ids.length})}</div>
<div><span dangerouslySetInnerHTML={{__html:props.ids.length == 1 ?t('news.delete_description') :t('news.delete_description_count')}}></span></div>
</div>,
onOk: handlePush, onOk: handlePush,
centered: true centered: true
}) })

View File

@ -4,9 +4,10 @@ import {useNavigate} from "react-router-dom";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {useSetState} from "ahooks"; import {useSetState} from "ahooks";
import {showErrorToast, showToast} from "@/components/message.ts"; import {showToast} from "@/components/message.ts";
import {push2video} from "@/service/api/article.ts"; import {push2video} from "@/service/api/article.ts";
import {IconArrowRight, IconWarningCircle} from "@/components/icons"; import {IconArrowRight} from "@/components/icons";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
export enum ProcessResult { export enum ProcessResult {
All, All,
@ -21,6 +22,7 @@ type PushVideoProps = {
} }
export default function ButtonPush2Video(props: PushVideoProps) { export default function ButtonPush2Video(props: PushVideoProps) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// const {modal} = App.useApp()
const [state, setState] = useSetState<{ const [state, setState] = useSetState<{
modalVisible?: boolean; modalVisible?: boolean;
errorTitle?: string[]; errorTitle?: string[];
@ -30,32 +32,66 @@ export default function ButtonPush2Video(props: PushVideoProps) {
}) })
const {t} = useTranslation() const {t} = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const handlePush = (action: ProcessResult) => { /**
const skip = action === ProcessResult.Skip && state.errorIds.length > 0 *
const ids = !skip ? props.ids : props.ids.filter(id => !state.errorIds.includes(id)); * @deprecated
if(skip && (state.errorIds.length == props.ids.length || ids.length == 0)){ */
setState({modalVisible: false}) // const checkHotNewsValid = async ()=>{
return; // return new Promise<string>((resolve)=>{
} // const manualErrorCount = props.articles?.filter(s=>{
setLoading(true) // return s.hot_news.replace(/,/ig,'').trim().length == 0
push2video(ids).then(() => { // })?.length || 0
setState({modalVisible: false}) // if(manualErrorCount == 0) {
if (skip) { // resolve('default')
props.onResult?.(ProcessResult.Skip, state.errorIds || []) // return;
// }
// modal.confirm({
// wrapClassName: 'root-modal-confirm',
// icon: <span className="anticon anticon-exclamation-circle"><IconWarningCircle/></span>,
// title: t('modal.push_article.empty_notice_title'),
// content: t('modal.push_article.empty_notice_message'),
// centered:true,
// onOk: () => {
// resolve('auto')
// },
// onCancel: () => {
// resolve('reject')
// }
// })
// })
// }
const handlePush = async (action: ProcessResult) => {
const skip = action === ProcessResult.Skip && state.errorIds.length > 0
const ids = !skip ? props.ids : props.ids.filter(id => !state.errorIds.includes(id));
if (skip && (state.errorIds.length == props.ids.length || ids.length == 0)) {
setState({modalVisible: false})
return; return;
} }
showToast(t('news.push_stream_success'), 'success') //
navigate('/create?state=push-success', { // const result = await checkHotNewsValid();
state: 'push-success' // // TODO: 有热点新闻自动?
// if(result == 'reject'){ // 有热点新闻未填写 但点击取消并终止后续操作
// return;
// }
setLoading(true)
push2video(ids).then(() => {
setState({modalVisible: false})
if (skip) {
props.onResult?.(ProcessResult.Skip, state.errorIds || [])
return;
}
showToast(t('news.push_stream_success'), 'success')
navigate('/create?state=push-success', {
state: 'push-success'
})
// props.onSuccess?.()
}).catch(() => {
showToast(t('service_error'), 'error')
//showErrorToast
}).finally(() => {
setLoading(false)
}) })
// props.onSuccess?.() }
}).catch(()=>{
showToast(t('service_error'), 'error')
//showErrorToast
}).finally(() => {
setLoading(false)
})
}
// double check 25-02-15 https://pu7y37y121.feishu.cn/docx/FwRrddAFWotRZlxgbr5cP7b6nud // double check 25-02-15 https://pu7y37y121.feishu.cn/docx/FwRrddAFWotRZlxgbr5cP7b6nud
// 1.normal 数字人播报部分有内容不少于50字或者数字人播报部分无内容 // 1.normal 数字人播报部分有内容不少于50字或者数字人播报部分无内容
// 2.error 数字人播报部分有内容但是少于50字 // 2.error 数字人播报部分有内容但是少于50字
@ -98,8 +134,7 @@ export default function ButtonPush2Video(props: PushVideoProps) {
width={440} width={440}
> >
<div className="modal-title flex items-center"> <div className="modal-title flex items-center">
<div className="anticon anticon-exclamation-circle text-red-400 w-10"><IconWarningCircle <div className="anticon anticon-exclamation-circle text-red-400 w-10"><ModalWarning.Icon/></div>
style={{fontSize: 24, color: 'rgba(250, 173, 20, 1)'}}/></div>
<div className="text-base">{t('modal.warning')}</div> <div className="text-base">{t('modal.warning')}</div>
</div> </div>
<div className="confirm-message-wrapper flex mt-2"> <div className="confirm-message-wrapper flex mt-2">
@ -114,13 +149,13 @@ export default function ButtonPush2Video(props: PushVideoProps) {
}}> }}>
</div> </div>
{state.errorTitle && state.errorTitle.length > 0 && {state.errorTitle && state.errorTitle.length > 0 &&
<div className="error-list text-red-400 mt-6 w-[350px]"> <div className="error-list text-red-400 mt-6 w-[350px]">
<div className="title">{t('modal.push_article.error_title')}:</div> <div className="title">{t('modal.push_article.error_title')}:</div>
<div className="max-h-[100px] overflow-auto" style={{lineHeight: '20px'}}> <div className="max-h-[100px] overflow-auto" style={{lineHeight: '20px'}}>
{state.errorTitle.map(s => <div {state.errorTitle.map((s, idx) => <div key={idx}
className="error-item overflow-hidden pr-1 text-nowrap overflow-ellipsis">{s}</div>)} className="error-item overflow-hidden pr-1 text-nowrap overflow-ellipsis">{s}</div>)}
</div> </div>
</div>} </div>}
</div> </div>
</div> </div>
<div className="flex justify-end mt-6"> <div className="flex justify-end mt-6">
@ -128,10 +163,15 @@ export default function ButtonPush2Video(props: PushVideoProps) {
<Button disabled={loading} onClick={() => { <Button disabled={loading} onClick={() => {
setState({modalVisible: false}) setState({modalVisible: false})
}}>{t('modal.push_article.action_cancel')}</Button> }}>{t('modal.push_article.action_cancel')}</Button>
{state.errorIds?.length > 0 && <Button disabled={loading} type="primary" {state.errorIds?.length > 0 && (
onClick={() => handlePush(ProcessResult.Skip)}>{t('modal.push_article.action_skip')}</Button>} <Button
<Button disabled={loading} type={state.errorIds.length == 0 ? 'primary' : 'default'} disabled={loading} type="primary"
onClick={() => handlePush(ProcessResult.All)} >{t('modal.push_article.action_all')}</Button> onClick={() => handlePush(ProcessResult.Skip)}
>{t('modal.push_article.action_skip')}</Button>
)}
<Button
disabled={loading} type={state.errorIds.length == 0 ? 'primary' : 'default'}
onClick={() => handlePush(ProcessResult.All)}>{t('modal.push_article.action_all')}</Button>
</Space> </Space>
</div> </div>
</Modal> </Modal>

View File

@ -1,6 +1,6 @@
import {Input} from "antd"; import {Input} from "antd";
import {useBoolean, useLocalStorageState, useSetState,useClickAway} from "ahooks"; import {useLocalStorageState, useSetState, useClickAway} from "ahooks";
import {useCallback, useEffect, useMemo, useRef, useState} from "react"; import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {clsx} from "clsx"; import {clsx} from "clsx";
import useArticleTags from "@/hooks/useArticleTags.ts"; import useArticleTags from "@/hooks/useArticleTags.ts";
@ -12,8 +12,11 @@ import {IconPin} from "@/components/icons";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
type SearchPanelProps = { type SearchPanelProps = {
rootClassName?: string;
onSearch?: (params: ApiArticleSearchParams) => void; onSearch?: (params: ApiArticleSearchParams) => void;
defaultParams?: Partial<ApiArticleSearchParams>; defaultParams?: Partial<ApiArticleSearchParams>;
hideNewsSource?: boolean;
rightRender?: React.ReactNode;
} }
const pagination = { const pagination = {
limit: 12, page: 1 limit: 12, page: 1
@ -23,15 +26,22 @@ const DEFAULT_STATE = {
tag_level_2_id: -1, tag_level_2_id: -1,
subOptions: [] subOptions: []
} }
export default function SearchPanel({onSearch,defaultParams}: SearchPanelProps) { export default function SearchPanel(
{
onSearch,
defaultParams,
hideNewsSource,
rightRender,
rootClassName
}: SearchPanelProps) {
const tags = useArticleTags(); const tags = useArticleTags();
const {t} = useTranslation() const {t} = useTranslation()
const [params, setParams] = useSetState<ApiArticleSearchParams>({ const [params, setParams] = useSetState<ApiArticleSearchParams>({
pagination, pagination,
time_flag:1, time_flag: 1,
...(defaultParams || {}) ...(defaultParams || {})
}); });
const [prevSearchName, setPrevSearchName] = useState<string>(defaultParams?.title||'') const [prevSearchName, setPrevSearchName] = useState<string>(defaultParams?.title || '')
const [state, setState] = useSetState<{ const [state, setState] = useSetState<{
tag_level_1_id: number; tag_level_1_id: number;
@ -39,11 +49,11 @@ export default function SearchPanel({onSearch,defaultParams}: SearchPanelProps)
subOptions: (string | number)[] subOptions: (string | number)[]
}>({ }>({
...DEFAULT_STATE, ...DEFAULT_STATE,
...(defaultParams&&defaultParams.tag_level_1_id?{tag_level_1_id:defaultParams.tag_level_1_id}: {}), ...(defaultParams && defaultParams.tag_level_1_id ? {tag_level_1_id: defaultParams.tag_level_1_id} : {}),
...(defaultParams&&defaultParams.tag_level_2_id?{tag_level_2_id:defaultParams.tag_level_2_id}: {}) ...(defaultParams && defaultParams.tag_level_2_id ? {tag_level_2_id: defaultParams.tag_level_2_id} : {})
}) })
useEffect(()=>{ useEffect(() => {
if(!defaultParams){ if (!defaultParams) {
return; return;
} }
const _state = { const _state = {
@ -51,18 +61,18 @@ export default function SearchPanel({onSearch,defaultParams}: SearchPanelProps)
tag_level_2_id: -1, tag_level_2_id: -1,
} }
if(defaultParams.tag_level_1_id){ if (defaultParams.tag_level_1_id) {
_state.tag_level_1_id = defaultParams.tag_level_1_id _state.tag_level_1_id = defaultParams.tag_level_1_id
if(tags && tags.length > 0){ if (tags && tags.length > 0) {
const tag = tags.find(s => s.value == defaultParams.tag_level_1_id) const tag = tags.find(s => s.value == defaultParams.tag_level_1_id)
setSubOptions(tag?.children || []) setSubOptions(tag?.children || [])
} }
} }
if(defaultParams.tag_level_2_id){ if (defaultParams.tag_level_2_id) {
_state.tag_level_2_id = defaultParams.tag_level_2_id _state.tag_level_2_id = defaultParams.tag_level_2_id
} }
setState(_state) setState(_state)
},[tags]) }, [tags])
const [pinnedTag, setPinnedTag] = useLocalStorageState<number[]>( const [pinnedTag, setPinnedTag] = useLocalStorageState<number[]>(
'user-pinned-tag-list', 'user-pinned-tag-list',
{ {
@ -117,31 +127,31 @@ export default function SearchPanel({onSearch,defaultParams}: SearchPanelProps)
} }
return [] as OptionItem[]; return [] as OptionItem[];
}, [pinnedTag, tags]) }, [pinnedTag, tags])
const pinnedManagePanel = useRef<HTMLDivElement|null>(null) const pinnedManagePanel = useRef<HTMLDivElement | null>(null)
const togglePinnedManagePanel = useCallback((visible: boolean) => { const togglePinnedManagePanel = useCallback((visible: boolean) => {
if(!pinnedManagePanel.current){ if (!pinnedManagePanel.current) {
return; return;
} }
const _target = pinnedManagePanel.current!; const _target = pinnedManagePanel.current!;
if(visible){ if (visible) {
_target.style.height = 'auto' _target.style.height = 'auto'
const {height} = _target.getBoundingClientRect() const {height} = _target.getBoundingClientRect()
_target.style.height = '38px' _target.style.height = '38px'
requestAnimationFrame(()=>{ requestAnimationFrame(() => {
_target.style.height = `${height}px` _target.style.height = `${height}px`
}) })
}else{ } else {
requestAnimationFrame(()=>{ requestAnimationFrame(() => {
_target.style.height = '0' _target.style.height = '0'
}) })
} }
},[pinnedManagePanel]) }, [pinnedManagePanel])
const setTrue = ()=> togglePinnedManagePanel(true) const setTrue = () => togglePinnedManagePanel(true)
const setFalse = ()=>togglePinnedManagePanel(false) const setFalse = () => togglePinnedManagePanel(false)
useClickAway(() => setFalse(), pinnedManagePanel) useClickAway(() => setFalse(), pinnedManagePanel)
return (<div className={`${styles.searchPanel} pt-6 pb-2`}> return (<div className={`${styles.searchPanel} ${rootClassName??'pt-6 pb-2'}`}>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="search-form flex items-center gap-4"> <div className="search-form flex items-center gap-4">
<Input <Input
@ -155,12 +165,13 @@ export default function SearchPanel({onSearch,defaultParams}: SearchPanelProps)
/> />
<TimeSelect <TimeSelect
className="w-[140px] ml-1" className="w-[140px] ml-1"
value={typeof(params.time_flag) != "undefined" ? params.time_flag : 1} value={typeof (params.time_flag) != "undefined" ? params.time_flag : 1}
onChange={handleTimeFilter} onChange={handleTimeFilter}
/> />
</div> </div>
{rightRender && <div className="right-placeholder">{rightRender}</div>}
</div> </div>
<div className="filter-container mt-5"> {!hideNewsSource && <div className="filter-container mt-5">
<div className="list-container relative"> <div className="list-container relative">
<div className="justify-between flex items-start border-b pb-2 overflow-hidden"> <div className="justify-between flex items-start border-b pb-2 overflow-hidden">
<div className="pinned-tag-list flex flex-wrap flex-1 min-w-0"> <div className="pinned-tag-list flex flex-wrap flex-1 min-w-0">
@ -182,7 +193,7 @@ export default function SearchPanel({onSearch,defaultParams}: SearchPanelProps)
)} )}
</div> </div>
<div className="pinned-menu mt-2"> <div className="pinned-menu mt-2">
<span className={'cursor-pointer block hover:text-blue-500'} onClick={e=>{ <span className={'cursor-pointer block hover:text-blue-500'} onClick={e => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
setTrue(); setTrue();
@ -193,56 +204,56 @@ export default function SearchPanel({onSearch,defaultParams}: SearchPanelProps)
</div> </div>
<div ref={pinnedManagePanel} className={clsx(styles.pinnedManagePanelContainer)}> <div ref={pinnedManagePanel} className={clsx(styles.pinnedManagePanelContainer)}>
{/* 固定新闻来源 */} {/* 固定新闻来源 */}
<div className={clsx(styles.pinnedManagePanel)}> <div className={clsx(styles.pinnedManagePanel)}>
<div className="header flex justify-between"> <div className="header flex justify-between">
<div className="title font-bold">{t('news.filter_source')}</div> <div className="title font-bold">{t('news.filter_source')}</div>
<div className={'cursor-pointer block hover:text-blue-500'} onClick={setFalse}> <div className={'cursor-pointer block hover:text-blue-500'} onClick={setFalse}>
<UpOutlined style={{fontSize: 20}}/> <UpOutlined style={{fontSize: 20}}/>
</div>
</div>
<div className="tags-list-container">
{
tags.filter(s => s.value !== 999999).map(it => {
const currentPinned = pinnedTag?.includes(Number(it.value));
return (<div
className={`filter-item border flex items-center px-2 py-1 mt-1 text-sm mr-1 cursor-pointer rounded ${currentPinned ? 'bg-gray-100' : ''} hover:border-gray-400`}
key={it.value}
onClick={() => {
const value = Number(it.value)
if (pinnedTag && pinnedTag.includes(value)) {
setPinnedTag(pinnedTag.filter(s => s != value))
} else {
setPinnedTag([...(pinnedTag || []), value])
}
}}>
<span>{it.label}</span>
{currentPinned &&
<span className={'ml-2'}><IconPin/></span>}
</div>)
})
}
</div> </div>
</div> </div>
<div className="tags-list-container">
{
tags.filter(s => s.value !== 999999).map(it => {
const currentPinned = pinnedTag?.includes(Number(it.value));
return (<div
className={`filter-item border flex items-center px-2 py-1 mt-1 text-sm mr-1 cursor-pointer rounded ${currentPinned?'bg-gray-100':''} hover:border-gray-400`}
key={it.value}
onClick={() => {
const value = Number(it.value)
if (pinnedTag && pinnedTag.includes(value)) {
setPinnedTag(pinnedTag.filter(s => s != value))
} else {
setPinnedTag([...(pinnedTag || []), value])
}
}}>
<span>{it.label}</span>
{currentPinned &&
<span className={'ml-2'}><IconPin/></span>}
</div>)
})
}
</div>
</div>
</div> </div>
{/* 二级目录 */} {/* 二级目录 */}
{state.tag_level_1_id != -1 && subOptions.length > 0 && {state.tag_level_1_id != -1 && subOptions.length > 0 &&
<div <div
className="absolute news-source-lv-2 flex items-center absolute left-0 right-0"> className="absolute news-source-lv-2 flex items-center absolute left-0 right-0">
{ {
subOptions.map(it => ( subOptions.map(it => (
<div <div
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 ? 'text-black' : ' text-gray-400 hover:text-gray-600'}`} 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 ? 'text-black' : ' text-gray-400 hover:text-gray-600'}`}
key={it.value} key={it.value}
onClick={() => { onClick={() => {
handleFilter({tag_level_1_id:state.tag_level_1_id,tag_level_2_id: Number(it.value)}) handleFilter({tag_level_1_id: state.tag_level_1_id, tag_level_2_id: Number(it.value)})
}}>{it.label}</div>) }}>{it.label}</div>)
) )
} }
</div>} </div>}
</div> </div>
</div> </div>}
</div>) </div>)
} }

View File

@ -44,7 +44,7 @@
} }
} }
.col{ .col{
@apply flex items-center justify-center relative pl-6; @apply flex items-center justify-center relative pl-6 text-sm;
height: 54px; height: 54px;
&:after{ &:after{
@apply absolute; @apply absolute;
@ -55,6 +55,9 @@
left:0; left:0;
} }
} }
.cover{
@apply pl-2;
}
.title{ .title{
@apply flex-1 pl-0; @apply flex-1 pl-0;
&:after{ &:after{
@ -82,9 +85,8 @@
.header{ .header{
@apply bg-primary-bg; @apply bg-primary-bg;
.col{ .col{
@apply text-sm; @apply text-base;
height: 42px; height: 42px;
} }
.operations{ .operations{
} }
@ -97,3 +99,33 @@
} }
} }
} }
.orderDataList{
:global {
.title{
text-align: center;
}
.id{
@apply pl-0;
width: 140px;
line-height: 1.2em;
&:after{
display: none;
}
}
.cover{
width: 140px;
//img{
// max-width: 100px;
// max-height: 56px;
//}
}
.title {
@apply flex-1 pl-4;
min-width: 100px;
&:after {
display: block;
}
}
}
}

View File

@ -1,4 +1,4 @@
import {Checkbox, Popconfirm, Space} from "antd"; import {Checkbox, Space} from "antd";
import React, {useRef, useState} from "react"; import React, {useRef, useState} from "react";
import {useRequest} from "ahooks"; import {useRequest} from "ahooks";
@ -10,12 +10,13 @@ import ButtonPush2Video, {ProcessResult} from "@/pages/news/components/button-pu
import styles from './components/style.module.scss' import styles from './components/style.module.scss'
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx"; import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
import {IconDelete, IconEdit, IconWarningCircle} from "@/components/icons"; import {IconDelete, IconEdit} from "@/components/icons";
import {clsx} from "clsx"; import {clsx} from "clsx";
import ButtonToTop from "@/components/scoller/button-to-top.tsx"; import ButtonToTop from "@/components/scoller/button-to-top.tsx";
import ButtonDeleteBatch from "@/pages/news/components/button-delete-batch.tsx"; import ButtonDeleteBatch from "@/pages/news/components/button-delete-batch.tsx";
import {showErrorToast, showToast} from "@/components/message.ts"; import {showErrorToast, showToast} from "@/components/message.ts";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {DeleteItemPopoverConfirm} from "@/components/message/confirm.tsx";
const FilterCache: Partial<ApiArticleSearchParams> = { const FilterCache: Partial<ApiArticleSearchParams> = {
tags: [], tags: [],
@ -39,6 +40,8 @@ export default function NewEdit() {
onSuccess: (data) => { onSuccess: (data) => {
FilterCache.title = params.title; FilterCache.title = params.title;
FilterCache.tags = params.tags; FilterCache.tags = params.tags;
setSelectedRowKeys(()=>([]))
setState({checkAll: false})
setData(prev => { setData(prev => {
// 判断页码是否是第1页 // 判断页码是否是第1页
if (data.pagination.page == 1) return data; if (data.pagination.page == 1) return data;
@ -99,10 +102,12 @@ export default function NewEdit() {
<span className={'inline-block cursor-pointer mr-2'} onClick={() => { <span className={'inline-block cursor-pointer mr-2'} onClick={() => {
handleCheckAll(!state.checkAll) handleCheckAll(!state.checkAll)
}}>{t('select.select_all')}</span> }}>{t('select.select_all')}</span>
<Checkbox checked={state.checkAll && (!data?.list || selectedRowKeys.length == data?.list?.length)} <Checkbox
onChange={e => { checked={state.checkAll && (!data?.list || selectedRowKeys.length == data?.list?.length)}
handleCheckAll(e.target.checked) onChange={e => {
}}/> handleCheckAll(e.target.checked)
}}
/>
</div> </div>
</div> </div>
<div className={styles.newListTable}> <div className={styles.newListTable}>
@ -119,15 +124,15 @@ export default function NewEdit() {
...prev, ...prev,
pagination: {page, limit: 10} pagination: {page, limit: 10}
})) }))
}} onScroll={(top) => setState({showToTop: top > 30})} loading={loading} }} onScroll={(top) => setState(s=>({...s,showToTop: top > 30}))} loading={loading}
pagination={data?.pagination}> pagination={data?.pagination}>
<div className="body"> <div className="body">
{data?.list?.map((item, i) => { {data?.list?.map((item, i) => {
const checked = selectedRowKeys.includes(item.id) const checked = selectedRowKeys.includes(item.id)
return <div key={i} className={clsx("row flex", {checked})}> return <div key={item.id} className={clsx("row flex", {checked})}>
<div className="col title cursor-pointer" onClick={() => setEditId(item.id)}> <div className="col title cursor-pointer" onClick={() => setEditId(item.id)}>
<div className="flex-1"> <div className="flex-1">
<div className="text-base">{item.title}</div> <div className="text-base line-clamp-1">{item.title}</div>
<div <div
className="summary text-xs text-gray-400 line-clamp-1">{item.summary}</div> className="summary text-xs text-gray-400 line-clamp-1">{item.summary}</div>
</div> </div>
@ -147,19 +152,9 @@ export default function NewEdit() {
</div> </div>
<div className="col operations"> <div className="col operations">
<span className="icon-btn" onClick={()=>setEditId(item.id)}><IconEdit/></span> <span className="icon-btn" onClick={()=>setEditId(item.id)}><IconEdit/></span>
<Popconfirm <DeleteItemPopoverConfirm onConfirm={() => {handleDelete(item.id)}}>
rootClassName={'popconfirm-main'}
placement={'left'}
arrow={false}
icon={<IconWarningCircle/>}
title={t('news.delete_confirm')}
description={<span dangerouslySetInnerHTML={{__html:t('news.delete_description')}}></span>}
onConfirm={() => {
handleDelete(item.id)
}}
>
<span className="icon-btn"><IconDelete/></span> <span className="icon-btn"><IconDelete/></span>
</Popconfirm> </DeleteItemPopoverConfirm>
<Checkbox checked={checked} <Checkbox checked={checked}
onChange={e => handleItemChecked(e.target.checked, item)}/> onChange={e => handleItemChecked(e.target.checked, item)}/>
</div> </div>
@ -179,6 +174,7 @@ export default function NewEdit() {
<ArticleEditModal <ArticleEditModal
type="news" type="news"
id={editId} id={editId}
onRefresh={refresh}
onClose={(saved) => { onClose={(saved) => {
setEditId(-1) setEditId(-1)
if (saved) refresh() if (saved) refresh()

View File

@ -1,6 +1,6 @@
import React, {useMemo, useRef, useState} from "react"; import React, {useMemo, useRef, useState} from "react";
import {Checkbox, Divider, Empty, Modal, Space} from "antd"; import {Checkbox, Divider, Empty, Modal, Space} from "antd";
import {useRequest} from "ahooks"; import { useRequest, useSetState } from 'ahooks';
import {CloseOutlined} from "@ant-design/icons" import {CloseOutlined} from "@ant-design/icons"
import {clsx} from "clsx"; import {clsx} from "clsx";
@ -30,7 +30,7 @@ export default function NewsIndex() {
const [activeNews, setActiveNews] = useState<NewsInfo>() const [activeNews, setActiveNews] = useState<NewsInfo>()
const [state, setState] = useState<{ const [state, setState] = useSetState<{
checkAll?: boolean; checkAll?: boolean;
showToTop?: boolean; showToTop?: boolean;
}>({}) }>({})
@ -43,10 +43,10 @@ export default function NewsIndex() {
FilterCache.tag_level_2_id = params.tag_level_2_id; FilterCache.tag_level_2_id = params.tag_level_2_id;
FilterCache.title = params.title; FilterCache.title = params.title;
FilterCache.time_flag = params.time_flag; FilterCache.time_flag = params.time_flag;
console.log('success',FilterCache)
if (params.pagination.page === 1) { if (params.pagination.page === 1) {
setCheckedId([])
setData(_data) setData(_data)
setState({checkAll: checkedId && _data.list && checkedId.length === _data.list.length}) setState({checkAll: false,showToTop: false})
} else { } else {
setData({ setData({
pagination: _data.pagination, pagination: _data.pagination,
@ -89,7 +89,11 @@ export default function NewsIndex() {
} }
} }
return (<div className={'container pb-5'}> return (<div className={'container pb-5'}>
<SearchPanel defaultParams={params} onSearch={setParams}/> <SearchPanel defaultParams={params} onSearch={(params)=>{
// 滚动到顶部
scrollerRef.current?.scrollToPosition(0)
setParams(params)
}}/>
{activeNews && <Modal {activeNews && <Modal
rootClassName={'news-detail-modal'} rootClassName={'news-detail-modal'}
closeIcon={null} open={true} width={1000} closeIcon={null} open={true} width={1000}

104
src/pages/order/index.tsx Normal file
View File

@ -0,0 +1,104 @@
import React, {useState} from "react";
import {useTranslation} from "react-i18next";
import {Empty, Pagination} from "antd";
import {useRequest} from "ahooks";
import SearchPanel from "@/pages/news/components/search-panel.tsx";
import styles from "@/pages/news/components/style.module.scss";
import {formatDurationToTime, formatTime} from "@/util/strings.ts";
import {getList} from "@/service/api/order.ts";
import ImageErr from "@/assets/images/error/ic_broken_image.png"
function OrderIndex() {
const {t} = useTranslation()
const [params, setParams] = useState<ApiArticleSearchParams>({
pagination: {page: 1, limit: 10},
time_flag: 0,
})
const {data} = useRequest(() => getList(params), {
refreshDeps: [params],
})
return <div className="pb-5 page-order-index">
<div className=" mb-5" style={{backgroundColor:'#dae8fc'}}>
<div className="container" style={{padding:0}}>
<SearchPanel
rootClassName="py-6"
hideNewsSource={true}
defaultParams={params}
onSearch={setParams}
rightRender={<div>{t('order.left_time')}: <span
className={`${!data?.remaining_duration || Number(data?.remaining_duration) < 3600 ? 'text-red-600' : ''}`}>{formatDurationToTime(data?.remaining_duration)}</span>
</div>}
/>
</div></div>
<div className="mt-5 container" style={{padding:"20px 0"}}>
<div className={`${styles.newListTable} ${styles.orderDataList} `}>
<div className="header row flex">
<div className="col id w-[160px]">{t('order.list.id')}</div>
<div className="col cover">{t('order.list.cover')}</div>
<div className="col title w-min-60px">{t('order.list.title')}</div>
<div className="col w-[180px]">{t('order.list.order_time')}</div>
<div className="col w-[120px]">{t('order.list.consume_time')}</div>
<div className="col w-[180px]">{t('order.list.operator')}</div>
</div>
<div>
{data?.list.length === 0 && <div style={{marginTop: 50}}>
<Empty/>
</div>}
{data?.list.map((item, i) => {
return <div key={i} className="row flex">
<div className="col id w-[160px] text-center">
<div className="flex-1">
<div className="break-all">{item.order_id}</div>
</div>
</div>
<div className="col cover">
<div
className="w-[100px] h-[56px] flex items-center rounded overflow-hidden border border-gray-50"
>
<img
src={item.img_url || ImageErr}
className="w-[100px] object-cover"
/>
</div>
</div>
<div className="col title order-title flex-1 w-min-60px">
<div className="line-clamp-2">{item.title}</div>
</div>
<div className="col w-[180px]">{formatTime(item.order_time, 'YYYY-MM-DD HH:mm')}
</div>
<div className="col w-[120px]">{formatDurationToTime(item.consumption_duration)}
</div>
<div className="col w-[180px]">{item.operator}</div>
</div>
})}
<div className="footer flex justify-end mt-10">
<Pagination
onChange={(page, limit) => {
setParams({
...params,
pagination: {page, limit}
})
}}
total={data?.pagination.total || 0}
showTotal={(total) => <div>{t('page.total_item', {total})}</div>}
showSizeChanger={{
options: [
{value: 10, label: t('page.size_10')},
{value: 20, label: t('page.size_20')},
{value: 30, label: t('page.size_30')}
]
}}
showQuickJumper={true}
/>
</div>
</div>
</div>
</div>
</div>
}
export default OrderIndex

View File

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -4,6 +4,9 @@
:global { :global {
.video-bottom { .video-bottom {
} }
.video-time-info{
min-width: 60px;
}
} }
} }

View File

@ -7,7 +7,7 @@ import styles from './style.module.scss'
type VideoItemProps = { type VideoItemProps = {
videoInfo: VideoInfo; videoInfo: VideoInfo;
onLive?: boolean; onLive?: boolean;
onClick?: (autoPlay:boolean) => void; onClick?: (autoPlay: boolean) => void;
onRemove?: () => void; onRemove?: () => void;
onCheckedChange?: (checked: boolean) => void; onCheckedChange?: (checked: boolean) => void;
checked?: boolean; checked?: boolean;
@ -25,14 +25,14 @@ export default function VideoItem(props: VideoItemProps) {
<div className="cover"> <div className="cover">
<img className={'w-full cursor-pointer object-cover'} src={props.videoInfo.cover}/> <img className={'w-full cursor-pointer object-cover'} src={props.videoInfo.cover}/>
<div className={'absolute inset-x-0 top-0 flex items-center justify-center bottom-[36px]'}> <div className={'absolute inset-x-0 top-0 flex items-center justify-center bottom-[36px]'}>
<div className={styles.playIcon} onClick={()=>props.onClick?.(true)}><CaretRightOutlined /></div> <div className={styles.playIcon} onClick={() => props.onClick?.(true)}><CaretRightOutlined/></div>
</div> </div>
</div> </div>
<div <div
className="video-bottom bg-black/30 backdrop-blur-[2px] text-sm absolute inset-x-0 bottom-0 text-white py-2 px-3 items-center flex justify-between"> className="video-bottom bg-black/30 backdrop-blur-[2px] text-sm absolute inset-x-0 bottom-0 text-white py-2 px-3 items-center flex justify-between">
<div className="title cursor-pointer flex-1 text-nowrap overflow-hidden text-ellipsis min-w-0 mr-4" <div className="title cursor-pointer flex-1 text-nowrap overflow-hidden text-ellipsis min-w-0 mr-4"
onClick={()=>props.onClick?.(false)}>{props.videoInfo.title}</div> onClick={() => props.onClick?.(false)}>{props.videoInfo.title}</div>
<div className="video-time-info">{timeFromNow(props.videoInfo.ctime)}</div> <div className="video-time-info text-right">{timeFromNow(props.videoInfo.d_time)}</div>
</div> </div>
<div <div
className={"absolute top-1 left-1 bg-black/50 rounded-3xl text-white px-3 py-0.5"}>{Math.ceil(props.videoInfo.duration / 1000)}s className={"absolute top-1 left-1 bg-black/50 rounded-3xl text-white px-3 py-0.5"}>{Math.ceil(props.videoInfo.duration / 1000)}s

View File

@ -1,25 +1,25 @@
import React, {useEffect, useRef, useState} from "react"; import React, {useEffect, useRef, useState} from "react";
import {Checkbox, Modal, Space} from "antd"; import {Checkbox, Empty, Modal, Space} from "antd";
import {useRequest, useSetState} from "ahooks"; import {useRequest, useSetState} from "ahooks";
import {useTranslation} from "react-i18next";
import VideoItem from "@/pages/library/components/video-item.tsx"; import VideoItem from "@/pages/recycle/components/video-item.tsx";
import SearchForm from "@/pages/library/components/search-form.tsx"; import SearchForm from "@/pages/recycle/components/search-form.tsx";
import VideoDetail from "@/pages/library/components/video-detail.tsx"; import VideoDetail from "@/pages/recycle/components/video-detail.tsx";
import {deleteHistories, push2room, search} from "@/service/api/video.ts";
import {getList} from "@/service/api/live.ts";
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx"; import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
import ButtonBatch from "@/components/button-batch.tsx"; import ButtonBatch from "@/components/button-batch.tsx";
import ButtonToTop from "@/components/scoller/button-to-top.tsx"; import ButtonToTop from "@/components/scoller/button-to-top.tsx";
import {IconArrowRight, IconDelete} from "@/components/icons"; import {IconArrowRight, IconDelete} from "@/components/icons";
import {useTranslation} from "react-i18next";
import {showToast} from "@/components/message.ts"; import {showToast} from "@/components/message.ts";
import {BizError} from "@/service/types.ts"; import {BizError} from "@/service/types.ts";
import {getList as getLiveList} from "@/service/api/live.ts";
import {getList, remove, restore} from "@/service/api/recycle.ts";
const DEFAULT_PAGE_LIMIT = { const DEFAULT_PAGE_LIMIT = {
page: 1, page: 1,
limit: 12 limit: 12
} }
export default function LibraryIndex() { export default function RecycleIndex() {
const {t} = useTranslation() const {t} = useTranslation()
const [modal, contextHolder] = Modal.useModal(); const [modal, contextHolder] = Modal.useModal();
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([]) const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
@ -31,20 +31,27 @@ export default function LibraryIndex() {
checkedAll: false, checkedAll: false,
loading: false, loading: false,
pushedCount: 0, pushedCount: 0,
pushedList: [-1],
showToTop: false showToTop: false
}) })
const [data, setData] = useState<DataList<VideoInfo>>() const [data, setData] = useState<DataList<VideoInfo>>()
const scrollerRef = useRef<InfiniteScrollerRef | null>(null) const scrollerRef = useRef<InfiniteScrollerRef | null>(null)
const {loading} = useRequest(() => search(params), { const {loading} = useRequest(() => getList(params), {
refreshDeps: [params], refreshDeps: [params],
onSuccess: (data) => { onSuccess: (data) => {
setData(prev => { setData(prev => {
// 判断页码是否是第1页 // 判断页码是否是第1页
if (data.pagination.page == 1) return data; if (data.pagination.page == 1) {
setCheckedIdArray([])
return data;
}
return { return {
list: [...(prev?.list || []), ...(data?.list || [])], list: [...(prev?.list || []), ...(data?.list || [])],
pagination: data.pagination pagination: data.pagination || {
page: 1,
limit: DEFAULT_PAGE_LIMIT.limit
}
} }
}) })
} }
@ -74,23 +81,27 @@ export default function LibraryIndex() {
autoPlay: boolean autoPlay: boolean
}>() }>()
const handleAllCheckedChange = (checked: boolean) => { const handleAllCheckedChange = (checked: boolean) => {
if (!data) return; if (!data || data.pagination.total == 0) return;
setCheckedIdArray(checked ? data.list.map(v => v.id) : []) setCheckedIdArray(checked ? data.list.map(v => v.id) : [])
setState({ setState({
checkedAll: !state.checkedAll checkedAll: !state.checkedAll
}) })
} }
const loadPushedState = () => { const loadPushedState = () => {
getList().then((ret) => { getLiveList().then((ret) => {
if (ret.list) { if (ret.list) {
setState({pushedCount: ret.list.length}) setState({pushedCount: ret.list.length, pushedList: ret.list.map(s => s.id)})
} }
}) })
} }
const refresh = () => { const refresh = () => {
loadPushedState(); // loadPushedState();
setParams(prev => ({...prev, pagination: {page: 1, limit: DEFAULT_PAGE_LIMIT.limit}, request_time: Date.now()})) setParams(prev => ({...prev, pagination: {page: 1, limit: DEFAULT_PAGE_LIMIT.limit}, request_time: Date.now()}))
} }
// const pusdedCount = useMemo(() => {
// if (state.pushedCount == 0 || !data || !data.list || data.list.length == 0) return 0;
// return data.list.filter(s => state.pushedList.includes(s.id)).length
// }, [state.pushedList, state.pushedCount, data])
useEffect(loadPushedState, []) useEffect(loadPushedState, [])
@ -99,7 +110,12 @@ export default function LibraryIndex() {
{contextHolder} {contextHolder}
<div className="search-form-container"> <div className="search-form-container">
<SearchForm <SearchForm
onSearch={setParams} onSearch={(params) => {
setParams({
...params,
pagination: {...DEFAULT_PAGE_LIMIT}
})
}}
onBtnStartClick={handleLive} onBtnStartClick={handleLive}
loading={loading} loading={loading}
/> />
@ -109,17 +125,19 @@ export default function LibraryIndex() {
<div className="pl-[70px]"></div> <div className="pl-[70px]"></div>
<div className="flex items-center"> <div className="flex items-center">
<Space className="text-gray-400" size={20}> <Space className="text-gray-400" size={20}>
<span>{t('select.total',{count:data?.list?.length || 0})}</span> <span>{t('select.total', {count: data?.list?.length || 0})}</span>
<span>{t('history.pushed',{count:state.pushedCount})}</span> {/*<span>{t('history.pushed', {count: state.pushedCount})}</span>*/}
<span className={'text-blue-500'}>{t('select.selected_some',{count:checkedIdArray.length})}</span> <span className={'text-blue-500'}>{t('select.selected_some', {count: checkedIdArray.length})}</span>
</Space> </Space>
<button className="hover:text-blue-300 text-gray-400 ml-4" <button className="hover:text-blue-300 text-gray-400 ml-4"
onClick={() => handleAllCheckedChange(checkedIdArray.length != data?.list.length)}> onClick={() => handleAllCheckedChange(checkedIdArray.length != data?.list.length)}>
<span className="text-sm mr-2">{t("select.select_all")}</span> <span className="text-sm mr-2">{t("select.select_all")}</span>
{/*<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>*/} {/*<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>*/}
</button> </button>
<Checkbox checked={checkedIdArray.length == data?.list?.length} <Checkbox
onChange={e => handleAllCheckedChange(e.target.checked)}/> disabled={data?.pagination.total == 0 || data?.list?.length == 0}
checked={checkedIdArray.length == data?.list?.length}
onChange={e => handleAllCheckedChange(e.target.checked)}/>
</div> </div>
</div> </div>
<InfiniteScroller <InfiniteScroller
@ -131,6 +149,9 @@ export default function LibraryIndex() {
})) }))
}} onScroll={(top) => setState({showToTop: top > 30})} }} onScroll={(top) => setState({showToTop: top > 30})}
> >
{data?.pagination.total == 0 && !loading && <div className="mt-20">
<Empty/>
</div>}
<div className={'video-list-container grid gap-4 grid-cols-3 xl:grid-cols-4'}> <div className={'video-list-container grid gap-4 grid-cols-3 xl:grid-cols-4'}>
{data?.list?.map((it, idx) => ( {data?.list?.map((it, idx) => (
<VideoItem <VideoItem
@ -152,39 +173,44 @@ export default function LibraryIndex() {
</div> </div>
</div> </div>
{detailVideo && <VideoDetail video={detailVideo.video} autoPlay={detailVideo.autoPlay} {detailVideo && <VideoDetail video={detailVideo.video} autoPlay={detailVideo.autoPlay}
onClose={() => setDetailVideo(undefined)}/>} onClose={() => setDetailVideo(undefined)}/>}
<div className="page-action"> <div className="page-action">
<ButtonToTop visible={state.showToTop} onClick={() => scrollerRef.current?.scrollToPosition(0)}/> <ButtonToTop visible={state.showToTop} onClick={() => scrollerRef.current?.scrollToPosition(0)}/>
{checkedIdArray?.length > 0 && <ButtonBatch {checkedIdArray?.length > 0 && <ButtonBatch
selected={checkedIdArray} selected={checkedIdArray}
onSuccess={refresh} onSuccess={refresh}
className='bg-gray-300 hover:bg-gray-400 text-white' className='bg-gray-300 hover:bg-gray-400 text-white'
icon={<IconDelete className=""/>} icon={<IconDelete className=""/>}
title={ title={
checkedIdArray.length == 1 checkedIdArray.length == 1
? t('video.delete_description',{count:checkedIdArray.length}) ? t('video.delete_description', {count: checkedIdArray.length})
: t('video.delete_description_count',{count:checkedIdArray.length}) : t('video.delete_description_count', {count: checkedIdArray.length})
} }
emptyMessage={t('video.delete_empty')} emptyMessage={t('video.delete_empty')}
confirmMessage={<span dangerouslySetInnerHTML={{ confirmMessage={<span dangerouslySetInnerHTML={{
__html: checkedIdArray.length == 1 __html: checkedIdArray.length == 1
? t('video.delete_confirm') ? t('video.delete_forever_confirm')
: t('video.delete_confirm_count',{count:checkedIdArray.length}) : t('video.delete_forever_confirm_count', {count: checkedIdArray.length})
}}></span>} }}></span>}
onProcess={deleteHistories} onProcess={remove}
>{t('delete_batch')}</ButtonBatch>} >{t('recycle.remove_forever')}</ButtonBatch>}
{checkedIdArray?.length > 0 && <ButtonBatch {checkedIdArray?.length > 0 && <ButtonBatch
selected={checkedIdArray} selected={checkedIdArray}
onSuccess={refresh} onSuccess={refresh}
className='bg-[#4096ff] hover:bg-blue-600 text-white' className='bg-[#4096ff] hover:bg-blue-600 text-white'
icon={<IconArrowRight className={'text-white'}/>} icon={<IconArrowRight className={'text-white'}/>}
onProcess={push2room} onProcess={restore}
emptyMessage={t('video.push_empty')} confirmMessage={<span dangerouslySetInnerHTML={{
onError={e=>{ __html: checkedIdArray.length == 1
showToast(String((e as BizError).data || e.message),'error') ? t('video.restore_confirm')
}} : t('video.restore_confirm_count', {count: checkedIdArray.length})
>{t('video.push_to_live')}</ButtonBatch>} }}></span>}
emptyMessage={t('video.push_empty')}
onError={e => {
showToast(String((e as BizError).data || e.message), 'error')
}}
>{t('recycle.restore_video')}</ButtonBatch>}
</div> </div>
</>) </>)
} }

View File

@ -1,12 +1,13 @@
import {Button, Modal} from "antd"; import {Modal} from "antd";
import React, {useState} from "react"; import React, {useState} from "react";
import {showErrorToast, showToast} from "@/components/message.ts"; import {showErrorToast, showToast} from "@/components/message.ts";
import {push2room, VideoStatus} from "@/service/api/video.ts"; import {push2room, VideoStatus} from "@/service/api/video.ts";
import {IconArrowRight, IconWarningCircle} from "@/components/icons"; import {IconArrowRight} from "@/components/icons";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
export default function ButtonPush2Room(props: { ids: Id[]; list: VideoInfo[];onSuccess?:()=>void; }) { export default function ButtonPush2Room(props: { ids: Id[]; list: VideoInfo[]; onSuccess?: () => void; }) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const {t} = useTranslation() const {t} = useTranslation()
const handlePush = () => { const handlePush = () => {
@ -15,9 +16,9 @@ export default function ButtonPush2Room(props: { ids: Id[]; list: VideoInfo[];on
const vids = props.list.filter(v => v.status == VideoStatus.Generated && props.ids.includes(v.id)).map(v => v.id) const vids = props.list.filter(v => v.status == VideoStatus.Generated && props.ids.includes(v.id)).map(v => v.id)
push2room(vids).then(() => { push2room(vids).then(() => {
props.onSuccess?.() props.onSuccess?.()
if(props.ids.length == vids.length){ if (props.ids.length == vids.length) {
showToast(t("video.push_success"), 'success') showToast(t("video.push_success"), 'success')
}else{ } else {
showToast(t("video.push_failed"), 'success') showToast(t("video.push_failed"), 'success')
} }
}).catch(showErrorToast).finally(() => { }).catch(showErrorToast).finally(() => {
@ -25,17 +26,18 @@ export default function ButtonPush2Room(props: { ids: Id[]; list: VideoInfo[];on
}) })
} }
const onPushClick = () => { const onPushClick = () => {
if(loading) return; if (loading) return;
if (props.ids.length === 0) { if (props.ids.length === 0) {
showToast(t("video.push_empty"), 'warning') showToast(t("video.push_empty"), 'warning')
return return
} }
Modal.confirm({ Modal.confirm({
wrapClassName:'root-modal-confirm', wrapClassName:'root-modal-confirm',
title: '操作提示', title: <ModalWarning.Title/>,
icon: <span className="anticon anticon-exclamation-circle"><IconWarningCircle/></span>, icon: <ModalWarning.Icon/>,
content: t("video.push_confirm"), content: t("video.push_confirm"),
onOk: handlePush onOk: handlePush,
centered: true
}) })
} }
return ( return (
@ -47,7 +49,7 @@ export default function ButtonPush2Room(props: { ids: Id[]; list: VideoInfo[];on
onClick={onPushClick} onClick={onPushClick}
> >
<span className={'text'}>{t("video.push_to_live")}</span> <span className={'text'}>{t("video.push_to_live")}</span>
<IconArrowRight /> <IconArrowRight/>
</button> </button>
</div> </div>
) )

View File

@ -1,8 +1,10 @@
import {Checkbox, Empty, Space} from "antd"; import {Checkbox, Empty, Space} from "antd";
import React, {useEffect, useMemo, useRef, useState} from "react"; import React, {useEffect, useRef, useState} from "react";
import {DndContext} from "@dnd-kit/core"; import {DndContext} from "@dnd-kit/core";
import {arrayMove, SortableContext} from "@dnd-kit/sortable"; import {arrayMove, SortableContext} from "@dnd-kit/sortable";
import {useSetState} from "ahooks"; import {useSetState} from "ahooks";
import {useLocation, useNavigate} from "react-router-dom";
import {useTranslation} from "react-i18next";
import {VideoListItem} from "@/components/video/video-list-item.tsx"; import {VideoListItem} from "@/components/video/video-list-item.tsx";
import ArticleEditModal from "@/components/article/edit-modal.tsx"; import ArticleEditModal from "@/components/article/edit-modal.tsx";
@ -10,13 +12,11 @@ import {deleteFromList, getList, modifyOrder, regenerateById, VideoStatus} from
import {formatDuration} from "@/util/strings.ts"; import {formatDuration} from "@/util/strings.ts";
import ButtonBatch from "@/components/button-batch.tsx"; import ButtonBatch from "@/components/button-batch.tsx";
import {showErrorToast, showToast} from "@/components/message.ts"; import {showErrorToast, showToast} from "@/components/message.ts";
import {Player, PlayerInstance} from "@/components/video/player.tsx"; import {Mp4Player as Player, PlayerInstance} from "@/components/video/Mp4Player.tsx";
import ButtonPush2Room from "@/pages/video/components/button-push2room.tsx"; import ButtonPush2Room from "@/pages/video/components/button-push2room.tsx";
import ButtonToTop from "@/components/scoller/button-to-top.tsx"; import ButtonToTop from "@/components/scoller/button-to-top.tsx";
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx"; import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
import {IconDelete} from "@/components/icons"; import {IconDelete} from "@/components/icons";
import {useLocation, useNavigate} from "react-router-dom";
import {useTranslation} from "react-i18next";
export default function VideoIndex() { export default function VideoIndex() {
const {t} = useTranslation() const {t} = useTranslation()
@ -29,6 +29,7 @@ export default function VideoIndex() {
const [state, setState] = useSetState({ const [state, setState] = useSetState({
checkedAll: false, checkedAll: false,
playingId: -1, playingId: -1,
videoPlaying: false,
showToTop: false, showToTop: false,
showStatePos: false, showStatePos: false,
playState: { playState: {
@ -76,7 +77,7 @@ export default function VideoIndex() {
// 播放视频 // 播放视频
const playVideo = (video: VideoInfo) => { const playVideo = (video: VideoInfo) => {
console.log('play video',video)
if (state.playingId == video.id) { if (state.playingId == video.id) {
player.current?.pause(); player.current?.pause();
setState({playingId: -1}) setState({playingId: -1})
@ -86,9 +87,9 @@ export default function VideoIndex() {
// setState({playingIndex}) // setState({playingIndex})
// player.current?.play('https://staticplus.gachafun.com/ai-collect/composite_video/2024-12-17/1186196465916190720.flv', 30) // player.current?.play('https://staticplus.gachafun.com/ai-collect/composite_video/2024-12-17/1186196465916190720.flv', 30)
// //
if (video.oss_video_url && video.status !== 1) { if (video.oss_video_mp4_url && video.status !== 1) {
setState({playingId: video.id}) setState({playingId: video.id})
player.current?.play(video.oss_video_url, 0) player.current?.play(video.oss_video_mp4_url, 0)
} }
} }
// 处理全选 // 处理全选
@ -170,6 +171,12 @@ export default function VideoIndex() {
onChange={(state) => { onChange={(state) => {
if (state.end || state.error) setState({playingId: -1}) if (state.end || state.error) setState({playingId: -1})
}} }}
onPause={() => {
setState({videoPlaying:false})
}}
onPlay={() => {
setState({videoPlaying:true})
}}
onProgress={(current, duration) => { onProgress={(current, duration) => {
setState({ setState({
playState: { playState: {
@ -185,7 +192,7 @@ export default function VideoIndex() {
</div> </div>
</div> </div>
<div className="video-list-container rounded mt-2 flex flex-col flex-1"> <div className="video-list-container rounded mt-2 flex flex-col flex-1">
<div className="live-control flex justify-between"> <div className="live-control flex justify-between h-[30px]">
<div className="pl-[70px]"></div> <div className="pl-[70px]"></div>
<div className="flex items-center"> <div className="flex items-center">
<Space size={20}> <Space size={20}>
@ -227,14 +234,6 @@ export default function VideoIndex() {
handleModifySort(newSorts) handleModifySort(newSorts)
return newSorts; return newSorts;
}); });
// modal.confirm({
// title: '提示',
// content: '是否要移动到指定位置',
// onOk: handleModifySort,
// onCancel: () => {
// setVideoData(originArr);
// }
// })
} }
}}> }}>
<SortableContext items={videoData}> <SortableContext items={videoData}>
@ -246,7 +245,7 @@ export default function VideoIndex() {
key={index} key={index}
type={'create'} type={'create'}
active={checkedIdArray.includes(v.id)} active={checkedIdArray.includes(v.id)}
playing={state.playingId == v.id} playing={state.playingId == v.id && state.videoPlaying}
checked={checkedIdArray.includes(v.id)} checked={checkedIdArray.includes(v.id)}
className={`list-item-${index} mt-3 mb-2 list-item-state-${v.status} `} className={`list-item-${index} mt-3 mb-2 list-item-state-${v.status} `}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
@ -262,8 +261,9 @@ export default function VideoIndex() {
setEditId(v.article_id) setEditId(v.article_id)
}:undefined} }:undefined}
onRegenerate={v.status != VideoStatus.Generating && v.status != VideoStatus.Generated?()=>{ onRegenerate={v.status != VideoStatus.Generating && v.status != VideoStatus.Generated?()=>{
processGenerateVideo(v) processGenerateVideo(v).catch(console.log)
}:undefined} }:undefined}
downloadUrl={v.oss_video_mp4_url}
hideCheckBox={v.status != VideoStatus.Generating && v.status != VideoStatus.Generated} hideCheckBox={v.status != VideoStatus.Generating && v.status != VideoStatus.Generated}
editable={v.status != VideoStatus.Generating} editable={v.status != VideoStatus.Generating}
sortable={v.status == VideoStatus.Generated} sortable={v.status == VideoStatus.Generated}
@ -278,31 +278,38 @@ export default function VideoIndex() {
</div> </div>
<div className="page-action"> <div className="page-action">
<ButtonToTop visible={state.showToTop} onClick={() => scrollerRef.current?.scrollToPosition(0)}/> <ButtonToTop visible={state.showToTop} onClick={() => scrollerRef.current?.scrollToPosition(0)}/>
{checkedIdArray.length > 0 && <ButtonBatch {
onProcess={deleteFromList} checkedIdArray.length > 0 && <ButtonBatch
selected={checkedIdArray} onProcess={deleteFromList}
emptyMessage={t('video.delete_empty')} selected={checkedIdArray}
title={ emptyMessage={t('video.delete_empty')}
checkedIdArray.length == 1 ? t('video.delete_description',{count:checkedIdArray.length}): title={
t('video.delete_description_count',{count:checkedIdArray.length}) checkedIdArray.length == 1 ? t('video.delete_description', {count: checkedIdArray.length}) :
} t('video.delete_description_count', {count: checkedIdArray.length})
className='bg-gray-300 hover:bg-gray-400 text-white' }
confirmMessage={<span dangerouslySetInnerHTML={{ className='bg-gray-300 hover:bg-gray-400 text-white'
__html:checkedIdArray.length == 1? confirmMessage={<span dangerouslySetInnerHTML={{
t('video.delete_confirm',{count:checkedIdArray.length}): __html: checkedIdArray.length == 1 ?
t('video.delete_confirm_count',{count:checkedIdArray.length}) t('video.delete_confirm', {count: checkedIdArray.length}) :
}}></span>} t('video.delete_confirm_count', {count: checkedIdArray.length})
onSuccess={() => { }}></span>}
showToast(t('delete_success'), 'success') onSuccess={() => {
loadList() showToast(t('delete_success'), 'success')
}} loadList()
> }}
<span className="text">{t('delete_batch')}</span> >
<IconDelete/> <span className="text">{t('delete_batch')}</span>
</ButtonBatch>} <IconDelete/>
</ButtonBatch>
}
<ButtonPush2Room ids={checkedIdArray} list={videoData} onSuccess={loadList}/> <ButtonPush2Room ids={checkedIdArray} list={videoData} onSuccess={loadList}/>
</div> </div>
</div> </div>
<ArticleEditModal type={'video'} id={editId} onClose={() => setEditId(-1)}/> <ArticleEditModal type={'video'} id={editId} onClose={(saved) =>{
setEditId(-1)
if(saved) {
loadList()
}
}}/>
</div>) </div>)
} }

View File

@ -1,6 +1,6 @@
import {createBrowserRouter, RouterProvider,} from "react-router-dom"; import {createBrowserRouter, RouterProvider,} from "react-router-dom";
import {Suspense, useEffect,} from "react"; import React, {Suspense, useEffect,} from "react";
import {ConfigProvider, App} from "antd"; import {ConfigProvider, App, Modal} from "antd";
import zhCN from 'antd/locale/zh_CN'; import zhCN from 'antd/locale/zh_CN';
// for date-picker i18n // for date-picker i18n
import dayjs from "dayjs"; import dayjs from "dayjs";
@ -9,9 +9,10 @@ import ErrorBoundary from "./error.tsx";
import Loader from "@/components/loader.tsx"; import Loader from "@/components/loader.tsx";
import routes from "@/routes/routes.tsx"; import routes from "@/routes/routes.tsx";
import {DocumentTitle} from "@/components/document.tsx"; import {DocumentTitle} from "@/components/document.tsx";
import useConfig from "@/hooks/useConfig.ts";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import useGlobalConfig from "@/hooks/useGlobalConfig.ts"; import useGlobalConfig from "@/hooks/useGlobalConfig.ts";
import {getRemainingDuration} from "@/service/api/order.ts";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
const router = createBrowserRouter([ const router = createBrowserRouter([
@ -33,15 +34,38 @@ const AppRouter = () => {
const {globalConfig} = useGlobalConfig(); const {globalConfig} = useGlobalConfig();
const {t,i18n} = useTranslation(); const {t,i18n} = useTranslation();
const initRemainingDuration = () => {
getRemainingDuration().then(remain => {
if(remain <= 0){
Modal.warning({
wrapClassName:'root-modal-confirm',
title: t('confirm.title'),
icon: <ModalWarning.Icon/>,
content: t("order.remaining_duration_warning"),
okText: t('confirm.ok'),
centered: true
})
}
console.log('remain', remain)
})
}
useEffect(() => { useEffect(() => {
if(i18n.language){
if(i18n.language == 'multiple'){
const lang = localStorage.getItem('ai-human-lang') || (navigator.language.toLocaleLowerCase().indexOf('cn') != -1 ? 'zh-CN' : 'en-US')
i18n.changeLanguage(lang).catch(console.log)
return;
}
}
if (i18n && i18n.language == 'zh-CN') { if (i18n && i18n.language == 'zh-CN') {
dayjs.locale('zh-cn'); dayjs.locale('zh-cn');
}else{ }else{
dayjs.locale('en') dayjs.locale('en')
} }
initRemainingDuration()
globalConfig.i18n = i18n.language globalConfig.i18n = i18n.language
// i18n.changeLanguage(i18n).then(()=>console.log('change lang to ',i18n)) // i18n.changeLanguage(i18n).then(()=>console.log('change lang to ',i18n))
}, [i18n]) }, [i18n.language])
return (<ConfigProvider return (<ConfigProvider
locale={i18n?.language?.toString() == 'zh-CN' ? zhCN : undefined} locale={i18n?.language?.toString() == 'zh-CN' ? zhCN : undefined}

View File

@ -14,7 +14,6 @@ const AuthGuard = ({ children }:BasicComponentProps) => {
useEffect(() => { useEffect(() => {
if (isInitialized && !isLoggedIn && location.pathname !== '/user') { if (isInitialized && !isLoggedIn && location.pathname !== '/user') {
console.log(location)
navigate(`/user?from=${location.pathname}`, { navigate(`/user?from=${location.pathname}`, {
state: { state: {
from: location.pathname from: location.pathname

View File

@ -1,6 +1,7 @@
import {Outlet, useLocation, useNavigate, useSearchParams} from "react-router-dom"; import {Outlet, useLocation, useNavigate} from "react-router-dom";
import {Button, Divider, Dropdown, MenuProps} from "antd"; import {Divider, Dropdown, MenuProps} from "antd";
import React, {useEffect} from "react"; import React, {useEffect, useMemo} from "react";
import {useTranslation} from "react-i18next";
import AuthGuard from "@/routes/layout/auth-guard.tsx"; import AuthGuard from "@/routes/layout/auth-guard.tsx";
import {LogoText} from "@/components/icons/logo.tsx"; import {LogoText} from "@/components/icons/logo.tsx";
@ -11,9 +12,8 @@ import {DashboardNavigation} from "@/routes/layout/dashboard-navigation.tsx";
import useAuth from "@/hooks/useAuth.ts"; import useAuth from "@/hooks/useAuth.ts";
import {hidePhone} from "@/util/strings.ts"; import {hidePhone} from "@/util/strings.ts";
import {defaultCache} from "@/hooks/useCache.ts"; import {defaultCache} from "@/hooks/useCache.ts";
import {IconVideo} from "@/components/icons"; import {IconOrderFill, IconRecycleFill} from "@/components/icons";
import {useTranslation} from "react-i18next"; import LanguageSwitcher from "@/components/icons/language-switcher.tsx";
import useConfig from "@/hooks/useConfig.ts";
type LayoutProps = { type LayoutProps = {
@ -21,17 +21,24 @@ type LayoutProps = {
} }
const NavigationUserContainer = () => { const NavigationUserContainer = () => {
const {t } = useTranslation() const {t} = useTranslation()
const {logout, user} = useAuth() const {logout, user} = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const handleLogout = ()=>{ const handleLogout = () => {
logout().then(() => navigate('/user')) logout().then(() => navigate('/user'))
} }
const items: MenuProps['items'] = [ const items: MenuProps['items'] = [
{ {
key: 'profile', key: 'order',
label: <div className="nav-item" onClick={() => navigate('/history')}> label: <div className="nav-item" onClick={() => navigate('/order')}>
<IconVideo /> <IconOrderFill/>
<span className={"nav-text"}>{t('order.text')}</span>
</div>,
},
{
key: 'recycle',
label: <div className="nav-item" onClick={() => navigate('/recycle')}>
<IconRecycleFill/>
<span className={"nav-text"}>{t('history.text')}</span> <span className={"nav-text"}>{t('history.text')}</span>
</div>, </div>,
}, },
@ -51,7 +58,7 @@ const NavigationUserContainer = () => {
{user ? <Dropdown {user ? <Dropdown
rootClassName={'z-[999999] userinfo-drop-menu'} rootClassName={'z-[999999] userinfo-drop-menu'}
menu={{items}} placement="bottomRight" menu={{items}} placement="bottomRight"
dropdownRender={(menu)=>( dropdownRender={(menu) => (
<div> <div>
<div className="user-profile flex gap-4"> <div className="user-profile flex gap-4">
<div className="avatar"><UserAvatar className="user-avatar"/></div> <div className="avatar"><UserAvatar className="user-avatar"/></div>
@ -60,11 +67,11 @@ const NavigationUserContainer = () => {
<div>ID: {user?.id}</div> <div>ID: {user?.id}</div>
</div> </div>
</div> </div>
<Divider style={{ margin: 0 }} /> <Divider style={{margin: 0}}/>
<div className="menu-list-container"> <div className="menu-list-container">
{menu} {menu}
</div> </div>
<Divider style={{ margin: 0 }} /> <Divider style={{margin: 0}}/>
<div className="logout"> <div className="logout">
<div onClick={handleLogout}>{t('user.logout')}</div> <div onClick={handleLogout}>{t('user.logout')}</div>
</div> </div>
@ -76,26 +83,29 @@ const NavigationUserContainer = () => {
</Dropdown> : <UserButton/>} </Dropdown> : <UserButton/>}
</div>) </div>)
} }
const ExtraNavItems = {
'/order':'order.text',
'/recycle':'history.text',
}
export const BaseLayout: React.FC<LayoutProps> = ({children}) => { export const BaseLayout: React.FC<LayoutProps> = ({children}) => {
const {i18n} = useTranslation(); const {pathname} = useLocation()
const [params] = useSearchParams(); const {t,i18n} = useTranslation()
const extraNav = useMemo(()=>{
if(!pathname || !ExtraNavItems[pathname]) return null
return t(ExtraNavItems[pathname])
},[pathname,i18n.language])
return (<div className={'dashboard-layout min-h-screen'}> return (<div className={'dashboard-layout min-h-screen'}>
<div className="min-h-screen w-full"> <div className="min-h-screen w-full">
<div className="app-header"> <div className="app-header">
<div className="logo-container"> <div className="logo-container flex items-center">
<LogoText style={{fontSize: 30}}/> <LogoText style={{fontSize: 30}}/>
{extraNav && <div className="extra-nav-name ml-2">
<span className="nav-item active">{extraNav}</span>
</div>}
</div> </div>
<DashboardNavigation/> <DashboardNavigation/>
<div className="flex items-center"> <div className="flex items-center">
{(params.get('lang') == 'yes' || AppConfig.APP_LANG == 'multiple') && <div> <LanguageSwitcher />
{
i18n.language == 'zh-CN'?(
<Button className="ml-2" onClick={()=>i18n.changeLanguage('en-US')}>Change To EN</Button>
):(
<Button className="ml-2" onClick={()=>i18n.changeLanguage('zh-CN')}></Button>
)
}
</div>}
<NavigationUserContainer/> <NavigationUserContainer/>
</div> </div>
</div> </div>
@ -112,12 +122,12 @@ export const BaseLayout: React.FC<LayoutProps> = ({children}) => {
const DashboardLayout: React.FC<{ children?: React.ReactNode }> = ({children}) => { const DashboardLayout: React.FC<{ children?: React.ReactNode }> = ({children}) => {
const loc = useLocation() const loc = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
useEffect(()=>{ useEffect(() => {
if(!defaultCache.firstLoadPath && loc.pathname == '/live'){ if (!defaultCache.firstLoadPath && loc.pathname == '/live') {
defaultCache.firstLoadPath = loc.pathname; defaultCache.firstLoadPath = loc.pathname;
navigate('/') navigate('/')
} }
},[]) }, [])
return <AuthGuard> return <AuthGuard>
<div className="fixed">{defaultCache.firstLoadPath}</div> <div className="fixed">{defaultCache.firstLoadPath}</div>
<BaseLayout> <BaseLayout>

View File

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

View File

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

View File

@ -15,6 +15,9 @@ export function modifyOrder(ids: Id[]) {
export function deleteByIds(ids: Id[]) { export function deleteByIds(ids: Id[]) {
return post('/room/remove', {ids}) return post('/room/remove', {ids})
} }
export function restoreByIds(ids: Id[]) {
return post('/room/restore', {ids})
}
export function getLiveUrl() { export function getLiveUrl() {
return get<{flv_url:string}>({ return get<{flv_url:string}>({

15
src/service/api/order.ts Normal file
View File

@ -0,0 +1,15 @@
import {post} from "@/service/request.ts";
type OrderInfoData = DataList<OrderInfo> & {
remaining_duration: string | number;
}
export function getList(params: OrderSearchParam) {
return post<OrderInfoData>('/order/list', params)
}
export async function getRemainingDuration() {
const result = await getList({pagination: {page: 1, limit: 1}})
return Number(result.remaining_duration)
}

View File

@ -0,0 +1,11 @@
import {post} from "@/service/request.ts";
export function getList(params: NormalSearchParams) {
return post<DataList<VideoInfo>>('/recycle/list', params)
}
export function remove(ids: Id[]) {
return post('/recycle/remove', {ids})
}
export function restore(ids: Id[]) {
return post('/recycle/restore', {ids})
}

View File

@ -17,21 +17,24 @@ export function deleteHistories(ids: Id[]) {
* @param content_group * @param content_group
* @param article_id * @param article_id
*/ */
export function regenerate(title: string, metahuman_text: string, content_group: BlockContent[][], article_id?: Id) { export function regenerate(params:{title: string, metahuman_text: string, content_group: BlockContent[][], id?: Id}) {
return post<{ content: string }>({ return post<{ content: string }>({
url: '/video/regenerate', url: '/video/regenerate',
data: { data: {
title, ...params,
metahuman_text, article_id:params.id
content_group,
article_id
} }
}) })
} }
// 重新生成视频 // 重新生成视频
export async function regenerateById(article_id: Id) { export async function regenerateById(article_id: Id) {
const article = await getArticle(article_id); const article = await getArticle(article_id);
return await regenerate(article.title, article.metahuman_text, article.content_group, article_id) return await regenerate({
title:article.title,
metahuman_text:article.metahuman_text,
content_group:article.content_group,
id:article_id
})
} }
export function getById(id: Id) { export function getById(id: Id) {
@ -39,7 +42,7 @@ export function getById(id: Id) {
} }
export function deleteFromList(ids: Id[]) { export function deleteFromList(ids: Id[]) {
return post('/video/outside', {ids}) return post('/video/remove', {ids})
} }

View File

@ -12,6 +12,7 @@ const Axios = axios.create({
headers: {'Content-Type': JSON_FORMAT} headers: {'Content-Type': JSON_FORMAT}
}) })
// eslint-disable-next-line react-hooks/rules-of-hooks
const {globalConfig} = useGlobalConfig(); const {globalConfig} = useGlobalConfig();
// 请求前拦截 // 请求前拦截
Axios.interceptors.request.use(config => { Axios.interceptors.request.use(config => {

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

@ -67,6 +67,7 @@ interface BasicArticleInfo {
content_word_count?: number; content_word_count?: number;
media_id: number; media_id: number;
fanwen_column_id: number; fanwen_column_id: number;
hot_news: string;
} }
/** /**
@ -85,6 +86,11 @@ declare interface ListCrawlerNewsItem extends BasicArticleInfo {
// 内部文章关联id // 内部文章关联id
internal_article_id: number; internal_article_id: number;
} }
declare interface NormalSearchParams extends ApiRequestPageParams{
// 标题
title?: string;
time_flag?: number;
}
declare interface VideoSearchParams extends ApiRequestPageParams{ declare interface VideoSearchParams extends ApiRequestPageParams{
// 标题 // 标题
title?: string; title?: string;
@ -96,11 +102,13 @@ declare interface VideoInfo {
title: string; title: string;
cover: string; cover: string;
oss_video_url: string; oss_video_url: string;
oss_video_mp4_url?: string;
duration: number; duration: number;
article_id: number; article_id: number;
status: number; status: number;
publish_time?: number|string; publish_time?: number|string;
ctime?: number|string; ctime?: number|string;
d_time?: number|string;
} }
declare interface VideoListItem extends VideoInfo { declare interface VideoListItem extends VideoInfo {
playing?: boolean; playing?: boolean;
@ -113,6 +121,7 @@ declare interface LiveVideoInfo {
video_title: string; video_title: string;
cover: string; cover: string;
video_duration: number; video_duration: number;
oss_video_url?: string;
video_oss_url: string; video_oss_url: string;
status: number; status: number;
order_no: string; order_no: string;
@ -124,3 +133,23 @@ declare interface LiveState{
id: number; id: number;
live_start_time: number; live_start_time: number;
} }
// order
declare interface OrderSearchParam extends ApiRequestPageParams{
// 标题
title?: string;
time_flag?: number;
}
declare interface OrderInfo {
order_id: number| string;
// 缩略图
img_url: string;
// 标题
title: string;
// 下单时间
order_time: number | string;
// 消费时长
consumption_duration: number;
// 操作人
operator: string;
}

26
src/types/core.d.ts vendored
View File

@ -28,11 +28,35 @@ declare interface ArticleContentGroup {
blocks: BlockContent[]; blocks: BlockContent[];
} }
interface TemplateOption {
background: string;
template_id: string;
}
interface ArticleTemplateInfo {
select: string;
options: TemplateOption[];
}
interface ArticleSaveParam {
title: string;
metahuman_text: string;
video_tag?: string;
background?: string;
content_group: BlockContent[][];
hot_news: string[];
id?: number;
}
declare interface ArticleDetail { declare interface ArticleDetail {
id: number; id: number;
title: string; title: string;
metahuman_text: string; metahuman_text: string;
content_group: BlockContent[][] video_tag: string;
template_info: ArticleTemplateInfo;
hot_news_mode?: string;
hot_news: string[]; // 4月 6 日新增
content_group: BlockContent[][];
} }
declare interface NewsInfo { declare interface NewsInfo {

61
src/types/tcplayer.d.ts vendored Normal file
View File

@ -0,0 +1,61 @@
type TCPlayerEvents = 'play' // 已经开始播放,调用 play() 方法或者设置了 autoplay 为 true 且生效时触发,这时 paused 属性为 false。
| 'playing' // 因缓冲而暂停或停止后恢复播放时触发paused 属性为 false 。通常用这个事件来标记视频真正播放play 事件只是开始播放,画面并没有开始渲染。
| 'loadstart' // 开始加载数据时触发。
| 'durationchange' // 视频的时长数据发生变化时触发。
| 'loadedmetadata' // 已加载视频的 metadata。
| 'loadeddata' // 当前帧的数据已加载,但没有足够的数据来播放视频的下一帧时,触发该事件。
| 'progress' // 在获取到媒体数据时触发。
| 'canplay' // 当播放器能够开始播放视频时触发。
| 'canplaythrough' // 当播放器预计能够在不停下来进行缓冲的情况下持续播放指定的视频时触发。
| 'error' // 视频播放出现错误时触发。
| 'pause' // 暂停时触发。
| 'blocked' // 自动播放被浏览器阻止时触发。(原 2005 回调事件统一合并到 blocked 事件中)。
| 'ratechange' // 播放速率变更时触发。
| 'seeked' // 搜寻指定播放位置结束时触发。
| 'seeking' // 搜寻指定播放位置开始时触发。
| 'timeupdate' // 当前播放位置有变更,可以理解为 currentTime 有变更。
| 'volumechange' // 设置音量或者 muted 属性值变更时触发。
| 'waiting' // 播放停止,下一帧内容不可用时触发。
| 'ended' // 视频播放已结束时触发。此时 currentTime 值等于媒体资源最大值。
| 'resolutionswitching' // 清晰度切换进行中。
| 'resolutionswitched' // 清晰度切换完毕。
| 'fullscreenchange' //全屏状态切换时触发。
| 'webrtcevent' // 播放 webrtc 时的事件集合。
| 'webrtcstats' // 播放 webrtc 时的统计数据。
| 'webrtcfallback' // 播放 webrtc 时触发降级。
declare type TCPlayerInstance = {
//监听事件。
on: (event: TCPlayerEvents, callback: () => void) => void;
// 取消监听事件。
off: (event: TCPlayerEvents, callback: () => void) => void;
// 监听事件事件处理函数最多只执行1次。
one: (event: TCPlayerEvents, callback: () => void) => void;
// 设置播放地址。
src: (url: string) => void;
// 设置播放器初始化完成后的回调。
ready: (callback: () => void) => void;
play: () => void;
pause: () => void;
unload: () => void;
//获取或设置播放器是否静音。
muted: (mute: boolean) => boolean | void;
//获取或设置播放器音量。
volume: (percent: number) => number | void;
// 获取或设置播放倍速。
playbackRate: (percent: number) => number | void;
//获取当前播放时间点,或者设置播放时间点,该时间点不能超过视频时长。
currentTime: (seconds?: number) => number;
//获取视频时长。
duration: () => number;
// 销毁播放器。
dispose: () => number;
};
declare function TCPlayer(container: HTMLVideoElement | string, options: any): TCPlayerInstance;
declare module 'tcplayer.js' {
export default TCPlayer;
}

View File

@ -54,6 +54,18 @@ function getDayjs(time:any){
} }
return dayjs(time); return dayjs(time);
} }
// 将时长(秒)转换成时间
export function formatDurationToTime(duration?: number|string) {
duration = duration ? Number(duration) : 0;
if (!duration || isNaN(duration) || duration < 0) return '00:00';
duration = Math.ceil(duration / 1000);
// 计算
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')
}
export function formatTime(time: any, template: 'min' | 'date' | string = 'YYYY-MM-DD HH:mm:ss') { export function formatTime(time: any, template: 'min' | 'date' | string = 'YYYY-MM-DD HH:mm:ss') {
if (!time) return '-'; if (!time) return '-';

2
src/vite-env.d.ts vendored
View File

@ -14,6 +14,8 @@ declare const AppConfig: {
API_PREFIX: string; API_PREFIX: string;
ONLY_LIVE: string; ONLY_LIVE: string;
APP_LANG: string; APP_LANG: string;
// 腾讯播放器
TCPlayerLicense: string;
}; };
declare const AppMode: 'test' | 'production' | 'development'; declare const AppMode: 'test' | 'production' | 'development';

View File

@ -4,6 +4,10 @@ import {resolve} from "path";
import AppPackage from './package.json' import AppPackage from './package.json'
import dayjs from "dayjs"; import dayjs from "dayjs";
// 播放器 SDK Web 端TCPlayer自 5.0.0 版本起需获取 License 授权后方可使用。
// <p>https://cloud.tencent.com/document/product/881/77877#.E5.87.86.E5.A4.87.E5.B7.A5.E4.BD.9C</p>
const TCPlayerLicense = 'https://license.vod2.myqcloud.com/license/v2/1328581896_1/v_cube.license'
const DevServerList:{ const DevServerList:{
[key:string]:string [key:string]:string
} = { } = {
@ -30,7 +34,8 @@ export default defineConfig(({mode}) => {
AUTH_TOKEN_KEY: process.env.AUTH_TOKEN_KEY || AUTH_TOKEN_KEY, 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', AUTHED_PERSON_DATA_KEY: process.env.AUTHED_PERSON_DATA_KEY || 'digital-person-user-info',
ONLY_LIVE: process.env.ONLY_LIVE || 'no', ONLY_LIVE: process.env.ONLY_LIVE || 'no',
APP_LANG: process.env.APP_LANGUAGE || 'zh-CN' APP_LANG: process.env.APP_LANGUAGE,
TCPlayerLicense
}), }),
AppMode: JSON.stringify(mode), AppMode: JSON.stringify(mode),
AppBuildVersion: JSON.stringify(AppPackage.name + '-' + AppPackage.version + '-' + dayjs().format('YYYYMMDDHH_mmss')) AppBuildVersion: JSON.stringify(AppPackage.name + '-' + AppPackage.version + '-' + dayjs().format('YYYYMMDDHH_mmss'))

1828
yarn.lock

File diff suppressed because it is too large Load Diff