Compare commits

...

67 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
fcf31294b7 feat(dev/main): ️更新错误页面展示内容 2025-03-10 13:45:18 +08:00
45b0912d48 🐛 fixed : 跳过异常新闻和选中新闻一致时直接关闭 2025-03-08 22:15:54 +08:00
0bf20343d0 feat: 不能删除最后一组素材 2025-03-08 21:43:01 +08:00
d782801420 🐛 feat: 更新英文推送double check notice文案 2025-03-07 19:10:31 +08:00
0dec5aa1f2 🐛 feat: 直播界面解锁排序时禁止全选操作 2025-03-02 22:51:48 +08:00
51e133b273 💄 feat: 更新回滚图标 2025-03-02 22:50:42 +08:00
54056aec3a 🐛 feat: 新增 disableRemoveMessage 属性以控制删除提示信息 2025-03-02 12:24:50 +08:00
496192061f 📝 update: update toast message 2025-03-02 12:18:04 +08:00
b65631ad9c 🐛 fixed: deselect in empty data 2025-03-02 12:17:00 +08:00
826712f910 fixed: deselect in empty data 2025-03-02 11:11:27 +08:00
90bd5cbde6 update: 跳过异常后加载新闻列表 2025-02-17 14:31:32 +08:00
3ec2ae6d0e update: 跳过异常后加载新闻列表 2025-02-15 13:02:38 +08:00
17b0348ca6 feat: 推送新闻前进行异常确认 2025-02-15 11:33:59 +08:00
227688be25 fixed ui 2025-02-14 16:55:57 +08:00
5977e1f76e fixed 2025-02-14 16:28:34 +08:00
3551601709 fixed: 直播间删除视频导致的直播状态异常 2025-02-14 15:37:14 +08:00
a3643ee9e5 feat: 失败的视频重新生成 2025-02-13 22:52:13 +08:00
1db6a1e19c update: 调整直播间时长UI 2025-02-13 17:44:05 +08:00
ac0ab41dcb fixed: 素材为空时的报错 2025-02-11 14:53:27 +08:00
ee0651fa36 feat: 添加多语言编译命令 2025-02-09 20:48:34 +08:00
43ca886b74 fixed: 更新英文文案 2025-02-09 16:11:45 +08:00
34cc9a75bb fixed: 更新中文文案 2025-02-09 15:33:47 +08:00
e1779d7923 fixed: 直播将空数据导致异常 2025-02-09 14:56:31 +08:00
098791edf4 fixed: push error message 2025-02-06 16:56:49 +08:00
8c384d5723 fixed: ts build error 2025-02-06 16:19:37 +08:00
2c1ea4a31a fixed: 视频播放时调整顺寻影响播放状态 2025-02-06 16:19:37 +08:00
829a135ef3 fixed: 不可以删除正在直播的视频;素材正文内容和图片都必须填写 2025-02-06 16:19:37 +08:00
ac7e4b1b27 fixed: 编辑内容时,当内容为空时禁止新建组 2025-02-06 16:19:37 +08:00
9eecaa4294 fixed: 调整标题样式 2025-02-06 16:19:37 +08:00
f7823b7390 fixed: 登录token失效后验证失效 2025-02-06 16:19:37 +08:00
61426ace81 feat: add coding ci script file 2025-02-05 11:13:05 +08:00
38351e6873 fixed: 英文文案调整 2025-01-26 10:03:36 +08:00
ab9e1b7e10 更新英文相关文案 2025-01-26 00:00:41 +08:00
1e3f8dc3c6 fixed: ts error 2025-01-25 16:34:44 +08:00
07d3bb2bb9 fixed: 编译无法切换语言 2025-01-25 15:02:45 +08:00
381e1f16d1 添加多语言请求接口参数 2025-01-25 11:38:12 +08:00
e022bc8036 添加并完善多语言 2025-01-24 23:57:44 +08:00
71e90e7edd Merge branch 'dev/i18n' into dev/main 2025-01-24 13:02:04 +08:00
a1bae30e2d feat: 添加多语言 2025-01-22 21:07:44 +08:00
86 changed files with 4085 additions and 1843 deletions

24
.coding-ci.yml Normal file
View File

@ -0,0 +1,24 @@
.pipeline: &pipeline
runner:
cpus: 2
docker:
image: node:20.11.1
stages:
- name: 启用yarn
script: corepack enable yarn
- name: 安装依赖
script: yarn install
- name: build
script: yarn run build
dev/main:
push:
- <<: *pipeline
main:
push:
- <<: *pipeline
release:
push:
- <<: *pipeline

1
.gitignore vendored
View File

@ -18,6 +18,7 @@ _local
.vscode/*
!.vscode/extensions.json
.idea
.ide
.DS_Store
*.suo
*.ntvs*

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

@ -22,6 +22,11 @@ npm run build
2、单独域名部署设置环境变量ONLY_LIVE=yes使用正常编译即可
#### 多语言部署
应用默认使用简体中文进行编译;如果需要指定其他语言需要设置环境变量:`APP_LANGUAGE`;
`APP_LANGUAGE`目前支持的值有:`zh-CN`(中文)、`en-US`(英语)。
**使用docker**
[x] TODO

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"/>
<link rel="icon" type="image/png" href="/logo.svg"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>数字人直播</title>
<title></title>
<style>
.app-loading-text{font-family:"PingFang SC","Microsoft YaHei",sans-serif;position:fixed;left:0;right:0;text-align:center;top:50%;transform:translateY(-50%);font-size:20px}
</style>

View File

@ -7,7 +7,10 @@
"scripts": {
"dev": "vite --host",
"dev-test": "vite --host --mode=test",
"dev-lang-en": "set APP_LANGUAGE=en-US && vite --host --mode=lang-en",
"build": "tsc && vite build",
"zh": "cross-env APP_LANGUAGE=zh-CN tsc && vite build --mode=production",
"en": "cross-env APP_LANGUAGE=en-US vite build --mode=production",
"build-test": "tsc && vite build --mode=test",
"build-relative": "tsc && vite build --mode=relative",
"build-prod": "tsc && vite build --mode=production",
@ -16,20 +19,23 @@
"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",
"clsx": "^2.1.1",
"cross-env": "^7.0.3",
"dayjs": "^1.11.11",
"file-saver": "^2.0.5",
"flv.js": "^1.6.2",
"i18next": "^25.0.1",
"jszip": "^3.10.1",
"qs": "^6.12.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.0",
"react-player": "^2.16.0",
"react-router-dom": "^6.28.0",
"sass": "^1.81.0",
@ -45,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

@ -160,11 +160,17 @@
&.disabled{
@apply border-primary-blue bg-[#f4f7fc];
}
&.status-generating{
background: rgba(209, 209, 209, 1);
}
&.status-generate-failed{
background: rgba(255, 0, 0, 0.12);
}
&.header-row{
@apply text-sm;
background: none;
.col{
@apply text-sm text-gray-800;
@apply text-base text-gray-800;
height: 42px;
}
}
@ -174,12 +180,12 @@
}
.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 {
@apply absolute;
border-right: solid 1px #e8e8e8;
border-right: solid 1px rgba(0,0,0,0.1);
content: ' ';
top: 2px;
bottom: 2px;
@ -203,7 +209,7 @@
}
.title {
@apply flex-1 text-base;
@apply flex-1;
}
.generated-time {
@ -211,8 +217,8 @@
}
.operation {
@apply flex items-center ml-2 gap-4 text-lg text-gray-400 justify-center;
width: 120px;
@apply flex items-center ml-2 text-lg text-gray-400 justify-center;
width: 180px;
padding: 0;
}
}
@ -224,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 {
@ -272,12 +251,21 @@
@apply text-right;
.btn-to-top {
@apply w-[44px] h-[44px] inline-block bg-blue-300 text-center p-0 transition hover:bg-blue-500;
min-width: 0;
min-width: 0 !important;
border-radius: 50px;
font-size: 24px;
position: absolute;
top:0;
right:0;
transform: translateY(-100%);
svg{
margin-left: 0 !important;
}
}
}
.w-min-60px{
min-width: 60px;
}
.list-scroller-container {
overflow: auto;
margin-right: -20px;
@ -329,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;
}
}
}
@ -378,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;
@ -399,7 +430,7 @@
}
.ant-modal-confirm-btns {
margin-top: 40px;
@apply mt-6;
}
}
}
@ -416,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;
@ -443,6 +498,7 @@
flex: 1;
text-align: left;
padding-left: 15px;
margin-right: 10px;
}
&:disabled {
@ -454,6 +510,19 @@
}
}
}
.lang-en-US{
.page-action {
button{
@apply min-w-[140px];
.text{
text-align: center;
}
svg{
margin-left: 5px;
}
}
}
}
// 时间选择
.timer-select-container {
@ -524,4 +593,11 @@
}
}
}
}
.modal-count-normal {
color:rgba(87, 148, 247, 1);
}
.modal-count-warning {
color:rgba(255, 0, 4, 0.85);
}

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

@ -134,7 +134,7 @@ img {
--navigation-width: 200px;
}
.container {
max-width: 98%;
max-width: 90%;
padding: 20px 0;
}

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

@ -2,11 +2,13 @@ import React from "react";
import clsx from "clsx";
import {Divider, Popconfirm} from "antd";
import {IconAdd, IconAddCircle, IconDelete, IconWarningCircle} from "@/components/icons";
import {IconAdd, IconDelete, IconWarningCircle} from "@/components/icons";
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;
@ -15,8 +17,9 @@ type Props = {
blocks: BlockContent[];
editable?: boolean;
onChange?: (blocks: BlockContent[]) => void;
disableRemoveMessage?:string;
onRemove?: () => void;
onAdd?: (index?:number) => void;
onAdd?: (index:number,checkIndex:number) => void;
errorMessage?: string;
}
@ -45,10 +48,12 @@ export default function ArticleBlock(
blocks: defaultBlocks,
editable,
onRemove,
disableRemoveMessage,
onAdd,
onChange,
index,
}: Props) {
const {t} = useTranslation()
const blocks = rebuildBlockArray(defaultBlocks)
const handleBlockChange = (index: number, block: BlockContent) => {
@ -59,7 +64,7 @@ export default function ArticleBlock(
return <div className={`${styles.blockContainer} group`}>
{editable && index == 1 && <div className={'divider-container before'}><Divider>
<span onClick={()=>onAdd?.(1)} className="article-action-add" title="新增分组"><IconAdd style={{fontSize: 24}}/></span>
<span onClick={()=>onAdd?.(1,1)} className="article-action-add" title={t('news.edit_add_group')}><IconAdd style={{fontSize: 24}}/></span>
</Divider></div> }
<div className={styles.blockInner}>
<div className={clsx(className || '', styles.block, index == 0 ? styles.blockFist : '', ' hover:bg-blue-10')}>
@ -78,26 +83,27 @@ export default function ArticleBlock(
</div>
{editable && <div className="ml-2 flex items-center">
{
index > 0 ? <Popconfirm
disableRemoveMessage? <span></span> : <Popconfirm
rootClassName={'popconfirm-main'}
placement={'left'}
arrow={false}
icon={<IconWarningCircle/>}
title={<div style={{minWidth: 150}}><span>?</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="删除"
cancelText="取消"
okText={t('delete')}
cancelText={t('cancel')}
>
<span className="article-action-icon hidden group-hover:block ml-1" title="删除此分组">
<span className="article-action-icon hidden group-hover:block ml-1" title={t('news.edit_delete_group')}>
<IconDelete style={{fontSize: 24}}/>
</span>
</Popconfirm> : <span></span>
</Popconfirm>
}
</div>}
</div>
{editable && <div className={'divider-container after'}><Divider>
<span onClick={()=>onAdd?.(index + 1)} className="article-action-add" title="新增分组"><IconAdd style={{fontSize: 24}}/></span>
<span onClick={()=>onAdd?.(index + 1,index)} className="article-action-add" title={t('news.materials.add_group')}><IconAdd style={{fontSize: 24}}/></span>
</Divider></div> }
</div>
}

View File

@ -1,16 +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 { 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 = {
@ -19,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);
}
}
@ -38,103 +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)
// 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);
}
// 验证分组数据是否合法
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;
}
return true;
}
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 [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 (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) {
// setState({msgGroup: '请输入正文文本内容'});
if(i18n.language == 'zh-CN' && tag.length > 4 ){
// 获取图文设置不正确的数据
setState({ msgGroup: t('news.edit.tag_length_error') });
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)
if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) {
setState({ msgGroup: t('news.edit_empty_human_content') });
return;
}
// 验证图文都存在时,文图是否匹配
if (!checkGroupsValid(groups)) {
// 获取图文设置不正确的数据
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,
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.data || '保存失败,请重试!'})
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 (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) {
// setState({msgGroup: '请输入正文文本内容'});
if(i18n.language == 'zh-CN' && tag.length > 4 ){
// 获取图文设置不正确的数据
setState({ msgGroup: t('news.edit.tag_length_error') });
return;
}
if(!props.id || state.generating) return;
setState({generating:true})
await article.save(title, groups[0][0].content, groups.slice(1), props.id)
if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) {
setState({ msgGroup: t('news.edit_empty_human_content') });
return;
}
// 验证图文都存在时,文图是否匹配
if (!checkGroupsValid(groups)) {
// 获取图文设置不正确的数据
setState({ msgGroup: t('news.edit_empty_group_content') });
return;
}
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('推流成功', '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}
@ -142,34 +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' ? '确定' : '重新生成'}
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 ? '' : '请输入标题内容'})
}} placeholder={'请输入文章标题'}/>
<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) ? '请输入正文文本内容' : ''});
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 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}>{state.generating?'推送中...':'生成视频'}</button> : null}
<button className="text-gray-400 hover:text-gray-800" onClick={() => props.onClose?.()}></button>
<button onClick={handleSave} className="text-gray-800 hover:text-blue-500">{props.type == 'news' ? '确定' : '重新生成'}</button>
{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('news.save_text') : t('news.edit_generate_again')}</button>
</div>
</div>
</Modal>);

View File

@ -1,30 +1,42 @@
import {Input, message} from "antd"
import {Divider, Input, message} from "antd"
import ArticleBlock from "@/components/article/block.tsx";
import styles from './article.module.scss'
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) {
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 = -1) => {
if (insertIndex !== -1 && insertIndex !== 1) {
const triggerGroup = insertIndex == -1 || insertIndex >= groups.length ? groups[groups.length - 1] : groups[insertIndex - 1];
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
const triggerGroup = groups[checkId];
// 判断
if (triggerGroup.length == 0 || triggerGroup.some(s => !s.content)) {
showToast('请先添加内容')
if (!triggerGroup || triggerGroup.length == 0 || triggerGroup.some(s => !s.content)) {
showToast(t('news.edit_notice_enter_text'))
return;
}
}
@ -36,45 +48,66 @@ 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=""></span>
<span className="text-gray-400"></span>
<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={'请输入文本内容'}
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">12123</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"}>
<span className=""></span>
<span className="text-gray-400"></span>
<span className="">{t('news.edit_other_text')}</span>
{i18n.language == 'zh-CN' && <span className="text-gray-400"></span>}
</div>
<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>}
{groups.map((g, index) => (
index == 0 ? null : <ArticleBlock
editable={editable}
@ -82,19 +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) => {
handleAddGroup?.(_index ? _index :index + 1)
onAdd={(_index, checkIndex) => {
handleAddGroup?.(_index ? _index : index + 1, checkIndex)
}}
disableRemoveMessage={groups.length <= 1 ? t('news.edit_notice_keep_1') : ''}
onRemove={async () => {
if (groups.length == 1) {
message.warning('至少保留一个内容块')
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)
}}
/>
))}
@ -102,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,11 +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 {IconAddImage} from "@/components/icons";
import {ModalWarningIcon, ModalWarningTitle} from "@/components/icons/ModalWarning.tsx";
import { BizError } from '@/service/types.ts';
type Props = {
children?: React.ReactNode;
@ -25,7 +28,7 @@ const MimeTypes = ['image/jpeg', 'image/png', 'image/jpg']
const Data: { uploadConfig?: TOSSPolicy } = {}
export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: ImageProps) {
const {t} = useTranslation()
const [loading, setLoading] = useState<number>(-1)
// oss上传文件所需的数据
const getUploadData: UploadProps['data'] = (file) => ({
@ -36,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('.'));
@ -51,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('上传图片失败,请重试', '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'}>
@ -79,10 +91,10 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
{!onlyUpload && <Popconfirm
rootClassName={'popconfirm-main'}
placement={'right'}
title={<div style={{minWidth: 150}}><span>?</span></div>}
title={<div style={{minWidth: 150}}><span>{t('upload.delete_confirm')}</span></div>}
onConfirm={onRemove}
okText="删除"
cancelText="取消"
okText={t('delete')}
cancelText={t('cancel')}
>
<span className={styles.imageDelete}><CloseOutlined/></span>
</Popconfirm>}
@ -90,7 +102,7 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
</> : <div className={styles.imagePlaceholder}>
<div className={'text-center'}>
<IconAddImage className={"text-4xl inline-block"} />
<div className={'text-sm'}></div>
<div className={'text-sm'}>{t('upload.upload_image')}</div>
</div>
</div>}
</div>
@ -103,11 +115,12 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
rootClassName={'popconfirm-main'}
placement={'right'}
arrow={false}
icon={<IconWarningCircle/>}
title={<div style={{minWidth: 150}}><span>?</span></div>}
icon={<ModalWarningIcon/>}
title={<ModalWarningTitle/>}
description={<div style={{minWidth: 150}}><span>{t('upload.delete_confirm')}</span></div>}
onConfirm={onRemove}
okText="删除"
cancelText="取消"
okText={t('delete')}
cancelText={t('cancel')}
>
<span className={styles.imageDelete}><CloseOutlined/></span>
</Popconfirm>}
@ -117,15 +130,17 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
}
export function BlockText({data, editable, onChange, isFirstBlock}: Props) {
const {t} = useTranslation()
return <div className='flex-1'>
<div
className={clsx(styles.text, isFirstBlock ? 'border-red-400 hover:border-red-500 focus-within:border-red-500' : '')}>
{editable ? <div className="relative">
{/*请输入文本内容*/}
<Input.TextArea
onChange={e => {
onChange?.({type: 'text', content: e.target.value})
}}
placeholder={'请输入文本内容'} value={data.content} autoSize={{minRows: 4, maxRows: 5}}
placeholder={t('news.edit_notice_enter_article_content')} value={data.content} autoSize={{minRows: 4, maxRows: 5}}
variant={"borderless"}/>
</div> : <p className="p-2">{data.content}</p>}
</div>

View File

@ -6,6 +6,8 @@ import {showErrorToast, showToast} from "@/components/message.ts";
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[],
@ -19,7 +21,7 @@ type Props = {
children?: React.ReactNode;
title?: React.ReactNode;
className?: string;
onError?: (e: Error | BizError) => void;
}
/**
*
@ -27,8 +29,9 @@ type Props = {
export default function ButtonBatch(
{
selected, emptyMessage, successMessage, children, icon,
title, confirmMessage, onProcess, onSuccess, className
title, confirmMessage, onProcess, onSuccess, className, onError
}: Props) {
const {t} = useTranslation()
const [loading, setLoading] = useState(false)
const {modal} = App.useApp()
const onBatchProcess = async () => {
@ -40,7 +43,8 @@ export default function ButtonBatch(
onSuccess()
}
} catch (e) {
showErrorToast(e as unknown as BizError)
const _e = e as unknown as BizError
onError?onError(_e) : showErrorToast(_e)
} finally {
setLoading(false)
}
@ -54,14 +58,16 @@ export default function ButtonBatch(
if(confirmMessage){
modal.confirm({
wrapClassName: 'root-modal-confirm',
title: title || '操作提示',
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{
onBatchProcess().catch(showErrorToast);
onBatchProcess().catch(onError || showErrorToast);
}
}

View File

@ -2,13 +2,13 @@ import React, {useEffect} from "react";
type DocumentTitleProps = {
children?: string;
text?: string;
title?: string;
}
export const DocumentTitle: React.FC<DocumentTitleProps> = ({children, text}) => {
export const DocumentTitle: React.FC<DocumentTitleProps> = ({children, title}) => {
useEffect(() => {
if (text || children) {
document.title = text || children || '';
if (title || children) {
document.title = title || children || '';
}
}, []);
}, [title,children]);
return <></>
}

View File

@ -2,6 +2,7 @@ import React, {useEffect, useMemo, useRef} from "react";
import {Checkbox, Popover} from "antd";
import {useBoolean, useClickAway} from "ahooks";
import {CaretUpOutlined} from "@ant-design/icons";
import {useTranslation} from "react-i18next";
type ValueType = Id[][];
type ValueFunc = (prev:ValueType)=>ValueType;
@ -101,6 +102,7 @@ const TagSelect = (props: {
return parentList.findIndex(s => s[1] == item.value) != -1;
}
const {t} = useTranslation()
const ref = useRef<HTMLDivElement|null>(null)
useClickAway(()=>{
set(false)
@ -120,7 +122,7 @@ const TagSelect = (props: {
set(!visible)
}}
>
<span>{checkedAll || selectValues.length == 0 ? '全部来源' : '来源'}</span>
<span>{(checkedAll || selectValues.length == 0) ? t('news.news_all_source') : t('news.source')}</span>
<CaretUpOutlined className={`ml-2 arrow-icon ${visible ? 'rotate-0' : 'rotate-180'}`}/>
</div>
<div className={`options-list-container absolute ${visible ? 'block' : 'hidden'}`}>
@ -129,7 +131,7 @@ const TagSelect = (props: {
<li className="select-option-item relative">
<div className="option-value whitespace-nowrap flex justify-between">
<span className="text-center flex-1"
onClick={() => handleAllChanged(!checkedAll)}></span>
onClick={() => handleAllChanged(!checkedAll)}>{t('news.news_all_source')}</span>
<Checkbox className="ml-6" checked={checkedAll}
onChange={e => handleAllChanged(e.target.checked)}/>
</div>

View File

@ -1,5 +1,6 @@
import {useMemo, useState} from "react";
import {CaretUpOutlined} from "@ant-design/icons";
import {useTranslation} from "react-i18next";
export type TimeSelectProps = {
value: number;
@ -10,33 +11,35 @@ type OptionItem = {
label: string;
value: number;
}
const AllTimeOption: OptionItem[] = [
{
label: '半小时内',
value: 1
},
{
label: '一小时内',
value: 2
},
{
label: '四小时内',
value: 3
},
{
label: '一天内',
value: 4
},
{
label: '近一周',
value: 5
},
{
label: '所有时间',
value: 0
}
]
const TimeSelect = (props: TimeSelectProps) => {
const {t,i18n} = useTranslation();
const AllTimeOption: OptionItem[] = useMemo(()=>([
{
label: t('time_filter.past_30_min'),
value: 1
},
{
label: t('time_filter.past_hour'),
value: 2
},
{
label: t('time_filter.past_4_hour'),
value: 3
},
{
label: t('time_filter.past_24_hour'),
value: 4
},
{
label: t('time_filter.last_week'),
value: 5
},
{
label: t('time_filter.all'),
value: 0
}
]),[i18n.language])
const selectLabel = useMemo(() => {
return AllTimeOption.find(item => item.value == props.value)?.label || ''
}, [props.value])

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">
@ -118,7 +156,15 @@ export const IconAddCircle = ({style, className}: IconProps) => (
fill="currentColor"/>
</svg>
)
export const IconRollbackCircle = ({style, className}: IconProps) => (
<svg className={`svg-icon ${className || ''} icon-warning`} style={style} xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em" viewBox="0 0 21 21" version="1.1">
<g>
<path d="M6.35192 0.404738C6.35236 0.404738 6.35272 0.4051 6.35272 0.405547V3.78207H12.3726C14.6365 3.78177 16.8099 4.66937 18.4238 6.25343C20.0378 7.8375 20.963 9.99107 21 12.2496V12.3918C20.9999 14.6502 20.1103 16.818 18.5231 18.428C16.9359 20.0379 14.7782 20.9611 12.5151 20.9984L12.3726 21H4.16146C4.05408 21 3.9511 20.9574 3.87516 20.8817C3.79923 20.8059 3.75657 20.7032 3.75657 20.596V18.9801C3.75657 18.8729 3.79923 18.7702 3.87516 18.6944C3.9511 18.6186 4.05408 18.5761 4.16146 18.5761H12.3726C14.0164 18.5932 15.5998 17.9581 16.7743 16.8105C17.9488 15.6628 18.6183 14.0966 18.6354 12.4565C18.6526 10.8163 18.0161 9.23653 16.8659 8.06464C15.7157 6.89274 14.146 6.22475 12.5022 6.20761H0.405711C0.324927 6.20777 0.24594 6.18382 0.178909 6.13883C0.111878 6.09384 0.0598667 6.02988 0.0295641 5.95516C-0.000738426 5.88044 -0.00794696 5.79839 0.00886551 5.71955C0.025678 5.64071 0.0657426 5.56869 0.123908 5.51275L5.66279 0.115483C5.71978 0.0597486 5.792 0.0220586 5.87039 0.00714763C5.94878 -0.00776331 6.02983 0.000769782 6.10338 0.031675C6.17692 0.0625803 6.23967 0.114479 6.28374 0.180854C6.32767 0.247003 6.35109 0.324588 6.35111 0.403927C6.35111 0.404375 6.35147 0.404738 6.35192 0.404738Z" fill="#B6A8AB"/>
</g>
</svg>
)
export const IconWarningCircle = ({style, className}: IconProps) => (
<svg className={`svg-icon ${className || ''} icon-warning`} style={style} xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em" viewBox="0 0 22 22" version="1.1">
@ -162,12 +208,31 @@ 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-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-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-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>
)
export const IconPlay = ({style, className}: IconProps) => (
<svg className={`svg-icon ${className || ''} icon-delete`} style={style} xmlns="http://www.w3.org/2000/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

@ -1,5 +1,6 @@
import React from "react";
import useConfig from "@/hooks/useConfig.ts";
import {useTranslation} from "react-i18next";
const AppLogo = ({style}: { style?: React.CSSProperties, theme?: 'origin' | 'color' }) => {
return (
@ -21,11 +22,11 @@ const AppLogo = ({style}: { style?: React.CSSProperties, theme?: 'origin' | 'col
)
}
export const LogoText = ({style, className}: { style?: React.CSSProperties, className?: string }) => {
const {appName} = useConfig()
const {t} = useTranslation()
return (
<div className={`flex h-full ${className}`}>
<AppLogo style={style}/>
<span className={'ml-2 text-lg relative top-1'}>{appName}</span>
<span className={'ml-2 text-lg relative top-1'}>{t('AppTitle')}</span>
</div>
)
}

View File

@ -12,7 +12,7 @@ export function showToast(content: string, type?: 'success' | 'info' | 'warning'
}
export function showErrorToast(e: Error | BizError) {
showToast(String(((e instanceof BizError) ? e.data : '') || e.message), 'error')
showToast(String(e.message), 'error')
}

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

@ -4,13 +4,27 @@ import React, {useEffect} from "react";
import {Checkbox, Popconfirm} from "antd";
import ImageCover from '@/assets/images/cover.png'
import {IconDelete, IconEdit, IconPlaying, IconWarningCircle} from "@/components/icons";
import {
IconDelete, IconDownloadOutline,
IconEdit,
IconGenerateFailed,
IconGenerating,
IconPlaying, IconRegenerate, IconRollbackCircle,
IconWarningCircle
} from "@/components/icons";
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,
additionOperationBefore?: React.ReactNode;
additionOperationAfter?: React.ReactNode;
editable?: boolean;
downloadUrl?: string;
sortable?: boolean;
index?: number;
checked?: boolean;
@ -19,8 +33,11 @@ type Props = {
onCheckedChange?: (checked: boolean) => void;
onPlay?: () => void;
onEdit?: () => void;
onRegenerate?: () => void;
hideCheckBox?: boolean;
onItemClick?: () => void;
onRemove?: () => void;
onRemove?: (action?: 'delete' | 'rollback') => void;
removeIcon?: React.ReactNode;
id: number;
className?: string;
type?: 'live' | 'create'
@ -28,54 +45,83 @@ type Props = {
export const VideoListItem = (
{
id, video, onRemove, checked,playing,
onCheckedChange, onEdit, active, editable,
className, sortable, type, index,onItemClick
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,i18n} = useTranslation()
const [state, setState] = useSetState<{ checked?: boolean }>({})
useEffect(() => {
setState({checked})
}, [checked])
const generating = (type == 'create' && video.status == VideoStatus.Generating )
const generating = (type == 'create' && video.status == VideoStatus.Generating)
const failed = (type == 'create' && (video.status != VideoStatus.Generating && video.status != VideoStatus.Generated))
const 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 ? 'disabled' : ''} ${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 inset-0 bg-black/30 text-white flex items-center justify-center'}>
<span className="ml-1"></span>
</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>
}
{/* && active*/}
{!generating && playing && <div className={'absolute rounded inset-0 bg-black/30 text-sm text-white flex items-center justify-center'}>
<div className="text-center">
<IconPlaying className="inline-block text-xl" />
<div></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}
@ -83,42 +129,63 @@ export const VideoListItem = (
</div>
<div
className="col generated-time"
{... (sortable && !generating?listeners:{})}
{... (sortable && !generating?attributes:{})}
>{video.ctime ? formatTime(video.ctime,'min') : '-'}</div>
{...(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 gap-4"}>
{editable && !generating && <>
{onEdit &&
<button className="hover:text-blue-500" onClick={e=>{
<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()
onEdit?.()
}} style={{fontSize: '1.1em'}}>
<IconEdit/>
</button>}
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'}} 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={'你确定要删除此视频吗?'}
// description={`删除后需从重新${type == 'create' ? '生成' : '推流'}`}
onConfirm={onRemove}
><button className="hover:text-blue-500"><IconDelete/></button></Popconfirm>}
<Checkbox checked={state.checked} onChange={() => {
if (onCheckedChange) {
onCheckedChange(!state.checked)
} else {
setState({checked: !state.checked})
}
}} />
</>}
{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>

View File

@ -3,6 +3,12 @@ import React, {createContext, useEffect, useReducer} from "react";
import Loader from "@/components/loader";
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';
@ -39,15 +45,35 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
if (token) {
const result = localStorage.getItem(AppConfig.AUTHED_PERSON_DATA_KEY)
if (result) {
const user = JSON.parse(result) as UserProfile
dispatch({
payload: {
isInitialized: true,
isLoggedIn: true,
user,
token
// 验证用户token是否正确
try{
// 获取一个分类接口进行验证token是否有效
await getAllCategory();
const user = JSON.parse(result) as UserProfile
dispatch({
payload: {
isInitialized: true,
isLoggedIn: true,
user,
token
}
})
} catch (e) {
const err = e as BizError;
if (err.code == 1001) {
// token失效
setAuthToken(null)
dispatch({
payload: {
isInitialized: true,
isLoggedIn: false,
user: null,
token: null
}
})
return 'token was invalid'
}
})
}
return 'initialized'
}
}

View File

@ -1,5 +1,5 @@
import useLocalStorage from "@/hooks/useLocalStorage";
import {createContext} from "react";
import React, {createContext} from "react";
const config: ConfigProps = {
fontFamily: `'Public Sans', sans-serif`,
@ -21,7 +21,7 @@ const initialState: CustomizationProps = {
const ConfigContext = createContext(initialState);
export const ConfigProvider = ({children}: { children: React.ReactNode }) => {
const [config, setConfig] = useLocalStorage('app-payment-config', initialState);
const [config, setConfig] = useLocalStorage('app-video-admin-config', initialState);
// 改变语言
const onChangeLocalization = (lang: I18n) => {
setConfig({

View File

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

View File

@ -0,0 +1,13 @@
const globalConfig:{
i18n?:I18n
} = {
}
function useGlobalConfig(){
return {
globalConfig
}
}
export default useGlobalConfig;

15
src/i18n/config.ts Normal file
View File

@ -0,0 +1,15 @@
import i18next from 'i18next';
import {initReactI18next} from 'react-i18next';
import LangEN from './translations/en-US.json';
import LangCN from './translations/zh-CN.json';
console.log('AppConfig',AppMode,AppConfig)
i18next.use(initReactI18next).init({
debug: true,
lng: (AppConfig.APP_LANG || 'zh-CN').trim(),
fallbackLng: 'en-US',
resources: {
'en-US': {translation:LangEN},
'zh-CN': {translation:LangCN},
},
});

0
src/i18n/index.ts Normal file
View File

View File

@ -0,0 +1,232 @@
{
"AppTitle": "AI Livesteam",
"Hello": "Hello",
"cancel": "Cancel",
"close": "Close",
"confirm": {
"ok": "Confirm",
"push_title": "Push Notice",
"push_video": "Are you sure editing selected news?",
"title": "Notice"
},
"confirm_text": "Confirm",
"delete": "Delete",
"delete_batch": "Delete Select",
"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": "Recycle Bin"
},
"history.pushed": "Streaming: {{count}}",
"live": {
"duration": "Duration",
"edit_locked": "Locked",
"edit_unlock": "Unlock",
"play_first": "Play first video",
"playlist_count": "{{count}} videos in total",
"title": "Livestream"
},
"loading": "Loading...",
"login": {
"code_sending": "Sending...",
"invalid_username_or_pwd": "Invalid phone number or code",
"loading": "Login...",
"password": "Enter the verification code",
"send_sms_code": "Send code",
"text": "Sign in",
"title": "Sign in",
"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",
"live": "Streaming",
"materials": "News"
},
"news": {
"delete_confirm": "Are you sure you want to delete this item?",
"delete_confirm_count": "Are you sure you want to delete these {{count}} items?",
"delete_description": "This item will be deleted.<br/>It can be recovered from the \"News\" page. ",
"delete_description_count": "These items will be deleted.<br/>They can be recovered from the \"News\" page. ",
"delete_empty": "Please select the items to delete",
"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": "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",
"edit_generate_video": "Generating",
"edit_generate_video_again": "Regenerate",
"edit_notice_enter_article_content": "Please enter content",
"edit_notice_enter_article_title": "Please enter title",
"edit_notice_enter_article_title1": "Please enter news title",
"edit_notice_enter_text": "Please enter content",
"edit_notice_keep_1": "Keep at least one content block",
"edit_other_text": "Other media Material",
"edit_save_failed": "Save failed!",
"editing": "Editing",
"filter_all": "All",
"filter_source": "News source",
"generate_video": "Generating",
"get_detail": "Get news details",
"get_detail_error": "Get new details failed",
"image_count": "Images",
"materials": {
"add_group": "Add Group",
"title": "News Materials"
},
"news_all_source": "All",
"push_empty": "please select the news to edit",
"push_failed": "Failed to editing",
"push_stream_empty": "please select the news to streaming",
"push_stream_success": "Success",
"push_streaming": "Pushing...",
"push_success": "Push success",
"push_to_edit": "Editing",
"pushed": "Editing",
"save_text": "Save",
"search_key_title": "Please enter title keywords",
"source": "Source",
"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",
"selected": "Selected",
"selected_some": "Selected: {{count}}",
"text": "Select",
"total": "Total: {{count}}"
},
"service_error": "Service exception, please contact customer support.",
"time_filter": {
"all": "All",
"last_week": "Last week",
"past_24_hour": "Past 24 hour",
"past_30_min": "Past 30 min",
"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": {
"logout": "Logout"
},
"video": {
"delete_confirm": "The video will be deleted.<br/> It can be recovered from the \"News\" page. ",
"delete_confirm_count": "These videos will be deleted.<br/> They can be recovered from the \"News\" page. ",
"delete_confirm_title": "Are you sure you want to delete this video?",
"delete_description": "Are you sure you want to delete the video?",
"delete_description_count": "Are you sure you want to delete these {{count}} videos?",
"delete_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",
"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",
"sort_modify_rollback": "Exit and video sequence restored!",
"sort_modify_success": "Video sequence changed",
"title": "Title",
"title_generated_time": "Time stamp",
"title_operation": "",
"title_thumb": "Cover"
}
}

View File

@ -0,0 +1,232 @@
{
"AppTitle": "数字人直播",
"Hello": "你好",
"cancel": "取消",
"close": "关闭",
"confirm": {
"ok": "确定",
"push_title": "推流提示",
"push_video": "是否确定一键推流选中新闻视频?",
"title": "提示"
},
"confirm_text": "确定",
"delete": "删除",
"delete_batch": "批量删除",
"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": "回收站"
},
"history.pushed": "已推送 {{count}} 条",
"live": {
"duration": "时长",
"edit_locked": "锁定状态不可排序",
"edit_unlock": "已解锁",
"play_first": "即将播放第一条视频",
"playlist_count": "当前播放列表共 {{count}} 条",
"title": "直播界面"
},
"loading": "加载中...",
"login": {
"code_sending": "发送中",
"invalid_username_or_pwd": "账号或密码错误",
"loading": "登录中...",
"password": "请输入验证码",
"send_sms_code": "获取验证码",
"text": "立即登录",
"title": "登录",
"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": "视频生成",
"live": "数字人直播间",
"materials": "新闻素材"
},
"news": {
"delete_confirm": "您确定要删除吗?",
"delete_confirm_count": "你确定要删除选择的 {{count}} 条新闻吗?",
"delete_description": "删除后需从新闻素材中重新选择",
"delete_description_count": "删除后需从新闻素材中重新选择",
"delete_empty": "请选择要删除的新闻",
"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_human_content": "请先填写数字人播报内容",
"edit_form_search": "请输入新闻标题关键词进行搜索",
"edit_generate_again": "重新生成",
"edit_generate_video": "生成视频",
"edit_generate_video_again": "重新生成",
"edit_notice_enter_article_content": "请输入正文文本内容",
"edit_notice_enter_article_title": "请输入文章标题",
"edit_notice_enter_article_title1": "请输入标题内容",
"edit_notice_enter_text": "请先填写当前素材组",
"edit_notice_keep_1": "至少保留一个内容块",
"edit_other_text": "素材融合呈现编辑区",
"edit_save_failed": "保存失败,请重试!",
"editing": "新闻编辑",
"filter_all": "全部",
"filter_source": "新闻来源",
"generate_video": "生成视频",
"get_detail": "获取新闻详情",
"get_detail_error": "获取新闻详情失败",
"image_count": "图片数",
"materials": {
"add_group": "新增分组",
"title": "新闻素材"
},
"news_all_source": "全部来源",
"push_empty": "请选择要推入编辑的新闻",
"push_failed": "推送失败",
"push_stream_empty": "请选择要开播的新闻",
"push_stream_success": "推流成功",
"push_streaming": "推流中...",
"push_success": "推送成功",
"push_to_edit": "推入编辑",
"pushed": "已推送",
"save_text": "保存",
"search_key_title": "请输入新闻标题关键词进行搜索",
"source": "来源",
"title": "标题",
"title_image_count": "图片数",
"title_operate": "操作",
"title_time": "时间",
"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": "全选",
"selected": "已选",
"selected_some": "已选 {{count}} 条",
"text": "选择",
"total": "总共 {{count}} 条"
},
"service_error": "新闻异常,无法生成,请咨询客服",
"time_filter": {
"all": "所有时间",
"last_week": "近一周",
"past_24_hour": "一天内",
"past_30_min": "半小时内",
"past_4_hour": "四小时内",
"past_hour": "一小时内"
},
"title": "标题",
"upload": {
"delete_confirm": "请确认删除此图片?",
"upload_failed": "上传图片失败,请重试",
"upload_file_type_error": "仅支持上传图片",
"upload_image": "上传图片"
},
"user": {
"logout": "退出登录"
},
"video": {
"delete_confirm": "删除后需重新生成视频",
"delete_confirm_count": "删除后需重新生成视频",
"delete_confirm_title": "你确定要删除此视频吗 ",
"delete_description": "已选择{{count}}条,确定要全部删除吗?",
"delete_description_count": "已选择{{count}}条,确定要全部删除吗?",
"delete_empty": "请选择要删除的视频",
"delete_forever_confirm": "是否彻底删除选中的视频? <br />这些视频将无法找回",
"delete_forever_confirm_count": "是否彻底删除选中的视频? <br />这些视频将无法找回!",
"download": "下载视频",
"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": "已完成直播队列的修改",
"sort_modify_rollback": "退出并恢复之前的直播队列!",
"sort_modify_success": "调整视频顺序成功",
"title": "标题",
"title_generated_time": "生成时间",
"title_operation": "操作",
"title_thumb": "缩略图"
}
}

View File

@ -1,5 +1,5 @@
import ReactDOM from 'react-dom/client'
import '@/i18n/config.ts'
import App from './App.tsx'
import '@/assets/index.scss'

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'

View File

@ -1,34 +1,39 @@
import React, {useEffect, useMemo, useRef, useState} from "react";
import {Checkbox, Empty, Modal, Space} from "antd";
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
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 {deleteByIds, getList, modifyOrder, playState} from "@/service/api/live.ts";
import {deleteByIds, getList, modifyOrder, playState, restoreByIds} from "@/service/api/live.ts";
import {showErrorToast, showToast} from "@/components/message.ts";
import ButtonBatch from "@/components/button-batch.tsx";
import FlvJs from "flv.js";
import {formatDuration} from "@/util/strings.ts";
import {useSetState} from "ahooks";
import {Player, PlayerInstance} from "@/components/video/player.tsx";
import {IconDelete, IconLocked, IconUnlock} from "@/components/icons";
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 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() {
export default function LiveIndex() {
const {t} = useTranslation()
const player = useRef<PlayerInstance | null>(null)
const [videoData, setVideoData] = useState<LiveVideoInfo[]>([])
const [modal, contextHolder] = Modal.useModal()
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
const [editable, setEditable] = useState<boolean>(false)
const scrollerRef = useRef<InfiniteScrollerRef | null>(null)
const [rollbackIds,setRollbackIds] = useState<Id[]>([])
const [delIds,setDelIds] = useState<Id[]>([])
const [state, setState] = useSetState({
activeIndex: -1,
playId:-1,
muted: true,
showToTop: false,
checkedAll: false,
@ -36,15 +41,16 @@ export default function LiveIndex() {
playProgress: 0,
loading:false
})
const activeIndex = useRef(state.activeIndex)
const activeIndex = useRef(-1)
useEffect(() => {
activeIndex.current = state.activeIndex
}, [state.activeIndex])
activeIndex.current = videoData.findIndex(s=>s.id == state.playId)
}, [state.playId,videoData])
const showVideoItem = (index: number) => {
// 显示当前播放视频对应 view item
const showVideoItem = (index: number,id: number) => {
// 找到对应video item 并显示在视图可见区域
const container = document.querySelector('.live-video-list-sort-container')
const item = document.querySelector(`.list-item-${index}`)
const item = document.querySelector(`.list-item-${id}`)
if (item && container) {
// 获取容器数据
const containerRect = container.getBoundingClientRect()
@ -59,25 +65,30 @@ export default function LiveIndex() {
})
}
}
const activeToNext = (index?: number) => {
// 播放下一个视频
const activeToNext = useCallback( (index?: number) => {
const endToFirst = index != undefined && index > -1 ? false : activeIndex.current >= videoData.length - 1
const _activeIndex = index != undefined && index > -1 ? index : (endToFirst ? 0 : activeIndex.current + 1)
setState({activeIndex: _activeIndex})
const playVideo = videoData[_activeIndex];
setState({playId: playVideo.id});
if (endToFirst) {
showToast('即将播放第一条视频');
showToast(t("live.play_first"));
}
// 找到对应video item 并显示在视图可见区域
showVideoItem(_activeIndex)
showVideoItem(_activeIndex,playVideo.id)
return _activeIndex;
}
}, [videoData, activeIndex])
// 播放视频
const playVideo = (video: LiveVideoInfo, liveState: LiveState) => {
if (player.current && video.video_oss_url) {
if (cache.timerPlayNext) clearTimeout(cache.timerPlayNext)
const duration = Math.ceil(video.video_duration / 1000)
// 计算已经播放时间
const playedTime = (Date.now() / 1000 >> 0) - liveState.live_start_time
if (playedTime < 0 || playedTime > duration) { // 已播放时间大于总时长了
//initPlayingState() // 重新获取播放状态
console.log('已播放时间大于总时长')
cache.timerLoadState = setTimeout(initPlayingState, 5000)
return;
}
player.current?.play(video.video_oss_url, playedTime)
@ -88,19 +99,24 @@ export default function LiveIndex() {
}
}
// 初始化播放状态
const initPlayingState = () => {
player.current?.pause();
if (cache.timerLoadState) clearTimeout(cache.timerLoadState)
if (videoData.length == 0) {
if (!videoData || videoData.length == 0) {
cache.timerLoadState = setTimeout(initPlayingState, 1000)
return;
}
playState().then(liveState => {
// 获取当前播放视频
const video = videoData.find(v => v.id === liveState.id)
if (video) {
// 开始播放
activeToNext(videoData.findIndex(v => v.id === liveState.id))
playVideo(video, liveState)
} else {
setState({activeIndex: -1})
setState({playId: -1})
cache.timerLoadState = setTimeout(initPlayingState, 5000)
}
});
@ -141,76 +157,103 @@ export default function LiveIndex() {
}
}, [])
// 删除视频
const processDeleteVideo = async (ids: Id[]) => {
deleteByIds(ids).then(() => {
showToast('删除成功!', 'success')
loadList()
}).catch(showErrorToast)
// 临时记录删除的id
setDelIds(_=>[...ids,..._])
// deleteByIds(ids).then(() => {
// showToast(t('delete_success'), 'success')
// loadList()
// }).catch(showErrorToast)
}
const handleConfirm = () => {
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) {
setEditable(true)
return;
}
const newSort = videoData.map(s => s.id).join(',')
if (newSort == state.originSort) {
setEditable(false)
return;
}
modal.confirm({
title: '提示',
content: '是否采纳移动视频位置操作?',
centered: true,
onOk: () => {
//showToast('编辑成功!!!', 'info');
modifyOrder(videoData.map(s => s.id)).then(() => {
showToast('已完成直播队列的修改!', 'success')
setEditable(false)
}).catch(() => {
showToast('调整视频顺序失败,请重试!', 'warning')
})
},
onCancel: () => {
showToast('退出并恢复之前的直播队列!', 'info');
loadList()
setEditable(false)
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;
// 计算总时长
return videoData.reduce((sum, v) => sum + Math.ceil(v.video_duration / 1000), 0);
}, [videoData])
// 根据当前播放index计算已经播放时长
const currentTotalDuration = useMemo(() => {
if (state.activeIndex == -1 || !videoData || videoData.length == 0) return 0;
// 计算总时长
return videoData
.filter((_, index) => (index < state.activeIndex))
.reduce((sum, v) => sum + Math.ceil(v.video_duration / 1000), 0) + state.playProgress
;
}, [videoData, state.playProgress])
const currentVideoDuration = useMemo(()=>{
const video = videoData.find(s=>s.id == state.playId)
return (video?.video_duration || 0) / 1000;
},[state.playId, videoData])
// // 根据当前播放index计算已经播放时长
// const currentTotalDuration = useMemo(() => {
// if (state.activeIndex == -1 || !videoData || videoData.length == 0) return 0;
// // 计算总时长
// return videoData
// .filter((_, index) => (index < state.activeIndex))
// .reduce((sum, v) => sum + Math.ceil(v.video_duration / 1000), 0) + state.playProgress
// ;
// }, [state.activeIndex, state.playProgress, videoData])
//
// const currentSelectedId = useMemo(() => {
// if (state.activeIndex < 0 || state.activeIndex >= videoData.length) return [];
// const currentId = videoData[state.activeIndex];
// return checkedIdArray.filter(id => currentId.id != id)
// }, [checkedIdArray, state.activeIndex])
const currentSelectedId = useMemo(() => {
if (state.activeIndex < 0 || state.activeIndex >= videoData.length) return [];
const currentId = videoData[state.activeIndex];
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 items-center">
<div>
<div className="text-center text-base text-gray-400"></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-[360px] h-[636px]"
style={{backgroundColor: 'hsl(210, 100%, 48%)'}}>
@ -224,44 +267,46 @@ export default function LiveIndex() {
</div>
</div>
<div className="text-center text-sm mt-4 text-gray-400">
<span>: {formatDuration(currentTotalDuration)} / {formatDuration(totalDuration)}</span>
<span>{t('live.duration')}: {formatDuration(state.playProgress)} / {formatDuration(currentVideoDuration)}</span>
</div>
</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>*/}
<span> {videoData.length} </span>
<span>{t('live.playlist_count',{count:videoData.length})}</span>
</Space>
</div>
<div className="flex items-center">
<div className={'flex items-center text-gray-400 cursor-pointer select-none'}
onClick={handleConfirm}>
<span>{editable ? '已解锁' : '锁定状态不可排序'}</span>
<span className="ml-2 text-sm">
{editable ? <IconUnlock/> : <IconLocked/>}
</span>
<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 className="hover:text-blue-300 text-gray-400"
<button disabled={editable} className={`${editable?'':'hover:text-blue-300'} text-gray-400`}
onClick={handleAllCheckedChange}>
<span className="text-sm mr-2 whitespace-nowrap"></span>
<span className="text-sm mr-2 whitespace-nowrap">{t('select.select_all')}</span>
{/*<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>*/}
</button>
<Checkbox checked={state.checkedAll} onChange={() => handleAllCheckedChange()}/>
<Checkbox disabled={!editable} checked={state.checkedAll} onChange={() => handleAllCheckedChange()}/>
</div>
</div>
</div>
<div className="list-header">
<div className="list-row header-row">
<div className="col number">No.</div>
<div className="col cover"></div>
<div className="col title"></div>
<div className="col generated-time"></div>
<div className="col operation"></div>
<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="">
@ -287,15 +332,15 @@ export default function LiveIndex() {
}
}}>
<SortableContext items={videoData}>
{videoData.map((v, index) => (
{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.activeIndex == index}
playing={state.activeIndex == index}
className={`list-item-${index} mt-3 mb-2`}
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);
@ -306,12 +351,24 @@ export default function LiveIndex() {
// })
}}
onRemove={() => processDeleteVideo([v.id])}
editable={!editable && state.activeIndex != index}
sortable={editable && state.activeIndex != index}
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>
@ -319,18 +376,20 @@ export default function LiveIndex() {
</div>
<div className="page-action">
<ButtonToTop visible={state.showToTop} onClick={() => scrollerRef.current?.scrollToPosition(0)}/>
{checkedIdArray.length > 0 && <ButtonBatch
{currentSelectedVideoIds.length > 0 && <ButtonBatch
className='bg-gray-300 hover:bg-gray-400 text-white'
selected={checkedIdArray}
emptyMessage={`请选择要删除的视频`}
confirmMessage={`是否删除当前的${checkedIdArray.length}条视频?`}
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'}></span>
<span className={'text'}>{t('delete_batch')}</span>
<IconDelete/>
</ButtonBatch>}
</div>
{contextHolder}
</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

@ -3,17 +3,21 @@ import {showToast} from "@/components/message.ts";
import React, {useState} from "react";
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()
const {modal} = App.useApp();
const [loading, setLoading] = useState(false)
const handlePush = () => {
setLoading(true)
deleteByIds(props.ids).then(() => {
props.onSuccess?.();
showToast('删除成功', 'success')
showToast(t('delete_success'), 'success')
}).catch(() => {
showToast('删除失败', 'error')
showToast(t('delete_failed'), 'error')
}).finally(() => {
setLoading(false)
})
@ -21,14 +25,17 @@ export default function ButtonDeleteBatch(props: { ids: Id[];onSuccess?: () => v
const onPushClick = () => {
if(loading) return;
if (props.ids.length === 0) {
showToast('请选择要删除的新闻', 'warning')
showToast(t('news.delete_empty'), 'warning')
return
}
modal.confirm({
wrapClassName:'root-modal-confirm',
icon: <span className="anticon anticon-exclamation-circle"><IconWarningCircle/></span>,
title: `你确定要删除选择的 ${props.ids.length} 条新闻吗?`,
content: '删除后需从新闻素材中重新选择',
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
})
@ -40,7 +47,7 @@ export default function ButtonDeleteBatch(props: { ids: Id[];onSuccess?: () => v
onClick={onPushClick}
className='bg-gray-300 hover:bg-gray-400 text-white'
>
<span className={'text'}></span>
<span className={'text'}>{t('delete_batch')}</span>
<IconDelete className=""/>
</button>
</div>

View File

@ -7,6 +7,7 @@ import {getById} from "@/service/api/news.ts";
import {showToast} from "@/components/message.ts";
import {IconDownload} from "@/components/icons";
import {useTranslation} from "react-i18next";
/**
@ -63,11 +64,12 @@ async function downloadAsZip(list: NewsInfo[]) {
}
export default function ButtonNewsDownload(props: { ids: Id[] }) {
const {t} = useTranslation()
const [loading, setLoading] = useState(false)
const onDownloadClick = async (ids: Id[]) => {
if(loading) return;
if (props.ids.length === 0) {
showToast('请选择要下载的新闻', 'warning')
showToast(t('news.download_empty'), 'warning')
return
}
setLoading(true)
@ -75,7 +77,7 @@ export default function ButtonNewsDownload(props: { ids: Id[] }) {
const list = await getAllNewsContent(ids)
await downloadAsZip(list)
} catch (e) {
showToast('下载新闻失败,请重试!', 'error')
showToast(t('news.download_failed'), 'error')
} finally {
setLoading(false)
}
@ -86,7 +88,7 @@ export default function ButtonNewsDownload(props: { ids: Id[] }) {
className={'btn-action bg-[#eef5ff] text-gray-800 hover:bg-[#d2e3ff]'}
onClick={() => onDownloadClick(props.ids)}
>
<span className="text"></span>
<span className="text">{t('download')}</span>
<IconDownload />
</button>
)

View File

@ -4,20 +4,22 @@ import {push2article} from "@/service/api/news.ts";
import {IconArrowRight} from "@/components/icons";
import {useNavigate} from "react-router-dom";
import {useIndexArrayCache} from "@/hooks/useCache.ts";
import {useTranslation} from "react-i18next";
export default function ButtonPushNews2Article(props: { ids: Id[]; }) {
// const {modal} = App.useApp();
const {t}= useTranslation()
const [loading,setLoading] = useState(false)
const navigate = useNavigate();
const {set} = useIndexArrayCache();
const handlePush = () => {
setLoading(true)
push2article(props.ids).then(() => {
showToast('推送成功', 'success')
showToast(t('news.push_success'), 'success')
set([])
navigate('/edit')
}).catch(() => {
showToast('推送失败', 'error')
showToast(t('news.push_failed'), 'error')
}).finally(() => {
setLoading(false)
})
@ -25,7 +27,7 @@ export default function ButtonPushNews2Article(props: { ids: Id[]; }) {
const onPushClick = () => {
if(loading) return;
if (props.ids.length === 0) {
showToast('请选择要推入编辑的新闻', 'warning')
showToast(t('news.push_empty'), 'warning')
return
}
handlePush();
@ -42,7 +44,7 @@ export default function ButtonPushNews2Article(props: { ids: Id[]; }) {
onClick={onPushClick}
className='bg-[#4096ff] hover:bg-blue-600 text-white'
>
<span className={'text'}></span>
<span className={'text'}>{t('news.push_to_edit')}</span>
<IconArrowRight className={'text-white'} />
</button>
)

View File

@ -1,48 +1,180 @@
import React, {useState} from "react";
import {showErrorToast, showToast} from "@/components/message.ts";
import {Modal, Space, Button} from "antd";
import {useNavigate} from "react-router-dom";
import {useTranslation} from "react-i18next";
import {useSetState} from "ahooks";
import {showToast} from "@/components/message.ts";
import {push2video} from "@/service/api/article.ts";
import {IconArrowRight} from "@/components/icons";
import {useNavigate} from "react-router-dom";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
export enum ProcessResult {
All,
Skip,
Cancel
}
export default function ButtonPush2Video(props: { ids: Id[]; onSuccess?: () => void; }) {
type PushVideoProps = {
ids: Id[];
articles?: ListArticleItem[];
onResult?: (result: ProcessResult, errorIds: Id[]) => void;
}
export default function ButtonPush2Video(props: PushVideoProps) {
const [loading, setLoading] = useState(false)
// const {modal} = App.useApp()
const [state, setState] = useSetState<{
modalVisible?: boolean;
errorTitle?: string[];
errorIds: Id[]
}>({
errorIds: []
})
const {t} = useTranslation()
const navigate = useNavigate()
const handlePush = () => {
setLoading(true)
push2video(props.ids).then(() => {
showToast('推流成功', 'success')
navigate('/create?state=push-success',{
state: 'push-success'
/**
*
* @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;
}
//
// 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(showErrorToast).finally(() => {
setLoading(false)
}
// double check 25-02-15 https://pu7y37y121.feishu.cn/docx/FwRrddAFWotRZlxgbr5cP7b6nud
// 1.normal 数字人播报部分有内容不少于50字或者数字人播报部分无内容
// 2.error 数字人播报部分有内容但是少于50字
const checkArticleContent = () => {
const errors: string[] = [], ids: Id[] = [];
props.articles?.filter(s => {
return props.ids.includes(s.id) && (s.metahuman_text && s.metahuman_text.length < 50)
}).forEach(s => {
errors.push(s.title)
ids.push(s.id)
})
return {errors, ids}
}
const onPushClick = () => {
if (loading) return;
if (props.ids.length === 0) {
showToast('请选择要开播的新闻', 'warning')
showToast(t('news.push_stream_empty'), 'warning')
return
}
// Modal.confirm({
// title: '操作提示',
// content: '是否确定一键开播选中新闻?',
// onOk: handlePush
// })
handlePush();
// check article content
const result = checkArticleContent()
setState({modalVisible: true, errorTitle: result.errors, errorIds: result.ids})
}
return (
<div>
<button
disabled={loading}
className='bg-[#4096ff] hover:bg-blue-600 text-white'
onClick={onPushClick}
>
<span className={'text'}>{loading?'推送中...':'生成视频'}</span>
<span className={'text'}>{loading ? t('news.push_streaming') : t('news.generate_video')}</span>
<IconArrowRight className={'text-white'}/>
</button>
<Modal
open={state.modalVisible}
centered
closeIcon={false}
footer={null}
width={440}
>
<div className="modal-title flex items-center">
<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">
<div className="min-w-10"></div>
<div>
<div className="message text-gray-600" dangerouslySetInnerHTML={{
__html: t(
state.errorTitle && state.errorTitle.length > 0
? (props.ids.length == 1 ? 'modal.push_article.content_error_single' : 'modal.push_article.content_error')
: (props.ids.length == 1 ? 'modal.push_article.content_normal_single' : 'modal.push_article.content_normal'),
{count: props.ids.length, error_count: state.errorTitle?.length})
}}>
</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, 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">
<Space>
<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>
</Space>
</div>
</Modal>
</div>
)
}

View File

@ -4,20 +4,22 @@ import React, {useEffect, useState} from "react";
import {useSetState} from "ahooks";
import useArticleTags from "@/hooks/useArticleTags.ts";
import TagSelect from "@/components/form/tag-select.tsx";
import {useTranslation} from "react-i18next";
export default function EditSearchForm(props: {
onSubmit: (values: ApiArticleSearchParams) => void;
defaultParams?: Partial<ApiArticleSearchParams>;
}) {
const {t} = useTranslation()
const articleTags = useArticleTags()
const [tags, _setTags] = useState<Id[][]>([]);
const [prevSearchName, setPrevSearchName] = useState<string>(props.defaultParams?.title||'')
const [prevSearchName, setPrevSearchName] = useState<string>(props.defaultParams?.title || '')
const [params, setParams] = useSetState<ApiArticleSearchParams>({
pagination: {limit: 10, page: 1},
title:props.defaultParams?.title||''
title: props.defaultParams?.title || ''
});
const handleSubmit = (_tags?:Id[][],from?:'input') => {
const handleSubmit = (_tags?: Id[][], from?: 'input') => {
if (from == 'input' && (params.title == prevSearchName || (!params.title && !prevSearchName))) return
params.title = prevSearchName;
setParams({title: prevSearchName})
@ -42,21 +44,21 @@ export default function EditSearchForm(props: {
}
})
}
useEffect(()=>{
useEffect(() => {
const {defaultParams} = props;
if(!defaultParams){
if (!defaultParams) {
return;
}
const tags:Id[][] = []
const tags: Id[][] = []
if(defaultParams.tags){
defaultParams.tags.forEach(it=>{
if (defaultParams.tags) {
defaultParams.tags.forEach(it => {
tags.push([it.level1, it.level2])
})
_setTags(tags)
}
},[articleTags])
const setTags = (_tags: Id[][])=>{
}, [articleTags])
const setTags = (_tags: Id[][]) => {
console.log(_tags)
_setTags(_tags)
@ -70,9 +72,9 @@ export default function EditSearchForm(props: {
onChange={e => setPrevSearchName(e.target.value)}
type="text" className="rounded-3xl px-3 w-[270px]"
prefix={<SearchOutlined/>}
placeholder="请输入新闻标题关键词进行搜索"
onPressEnter={()=>handleSubmit(undefined,'input')}
onBlur={()=>handleSubmit(undefined,'input')}
placeholder={t('news.edit_form_search')}
onPressEnter={() => handleSubmit(undefined, 'input')}
onBlur={() => handleSubmit(undefined, 'input')}
/>
{/*<span className="ml-5 text-sm">来源</span>*/}
{/*<ArticleCascader*/}

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";
@ -9,10 +9,14 @@ import TimeSelect from "@/components/form/time-select.tsx";
import styles from './style.module.scss'
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
@ -22,14 +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;
@ -37,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 = {
@ -49,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',
{
@ -115,50 +127,51 @@ 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
value={prevSearchName}
onChange={e => setPrevSearchName(e.target.value)}
className="w-[270px] rounded-3xl"
placeholder={'请输入新闻标题关键词进行搜索'}
placeholder={t('news.search_key_title')}
onPressEnter={onFinish}
onBlur={onFinish}
prefix={<SearchOutlined/>}
/>
<TimeSelect
className="w-[120px] ml-1"
value={typeof(params.time_flag) != "undefined" ? params.time_flag : 1}
className="w-[140px] ml-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">
@ -167,7 +180,7 @@ export default function SearchPanel({onSearch,defaultParams}: SearchPanelProps)
onClick={() => {
handleFilter({tag_level_1_id: -1, tag_level_2_id: -1})
setSubOptions([])
}}>
}}>{t('news.filter_all')}
</div>
{pinnedList.filter(s => (Number(s.value) !== 999999)).map(it => (
<span
@ -180,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();
@ -191,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"></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{
@ -64,7 +67,11 @@
.source{
width: 180px;
}
.count-picture,.count-words{
.count-picture{
width: 160px;
text-align: center;
}
.count-words{
width: 120px;
text-align: center;
}
@ -78,9 +85,8 @@
.header{
@apply bg-primary-bg;
.col{
@apply text-sm;
@apply text-base;
height: 42px;
}
.operations{
}
@ -92,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";
@ -6,20 +6,23 @@ import {formatTime} from "@/util/strings.ts";
import ArticleEditModal from "@/components/article/edit-modal.tsx";
import {deleteByIds, getList} from "@/service/api/article.ts";
import EditSearchForm from "@/pages/news/components/edit-search-form.tsx";
import ButtonPush2Video from "@/pages/news/components/button-push2video.tsx";
import ButtonPush2Video, {ProcessResult} from "@/pages/news/components/button-push2video.tsx";
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: [],
}
export default function NewEdit() {
const {t} = useTranslation()
const [state, setState] = useState<{
checkAll?: boolean;
showToTop?: boolean;
@ -32,11 +35,13 @@ export default function NewEdit() {
...FilterCache
})
const [data, setData] = useState<DataList<ListArticleItem>>()
const {refresh, loading} = useRequest(() => getList(params), {
const {refresh, loading,refreshAsync} = useRequest(() => getList(params), {
refreshDeps: [params],
onSuccess: (data) => {
FilterCache.title = params.title;
FilterCache.tags = params.tags;
setSelectedRowKeys(()=>([]))
setState({checkAll: false})
setData(prev => {
// 判断页码是否是第1页
if (data.pagination.page == 1) return data;
@ -68,9 +73,17 @@ export default function NewEdit() {
const handleDelete = (id) => {
deleteByIds([id]).then(() => {
refresh()
showToast('删除成功', 'success')
showToast(t('delete_success'), 'success')
}).catch(showErrorToast)
}
const handlePushProcessResult = (result: ProcessResult,errorIds: Id[])=>{
if(result == ProcessResult.Skip){
// 跳过, 加载最新并选择异常id
refreshAsync().then(()=>{
setSelectedRowKeys(errorIds)
})
}
}
return (<div className="container pb-5 news-edit">
<div className="search-panel-container my-5">
@ -80,44 +93,46 @@ export default function NewEdit() {
</div>
<div className="news-list-container mt-2">
<div className="controls flex justify-end mb-3 gap-2">
<Space>
<span> {data?.list?.length || 0} </span>
<span className={'text-blue-500'}> {selectedRowKeys.length} </span>
<div className="controls flex justify-end mb-3 gap-5">
<Space size={20}>
<span>{t('select.total',{count:data?.list?.length || 0})}</span>
<span className={'text-blue-500'}>{t('select.selected_some',{count:selectedRowKeys.length})}</span>
</Space>
<div>
<span className={'inline-block cursor-pointer mr-2'} onClick={() => {
handleCheckAll(!state.checkAll)
}}></span>
<Checkbox checked={state.checkAll && (!data?.list || selectedRowKeys.length == data?.list?.length)}
onChange={e => {
handleCheckAll(e.target.checked)
}}/>
}}>{t('select.select_all')}</span>
<Checkbox
checked={state.checkAll && (!data?.list || selectedRowKeys.length == data?.list?.length)}
onChange={e => {
handleCheckAll(e.target.checked)
}}
/>
</div>
</div>
<div className={styles.newListTable}>
<div className="header row flex">
<div className="col title"></div>
<div className="col source"></div>
<div className="col count-picture"></div>
<div className="col count-words"></div>
<div className="col time"></div>
<div className="col operations"></div>
<div className="col title">{t('news.title')}</div>
<div className="col source">{t('news.source')}</div>
<div className="col count-picture">{t('news.title_image_count')}</div>
<div className="col count-words">{t('news.title_word_count')}</div>
<div className="col time">{t('news.title_time')}</div>
<div className="col operations">{t('news.title_operate')}</div>
</div>
<InfiniteScroller ref={scrollerRef} onCallback={(page) => {
setParams(prev => ({
...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>
<div className="text-base">{item.title}</div>
<div className="flex-1">
<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>
@ -137,24 +152,15 @@ 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={'你确定要删除吗?'}
description={'删除后需从新闻素材中重新选择'}
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>
</div>
})}
<div className="h-[130px]"></div>
</div>
</InfiniteScroller>
</div>
@ -162,12 +168,13 @@ export default function NewEdit() {
<div className="page-action">
<ButtonToTop visible={state.showToTop} onClick={() => scrollerRef.current?.scrollToPosition(0)}/>
{selectedRowKeys?.length > 0 && <ButtonDeleteBatch ids={selectedRowKeys} onSuccess={refresh}/>}
<ButtonPush2Video ids={selectedRowKeys} onSuccess={refresh}/>
<ButtonPush2Video ids={selectedRowKeys} articles={data?.list} onResult={handlePushProcessResult}/>
</div>
</div>
<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";
@ -13,11 +13,13 @@ import ButtonNewsDownload from "@/pages/news/components/button-news-download.tsx
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
import ButtonToTop from "@/components/scoller/button-to-top.tsx";
import {useIndexArrayCache} from "@/hooks/useCache.ts";
import {useTranslation} from "react-i18next";
const FilterCache: Partial<ApiArticleSearchParams> = {
time_flag: 1,
}
export default function NewsIndex() {
const {t} = useTranslation()
const [params, setParams] = useState<ApiArticleSearchParams>({
pagination: {page: 1, limit: 12},
...FilterCache
@ -28,7 +30,7 @@ export default function NewsIndex() {
const [activeNews, setActiveNews] = useState<NewsInfo>()
const [state, setState] = useState<{
const [state, setState] = useSetState<{
checkAll?: boolean;
showToTop?: boolean;
}>({})
@ -41,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,
@ -54,13 +56,13 @@ export default function NewsIndex() {
}
})
const handleViewNewsDetail = (id: number) => {
const {update, close} = showLoading('获取新闻详情...')
const handleViewNewsDetail = (id: number,internal_article_id:number) => {
const {update, close} = showLoading(`${t('news.get_detail')}...`)
getById(id).then(res => {
close()
setActiveNews({...res, id})
setActiveNews({...res, id,internal_article_id})
}).catch(() => {
update('获取新闻详情失败', 'info')
update(t('news.get_detail_error'), 'info')
})
}
@ -81,19 +83,23 @@ export default function NewsIndex() {
const scrollerRef = useRef<InfiniteScrollerRef | null>(null)
const handleCheckChange = (id: number) => {
if (checkedId.includes(id)) {
setCheckedId(checkedId.filter(id => id != id))
setCheckedId(checkedId.filter(_id => _id != id))
} else {
setCheckedId([...checkedId, id])
}
}
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}
footer={null} onCancel={() => setActiveNews(undefined)}
>
<div className="news-detail pl-16 pr-1 flex pb-5">
<div className="news-detail pl-16 pr-1 flex pb-2">
<div className="px-4 py-6 bg-white flex-1">
<div className="new-title text-2xl">{activeNews?.title}</div>
<div className="info mt-2 mb-2 text-sm flex gap-3">
@ -104,30 +110,30 @@ export default function NewsIndex() {
<div className="overflow-auto leading-7 text-base news-detail-content-container"
style={{maxHeight: 500}} dangerouslySetInnerHTML={{__html: activeNews?.content || ''}}></div>
</div>
<div className="actions ml-3">
<div className="actions ml-2">
<div className="close">
<CloseOutlined className="text-xl text-gray-400 hover:text-gray-800"
onClick={() => setActiveNews(undefined)}/>
</div>
<div className="whitespace-nowrap text-sm mt-2">
<Checkbox
<div className="whitespace-nowrap text-sm mt-2 min-w-[58px]">
{activeNews.internal_article_id <= 0 && <Checkbox
checked={checkedId.includes(activeNews!.id)}
onChange={() => handleCheckChange(activeNews!.id)}
><span className="ml-[-4px]"></span></Checkbox>
><span className="ml-[-4px]">{t('select.text')}</span></Checkbox>}
</div>
</div>
</div>
</Modal>}
<div className="news-list-container">
<div className="controls flex justify-end mb-3 gap-2">
<Space>
<span> {data?.list?.length || 0} </span>
<span className={'text-blue-500'}> {checkedId.length} </span>
<div className="controls flex justify-end mb-3 gap-5">
<Space size={20}>
<span>{t('select.total',{count:data?.list?.length || 0})}</span>
<span className={'text-blue-500'}>{t('select.selected_some',{count:checkedId.length})}</span>
</Space>
<div>
<span className={'inline-block cursor-pointer mr-2'} onClick={() => {
handleCheckAll(!state.checkAll)
}}></span>
}}>{t('select.select_all')}</span>
<Checkbox checked={state.checkAll && checkedId.length == currentEnabledList.length} onChange={e => {
handleCheckAll(e.target.checked)
}}></Checkbox>
@ -155,7 +161,7 @@ export default function NewsIndex() {
<div className="news-content flex-1">
<div className="title h-[60px] line-clamp-2 text-lg cursor-pointer hover:text-blue-500"
onClick={() => {
handleViewNewsDetail(item.id)
handleViewNewsDetail(item.id,item.internal_article_id)
}}>{item.title}</div>
<div className="content flex gap-3 mt-2 mb-3">
<div
@ -169,15 +175,15 @@ export default function NewsIndex() {
</div>}
</div>
<div className="info text-gray-400 mt-4 text-sm">
<div className="line-clamp-1">: <span>{item.data_source_name}</span></div>
<div className="line-clamp-1">{t('news.source')}: <span>{item.data_source_name}</span></div>
<div className="extras flex items-center justify-between gap-3">
<div><span>{formatTime(item.publish_time, 'min')}</span></div>
<div><span>: {item.img_num}</span></div>
<div><span>: {item.content_word_count}</span></div>
<div><span>{t('news.image_count')}: {item.img_num}</span></div>
<div><span>{t('news.word_count')}: {item.content_word_count}</span></div>
<div
className={` mt-1`}>
{item.internal_article_id > 0 ?
<span className={"inline-block text-gray-600"}></span> :
<span className={"inline-block text-gray-600"}>{t('news.pushed')}</span> :
<Checkbox checked={checkedId.includes(item.id)} onChange={() => {
handleCheckChange(item.id)
}}/>}
@ -187,6 +193,7 @@ export default function NewsIndex() {
</div>
</div>
))}
<div className="h-[100px]"></div>
</InfiniteScroller>

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

@ -3,6 +3,7 @@ import {useSetState} from "ahooks";
import {SearchOutlined} from "@ant-design/icons";
import React from "react";
import TimeSelect from "@/components/form/time-select.tsx";
import {useTranslation} from "react-i18next";
type Props = {
onSearch?: (params: VideoSearchParams) => void;
@ -11,6 +12,7 @@ type Props = {
}
export default function SearchForm({onSearch}: Props) {
const {t} = useTranslation()
const [state, setState] = useSetState<{
pushing?: boolean;
time_flag: number;
@ -44,10 +46,10 @@ export default function SearchForm({onSearch}: Props) {
onPressEnter={() => onFinish(state)}
onBlur={() => onFinish(state)}
allowClear
placeholder={'请输入视频标题关键字进行信息'}
placeholder={t("history.search_key")}
/>
<TimeSelect
className="w-[120px] ml-1"
className="w-[140px] ml-1"
value={state.time_flag}
onChange={handleTimeFilter}
/>

View File

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

View File

@ -5,6 +5,7 @@ import {Player} from "@/components/video/player.tsx";
import {push2room} from "@/service/api/video.ts";
import {showErrorToast, showToast} from "@/components/message.ts";
import {useTranslation} from "react-i18next";
type Props = {
video?: VideoInfo;
@ -12,6 +13,7 @@ type Props = {
onClose?: () => void
}
export default function VideoDetail({video, onClose,autoPlay}: Props) {
const {t} = useTranslation()
const [state, setState] = useSetState({
exporting: false,
pushing: false,
@ -22,7 +24,7 @@ export default function VideoDetail({video, onClose,autoPlay}: Props) {
if (state.pushing) return
setState({pushing: true})
push2room([video.id]).then(() => {
showToast('一键推流成功,已推流至数字人直播间,请查看!', 'success')
showToast(t('history.push_success'), 'success')
}).catch(showErrorToast).finally(() => {
setState({pushing: false})
})
@ -51,11 +53,11 @@ export default function VideoDetail({video, onClose,autoPlay}: Props) {
</div>
<div className="flex justify-end modal-control-footer">
<div className="flex gap-4">
<button disabled={state.pushing} className="text-gray-400 hover:text-gray-800 " type="button" onClick={pushToRoom}></button>
<button disabled={state.pushing} className="text-gray-400 hover:text-gray-800 " type="button" onClick={pushToRoom}>{t('video.push_to_live')}</button>
<button disabled={state.exporting} className="text-gray-400 hover:text-gray-800 " onClick={downloadVideo}
type="button">
type="button">{t('video.download')}
</button>
<button onClick={onClose} type="button" className="text-gray-800 hover:text-blue-500"></button>
<button onClick={onClose} type="button" className="text-gray-800 hover:text-blue-500">{t('close')}</button>
</div>
</div>
</Modal>

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,22 +1,26 @@
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 {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[]>([])
const [params, setParams] = useState<VideoSearchParams>({
@ -27,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
}
}
})
}
@ -48,8 +59,8 @@ export default function LibraryIndex() {
const handleRemove = (video: VideoInfo) => {
modal.confirm({
title: '删除提示',
content: '是否要删除该视频',
title: t('confirm.title'),
content: t('history.delete_confirm'),
onOk: () => {
console.log('OK', video);
}
@ -58,8 +69,8 @@ export default function LibraryIndex() {
const handleLive = async () => {
if (checkedIdArray.length == 0) return;
modal.confirm({
title: '推流提示',
content: '是否确定一键推流选中新闻视频?',
title: t('confirm.push_title'),
content: t('confirm.push_video'),
onOk: () => {
console.log('OK');
}
@ -70,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, [])
@ -95,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}
/>
@ -104,18 +124,20 @@ export default function LibraryIndex() {
<div className="live-control flex justify-between mb-2">
<div className="pl-[70px]"></div>
<div className="flex items-center">
<Space className="text-gray-400">
<span> {data?.list.length || 0} </span>
<span> {state.pushedCount} </span>
<span className={'text-blue-500'}> {checkedIdArray.length} </span>
<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>
</Space>
<button className="hover:text-blue-300 text-gray-400 ml-2"
<button className="hover:text-blue-300 text-gray-400 ml-4"
onClick={() => handleAllCheckedChange(checkedIdArray.length != data?.list.length)}>
<span className="text-sm mr-2"></span>
<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
@ -127,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
@ -148,28 +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} 条视频吗?`}
emptyMessage={'请选择要删除的视频'}
confirmMessage={'删除后需重新生成视频'}
onProcess={deleteHistories}
></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={'请选择要推流的视频'}
></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

@ -7,6 +7,7 @@ import styles from './../style.module.scss'
import useAuth from "@/hooks/useAuth.ts";
import {useSmsCode} from "@/components/form/sms-code.tsx";
import {useTranslation} from "react-i18next";
type FieldType = {
username?: string;
@ -15,6 +16,7 @@ type FieldType = {
export default function FormLogin() {
const {t} = useTranslation()
const [disabled, setDisabled] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>()
@ -26,7 +28,7 @@ export default function FormLogin() {
const onFinish: FormProps<FieldType>['onFinish'] = (values) => {
if(disabled || loading) return
if (!values.username || !/^1\d{10}$/.test(values.username)) {
setError('账号或密码错误')
setError(t("login.invalid_username_or_pwd"))
return
}
setLoading(true)
@ -38,7 +40,7 @@ export default function FormLogin() {
};
return (<div className="form">
<div className={'text-center text-xl pb-6 pt-8'}></div>
<div className={'text-center text-xl pb-6 pt-8'}>{t("login.welcome")}</div>
<Form<FieldType>
name="basic"
style={{maxWidth: 600}}
@ -53,18 +55,18 @@ export default function FormLogin() {
>
<Form.Item<FieldType> name="username">
<div className={styles.loginBox}>
<Input size={'large'} variant={'borderless'} placeholder="请输入账号"/>
<Input size={'large'} variant={'borderless'} placeholder={t("login.username")}/>
</div>
</Form.Item>
<Form.Item name="password">
<div className={styles.loginBox}>
<Input style={{borderRadius: 20}} size={'large'} variant={'borderless'}
placeholder="请输入验证码"/>
placeholder={t("login.password")}/>
<span
className={clsx(`text-nowrap mr-1 text-sm ${countdown > 0 || sending || !phone ? 'text-gray-400 cursor-not-allowed' : 'text-blue-500 cursor-pointer'}`)}
onClick={() => sendCode(phone)}>
{sending ? '发送中...' : (countdown > 0 ? `${Math.ceil(countdown / 1000)} s` : '获取验证码')}
{sending ? `${t('login.code_sending')}...` : (countdown > 0 ? `${Math.ceil(countdown / 1000)} s` : t('login.send_sms_code'))}
</span>
</div>
</Form.Item>
@ -75,7 +77,7 @@ export default function FormLogin() {
<div className="absolute text-red-500 text-center inset-x-0" style={{top: -34}}>{error}</div>
<Button loading={loading} type="primary" size={'large'} htmlType="submit"
block shape={'round'}>
{loading ? '登录中...' : '立即登录'}
{loading ? t("login.loading") : t('login.text')}
</Button>
</Form.Item>
</Form>

View File

@ -1,39 +1,43 @@
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 = () => {
setLoading(true)
// 只需要已经生成视频的数据id
const vids = props.list.filter(v => v.status == VideoStatus.Generated && props.ids.includes(v.id)).map(v => v.id)
push2room(vids).then(() => {
props.onSuccess?.()
if(props.ids.length == vids.length){
showToast('一键推流成功,已推流至数字人直播间,请前往数字人直播间页面查看!', 'success')
}else{
showToast('选择视频中有部分视频还在生成中无法推送,推流成功视频前往数字人直播间页面查看!', 'success')
if (props.ids.length == vids.length) {
showToast(t("video.push_success"), 'success')
} else {
showToast(t("video.push_failed"), 'success')
}
}).catch(showErrorToast).finally(() => {
setLoading(false)
})
}
const onPushClick = () => {
if(loading) return;
if (loading) return;
if (props.ids.length === 0) {
showToast('请选择要推流的新闻', 'warning')
showToast(t("video.push_empty"), 'warning')
return
}
Modal.confirm({
wrapClassName:'root-modal-confirm',
title: '操作提示',
icon: <span className="anticon anticon-exclamation-circle"><IconWarningCircle/></span>,
content: '是否确定一键推流选中新闻视频??',
onOk: handlePush
title: <ModalWarning.Title/>,
icon: <ModalWarning.Icon/>,
content: t("video.push_confirm"),
onOk: handlePush,
centered: true
})
}
return (
@ -44,8 +48,8 @@ export default function ButtonPush2Room(props: { ids: Id[]; list: VideoInfo[];on
className='bg-[#4096ff] hover:bg-blue-600 text-white'
onClick={onPushClick}
>
<span className={'text'}></span>
<IconArrowRight />
<span className={'text'}>{t("video.push_to_live")}</span>
<IconArrowRight/>
</button>
</div>
)

View File

@ -1,38 +1,43 @@
import {Checkbox, Empty, Space} from "antd";
import React, {useEffect, useMemo, useRef, useState} from "react";
import React, {useEffect, useRef, useState} from "react";
import {DndContext} from "@dnd-kit/core";
import {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 ArticleEditModal from "@/components/article/edit-modal.tsx";
import {deleteFromList, getList, modifyOrder, VideoStatus} from "@/service/api/video.ts";
import {deleteFromList, getList, modifyOrder, regenerateById, VideoStatus} from "@/service/api/video.ts";
import {formatDuration} from "@/util/strings.ts";
import ButtonBatch from "@/components/button-batch.tsx";
import {showErrorToast, showToast} from "@/components/message.ts";
import {Player, PlayerInstance} from "@/components/video/player.tsx";
import {Mp4Player as Player, PlayerInstance} from "@/components/video/Mp4Player.tsx";
import ButtonPush2Room from "@/pages/video/components/button-push2room.tsx";
import ButtonToTop from "@/components/scoller/button-to-top.tsx";
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
import {IconDelete} from "@/components/icons";
import {useLocation} from "react-router-dom";
export default function VideoIndex() {
const {t} = useTranslation()
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,
playingIndex: -1,
playingId: -1,
videoPlaying: false,
showToTop: false,
showStatePos: false,
playState: {
current: -1,
total: -1
},
loading:false
loading:false,
playVideoUrl: ''
})
const [checkedIdArray, setCheckedIdArray] = useState<Id[]>([])
const [refreshTimer,setTimer] = useState(0)
@ -50,7 +55,7 @@ export default function VideoIndex() {
setVideoData(list)
if (needReset) {
setCheckedIdArray([])
setState({checkedAll: false, playingIndex: -1})
setState({checkedAll: false, playingId: -1})
}
// 判断是否有生成中的视频
if (list.filter(s => s.status == VideoStatus.Generating).length > 0) {
@ -71,24 +76,25 @@ export default function VideoIndex() {
}
// 播放视频
const playVideo = (video: VideoInfo, playingIndex: number) => {
if (state.playingIndex == playingIndex) {
const playVideo = (video: VideoInfo) => {
if (state.playingId == video.id) {
player.current?.pause();
setState({playingIndex: -1})
setState({playingId: -1})
return;
}
if (video.status == VideoStatus.Generating) return;
// setState({playingIndex})
// player.current?.play('https://staticplus.gachafun.com/ai-collect/composite_video/2024-12-17/1186196465916190720.flv', 30)
//
if (video.oss_video_url && video.status !== 1) {
setState({playingIndex})
player.current?.play(video.oss_video_url, 0)
if (video.oss_video_mp4_url && video.status !== 1) {
setState({playingId: video.id})
player.current?.play(video.oss_video_mp4_url, 0)
}
}
// 处理全选
const handleAllCheckedChange = () => {
setCheckedIdArray(state.checkedAll ? [] : videoData.map(v => v.id))
setCheckedIdArray(state.checkedAll ? [] : videoData.filter(s=>s.status == VideoStatus.Generated).map(v => v.id))
setState({
checkedAll: !state.checkedAll
})
@ -96,10 +102,10 @@ export default function VideoIndex() {
const handleModifySort = (items: VideoInfo[]) => {
modifyOrder(items.map(s => s.id)).then(() => {
showToast('调整视频顺序成功!', 'success')
showToast(t('video.sort_modify_success'), 'success')
}).catch(() => {
loadList();
showToast('调整视频顺序失败,请重试!', 'warning')
showToast(t('video.sort_modify_failed'), 'warning')
})
return ()=>{
@ -112,14 +118,15 @@ export default function VideoIndex() {
}
//
useEffect(loadList, [])
const totalDuration = useMemo(() => {
if (!videoData || videoData.length == 0) return 0;
if (state.playingIndex == -1 || state.playingIndex >= videoData.length) return 0
const v = videoData[state.playingIndex] as VideoInfo;
return Math.ceil(v.duration / 1000)
// 计算总时长
//return videoData.reduce((sum, v) => sum + Math.ceil(v.duration / 1000), 0);
}, [videoData, state.playingIndex])
// const totalDuration = useMemo(() => {
// if (!videoData || videoData.length == 0) return 0;
// 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)
// // 计算总时长
// //return videoData.reduce((sum, v) => sum + Math.ceil(v.duration / 1000), 0);
// }, [videoData, state.playingId])
useEffect(() => {
if (loc.state == 'push-success' && !state.showStatePos && videoData.length && scrollerRef.current) {
@ -130,9 +137,21 @@ export default function VideoIndex() {
}
}
}, [videoData, scrollerRef])
const processDeleteVideo = async (ids: Id[]) => {
const processDeleteVideo = async (ids: Id[],action ?: string) => {
deleteFromList(ids).then(() => {
showToast('删除成功!', 'success')
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)
}
@ -142,14 +161,21 @@ export default function VideoIndex() {
<div className="flex">
<div className="video-player-container mr-16 w-[360px] flex items-center">
<div>
<div className="text-center text-base text-gray-400"> - </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-[360px] h-[636px] rounded overflow-hidden">
{/*videoData[state.playingIndex]?.oss_video_url*/}
<Player
ref={player} url={videoData[state.playingIndex]?.oss_video_url}
ref={player}
url={state.playVideoUrl}
onChange={(state) => {
console.log(state)
if (state.end || state.error) setState({playingIndex: -1})
if (state.end || state.error) setState({playingId: -1})
}}
onPause={() => {
setState({videoPlaying:false})
}}
onPlay={() => {
setState({videoPlaying:true})
}}
onProgress={(current, duration) => {
setState({
@ -166,16 +192,16 @@ export default function VideoIndex() {
</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>
<span> {videoData.length || 0} </span>
<span className={'text-blue-500'}> {checkedIdArray.length} </span>
<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-2"
<button className="hover:text-blue-300 text-gray-400 ml-5"
onClick={handleAllCheckedChange}>
<span className="text-sm mr-2"></span>
<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()}/>
@ -185,10 +211,10 @@ export default function VideoIndex() {
<div className="list-header">
<div className="list-row header-row">
<div className="col number">No.</div>
<div className="col cover"></div>
<div className="col title"></div>
<div className="col generated-time"></div>
<div className="col operation"></div>
<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})}>
@ -208,14 +234,6 @@ export default function VideoIndex() {
handleModifySort(newSorts)
return newSorts;
});
// modal.confirm({
// title: '提示',
// content: '是否要移动到指定位置',
// onOk: handleModifySort,
// onCancel: () => {
// setVideoData(originArr);
// }
// })
}
}}>
<SortableContext items={videoData}>
@ -227,9 +245,9 @@ export default function VideoIndex() {
key={index}
type={'create'}
active={checkedIdArray.includes(v.id)}
playing={state.playingIndex == index}
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}`}
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);
@ -237,42 +255,61 @@ export default function VideoIndex() {
return newArr;
})
}}
onItemClick={() => playVideo(v, index)}
onRemove={() => processDeleteVideo([v.id])}
onEdit={v.status == VideoStatus.Generating ? undefined : () => {
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.Generating}
sortable={v.status == VideoStatus.Generated}
/>))}
</SortableContext>
</DndContext>
</div>
}
<div className="h-[100px]"></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={`请选择要删除的新闻视频`}
title={`已选择${checkedIdArray.length}条,确定要全部删除吗?`}
className='bg-gray-300 hover:bg-gray-400 text-white'
confirmMessage={`删除后需重新生成视频`}
onSuccess={() => {
showToast('删除成功!', 'success')
loadList()
}}
>
<span className="text"></span>
<IconDelete/>
</ButtonBatch>}
{
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={() => setEditId(-1)}/>
<ArticleEditModal type={'video'} id={editId} onClose={(saved) =>{
setEditId(-1)
if(saved) {
loadList()
}
}}/>
</div>)
}

View File

@ -1,47 +1,53 @@
import React from "react";
import React, {useMemo} from "react";
import {isRouteErrorResponse, useNavigate, useRouteError} from 'react-router-dom';
import {Button} from "antd";
import error500 from "@/assets/images/error/Error500.png";
import {useTranslation} from "react-i18next";
// ==============================|| ELEMENT ERROR - COMMON ||============================== //
const ErrorBoundary: React.FC<{
minHeight?: string | number;
errorCode?: 401 | 404 | 503
}> = ({ errorCode}) => {
}> = ({errorCode}) => {
const {t, i18n} = useTranslation()
const error = useRouteError() as Error;
let errorMessage = '服务异常,请稍后再试或者联系管理员.'
const errorConfig: {
[key: number]: string
} = {
401: '您没有权限访问本页面!',
404: '访问的页面不存在!',
503: '服务异常请联系管理员!',
}
if (isRouteErrorResponse(error)) {
if (errorConfig[error.status]) {
errorMessage = `Error ${error.status} - ${errorConfig[error.status]}`;
console.log(error)
const errorMessage = useMemo(() => {
let _message = t('error_500')
const errorConfig: {
[key: number]: string
} = {
401: t('error_401'),
403: t('error_403'),
404: t('error_404'),
500: t('error_500'),
}
}
if (errorCode) {
if (errorConfig[errorCode]) {
errorMessage = `Error ${errorCode} - ${errorConfig[errorCode]}`;
if (isRouteErrorResponse(error)) {
if (errorConfig[error.status]) {
_message = `Error ${error.status} - ${errorConfig[error.status]}`;
}
}
}
if (errorCode) {
if (errorConfig[errorCode]) {
_message = `Error ${errorCode} - ${errorConfig[errorCode]}`;
}
}
return _message
}, [i18n, errorCode])
const navigate = useNavigate()
const handleGoBack = () => {
navigate('/')
}
return (<div className="max-w-screen-lg mx-auto flex items-center h-screen">
<div className={'flex flex-row '}>
return (<div className="max-w-screen-lg mx-auto flex justify-center items-center h-screen">
<div className={'flex flex-row items-center'}>
<div className="flex-col">
<div className="sm:w-396px">
<img src={error500} alt="error" className="w-full"/>
</div>
</div>
<div className="flex-col md:w-full ml-10">
<div className="flex-col md:w-full ml-20">
<div className="text-center">
<div className="text-4xl">
<div className="text-3xl">
Internal Server Error
</div>
<div className="text-gray-400 my-5">
@ -62,10 +68,10 @@ const ErrorBoundary: React.FC<{
whiteSpace: 'break-spaces'
}}>{error.stack}</code>
</pre>
</div>}
<div className="flex flex-grow gap-2 mt-5 justify-center">
</div>}
<div className="flex flex-grow gap-2 mt-10 justify-center">
<Button type='primary' className="px-5" onClick={handleGoBack}>
<h1></h1>
<h1>{t('go_to_home')}</h1>
</Button>
</div>
</div>

View File

@ -1,6 +1,6 @@
import {createBrowserRouter, RouterProvider,} from "react-router-dom";
import {Suspense,} 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";
@ -8,13 +8,16 @@ 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 {getRemainingDuration} from "@/service/api/order.ts";
import ModalWarning from "@/components/icons/ModalWarning.tsx";
dayjs.locale('zh-cn');
const router = createBrowserRouter([
...routes,
{path: '*', element: <ErrorBoundary/>}
{path: '*', element: <ErrorBoundary errorCode={404}/>}
], {
basename: import.meta.env.VITE_APP_BASE_NAME,
future: {
@ -28,17 +31,52 @@ const router = createBrowserRouter([
// future={{v7_startTransition: true,v7_relativeSplatPath: true}}
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.language])
return (<ConfigProvider
locale={zhCN}
locale={i18n?.language?.toString() == 'zh-CN' ? zhCN : undefined}
theme={{
token: {
borderRadius: 4,
},
}}
>
<App>
<DocumentTitle title={t('AppTitle')}/>
<App className={`lang-${i18n.language}`}>
<Suspense fallback={<Loader/>}>
<RouterProvider future={{v7_startTransition: true}} router={router}/>
</Suspense>

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,6 +1,7 @@
import {Outlet, useLocation, useNavigate} from "react-router-dom";
import {Divider, Dropdown, MenuProps} from "antd";
import React, {useEffect} from "react";
import React, {useEffect, useMemo} from "react";
import {useTranslation} from "react-i18next";
import AuthGuard from "@/routes/layout/auth-guard.tsx";
import {LogoText} from "@/components/icons/logo.tsx";
@ -11,7 +12,8 @@ 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 {IconVideo} from "@/components/icons";
import {IconOrderFill, IconRecycleFill} from "@/components/icons";
import LanguageSwitcher from "@/components/icons/language-switcher.tsx";
type LayoutProps = {
@ -19,17 +21,25 @@ type LayoutProps = {
}
const NavigationUserContainer = () => {
const {t} = useTranslation()
const {logout, user} = useAuth()
const navigate = useNavigate()
const handleLogout = ()=>{
const handleLogout = () => {
logout().then(() => navigate('/user'))
}
const items: MenuProps['items'] = [
{
key: 'profile',
label: <div className="nav-item" onClick={() => navigate('/history')}>
<IconVideo />
<span className={"nav-text"}></span>
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>,
},
// {
@ -41,14 +51,14 @@ const NavigationUserContainer = () => {
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"></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)=>(
dropdownRender={(menu) => (
<div>
<div className="user-profile flex gap-4">
<div className="avatar"><UserAvatar className="user-avatar"/></div>
@ -57,13 +67,13 @@ const NavigationUserContainer = () => {
<div>ID: {user?.id}</div>
</div>
</div>
<Divider style={{ margin: 0 }} />
<Divider style={{margin: 0}}/>
<div className="menu-list-container">
{menu}
</div>
<Divider style={{ margin: 0 }} />
<Divider style={{margin: 0}}/>
<div className="logout">
<div onClick={handleLogout}>退</div>
<div onClick={handleLogout}>{t('user.logout')}</div>
</div>
</div>
@ -73,15 +83,29 @@ const NavigationUserContainer = () => {
</Dropdown> : <UserButton/>}
</div>)
}
const ExtraNavItems = {
'/order':'order.text',
'/recycle':'history.text',
}
export const BaseLayout: React.FC<LayoutProps> = ({children}) => {
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">
<LanguageSwitcher />
<NavigationUserContainer/>
</div>
</div>
@ -98,14 +122,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'){
useEffect(() => {
if (!defaultCache.firstLoadPath && loc.pathname == '/live') {
defaultCache.firstLoadPath = loc.pathname;
navigate('/')
}
},[])
}, [])
return <AuthGuard>
<div className="fixed">first path:{defaultCache.firstLoadPath}</div>
<div className="fixed">{defaultCache.firstLoadPath}</div>
<BaseLayout>
{children ? children : <Outlet/>}
</BaseLayout>

View File

@ -2,42 +2,46 @@ import {clsx} from "clsx";
import {NavLink} from "react-router-dom";
import {IconNavigationArrow} from "@/components/icons";
import useAuth from "@/hooks/useAuth.ts";
import {useMemo} from "react";
import {useTranslation} from "react-i18next";
const NavItems = [
{
key: 'news',
name: '新闻素材',
icon: 'news',
path: '/'
},
{
key: 'video',
name: '新闻编辑',
icon: 'e',
path: '/edit'
},
{
key: 'create',
name: '视频生成',
icon: 'ai',
path: '/create'
},
// {
// key: 'library',
// name: '视频库',
// icon: '+',
// path:'/library'
// },
{
key: 'live',
name: '数字人直播间',
icon: 'v',
path: '/live'
}
]
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: '/live'
}
]),[i18n.language])
return (<div className={'flex app-main-navigation'}>
{NavItems.map((it, idx) => (<div key={idx} className={"flex items-center"}>
{user ? <NavLink to={it.path} className={clsx('nav-item cursor-pointer items-center')}>

View File

@ -1,16 +1,16 @@
import {RouteObject} from "react-router-dom";
import ErrorBoundary from "@/routes/error.tsx";
;
import DashboardLayout from "@/routes/layout/dashboard-layout.tsx";
import React from "react";
import 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 LibraryIndex = React.lazy(() => import("@/pages/library"))
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[] = [
@ -36,8 +36,12 @@ const routes: RouteObject[] = [
element: <CreateVideoIndex/>
},
{
path: 'history',
element: <LibraryIndex/>
path: 'recycle',
element: <RecycleIndex/>
},
{
path: 'order',
element: <OrderIndex/>
},
{
path: 'live',

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

@ -1,4 +1,5 @@
import {get, post} from "@/service/request.ts";
import {post} from "@/service/request.ts";
import {getById as getArticle} from "./article"
export function getList() {
return post<DataList<VideoInfo>>('/video/list')
@ -16,24 +17,32 @@ 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({
title:article.title,
metahuman_text:article.metahuman_text,
content_group:article.content_group,
id:article_id
})
}
export function getById(id: Id) {
return post<VideoInfo>({url: '/video/detail/' + id})
}
export function deleteFromList(ids: Id[]) {
return post('/video/outside', {ids})
return post('/video/remove', {ids})
}

View File

@ -2,6 +2,7 @@ import axios from 'axios';
import {stringify} from 'qs'
import {BizError} from './types';
import {getAuthToken} from "@/hooks/useAuth.ts";
import useGlobalConfig from '@/hooks/useGlobalConfig';
const JSON_FORMAT: string = 'application/json';
const REQUEST_TIMEOUT = 300000; // 超时时长5min
@ -11,10 +12,20 @@ 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 => {
const token = getAuthToken();
if (globalConfig.i18n){
let url = config.url;
if(url){
url += (url.indexOf('?') == -1?'?':'&') + `lang=${globalConfig.i18n || ''}`
config.url = url;
}
//config.headers['language'] = globalConfig.i18n;
}
if (token) {
config.headers['Token'] = `${token}`;
}

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

@ -59,6 +59,7 @@ interface BasicArticleInfo {
id: number;
title: string;
summary: string;
metahuman_text: string;
publish_time: string;
media_name: string;
column_name?: string;
@ -66,6 +67,7 @@ interface BasicArticleInfo {
content_word_count?: number;
media_id: number;
fanwen_column_id: number;
hot_news: string;
}
/**
@ -84,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;
@ -95,11 +102,16 @@ 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;
}
// room live
declare interface LiveVideoInfo {
@ -109,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;
@ -120,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;
}

View File

@ -1,4 +1,4 @@
type I18n = 'en-US' | 'zh-CN' | 'zh-HK';
type I18n = 'en-US' | 'zh-CN' | 'zh-HK' | string;
type ConfigProps = {
fontFamily: string;

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

@ -28,15 +28,40 @@ 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 {
id: number;
internal_article_id: number;
title: string;
content: string;
media_name: string;

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 '-';

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

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

View File

@ -4,9 +4,21 @@ 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
} = {
'test':'https://fm-admin.starbitech.com',
'development':'http://192.168.0.231:9999',
'lang-en':'https://mh.starbitech.com'
}
// https://vitejs.dev/config/
export default defineConfig(({mode}) => {
const devServerHost = mode == 'test' ? 'https://fm-admin.starbitech.com' : 'http://192.168.0.231:9999'
const devServerHost = DevServerList[mode] || 'http://192.168.0.231:9999'
const AUTH_TOKEN_KEY = mode == 'production' ? 'digital-person-token' : `digital-person-token_${mode}`
if (mode !== 'production') {
@ -22,6 +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,
TCPlayerLicense
}),
AppMode: JSON.stringify(mode),
AppBuildVersion: JSON.stringify(AppPackage.name + '-' + AppPackage.version + '-' + dayjs().format('YYYYMMDDHH_mmss'))

1843
yarn.lock

File diff suppressed because it is too large Load Diff