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
66 changed files with 3372 additions and 1738 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"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"ahooks": "^3.8.1",
"antd": "^5.22.5",
"axios": "^1.7.7",
@ -30,7 +30,7 @@
"dayjs": "^1.11.11",
"file-saver": "^2.0.5",
"flv.js": "^1.6.2",
"i18next": "^24.2.1",
"i18next": "^25.0.1",
"jszip": "^3.10.1",
"qs": "^6.12.1",
"react": "^18.3.1",
@ -51,15 +51,15 @@
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^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",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.40",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.7",
"typescript": "^5.2.2",
"vite": "^5.2.0"
"typescript": "^5.8.3",
"vite": "^6.3.2"
},
"packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72"
}

View File

@ -1,10 +1,13 @@
import AppRouter from "@/routes";
import {ConfigProvider} from "@/contexts/config";
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}`)
const LivePlayer = React.lazy(() => import('@/pages/live-player'));
function App() {
return (
<ConfigProvider>

View File

@ -13,7 +13,7 @@
--navigation-width: 100vw;
--navigation-active-color: #ffe0e0;
--app-header-header: 70px;
--container-width: 1600px;
--container-width: 1800px;
--header-z-index: 99999;
--message-z-index: 100001;
}
@ -170,7 +170,7 @@
@apply text-sm;
background: none;
.col{
@apply text-sm text-gray-800;
@apply text-base text-gray-800;
height: 42px;
}
}
@ -180,7 +180,7 @@
}
.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;
&:after {
@ -209,7 +209,7 @@
}
.title {
@apply flex-1 text-base;
@apply flex-1;
}
.generated-time {
@ -218,7 +218,7 @@
.operation {
@apply flex items-center ml-2 text-lg text-gray-400 justify-center;
width: 150px;
width: 180px;
padding: 0;
}
}
@ -230,34 +230,7 @@
//max-height: calc(100vh - var(--app-header-header) - 200px);
//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-js {
@ -290,7 +263,9 @@
}
}
}
.w-min-60px{
min-width: 60px;
}
.list-scroller-container {
overflow: auto;
margin-right: -20px;
@ -300,7 +275,7 @@
.data-list-container {
@apply list-scroller-container;
height: calc(100vh - var(--app-header-header) - 100px);
height: calc(100vh - var(--app-header-header) - 200px);
.data-list-container-inner {
@ -342,35 +317,74 @@
}
.popconfirm-main{
.ant-popover-inner{
@apply bg-white px-6 py-6 rounded-xl;
border-radius: 4px;
padding: 20px 24px;
min-width: 360px;
background-color: #f2f2f2;
box-shadow: 0 0 10px rgba(25, 25, 25, 0.1);
}
.icon-warning{
@apply text-red-500;
font-size: 20px;
transform: translateY(5px);
margin-right: 10px;
margin-right: 20px;
}
.ant-popconfirm-message{
.ant-popconfirm-title{
@apply text-xl font-bold;
@apply text-xl;
font-weight: 400;
}
.ant-popconfirm-description{
@apply mt-4 text-gray-400 text-sm;
margin-left: -30px;
@apply mt-2 text-gray-600 text-sm;
margin-left: 0px;
}
}
.ant-popconfirm-buttons{
@apply mt-8;
@apply mt-6;
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{
@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;
//.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;
//}
}
}
.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 {
z-index: var(--message-z-index);
}
.background-template-popover{
.ant-popover-inner{
background-color: #E9E9E9;
}
}
.ant-modal-root {
.ant-modal-mask {
@apply bg-black/20;
@ -412,7 +430,7 @@
}
.ant-modal-confirm-btns {
margin-top: 40px;
@apply mt-6;
}
}
}
@ -429,24 +447,48 @@
}
.article-title {
@apply px-6 pt-10 pb-6;
border-bottom: 1px solid rgba(0,0,0,0.09);
}
.article-body {
@apply p-6
@apply p-6 pt-1;
}
.modal-control-footer {
@apply p-6
}
.hot-news-list{
@apply focus-within:bg-[#e6ebf1] focus-within:border-gray-100;
}
.input-box {
// 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;
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 {
@apply fixed right-10 bottom-10 flex flex-col gap-4 z-10;
@ -456,6 +498,7 @@
flex: 1;
text-align: left;
padding-left: 15px;
margin-right: 10px;
}
&: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

@ -19,14 +19,4 @@
@content;
}
}
@if $name == xxl {
@media (max-width: 1799px) {
@content;
}
}
@if $name == xxxl {
@media (max-width: 1999px) {
@content;
}
}
}

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;
:global{
.area-title{
@apply text-gray-400 text-sm text-gray-800;
@apply text-gray-400 text-base text-gray-800;
}
.digital-person{
width: 450px;
@ -132,4 +132,10 @@
.textarea {
@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 styles from './article.module.scss'
import {useTranslation} from "react-i18next";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
type Props = {
children?: React.ReactNode;
@ -86,8 +87,9 @@ export default function ArticleBlock(
rootClassName={'popconfirm-main'}
placement={'left'}
arrow={false}
icon={<IconWarningCircle/>}
title={<div style={{minWidth: 150}}><span>{t('news.edit_delete_group_confirm')}</span></div>}
icon={<ModalWarning.Icon />}
title={<ModalWarning.Title />}
description={<div style={{minWidth: 150}}><span>{t('news.edit_delete_group_confirm')}</span></div>}
onConfirm={onRemove}
okText={t('delete')}
cancelText={t('cancel')}

View File

@ -1,17 +1,25 @@
import {Modal} from "antd";
import ArticleGroup from "@/components/article/group.tsx";
import {useEffect, useState} from "react";
import {useSetState} from "ahooks";
import * as article from "@/service/api/article.ts";
import {regenerate} from "@/service/api/video.ts";
import {push2video} from "@/service/api/article.ts";
import {showErrorToast, showToast} from "@/components/message.ts";
import {useTranslation} from "react-i18next";
import { Modal, App, Radio, Popover } from 'antd';
import React, { useEffect, useState } from 'react';
import { useSetState } from 'ahooks';
import { useTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
import * as article from '@/service/api/article.ts';
import { regenerate } from '@/service/api/video.ts';
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 = {
id?: number;
type: 'news' | 'video';
onClose?: (saved?: boolean) => void;
onRefresh?: () => void
}
const DEFAULT_STATE = {
@ -20,15 +28,15 @@ const DEFAULT_STATE = {
msgTitle: '',
msgGroup: '',
error: ''
}
};
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) {
// 如果上一个group中没有文本则直接合并
lastGroup.push(...blocks)
lastGroup.push(...blocks);
} else {
groups.push(blocks)
groups.push(blocks);
}
}
@ -39,132 +47,217 @@ function rebuildGroups(groups: BlockContent[][]) {
if (!blocks) return;
blocks = blocks.filter(s => !!s).sort((a, b) => {
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 (index == 0) _groups.push(blocks)
else pushBlocksToGroup(blocks, _groups)
if (index == 0) _groups.push(blocks);
else pushBlocksToGroup(blocks, _groups);
} else {
if (index == 0) {
_groups.push([blocks[0]])
_groups.push(blocks.slice(1))
_groups.push([blocks[0]]);
_groups.push(blocks.slice(1));
} else {
pushBlocksToGroup(blocks, _groups)
pushBlocksToGroup(blocks, _groups);
}
}
});
if (_groups.length < 2) {
Array(2 - _groups.length).fill([{type: 'text', content: ''}]).forEach((it) => {
_groups.push(it)
})
Array(2 - _groups.length).fill([{ type: 'text', content: '' }]).forEach((it) => {
_groups.push(it);
});
}
// console.log('rebuildGroups', _groups)
return _groups;
}
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;
for (let index = 1;index< groups.length; index ++) {
if(!groupHasImageAndText(groups[index])) return false;
for (let index = 1; index < groups.length; index++) {
if (!groupHasImageAndText(groups[index])) return false;
}
return true;
}
export default function ArticleEditModal(props: Props) {
const {t} = useTranslation()
const [groups, setGroups] = useState<BlockContent[][]>([]);
const [title, setTitle] = useState('')
function checkHotNewsValid(hotNews: HotNewsData, modal: ModalHookAPI, t: TFunction<'translation', undefined>) {
return new Promise<boolean>((resolve) => {
// 验证热点新闻数据是否正确
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({
...DEFAULT_STATE,
generating:false
})
generating: false,
pushed: false
});
// 保存数据
const handleSave = () => {
setState({error: ''})
const handleSave = async () => {
setState({ error: '' });
if (!title) {
// setState({msgTitle: '请输入标题内容'});
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) {
setState({msgGroup: t('news.edit_empty_human_content')});
setState({ msgGroup: t('news.edit_empty_human_content') });
return;
}
// 验证图文都存在时,文图是否匹配
if(!checkGroupsValid(groups)) {
if (!checkGroupsValid(groups)) {
// 获取图文设置不正确的数据
setState({msgGroup: t('news.edit_empty_group_content')});
setState({ msgGroup: t('news.edit_empty_group_content') });
return;
}
const hotNewsValid = await checkHotNewsValid(hotNews, modal, t);
if (!hotNewsValid) return;
// if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) {
// // setState({msgGroup: '请输入正文文本内容'});
// return;
// }
const save = props.type == 'news' ? article.save : regenerate
setState({loading: true})
save(title, groups[0][0].content, groups.slice(1), props.id && props.id > 0 ? props.id : undefined).then(() => {
props.onClose?.(true)
const save = props.type == 'news' ? article.save : regenerate;
setState({ loading: true });
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 && props.id > 0 ? props.id : undefined
}).then(() => {
props.onClose?.(true);
}).catch(e => {
setState({error: e.message || t('news.edit_save_failed')})
setState({ error: e.message || t('news.edit_save_failed') });
}).finally(() => {
setState({loading: false})
setState({ loading: false });
});
}
const handlePush2Video = async () =>{
};
const handlePush2Video = async () => {
if (state.pushed) return;
if (!title) {
// setState({msgTitle: '请输入标题内容'});
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) {
setState({msgGroup: t('news.edit_empty_human_content')});
setState({ msgGroup: t('news.edit_empty_human_content') });
return;
}
// 验证图文都存在时,文图是否匹配
if(!checkGroupsValid(groups)) {
if (!checkGroupsValid(groups)) {
// 获取图文设置不正确的数据
setState({msgGroup: t('news.edit_empty_group_content')});
setState({ msgGroup: t('news.edit_empty_group_content') });
return;
}
if(!props.id || state.generating) return;
setState({generating:true})
await article.save(title, groups[0][0].content, groups.slice(1), props.id)
if (!props.id || state.generating) return;
const hotNewsValid = await checkHotNewsValid(hotNews, modal, t);
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(() => {
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',{
// state: 'push-success'
// })
// props.onSuccess?.()
}).catch(showErrorToast).finally(()=>{
setState({generating:false})
})
}
}).catch(showErrorToast).finally(() => {
setState({ generating: false });
});
};
useEffect(() => {
setState({...DEFAULT_STATE})
setState({ ...DEFAULT_STATE });
if (typeof (props.id) != 'undefined') {
// 如果传入了id则获取数据
if (props.id > 0) {
article.getById(props.id).then(res => {
setGroups(rebuildGroups([[{content: res.metahuman_text, type: "text"}], ...res.content_group]))
setTitle(res.title)
})
if (res.hot_news) {
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 {
// 新增
setGroups([])
setTitle('')
setGroups([]);
setTitle('');
}
}
}, [props.id])
}, [props.id]);
return (<Modal
title={null}
centered={true}
rootClassName={"article-edit-modal"}
rootClassName={'article-edit-modal'}
open={props.id != undefined && props.id >= 0}
maskClosable={false}
keyboard={false}
@ -172,35 +265,80 @@ export default function ArticleEditModal(props: Props) {
footer={null}
closeIcon={null}
onCancel={() => props.onClose?.()}
okButtonProps={{loading: state.loading}}
okButtonProps={{ loading: state.loading }}
onOk={handleSave}
okText={props.type == 'news' ? t('confirm_text') : t('news.edit_generate_video_again')}
>
<div className="article-title mt-5">
<input className={'input-box text-lg'} value={title} onChange={e => {
setTitle(e.target.value)
setState({msgTitle: e.target.value ? '' : t('news.edit_notice_enter_article_title1')})
}} placeholder={t('news.edit_notice_enter_article_title')}/>
<div className="text-red-500">{state.msgTitle}</div>
<div className="mt-5 px-6 pt-10">
<div className="flex items-center pb-3 article-title">
<span className="mr-2 text-lg">{t('news.title')}</span>
<input className={'input-box text-lg flex-1 py-2'} value={title} onChange={e => {
setTitle(e.target.value);
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 className="article-body">
<div className="box">
<div className="box text-base">
<ArticleGroup
errorMessage={state.msgGroup} editable groups={groups}
onChange={list => {
setGroups(() => list)
setState({msgGroup: (list.length == 0 || list[0].length == 0 || !list[0][0].content) ? t('news.edit_empty_human_content') : ''});
errorMessage={state.msgGroup}
editable
groups={groups}
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>
{state.error && <div className="text-red-500">{state.error}</div>}
{state.error && <div className="text-red-500 mt-2">{state.error}</div>}
</div>
<div className="modal-control-footer flex justify-end">
<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 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>
</Modal>);

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import {BizError} from "@/service/types.ts";
import {IconWarningCircle} from "@/components/icons";
import {LoadingOutlined} from "@ant-design/icons";
import {useTranslation} from "react-i18next";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
type Props = {
selected: any[],
@ -57,10 +58,12 @@ export default function ButtonBatch(
if(confirmMessage){
modal.confirm({
wrapClassName: 'root-modal-confirm',
title: <span dangerouslySetInnerHTML={{__html:title || t('confirm.title')}}></span>,
title: <ModalWarning.Title />,
centered: true,
icon: <span className="anticon anticon-exclamation-circle"><IconWarningCircle/></span>,
content: confirmMessage,
icon: <ModalWarning.Icon />,
content: <div>
<div>{confirmMessage}</div>
</div>,
onOk: onBatchProcess
})
}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 RecycleIndex from "@/pages/recycle";
type IconProps = { style?: React.CSSProperties; className?: string; }
@ -59,6 +60,43 @@ export const IconDownload = ({style, className}: IconProps) => (
</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) => (
<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">
@ -170,26 +208,26 @@ export const IconUnlock = ({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">
<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"/>
</svg>
)
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">
<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>
)
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">
<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>
)
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">
<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>

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 { LoadingOutlined } from '@ant-design/icons';
import {Spin} from "antd";
import {t} from "i18next";
export type InfiniteScrollerRef = {
scrollToPosition: (top: number) => void
@ -65,7 +66,7 @@ const InfiniteScroller = React.forwardRef<InfiniteScrollerRef, InfiniteScrollerP
{props.loading && <div style={{minHeight:'30vh'}}></div>}
{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="loading-text">...</div>
<div className="loading-text">{t('loading')}</div>
</div>)}
{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">

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 {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 React, { useEffect, useState } from 'react';
import TCPlayer from 'tcplayer.js';
import 'tcplayer.js/dist/tcplayer.min.css';
import { PlayerInstance } from '@/hooks/useCache.ts';
import videoPlayer from '@/components/video/VideoPlayer.ts';
type State = {
playing: boolean
muted: boolean
@ -22,7 +21,7 @@ type Props = {
url?: string; cover?: string; showControls?: boolean; className?: string;
poster?: string;
onChange?: (state: State) => void;
onProgress?: (current:number,duration:number) => void;
onProgress?: (current: number, duration: number) => void;
muted?: boolean;
autoPlay?: boolean;
}
@ -32,8 +31,9 @@ export type PlayerInstance = {
getState: () => State;
}
export const Player = React.forwardRef<PlayerInstance, Props>((props, ref) => {
const [tcPlayer, setTcPlayer] = useState<TCPlayer | null>(null)
const [prevUrl, setPrevUrl] = useState<string | undefined>();
const [tcPlayer, setTcPlayer] = useState<TCPlayerInstance | null>(null);
const [state, _setState] = useState<State>({
playing: false,
muted: false,
@ -42,105 +42,85 @@ export const Player = React.forwardRef<PlayerInstance, Props>((props, ref) => {
progress: 0,
playedSeconds: 0,
duration: 0
})
});
const setState = (data: StateUpdate) => {
console.log('playstate change', data)
console.log('playstate change', data);
_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])
const _state = typeof (data) === 'function' ? { ...prev, ...data(prev) } : { ...prev, ...data };
props.onChange?.(_state);
return _state;
});
};
useEffect(() => {
if(PlayerInstance.length != 0){
PlayerInstance.forEach(player => player.pause())
PlayerInstance.length = 0
if (props.url && tcPlayer) {
tcPlayer.src(props.url);
}
const playerVideo = document.createElement('video');
const playerId = `player-container-${Date.now().toString(16)}`;
playerVideo.setAttribute('id', playerId)
playerVideo.setAttribute('preload', 'auto')
playerVideo.setAttribute('playsInline', 'true')
playerVideo.setAttribute('webkit-playsinline', 'true')
if(props.className) playerVideo.setAttribute('className', props.className)
playerVideo.classList.add('digital-video-player')
PlayerInstance.push(playerVideo)
document.querySelector('.video-player-container-inner')!.appendChild(playerVideo)
}, [props.url, tcPlayer]);
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}],
controls: props.showControls,
// muted:props.muted,
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'
}
)
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})
})
setTcPlayer(() => player)
);
flvPlayer.on('pause', () => {
setState({ playing: false, end: false, error: false });
});
flvPlayer.on('playing', () => {
setState({ playing: true, end: false, error: false });
});
flvPlayer.on('ended', () => {
setState({ end: true, playing: false, error: false });
});
flvPlayer.on('timeupdate', () => {
props.onProgress?.(flvPlayer.currentTime(), flvPlayer.duration());
});
flvPlayer.on('error', () => {
setState({ end: false, playing: false, error: true });
});
setTcPlayer(() => flvPlayer);
return () => {
// if (tcPlayer) {
// tcPlayer.pause()
// tcPlayer.unload()
// }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)
}
}, [])
tcPlayer?.pause();
console.log('destroy video');
tcPlayer?.dispose();
};
}, []);
React.useImperativeHandle(ref, () => {
return {
pause(){
pause() {
if (!tcPlayer) return;
tcPlayer.pause()
tcPlayer.pause();
},
play: (url, currentTime = 0) => {
console.log('play', url, currentTime)
console.log('play', url, currentTime);
if (!tcPlayer) return;
const player = tcPlayer
const player = tcPlayer;
if (prevUrl == url) {
player.currentTime(0)
player.currentTime(0);
} else {
player.src(url)
player.src(url);
}
player.play()
setPrevUrl(url)
player.play();
setPrevUrl(url);
if (currentTime > 0) {
player.currentTime(currentTime)
player.currentTime(currentTime);
}
},
getState: () => state
}
})
};
});
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 {
IconDelete,
IconDelete, IconDownloadOutline,
IconEdit,
IconGenerateFailed,
IconGenerating,
@ -15,11 +15,16 @@ import {
import {VideoStatus} from "@/service/api/video.ts";
import {formatTime} from "@/util/strings.ts";
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 = {
video: VideoInfo | LiveVideoInfo,
additionOperation?: React.ReactNode;
additionOperationBefore?: React.ReactNode;
additionOperationAfter?: React.ReactNode;
editable?: boolean;
downloadUrl?: string;
sortable?: boolean;
index?: number;
checked?: boolean;
@ -30,9 +35,8 @@ type Props = {
onEdit?: () => void;
onRegenerate?: () => void;
hideCheckBox?: boolean;
operationRender?:React.ReactNode;
onItemClick?: () => void;
onRemove?: (action?:'delete' | 'rollback') => void;
onRemove?: (action?: 'delete' | 'rollback') => void;
removeIcon?: React.ReactNode;
id: number;
className?: string;
@ -41,68 +45,83 @@ type Props = {
export const VideoListItem = (
{
id, video, onRemove,removeIcon, checked,playing,
onCheckedChange, onEdit, active, editable,
className, sortable, type, index,onItemClick,
additionOperation,onRegenerate,hideCheckBox,
operationRender
id, video, onRemove, removeIcon, checked, playing,
onCheckedChange, onEdit, active, editable, downloadUrl,
className, sortable, type, index, onItemClick,
additionOperationAfter, additionOperationBefore, onRegenerate, hideCheckBox
}: Props) => {
const {
attributes, listeners,
setNodeRef, transform
} = useSortable({resizeObserverConfig: {}, id})
const {t} = useTranslation()
const {t,i18n} = useTranslation()
const [state, setState] = useSetState<{ checked?: boolean }>({})
useEffect(() => {
setState({checked})
}, [checked])
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
className={`video-item ${className}`}
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
className="col number"
{... (sortable && !generating?listeners:{})}
{... (sortable && !generating?attributes:{})}
{...(sortable && !generating ? listeners : {})}
{...(sortable && !generating ? attributes : {})}
>{index}</div>
<div className="col cover cursor-pointer" onClick={onItemClick}>
<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 &&
<div className={'absolute rounded inset-0 bg-black/40 backdrop-blur-[1px] text-white flex items-center justify-center'}>
<div className="text-center">
<IconGenerating className="inline-block text-xl" />
<div className="text-xs">{t('video.generating')}</div>
</div>
</div>
<div
className={'absolute rounded inset-0 bg-black/40 backdrop-blur-[1px] text-white flex items-center justify-center'}>
<div className="text-center">
<IconGenerating className="inline-block text-xl"/>
<div className="text-xs">{t('video.generating')}</div>
</div>
</div>
}
{failed &&
<div className={'absolute rounded inset-0 bg-black/40 backdrop-blur-[1px] text-white flex items-center justify-center'}>
<div className="text-center">
<IconGenerateFailed className="inline-block text-xl" />
<div className="text-xs">{t('video.generate_failed')}</div>
</div>
</div>
<div
className={'absolute rounded inset-0 bg-black/40 backdrop-blur-[1px] text-white flex items-center justify-center'}>
<div className="text-center">
<IconGenerateFailed className="inline-block text-xl"/>
<div className="text-xs">{t('video.generate_failed')}</div>
</div>
</div>
}
{/* && active*/}
{!generating && !failed && playing && <div className={'absolute rounded inset-0 backdrop-blur-[1px] bg-black/40 text-white flex items-center justify-center'}>
<div className="text-center">
<IconPlaying className="inline-block text-xl" />
<div className="text-xs">{t('video.playing')}</div>
</div>
</div>}
{!generating && !failed && playing && <div
className={'absolute rounded inset-0 backdrop-blur-[1px] bg-black/40 text-white flex items-center justify-center'}>
<div className="text-center">
<IconPlaying className="inline-block text-xl"/>
<div className="text-xs">{t('video.playing')}</div>
</div>
</div>}
</div>
</div>
<div
className="col title"
{... (sortable && !generating?listeners:{})}
{... (sortable && !generating?attributes:{})}
{...(sortable && !generating ? listeners : {})}
{...(sortable && !generating ? attributes : {})}
>
<div className="line-clamp-2">
{video.title || video.video_title}
@ -110,53 +129,65 @@ export const VideoListItem = (
</div>
<div
className="col generated-time"
{... (sortable && !generating?listeners:{})}
{... (sortable && !generating?attributes:{})}
>{video.ctime ? formatTime(video.ctime,'min') : '-'}</div>
{operationRender ?? <div className="col operation">
{...(sortable && !generating ? listeners : {})}
{...(sortable && !generating ? attributes : {})}
>{video.ctime ? formatTime(video.ctime, 'min') : '-'}</div>
<div className="col operation">
{/*{sortable && !generating && (!active ?*/}
{/* <button className="hover:text-blue-500 cursor-move">*/}
{/* <MenuOutlined/>*/}
{/* </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 && <>
{onEdit &&
<button className="hover:text-blue-500" onClick={e=>{
e.preventDefault()
e.stopPropagation()
onEdit?.()
}} style={{fontSize: '1.1em'}}>
<IconEdit/>
</button>}
{onRegenerate &&
<button className="text-red-400 hover:text-blue-500" onClick={e=>{
e.preventDefault()
e.stopPropagation()
onRegenerate?.()
}} style={{fontSize: '1.1em'}}>
<IconRegenerate/>
</button>}
{onEdit && <button
className="hover:text-blue-500" onClick={e => {
e.preventDefault()
e.stopPropagation()
onEdit?.()
}} style={{fontSize: '1.1em'}} title={i18n.language == 'zh-CN'?'修改':'Modify'}>
<IconEdit/>
</button>}
{onRegenerate && <button
className="text-red-400 hover:text-blue-500" onClick={e => {
e.preventDefault()
e.stopPropagation()
onRegenerate?.()
}} style={{fontSize: '1.1em'}} title={i18n.language == 'zh-CN'?'重新生成':'Regenerate'}>
<IconRegenerate/>
</button>}
{onRemove && <Popconfirm
rootClassName={'popconfirm-main'}
placement={'left'}
arrow={false}
icon={<IconWarningCircle/>}
title={t('video.delete_confirm_title')}
// description={`删除后需从重新${type == 'create' ? '生成' : '推流'}`}
onConfirm={() => onRemove(failed ? 'rollback' : 'delete')}
><button className="hover:text-blue-500">{removeIcon?removeIcon:(failed?<IconRollbackCircle />:<IconDelete/>)}</button></Popconfirm>}
{hideCheckBox ? <span className={"inline-block w-[18px] h-1"}></span> : <Checkbox checked={state.checked} onChange={() => {
if (onCheckedChange) {
onCheckedChange(!state.checked)
} else {
setState({checked: !state.checked})
}
}} />}
</>}
{additionOperation}
{onRemove && !failed && <DeleteItemPopoverConfirm
description={failed ? t('video.rollback_confirm_title') : undefined}
onConfirm={() => onRemove(failed ? 'rollback' : 'delete')}>
<button className="hover:text-blue-500" title={
failed ? (i18n.language == 'zh-CN'?'重新生成':'Regenerate') : i18n.language == 'zh-CN'?'删除':'Delete'
} style={{fontSize:20}}>
{removeIcon ? removeIcon : (failed ?
<IconRollbackCircle/> :
<IconDelete/>)}
</button>
</DeleteItemPopoverConfirm>}
{hideCheckBox ? <></> :
<Checkbox checked={state.checked} onChange={() => {
if (onCheckedChange) {
onCheckedChange(!state.checked)
} else {
setState({checked: !state.checked})
}
}}/>}
</>}
{additionOperationAfter}
</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 {getAllCategory} from "@/service/api/article.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';
@ -20,7 +24,7 @@ const AuthContext = createContext<AuthContextType | null>(null)
// 权限相关初始化数据
const initialState: AuthProps = {
isLoggedIn: false,
isInitialized: true,
isInitialized: false,
user: null
};
@ -54,9 +58,9 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
token
}
})
}catch (e){
} catch (e) {
const err = e as BizError;
if(err.code == 1001){
if (err.code == 1001) {
// token失效
setAuthToken(null)
dispatch({
@ -125,7 +129,7 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
};
useEffect(() => {
//init().then(console.log)
init().then(console.log)
}, [])
// 判断是否已经初始化

View File

@ -1,14 +0,0 @@
import React from 'react';
// 通过useRef及useEffect实现 获取最新的state值
export function useLastState<T>(value: T){
// 创建ref
const lastState = React.useRef<T>(value);
lastState.current = value;
// 通过useEffect监听value的变化
const getLastState = React.useCallback(() => lastState.current, []);
// 返回最新的state值
return {
lastState,
getLastState
};
}

View File

@ -1,15 +1,10 @@
{
"AppTitle": "AI Livesteam",
"go_to_home": "Go to Homepage" ,
"Hello": "Hello",
"cancel": "Cancel",
"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": {
"ok": "Confirm",
"push_title": "Push Notice",
"push_video": "Are you sure editing selected news?",
"title": "Notice"
@ -20,14 +15,21 @@
"delete_failed": "Delete failed",
"delete_success": "Delete success",
"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": {
"title": "Preview - Click the video to play"
},
"go_to_home": "Go to Homepage",
"history": {
"delete_confirm": "Are you sure you want to delete this video?",
"push_success": "Streaming success",
"search_key": "Please enter title keywords",
"text": "Video history"
"text": "Recycle Bin"
},
"history.pushed": "Streaming: {{count}}",
"live": {
@ -38,6 +40,7 @@
"playlist_count": "{{count}} videos in total",
"title": "Livestream"
},
"loading": "Loading...",
"login": {
"code_sending": "Sending...",
"invalid_username_or_pwd": "Invalid phone number or code",
@ -49,6 +52,32 @@
"username": "Please enter your phone number",
"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": {
"editing": "Editing",
"generating": "Generating",
@ -64,11 +93,17 @@
"delete_the_picture": "Are you sure delete the picture?",
"download_empty": "Please select the news to download",
"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_delete_group": "Delete Group",
"edit_delete_group_confirm": "Are you sure you want to delete the group?",
"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_form_search": "Please enter title keywords",
"edit_generate_again": "Regenerate",
@ -89,8 +124,8 @@
"get_detail_error": "Get new details failed",
"image_count": "Images",
"materials": {
"title": "News Materials",
"add_group": "Add Group"
"add_group": "Add Group",
"title": "News Materials"
},
"news_all_source": "All",
"push_empty": "please select the news to edit",
@ -101,15 +136,40 @@
"push_success": "Push success",
"push_to_edit": "Editing",
"pushed": "Editing",
"save_text": "Save",
"search_key_title": "Please enter title keywords",
"source": "Source",
"title": "Content",
"title": "Title",
"title_image_count": "No. of images",
"title_operate": "",
"title_time": "Time stamp",
"title_word_count": "Word count",
"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": {
"pushed": "Pushed: {{count}}",
"select_all": "Select all",
@ -118,6 +178,7 @@
"text": "Select",
"total": "Total: {{count}}"
},
"service_error": "Service exception, please contact customer support.",
"time_filter": {
"all": "All",
"last_week": "Last week",
@ -126,9 +187,11 @@
"past_4_hour": "Past 4 hour",
"past_hour": "Past 1 hour"
},
"title": "Title",
"upload": {
"delete_confirm": "Are you sure delete the picture?",
"upload_failed": "Upload failed",
"upload_file_type_error": "Only support upload image",
"upload_image": "Upload Image"
},
"user": {
@ -141,15 +204,21 @@
"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_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",
"generating": "Generating",
"generate_failed": "Generate Failed",
"generating": "Generating",
"live_rollback_confirm_title": "Are you sure you want to rollback this video?",
"playing": "Playing",
"push_confirm": "Are you sure you want to streaming these video?",
"push_empty": "Select the video you want to streaming",
"push_failed": "some video streaming failed!",
"push_success": "Streaming success,please goto \"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_failed": "Video sequence change failed",
"sort_modify_live_success": "Video sequence changed",
@ -159,18 +228,5 @@
"title_generated_time": "Time stamp",
"title_operation": "",
"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": "数字人直播",
"go_to_home": "返回首页" ,
"Hello": "你好",
"cancel": "取消",
"close": "关闭",
"service_error": "新闻异常,无法生成,请咨询客服",
"error_401": "您没有权限访问本页面",
"error_403": "您没有权限访问本页面",
"error_404": "访问的页面不存在",
"error_500": "服务异常,请咨询客服.",
"confirm": {
"ok": "确定",
"push_title": "推流提示",
"push_video": "是否确定一键推流选中新闻视频?",
"title": "提示"
@ -20,14 +15,21 @@
"delete_failed": "删除失败",
"delete_success": "删除成功",
"download": "下载",
"download_fail": "下载失败",
"downloading": "下载中...",
"error_401": "您没有权限访问本页面",
"error_403": "您没有权限访问本页面",
"error_404": "访问的页面不存在",
"error_500": "服务异常,请咨询客服.",
"generating": {
"title": "预览视频 - 点击视频列表播放"
},
"go_to_home": "返回首页",
"history": {
"delete_confirm": "是否要删除该视频",
"push_success": "一键推流成功,已推流至数字人直播间,请查看!",
"search_key": "请输入视频标题关键字进行信息",
"text": "历史视频"
"text": "回收站"
},
"history.pushed": "已推送 {{count}} 条",
"live": {
@ -38,6 +40,7 @@
"playlist_count": "当前播放列表共 {{count}} 条",
"title": "直播界面"
},
"loading": "加载中...",
"login": {
"code_sending": "发送中",
"invalid_username_or_pwd": "账号或密码错误",
@ -49,6 +52,32 @@
"username": "请输入账号",
"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": {
"editing": "新闻编辑",
"generating": "视频生成",
@ -56,7 +85,7 @@
"materials": "新闻素材"
},
"news": {
"delete_confirm": "确定要删除吗?",
"delete_confirm": "确定要删除吗?",
"delete_confirm_count": "你确定要删除选择的 {{count}} 条新闻吗?",
"delete_description": "删除后需从新闻素材中重新选择",
"delete_description_count": "删除后需从新闻素材中重新选择",
@ -64,11 +93,17 @@
"delete_the_picture": "请确认删除此图片",
"download_empty": "请选择要下载的新闻",
"download_failed": "下载新闻失败,请重试!",
"edit": {
"bg": "背景",
"tag": "标签",
"tag_length_error": "标签长度不能超过4个字",
"tag_placeholder": "例:企业动态"
},
"edit_add_group": "新增分组",
"edit_delete_group": "删除此分组",
"edit_delete_group_confirm": "请确认删除此分组?",
"edit_digital_text": "数字人主播台编辑区",
"edit_empty_group_content": "素材区的文本和图片,均不得为空",
"edit_empty_group_content": "如仅需数字人播报,请勿在素材融合区填写内容;如需展示图文信息,素材融合区的文本、图片均不得为空",
"edit_empty_human_content": "请先填写数字人播报内容",
"edit_form_search": "请输入新闻标题关键词进行搜索",
"edit_generate_again": "重新生成",
@ -89,8 +124,8 @@
"get_detail_error": "获取新闻详情失败",
"image_count": "图片数",
"materials": {
"title": "新闻素材",
"add_group": "新增分组"
"add_group": "新增分组",
"title": "新闻素材"
},
"news_all_source": "全部来源",
"push_empty": "请选择要推入编辑的新闻",
@ -101,6 +136,7 @@
"push_success": "推送成功",
"push_to_edit": "推入编辑",
"pushed": "已推送",
"save_text": "保存",
"search_key_title": "请输入新闻标题关键词进行搜索",
"source": "来源",
"title": "标题",
@ -110,6 +146,30 @@
"title_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": {
"pushed": "已推送: {{count}} 条",
"select_all": "全选",
@ -118,6 +178,7 @@
"text": "选择",
"total": "总共 {{count}} 条"
},
"service_error": "新闻异常,无法生成,请咨询客服",
"time_filter": {
"all": "所有时间",
"last_week": "近一周",
@ -126,9 +187,11 @@
"past_4_hour": "四小时内",
"past_hour": "一小时内"
},
"title": "标题",
"upload": {
"delete_confirm": "请确认删除此图片?",
"upload_failed": "上传图片失败,请重试",
"upload_file_type_error": "仅支持上传图片",
"upload_image": "上传图片"
},
"user": {
@ -141,15 +204,21 @@
"delete_description": "已选择{{count}}条,确定要全部删除吗?",
"delete_description_count": "已选择{{count}}条,确定要全部删除吗?",
"delete_empty": "请选择要删除的视频",
"delete_forever_confirm": "是否彻底删除选中的视频? <br />这些视频将无法找回",
"delete_forever_confirm_count": "是否彻底删除选中的视频? <br />这些视频将无法找回!",
"download": "下载视频",
"generating": "生成中",
"generate_failed": "生成失败",
"generating": "生成中",
"live_rollback_confirm_title": "你确定要回退此视频吗 ",
"playing": "播放中",
"push_confirm": "是否确定一键推流选中新闻视频?",
"push_empty": "请选择要推流的新闻视频",
"push_failed": "选择视频中有部分视频还在生成中无法推送,推流成功视频前往数字人直播间页面查看!",
"push_success": "一键推流成功,已推流至数字人直播间,请前往数字人直播间页面查看!",
"push_to_live": "一键推流",
"restore_confirm": "是否将选中视频,还原到视频生成页?",
"restore_confirm_count": "是否将选中视频,还原到视频生成页",
"rollback_confirm_title": "您确定要回退此视频吗?",
"sort_modify_confirm": "是否采纳移动视频位置操作?",
"sort_modify_failed": "调整视频顺序失败,请重试!",
"sort_modify_live_success": "已完成直播队列的修改",
@ -159,18 +228,5 @@
"title_generated_time": "生成时间",
"title_operation": "操作",
"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 {getLiveUrl} from "@/service/api/live.ts";
import React, {useState} from "react";
import {getLiveUrl} from "@/service/api/live.ts";
import {Player} from "@/components/video/player.tsx";
import './style.scss'
@ -8,7 +9,7 @@ export default function LivePlayer() {
const [liveUrl, setLiveUrl] = useState<string>('http://fm.live.starbitech.com/fm/prod_fm.flv')
useMount(async ()=>{
getLiveUrl().then((ret)=>{
//setLiveUrl(ret.flv_url)
setLiveUrl(ret.flv_url)
})
})
return <div className="live-player-wrapper ">

View File

@ -1,25 +1,36 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {Empty, Space} from "antd";
import {Checkbox, Empty, Popconfirm, Space} from "antd";
import {SortableContext, arrayMove} from '@dnd-kit/sortable';
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 {getList, 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 FlvJs from "flv.js";
import ButtonBatch from "@/components/button-batch.tsx";
import {formatDuration} from "@/util/strings.ts";
import {useSetState} from "ahooks";
import {Player, PlayerInstance} from "@/components/video/player.tsx";
import {Mp4Player as Player, PlayerInstance} from "@/components/video/Mp4Player.tsx";
import {IconDelete, IconLocked, IconRollbackCircle} from "@/components/icons";
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
import {useTranslation} from "react-i18next";
import ButtonToTop from "@/components/scoller/button-to-top.tsx";
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 } = {}
export default function LiveIndex() {
const {t} = useTranslation()
const player = useRef<PlayerInstance | null>(null)
const [videoData, setVideoData] = useState<LiveVideoInfo[]>([])
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
const [editable, setEditable] = useState<boolean>(false)
const scrollerRef = useRef<InfiniteScrollerRef | null>(null)
const [rollbackIds,setRollbackIds] = useState<Id[]>([])
const [delIds,setDelIds] = useState<Id[]>([])
const [state, setState] = useSetState({
playId:-1,
@ -33,7 +44,6 @@ export default function LiveIndex() {
const activeIndex = useRef(-1)
useEffect(() => {
activeIndex.current = videoData.findIndex(s=>s.id == state.playId)
document.querySelector(`.video-item-${state.playId}`)?.scrollIntoView({behavior: 'smooth'})
}, [state.playId,videoData])
// 显示当前播放视频对应 view item
@ -77,6 +87,8 @@ export default function LiveIndex() {
const playedTime = (Date.now() / 1000 >> 0) - liveState.live_start_time
if (playedTime < 0 || playedTime > duration) { // 已播放时间大于总时长了
//initPlayingState() // 重新获取播放状态
console.log('已播放时间大于总时长')
cache.timerLoadState = setTimeout(initPlayingState, 5000)
return;
}
player.current?.play(video.video_oss_url, playedTime)
@ -87,6 +99,7 @@ export default function LiveIndex() {
}
}
// 初始化播放状态
const initPlayingState = () => {
player.current?.pause();
@ -144,6 +157,67 @@ export default function LiveIndex() {
}
}, [])
// 删除视频
const processDeleteVideo = async (ids: Id[]) => {
// 临时记录删除的id
setDelIds(_=>[...ids,..._])
// deleteByIds(ids).then(() => {
// 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 handleCancel = ()=>{
resetState(false)
}
const handleRollback = (v:LiveVideoInfo)=>{
setRollbackIds(_=>[v.id,..._])
}
const handleConfirm = async () => {
if (!editable) {
return;
}
const ids = videoData
.filter(s=>!(delIds.includes(s.id) || rollbackIds.includes(s.id)))
.map(s => s.id)
try{
// 删除
if(delIds.length > 0) {
await deleteByIds(delIds)
}
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 = () => {
if(!editable) return;
setCheckedIdArray(state.checkedAll ? [] : videoData.map(v => v.id))
setState({
checkedAll: !state.checkedAll
})
}
// 视频相关时长
const totalDuration = useMemo(() => {
if (!videoData || videoData.length == 0) return 0;
@ -170,18 +244,21 @@ export default function LiveIndex() {
// return checkedIdArray.filter(id => currentId.id != id)
// }, [checkedIdArray, state.activeIndex])
const currentSelectedVideoIds = useMemo(()=>{
return checkedIdArray.length == 0 ? [] : checkedIdArray.filter(id => id != state.playId)
},[checkedIdArray, state.playId])
return (<div className="container py-5 page-live">
<div className="h-[36px]"></div>
<div className="flex">
<div className="video-player-container mr-16 flex ">
<div className="video-player-container mr-16 flex items-center">
<div>
<div className="text-center text-base text-gray-400">{formatDuration(totalDuration)}</div>
<div className="video-player flex justify-center flex-1 mt-1">
<div className="live-player relative rounded overflow-hidden w-[420px] h-[740px]"
<div className="live-player relative rounded overflow-hidden w-[360px] h-[636px]"
style={{backgroundColor: 'hsl(210, 100%, 48%)'}}>
<Player
ref={player} className="w-[420px] h-[740px] bg-white"
ref={player} className="w-[360px] h-[636px] bg-white"
muted={true}
onProgress={(progress) => {
setState({playProgress: progress})
@ -195,7 +272,7 @@ export default function LiveIndex() {
</div>
</div>
<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>
<Space>
{/*<span className={"text-blue-500"}>视频正在播放{state.activeIndex == -1 ? '' : `到 ${state.activeIndex + 1} 条`}</span>*/}
@ -204,7 +281,23 @@ export default function LiveIndex() {
</div>
<div className="flex items-center">
<div className={'flex items-center text-gray-400 cursor-pointer select-none'}>
{editable ? (<Space size={15}>
<button className={styles.btnDefault} onClick={handleCancel}>{t('cancel')}</button>
<button className={styles.btn} onClick={handleConfirm}>{t('save_operation')}</button>
</Space>):(<div className="flex items-center " onClick={handleSetEditable}>
{t('live.edit_locked')}
<span className="ml-2 text-sm"><IconLocked/></span>
</div>)}
</div>
<div className="check-all ml-10">
<button disabled={editable} className={`${editable?'':'hover:text-blue-300'} text-gray-400`}
onClick={handleAllCheckedChange}>
<span className="text-sm mr-2 whitespace-nowrap">{t('select.select_all')}</span>
{/*<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>*/}
</button>
<Checkbox disabled={!editable} checked={state.checkedAll} onChange={() => handleAllCheckedChange()}/>
</div>
</div>
</div>
<div className="list-header">
@ -213,6 +306,7 @@ export default function LiveIndex() {
<div className="col cover">{t('video.title_thumb')}</div>
<div className="col title">{t('video.title')}</div>
<div className="col generated-time">{t('video.title_generated_time')}</div>
<div className="col operation">{t('video.title_operation')}</div>
</div>
</div>
<div className="">
@ -226,33 +320,76 @@ export default function LiveIndex() {
>
{videoData.length == 0 && <div className="m-auto py-16"><Empty/></div>}
<div className="sort-list-container flex-1">
{videoData.map((v, index) => (
<VideoListItem
video={v}
index={index + 1}
id={v.id}
key={index}
active={state.playId == v.id}
playing={state.playId == v.id}
className={`list-index-${index} list-item-${v.id} video-item-${v.id} mt-3 mb-2`}
checked={checkedIdArray.includes(v.id)}
operationRender={<></>}
onCheckedChange={(checked) => {
const newIdArray = checked ? checkedIdArray.concat(v.id) : checkedIdArray.filter(id => id != v.id);
setState({checkedAll: newIdArray.length == videoData.length})
setCheckedIdArray(newIdArray)
// setCheckedIdArray(idArray => {
// return checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
// })
}}
editable={false}
sortable={false}
/>))}
<DndContext onDragEnd={(e) => {
const {active, over} = e;
if (over && active.id !== over.id) {
let oldIndex = -1, newIndex = -1;
setVideoData((items) => {
oldIndex = items.findIndex(s => s.id == active.id);
newIndex = items.findIndex(s => s.id == over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
}}>
<SortableContext items={videoData}>
{videoData.filter(v=>(!(delIds.includes(v.id) || rollbackIds.includes(v.id)))).map((v, index) => (
<VideoListItem
video={v}
index={index + 1}
id={v.id}
key={index}
active={state.playId == v.id}
playing={state.playId == v.id}
className={`list-index-${index} list-item-${v.id} mt-3 mb-2`}
checked={checkedIdArray.includes(v.id)}
onCheckedChange={(checked) => {
const newIdArray = checked ? checkedIdArray.concat(v.id) : checkedIdArray.filter(id => id != v.id);
setState({checkedAll: newIdArray.length == videoData.length})
setCheckedIdArray(newIdArray)
// setCheckedIdArray(idArray => {
// return checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
// })
}}
onRemove={() => processDeleteVideo([v.id])}
editable={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>
</DndContext>
</div>
<div className="h-[100px]"></div>
</InfiniteScroller>
</div>
</div>
</div>
</div>
<div className="page-action">
<ButtonToTop visible={state.showToTop} onClick={() => scrollerRef.current?.scrollToPosition(0)}/>
{currentSelectedVideoIds.length > 0 && <ButtonBatch
className='bg-gray-300 hover:bg-gray-400 text-white'
selected={currentSelectedVideoIds}
emptyMessage={t('video.delete_empty')}
confirmMessage={currentSelectedVideoIds.length > 1?
t('video.delete_description_count',{count:currentSelectedVideoIds.length})
:
t('video.delete_description',{count:currentSelectedVideoIds.length})}
onSuccess={loadList}
onProcess={processDeleteVideo}
>
<span className={'text'}>{t('delete_batch')}</span>
<IconDelete/>
</ButtonBatch>}
</div>
</div>)
}

View File

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

View File

@ -4,9 +4,10 @@ import {useNavigate} from "react-router-dom";
import {useTranslation} from "react-i18next";
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 {IconArrowRight, IconWarningCircle} from "@/components/icons";
import {IconArrowRight} from "@/components/icons";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
export enum ProcessResult {
All,
@ -21,6 +22,7 @@ type PushVideoProps = {
}
export default function ButtonPush2Video(props: PushVideoProps) {
const [loading, setLoading] = useState(false)
// const {modal} = App.useApp()
const [state, setState] = useSetState<{
modalVisible?: boolean;
errorTitle?: string[];
@ -30,32 +32,66 @@ export default function ButtonPush2Video(props: PushVideoProps) {
})
const {t} = useTranslation()
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));
if(skip && (state.errorIds.length == props.ids.length || ids.length == 0)){
setState({modalVisible: false})
return;
}
setLoading(true)
push2video(ids).then(() => {
setState({modalVisible: false})
if (skip) {
props.onResult?.(ProcessResult.Skip, state.errorIds || [])
/**
*
* @deprecated
*/
// const checkHotNewsValid = async ()=>{
// return new Promise<string>((resolve)=>{
// const manualErrorCount = props.articles?.filter(s=>{
// return s.hot_news.replace(/,/ig,'').trim().length == 0
// })?.length || 0
// if(manualErrorCount == 0) {
// resolve('default')
// 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;
}
showToast(t('news.push_stream_success'), 'success')
navigate('/create?state=push-success', {
state: 'push-success'
//
// const result = await checkHotNewsValid();
// // 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
// 1.normal 数字人播报部分有内容不少于50字或者数字人播报部分无内容
// 2.error 数字人播报部分有内容但是少于50字
@ -98,8 +134,7 @@ export default function ButtonPush2Video(props: PushVideoProps) {
width={440}
>
<div className="modal-title flex items-center">
<div className="anticon anticon-exclamation-circle text-red-400 w-10"><IconWarningCircle
style={{fontSize: 24, color: 'rgba(250, 173, 20, 1)'}}/></div>
<div className="anticon anticon-exclamation-circle text-red-400 w-10"><ModalWarning.Icon/></div>
<div className="text-base">{t('modal.warning')}</div>
</div>
<div className="confirm-message-wrapper flex mt-2">
@ -114,13 +149,13 @@ export default function ButtonPush2Video(props: PushVideoProps) {
}}>
</div>
{state.errorTitle && state.errorTitle.length > 0 &&
<div className="error-list text-red-400 mt-6 w-[350px]">
<div className="title">{t('modal.push_article.error_title')}:</div>
<div className="max-h-[100px] overflow-auto" style={{lineHeight: '20px'}}>
{state.errorTitle.map(s => <div
className="error-item overflow-hidden pr-1 text-nowrap overflow-ellipsis">{s}</div>)}
</div>
</div>}
<div className="error-list text-red-400 mt-6 w-[350px]">
<div className="title">{t('modal.push_article.error_title')}:</div>
<div className="max-h-[100px] overflow-auto" style={{lineHeight: '20px'}}>
{state.errorTitle.map((s, idx) => <div key={idx}
className="error-item overflow-hidden pr-1 text-nowrap overflow-ellipsis">{s}</div>)}
</div>
</div>}
</div>
</div>
<div className="flex justify-end mt-6">
@ -128,10 +163,15 @@ export default function ButtonPush2Video(props: PushVideoProps) {
<Button disabled={loading} onClick={() => {
setState({modalVisible: false})
}}>{t('modal.push_article.action_cancel')}</Button>
{state.errorIds?.length > 0 && <Button disabled={loading} type="primary"
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>
{state.errorIds?.length > 0 && (
<Button
disabled={loading} type="primary"
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>
</div>
</Modal>

View File

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

View File

@ -44,7 +44,7 @@
}
}
.col{
@apply flex items-center justify-center relative pl-6;
@apply flex items-center justify-center relative pl-6 text-sm;
height: 54px;
&:after{
@apply absolute;
@ -55,6 +55,9 @@
left:0;
}
}
.cover{
@apply pl-2;
}
.title{
@apply flex-1 pl-0;
&:after{
@ -82,9 +85,8 @@
.header{
@apply bg-primary-bg;
.col{
@apply text-sm;
@apply text-base;
height: 42px;
}
.operations{
}
@ -96,4 +98,34 @@
}
}
}
.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 {useRequest} from "ahooks";
@ -10,12 +10,13 @@ import ButtonPush2Video, {ProcessResult} from "@/pages/news/components/button-pu
import styles from './components/style.module.scss'
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 ButtonToTop from "@/components/scoller/button-to-top.tsx";
import ButtonDeleteBatch from "@/pages/news/components/button-delete-batch.tsx";
import {showErrorToast, showToast} from "@/components/message.ts";
import {useTranslation} from "react-i18next";
import {DeleteItemPopoverConfirm} from "@/components/message/confirm.tsx";
const FilterCache: Partial<ApiArticleSearchParams> = {
tags: [],
@ -39,6 +40,8 @@ export default function NewEdit() {
onSuccess: (data) => {
FilterCache.title = params.title;
FilterCache.tags = params.tags;
setSelectedRowKeys(()=>([]))
setState({checkAll: false})
setData(prev => {
// 判断页码是否是第1页
if (data.pagination.page == 1) return data;
@ -99,10 +102,12 @@ export default function NewEdit() {
<span className={'inline-block cursor-pointer mr-2'} onClick={() => {
handleCheckAll(!state.checkAll)
}}>{t('select.select_all')}</span>
<Checkbox checked={state.checkAll && (!data?.list || selectedRowKeys.length == data?.list?.length)}
onChange={e => {
handleCheckAll(e.target.checked)
}}/>
<Checkbox
checked={state.checkAll && (!data?.list || selectedRowKeys.length == data?.list?.length)}
onChange={e => {
handleCheckAll(e.target.checked)
}}
/>
</div>
</div>
<div className={styles.newListTable}>
@ -119,15 +124,15 @@ export default function NewEdit() {
...prev,
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}>
<div className="body">
{data?.list?.map((item, i) => {
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="flex-1">
<div className="text-base">{item.title}</div>
<div className="text-base line-clamp-1">{item.title}</div>
<div
className="summary text-xs text-gray-400 line-clamp-1">{item.summary}</div>
</div>
@ -147,19 +152,9 @@ export default function NewEdit() {
</div>
<div className="col operations">
<span className="icon-btn" onClick={()=>setEditId(item.id)}><IconEdit/></span>
<Popconfirm
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)
}}
>
<DeleteItemPopoverConfirm onConfirm={() => {handleDelete(item.id)}}>
<span className="icon-btn"><IconDelete/></span>
</Popconfirm>
</DeleteItemPopoverConfirm>
<Checkbox checked={checked}
onChange={e => handleItemChecked(e.target.checked, item)}/>
</div>
@ -179,6 +174,7 @@ export default function NewEdit() {
<ArticleEditModal
type="news"
id={editId}
onRefresh={refresh}
onClose={(saved) => {
setEditId(-1)
if (saved) refresh()

View File

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

View File

@ -7,7 +7,7 @@ import styles from './style.module.scss'
type VideoItemProps = {
videoInfo: VideoInfo;
onLive?: boolean;
onClick?: (autoPlay:boolean) => void;
onClick?: (autoPlay: boolean) => void;
onRemove?: () => void;
onCheckedChange?: (checked: boolean) => void;
checked?: boolean;
@ -25,14 +25,14 @@ export default function VideoItem(props: VideoItemProps) {
<div className="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={styles.playIcon} onClick={()=>props.onClick?.(true)}><CaretRightOutlined /></div>
<div className={styles.playIcon} onClick={() => props.onClick?.(true)}><CaretRightOutlined/></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">
<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>
<div className="video-time-info">{timeFromNow(props.videoInfo.ctime)}</div>
onClick={() => props.onClick?.(false)}>{props.videoInfo.title}</div>
<div className="video-time-info text-right">{timeFromNow(props.videoInfo.d_time)}</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

View File

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

View File

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

View File

@ -1,54 +1,51 @@
import {Empty} from "antd";
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {useGetState, useSetState} from "ahooks";
import {Checkbox, Empty, Space} from "antd";
import React, {useEffect, useRef, useState} from "react";
import {DndContext} from "@dnd-kit/core";
import {arrayMove, SortableContext} from "@dnd-kit/sortable";
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 {getList, VideoStatus} from "@/service/api/video.ts";
import ArticleEditModal from "@/components/article/edit-modal.tsx";
import {deleteFromList, getList, modifyOrder, regenerateById, VideoStatus} from "@/service/api/video.ts";
import {formatDuration} from "@/util/strings.ts";
import {showErrorToast} from "@/components/message.ts";
import {Player, PlayerInstance} from "@/components/video/player.tsx";
import ButtonBatch from "@/components/button-batch.tsx";
import {showErrorToast, showToast} from "@/components/message.ts";
import {Mp4Player as Player, PlayerInstance} from "@/components/video/Mp4Player.tsx";
import ButtonPush2Room from "@/pages/video/components/button-push2room.tsx";
import ButtonToTop from "@/components/scoller/button-to-top.tsx";
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
import {useTranslation} from "react-i18next";
import {useLastState} from "@/hooks/useLastState.ts";
function isFullyInViewport(ele: HTMLElement, container: HTMLElement) {
return ele.offsetTop >= container.scrollTop && ele.offsetTop + ele.offsetHeight <= container.scrollTop + container.offsetHeight
}
function getNormalVideoList(list: VideoInfo[]) {
return list?.filter(s => {
return s.status != VideoStatus.Generating && s.oss_video_url
}) || []
}
import {IconDelete} from "@/components/icons";
export default function VideoIndex() {
const {t} = useTranslation()
const [videoData, setVideoData, getLastVideoList] = useGetState<VideoInfo[]>([])
const [editId, setEditId] = useState(-1)
const loc = useLocation()
const navigate = useNavigate()
const [videoData, setVideoData] = useState<VideoInfo[]>([])
const player = useRef<PlayerInstance | null>(null)
const scrollerRef = useRef<InfiniteScrollerRef | null>(null)
const [state, setState] = useSetState({
checkedAll: false,
playingId: -1,
videoPlaying: false,
showToTop: false,
showStatePos: false,
playState: {
current: -1,
total: -1
},
loading: false,
loading:false,
playVideoUrl: ''
})
const {lastState} = useLastState(state);
const [checkedIdArray, setCheckedIdArray] = useState<Id[]>([])
const [refreshTimer, setTimer] = useState(0)
const [refreshTimer,setTimer] = useState(0)
// 加载列表
const loadList = (needReset = true, onLoad = false) => {
if (state.loading) return;
if (refreshTimer) {
const loadList = (needReset = true) => {
if(state.loading) return;
if(refreshTimer) {
clearTimeout(refreshTimer)
setTimer(0)
}
@ -63,53 +60,67 @@ export default function VideoIndex() {
// 判断是否有生成中的视频
if (list.filter(s => s.status == VideoStatus.Generating).length > 0) {
// 每5s重新获取一次最新数据
setTimer(() => setTimeout(() => loadList(false), 5000) as any);
}
if(onLoad){
const _list = getNormalVideoList(list)
if(_list.length > 0){
playVideo(_list[0])
}
setTimer(()=>setTimeout(() => loadList(false), 5000) as any);
}
}).catch(showErrorToast)
.finally(() => {
setState({loading: false})
})
return () => {
if (refreshTimer) {
.finally(()=>{
setState({loading: false})
})
return ()=>{
if(refreshTimer){
clearTimeout(refreshTimer)
}
console.log('go out', refreshTimer)
console.log('go out',refreshTimer)
}
}
// 播放视频
const playVideo = (video: VideoInfo) => {
if (state.playingId == video.id) {
player.current?.pause();
setState({playingId: -1})
return;
}
if (video.status == VideoStatus.Generating) return;
if (video.oss_video_url && video.status !== 1) {
// setState({playingIndex})
// player.current?.play('https://staticplus.gachafun.com/ai-collect/composite_video/2024-12-17/1186196465916190720.flv', 30)
//
if (video.oss_video_mp4_url && video.status !== 1) {
setState({playingId: video.id})
player.current?.play(video.oss_video_url, 0)
const videoElement = document.querySelector(`.video-item-${video.id}`) as HTMLElement
const scroller = document.querySelector('.data-list-container') as HTMLElement;
if(videoElement && isFullyInViewport(videoElement, scroller)) {
videoElement.scrollIntoView({behavior: 'smooth'})
player.current?.play(video.oss_video_mp4_url, 0)
}
}
// 处理全选
const handleAllCheckedChange = () => {
setCheckedIdArray(state.checkedAll ? [] : videoData.filter(s=>s.status == VideoStatus.Generated).map(v => v.id))
setState({
checkedAll: !state.checkedAll
})
}
const handleModifySort = (items: VideoInfo[]) => {
modifyOrder(items.map(s => s.id)).then(() => {
showToast(t('video.sort_modify_success'), 'success')
}).catch(() => {
loadList();
showToast(t('video.sort_modify_failed'), 'warning')
})
return ()=>{
try{
Array.from(document.querySelectorAll('video')).forEach(v => v.pause())
}catch (e){
console.log(e)
}
}
}
useEffect(() => {
loadList(true, true)
}, [])
//
useEffect(loadList, [])
// const totalDuration = useMemo(() => {
// if (!videoData || videoData.length == 0) return 0;
// const v = state.playingId == -1 ? null : videoData.find(s => s.id == state.playingId)
// const v = state.playingId == -1 ? null : videoData.find(s=>s.id == state.playingId)
// if (!v) return 0
// //const v = videoData[state.playingIndex] as VideoInfo;
// return Math.ceil(v.duration / 1000)
@ -117,37 +128,54 @@ export default function VideoIndex() {
// //return videoData.reduce((sum, v) => sum + Math.ceil(v.duration / 1000), 0);
// }, [videoData, state.playingId])
const handlePlayEnd = () => {
const list = getLastVideoList();
if (!list?.length) return;
const _list = getNormalVideoList(list)
if (_list.length > 0) {
const _currentIndex = _list.findIndex(s => s.id == lastState.current.playingId)
const _next = _currentIndex != -1 && _currentIndex < _list.length - 1 ? _list[_currentIndex + 1] : _list[0];
playVideo(_next)
useEffect(() => {
if (loc.state == 'push-success' && !state.showStatePos && videoData.length && scrollerRef.current) {
const generatingItem = document.querySelector(`.list-item-state-${VideoStatus.Generating}`)
if (generatingItem) {
generatingItem.scrollIntoView({behavior: 'smooth'})
setState({showStatePos: true})
}
}
}, [videoData, scrollerRef])
const processDeleteVideo = async (ids: Id[],action ?: string) => {
deleteFromList(ids).then(() => {
showToast(t('delete_success'), 'success')
if(action == 'rollback'){
navigate('/edit',{
state: {action: 'rollback',id: ids[0]},
})
}else{
loadList()
}
}).catch(showErrorToast)
}
const processGenerateVideo = async (video: VideoInfo) => {
regenerateById(video.article_id).then(() => {
//showToast(t('delete_success'), 'success')
loadList()
}).catch(showErrorToast)
}
return (<div className="container py-5 page-live">
<div className="h-[36px]"></div>
<div className="flex">
<div className="video-player-container mr-16 flex items-center">
<div className="video-player-container mr-16 w-[360px] flex items-center">
<div>
<div className="text-center text-base text-gray-400">{t("generating.title")}</div>
<div className="video-player flex items-center mt-2">
<div className=" w-[420px] h-[740px] rounded overflow-hidden">
<div className=" w-[360px] h-[636px] rounded overflow-hidden">
{/*videoData[state.playingIndex]?.oss_video_url*/}
<Player
ref={player}
url={state.playVideoUrl}
showControls={true}
onChange={(_state) => {
console.log('onChange', _state)
if (_state.end) {
handlePlayEnd();
return;
}
if (_state.error) setState({playingId: -1})
onChange={(state) => {
if (state.end || state.error) setState({playingId: -1})
}}
onPause={() => {
setState({videoPlaying:false})
}}
onPlay={() => {
setState({videoPlaying:true})
}}
onProgress={(current, duration) => {
setState({
@ -157,18 +185,26 @@ export default function VideoIndex() {
}
})
}}
className="w-[420px] h-[740px] bg-white"/>
className="w-[360px] h-[640px] bg-white"/>
</div>
</div>
<div
className="text-center text-sm mt-4 text-gray-400">{formatDuration(state.playState.current)} / {formatDuration(state.playState.total)}</div>
<div className="text-center text-sm mt-4 text-gray-400">{formatDuration(state.playState.current)} / {formatDuration(state.playState.total)}</div>
</div>
</div>
<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="flex items-center">
<Space size={20}>
<span>{t('select.total',{count:videoData.length || 0})}</span>
<span className={'text-blue-500'}>{t('select.selected_some',{count:checkedIdArray.length})}</span>
</Space>
<button className="hover:text-blue-300 text-gray-400 ml-5"
onClick={handleAllCheckedChange}>
<span className="text-sm mr-2">{t("select.select_all")}</span>
{/*<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>*/}
</button>
<Checkbox checked={state.checkedAll} onChange={() => handleAllCheckedChange()}/>
</div>
</div>
<div className={'video-list-sort-container flex-1 mt-1'}>
@ -178,47 +214,102 @@ export default function VideoIndex() {
<div className="col cover">{t('video.title_thumb')}</div>
<div className="col title">{t('video.title')}</div>
<div className="col generated-time">{t('video.title_generated_time')}</div>
<div className="col operation">{t('video.title_operation')}</div>
</div>
</div>
<InfiniteScroller loading={state.loading} ref={scrollerRef} onScroll={top => setState({showToTop: top > 30})}>
{
videoData.length == 0 ? <div className="m-auto py-16"><Empty/></div> :
<div className="sort-list-container flex-1">
{videoData.map((v, index) => (
<VideoListItem
video={v}
index={index + 1}
id={v.id}
key={index}
type={'create'}
active={checkedIdArray.includes(v.id)}
playing={state.playingId == v.id}
checked={checkedIdArray.includes(v.id)}
className={`list-item-${index} video-item-${v.id} mt-3 mb-2 list-item-state-${v.status} `}
onCheckedChange={(checked) => {
setCheckedIdArray(idArray => {
const newArr = checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
setState({checkedAll: newArr.length == videoData.length})
return newArr;
})
}}
onItemClick={() => playVideo(v)}
operationRender={<></>}
onEdit={undefined}
onRegenerate={v.status != VideoStatus.Generating && v.status != VideoStatus.Generated ? () => {
} : undefined}
hideCheckBox={v.status != VideoStatus.Generating && v.status != VideoStatus.Generated}
editable={v.status != VideoStatus.Generating}
sortable={v.status == VideoStatus.Generated}
/>))}
<DndContext onDragEnd={(e) => {
const {active, over} = e;
if (over && active.id !== over.id) {
let oldIndex = -1, newIndex = -1;
const originArr = [...videoData]
console.log(originArr.map(s => s.id))
setVideoData((items) => {
oldIndex = items.findIndex(s => s.id == active.id);
newIndex = items.findIndex(s => s.id == over.id);
const newSorts = arrayMove(items, oldIndex, newIndex);
handleModifySort(newSorts)
return newSorts;
});
}
}}>
<SortableContext items={videoData}>
{videoData.map((v, index) => (
<VideoListItem
video={v}
index={index + 1}
id={v.id}
key={index}
type={'create'}
active={checkedIdArray.includes(v.id)}
playing={state.playingId == v.id && state.videoPlaying}
checked={checkedIdArray.includes(v.id)}
className={`list-item-${index} mt-3 mb-2 list-item-state-${v.status} `}
onCheckedChange={(checked) => {
setCheckedIdArray(idArray => {
const newArr = checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
setState({checkedAll: newArr.length == videoData.length})
return newArr;
})
}}
onItemClick={() => playVideo(v)}
onRemove={(action) => processDeleteVideo([v.id],action)}
onEdit={v.status == VideoStatus.Generated ? () => {
setEditId(v.article_id)
}:undefined}
onRegenerate={v.status != VideoStatus.Generating && v.status != VideoStatus.Generated?()=>{
processGenerateVideo(v).catch(console.log)
}:undefined}
downloadUrl={v.oss_video_mp4_url}
hideCheckBox={v.status != VideoStatus.Generating && v.status != VideoStatus.Generated}
editable={v.status != VideoStatus.Generating}
sortable={v.status == VideoStatus.Generated}
/>))}
</SortableContext>
</DndContext>
</div>
}
<div className="h-[130px]"></div>
</InfiniteScroller>
</div>
</div>
<div className="page-action">
<ButtonToTop visible={state.showToTop} onClick={() => scrollerRef.current?.scrollToPosition(0)}/>
{
checkedIdArray.length > 0 && <ButtonBatch
onProcess={deleteFromList}
selected={checkedIdArray}
emptyMessage={t('video.delete_empty')}
title={
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={{
__html: checkedIdArray.length == 1 ?
t('video.delete_confirm', {count: checkedIdArray.length}) :
t('video.delete_confirm_count', {count: checkedIdArray.length})
}}></span>}
onSuccess={() => {
showToast(t('delete_success'), 'success')
loadList()
}}
>
<span className="text">{t('delete_batch')}</span>
<IconDelete/>
</ButtonBatch>
}
<ButtonPush2Room ids={checkedIdArray} list={videoData} onSuccess={loadList}/>
</div>
</div>
<ArticleEditModal type={'video'} id={editId} onClose={(saved) =>{
setEditId(-1)
if(saved) {
loadList()
}
}}/>
</div>)
}

View File

@ -1,21 +1,23 @@
import {createBrowserRouter, RouterProvider,} from "react-router-dom";
import {Suspense, useEffect,} from "react";
import {ConfigProvider, App} from "antd";
import React, {Suspense, useEffect,} from "react";
import {ConfigProvider, App, Modal} from "antd";
import zhCN from 'antd/locale/zh_CN';
// for date-picker i18n
import dayjs from "dayjs";
import 'dayjs/locale/zh-cn';
import ErrorBoundary from "./error.tsx";
import Loader from "@/components/loader.tsx";
import routes from "@/routes/routes.tsx";
import {DocumentTitle} from "@/components/document.tsx";
import {useTranslation} from "react-i18next";
import useGlobalConfig from "@/hooks/useGlobalConfig.ts";
import VideoIndex from "@/pages/video";
import {getRemainingDuration} from "@/service/api/order.ts";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
const router = createBrowserRouter([
...routes,
{path: '*', element: <VideoIndex />}
{path: '*', element: <ErrorBoundary errorCode={404}/>}
], {
basename: import.meta.env.VITE_APP_BASE_NAME,
future: {
@ -31,16 +33,39 @@ const router = createBrowserRouter([
const AppRouter = () => {
const {globalConfig} = useGlobalConfig();
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(() => {
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') {
dayjs.locale('zh-cn');
}else{
dayjs.locale('en')
}
initRemainingDuration()
globalConfig.i18n = i18n.language
// i18n.changeLanguage(i18n).then(()=>console.log('change lang to ',i18n))
}, [i18n])
}, [i18n.language])
return (<ConfigProvider
locale={i18n?.language?.toString() == 'zh-CN' ? zhCN : undefined}

View File

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

View File

@ -1,39 +1,112 @@
import {Outlet, useSearchParams} from "react-router-dom";
import {Button} from "antd";
import React from "react";
import {Outlet, useLocation, useNavigate} from "react-router-dom";
import {Divider, Dropdown, MenuProps} from "antd";
import React, {useEffect, useMemo} from "react";
import {useTranslation} from "react-i18next";
import AuthGuard from "@/routes/layout/auth-guard.tsx";
import {LogoText} from "@/components/icons/logo.tsx";
import {UserAvatar} from "@/components/icons/user-avatar.tsx";
import {DashboardNavigation} from "@/routes/layout/dashboard-navigation.tsx";
import useAuth from "@/hooks/useAuth.ts";
import {hidePhone} from "@/util/strings.ts";
import {defaultCache} from "@/hooks/useCache.ts";
import {useTranslation} from "react-i18next";
import {IconOrderFill, IconRecycleFill} from "@/components/icons";
import LanguageSwitcher from "@/components/icons/language-switcher.tsx";
type LayoutProps = {
children: React.ReactNode
}
const NavigationUserContainer = () => {
const {t} = useTranslation()
const {logout, user} = useAuth()
const navigate = useNavigate()
const handleLogout = () => {
logout().then(() => navigate('/user'))
}
const items: MenuProps['items'] = [
{
key: 'order',
label: <div className="nav-item" onClick={() => navigate('/order')}>
<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>
</div>,
},
// {
// key: 'logout',
// label: <div onClick={handleLogout}>退出</div>,
// },
];
const UserButton = () => (<div
className={`flex items-center rounded-3xl ${user ? 'bg-[#e3eeff]' : 'bg-primary-blue'} p-1 pr-2 cursor-pointer rounded`}>
<UserAvatar className="user-avatar size-7"/>
{user ? <span className={"username ml-2 text-sm"}>{hidePhone(user.nickname)}</span> : (
<span className="text-sm mx-2 text-white">{t('login.title')}</span>
)}
</div>)
return (<div className={"flex items-center justify-between gap-2 ml-10"}>
{user ? <Dropdown
rootClassName={'z-[999999] userinfo-drop-menu'}
menu={{items}} placement="bottomRight"
dropdownRender={(menu) => (
<div>
<div className="user-profile flex gap-4">
<div className="avatar"><UserAvatar className="user-avatar"/></div>
<div className="info">
<div>{user?.nickname}</div>
<div>ID: {user?.id}</div>
</div>
</div>
<Divider style={{margin: 0}}/>
<div className="menu-list-container">
{menu}
</div>
<Divider style={{margin: 0}}/>
<div className="logout">
<div onClick={handleLogout}>{t('user.logout')}</div>
</div>
</div>
)}
>
<div><UserButton/></div>
</Dropdown> : <UserButton/>}
</div>)
}
const ExtraNavItems = {
'/order':'order.text',
'/recycle':'history.text',
}
export const BaseLayout: React.FC<LayoutProps> = ({children}) => {
const {i18n} = useTranslation();
const [params] = useSearchParams();
const {pathname} = useLocation()
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'}>
<div className="min-h-screen w-full">
<div className="app-header">
<div className="logo-container">
<div className="logo-container flex items-center">
<LogoText style={{fontSize: 30}}/>
{extraNav && <div className="extra-nav-name ml-2">
<span className="nav-item active">{extraNav}</span>
</div>}
</div>
<DashboardNavigation/>
<div className="flex items-center">
{(params.get('lang') == 'yes' || AppConfig.APP_LANG == 'multiple') && <div>
{
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>}
<LanguageSwitcher />
<NavigationUserContainer/>
</div>
</div>
<div className="app-content flex-1 box-sizing">
@ -47,6 +120,14 @@ export const BaseLayout: React.FC<LayoutProps> = ({children}) => {
const DashboardLayout: React.FC<{ children?: React.ReactNode }> = ({children}) => {
const loc = useLocation()
const navigate = useNavigate()
useEffect(() => {
if (!defaultCache.firstLoadPath && loc.pathname == '/live') {
defaultCache.firstLoadPath = loc.pathname;
navigate('/')
}
}, [])
return <AuthGuard>
<div className="fixed">{defaultCache.firstLoadPath}</div>
<BaseLayout>

View File

@ -11,11 +11,35 @@ export function DashboardNavigation() {
const {t,i18n} = useTranslation()
const {user} = useAuth()
const NavItems = useMemo(()=>([
{
key: 'news',
name: t('nav.materials'),
icon: 'news',
path: '/'
},
{
key: 'video',
name: t('nav.editing'),
icon: 'e',
path: '/edit'
},
{
key: 'create',
name: t('nav.generating'),
icon: 'ai',
path: '/create'
},
// {
// key: 'library',
// name: '视频库',
// icon: '+',
// path:'/library'
// },
{
key: 'live',
name: t('nav.live'),
icon: 'v',
path: '/'
path: '/live'
}
]),[i18n.language])
return (<div className={'flex app-main-navigation'}>

View File

@ -1,13 +1,53 @@
import {RouteObject} from "react-router-dom";
import ErrorBoundary from "@/routes/error.tsx";
import React from "react";
import VideoIndex from "@/pages/video";
import ErrorBoundary from "@/routes/error.tsx";
import DashboardLayout from "@/routes/layout/dashboard-layout.tsx";
const UserAuth = React.lazy(() => import("@/pages/user"))
const CreateVideoIndex = React.lazy(() => import("@/pages/video"))
const RecycleIndex = React.lazy(() => import("../pages/recycle"))
const LiveIndex = React.lazy(() => import("@/pages/live"))
const NewsIndex = React.lazy(() => import("@/pages/news"))
const NewsEdit = React.lazy(() => import("@/pages/news/edit.tsx"))
const OrderIndex = React.lazy(() => import("@/pages/order/index.tsx"))
const routes: RouteObject[] = [
{
path: '/',
element: <VideoIndex/>,
element: <DashboardLayout/>,
errorElement: <ErrorBoundary/>,
children: [
{
path: '',
element: <NewsIndex/>
},
{
path: 'user',
element: <UserAuth/>,
},
{
path: 'edit',
element: <NewsEdit/>
},
{
path: 'create',
element: <CreateVideoIndex/>
},
{
path: 'recycle',
element: <RecycleIndex/>
},
{
path: 'order',
element: <OrderIndex/>
},
{
path: 'live',
element: <LiveIndex/>
},
]
},
]
export default routes

View File

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

View File

@ -15,6 +15,9 @@ export function modifyOrder(ids: Id[]) {
export function deleteByIds(ids: Id[]) {
return post('/room/remove', {ids})
}
export function restoreByIds(ids: Id[]) {
return post('/room/restore', {ids})
}
export function getLiveUrl() {
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 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 }>({
url: '/video/regenerate',
data: {
title,
metahuman_text,
content_group,
article_id
...params,
article_id:params.id
}
})
}
// 重新生成视频
export async function regenerateById(article_id: 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) {
@ -39,7 +42,7 @@ export function getById(id: 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}
})
// eslint-disable-next-line react-hooks/rules-of-hooks
const {globalConfig} = useGlobalConfig();
// 请求前拦截
Axios.interceptors.request.use(config => {

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

@ -67,6 +67,7 @@ interface BasicArticleInfo {
content_word_count?: number;
media_id: number;
fanwen_column_id: number;
hot_news: string;
}
/**
@ -85,6 +86,11 @@ declare interface ListCrawlerNewsItem extends BasicArticleInfo {
// 内部文章关联id
internal_article_id: number;
}
declare interface NormalSearchParams extends ApiRequestPageParams{
// 标题
title?: string;
time_flag?: number;
}
declare interface VideoSearchParams extends ApiRequestPageParams{
// 标题
title?: string;
@ -96,11 +102,13 @@ declare interface VideoInfo {
title: string;
cover: string;
oss_video_url: string;
oss_video_mp4_url?: string;
duration: number;
article_id: number;
status: number;
publish_time?: number|string;
ctime?: number|string;
d_time?: number|string;
}
declare interface VideoListItem extends VideoInfo {
playing?: boolean;
@ -113,6 +121,7 @@ declare interface LiveVideoInfo {
video_title: string;
cover: string;
video_duration: number;
oss_video_url?: string;
video_oss_url: string;
status: number;
order_no: string;
@ -124,3 +133,23 @@ declare interface LiveState{
id: 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[];
}
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 {
id: number;
title: 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 {

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);
}
// 将时长(秒)转换成时间
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') {
if (!time) return '-';

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

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

View File

@ -4,6 +4,10 @@ import {resolve} from "path";
import AppPackage from './package.json'
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:{
[key:string]:string
} = {
@ -30,7 +34,8 @@ export default defineConfig(({mode}) => {
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',
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),
AppBuildVersion: JSON.stringify(AppPackage.name + '-' + AppPackage.version + '-' + dayjs().format('YYYYMMDDHH_mmss'))

1828
yarn.lock

File diff suppressed because it is too large Load Diff