Compare commits
67 Commits
5b791716e2
...
0a4bb5426e
Author | SHA1 | Date | |
---|---|---|---|
0a4bb5426e | |||
6fc064fbc8 | |||
611a00a550 | |||
0ccbfb5f5a | |||
74f37055bc | |||
d270d615a2 | |||
b7b15e7471 | |||
a2b5df22f8 | |||
116c171249 | |||
c8e5d8a6ab | |||
99323df02b | |||
42e2d3fcc0 | |||
cbd476d1e2 | |||
1be407d34e | |||
605a769b89 | |||
4e23bb623f | |||
cea77ea231 | |||
be34a8bc9b | |||
fdb125c7ba | |||
bcbdac6673 | |||
0520cb8e1d | |||
4dee84a459 | |||
3d47964580 | |||
17c9fa6c10 | |||
500c849140 | |||
64ee960846 | |||
e61bfcc26c | |||
de7088f642 | |||
fcf31294b7 | |||
45b0912d48 | |||
0bf20343d0 | |||
d782801420 | |||
0dec5aa1f2 | |||
51e133b273 | |||
54056aec3a | |||
496192061f | |||
b65631ad9c | |||
826712f910 | |||
90bd5cbde6 | |||
3ec2ae6d0e | |||
17b0348ca6 | |||
227688be25 | |||
5977e1f76e | |||
3551601709 | |||
a3643ee9e5 | |||
1db6a1e19c | |||
ac0ab41dcb | |||
ee0651fa36 | |||
43ca886b74 | |||
34cc9a75bb | |||
e1779d7923 | |||
098791edf4 | |||
8c384d5723 | |||
2c1ea4a31a | |||
829a135ef3 | |||
ac7e4b1b27 | |||
9eecaa4294 | |||
f7823b7390 | |||
61426ace81 | |||
38351e6873 | |||
ab9e1b7e10 | |||
1e3f8dc3c6 | |||
07d3bb2bb9 | |||
381e1f16d1 | |||
e022bc8036 | |||
71e90e7edd | |||
a1bae30e2d |
24
.coding-ci.yml
Normal file
24
.coding-ci.yml
Normal 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
1
.gitignore
vendored
@ -18,6 +18,7 @@ _local
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.ide
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
|
12
.prettierignore
Normal file
12
.prettierignore
Normal file
@ -0,0 +1,12 @@
|
||||
/node_modules
|
||||
package*.json
|
||||
.gitignore
|
||||
*.local
|
||||
*_local
|
||||
__test__
|
||||
.ide
|
||||
.vscode
|
||||
.idea
|
||||
test
|
||||
dist
|
||||
public
|
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
@ -22,6 +22,11 @@ npm run build
|
||||
|
||||
2、单独域名部署,设置环境变量ONLY_LIVE=yes,使用正常编译即可
|
||||
|
||||
#### 多语言部署
|
||||
应用默认使用简体中文进行编译;如果需要指定其他语言需要设置环境变量:`APP_LANGUAGE`;
|
||||
|
||||
`APP_LANGUAGE`目前支持的值有:`zh-CN`(中文)、`en-US`(英语)。
|
||||
|
||||
**使用docker**
|
||||
|
||||
[x] TODO
|
||||
|
@ -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>
|
||||
|
20
package.json
20
package.json
@ -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"
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
4
src/assets/images/error/error_img.svg
Normal file
4
src/assets/images/error/error_img.svg
Normal 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 |
BIN
src/assets/images/error/ic_broken_image.png
Normal file
BIN
src/assets/images/error/ic_broken_image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.0 KiB |
@ -134,7 +134,7 @@ img {
|
||||
--navigation-width: 200px;
|
||||
}
|
||||
.container {
|
||||
max-width: 98%;
|
||||
max-width: 90%;
|
||||
padding: 20px 0;
|
||||
|
||||
}
|
||||
|
61
src/components/article/HotNews.tsx
Normal file
61
src/components/article/HotNews.tsx
Normal 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
|
@ -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{}
|
||||
|
||||
}
|
BIN
src/components/article/assets/bg1.jpg
Normal file
BIN
src/components/article/assets/bg1.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 30 KiB |
BIN
src/components/article/assets/bg2.jpg
Normal file
BIN
src/components/article/assets/bg2.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
@ -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>
|
||||
}
|
@ -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>);
|
||||
|
@ -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>
|
||||
}
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 <></>
|
||||
}
|
@ -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>
|
||||
|
@ -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])
|
||||
|
17
src/components/icons/ModalWarning.tsx
Normal file
17
src/components/icons/ModalWarning.tsx
Normal 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
|
@ -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"
|
||||
|
32
src/components/icons/language-switcher.tsx
Normal file
32
src/components/icons/language-switcher.tsx
Normal 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
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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')
|
||||
}
|
||||
|
||||
|
||||
|
21
src/components/message/confirm.tsx
Normal file
21
src/components/message/confirm.tsx
Normal 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>
|
||||
}
|
@ -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">
|
||||
|
135
src/components/video/Mp4Player.tsx
Normal file
135
src/components/video/Mp4Player.tsx
Normal 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>
|
||||
})
|
165
src/components/video/VideoPlayer.ts
Normal file
165
src/components/video/VideoPlayer.ts
Normal 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;
|
@ -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>;
|
||||
});
|
@ -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>
|
||||
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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;
|
||||
|
13
src/hooks/useGlobalConfig.ts
Normal file
13
src/hooks/useGlobalConfig.ts
Normal file
@ -0,0 +1,13 @@
|
||||
const globalConfig:{
|
||||
i18n?:I18n
|
||||
} = {
|
||||
|
||||
}
|
||||
|
||||
function useGlobalConfig(){
|
||||
return {
|
||||
globalConfig
|
||||
}
|
||||
}
|
||||
|
||||
export default useGlobalConfig;
|
15
src/i18n/config.ts
Normal file
15
src/i18n/config.ts
Normal 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
0
src/i18n/index.ts
Normal file
232
src/i18n/translations/en-US.json
Normal file
232
src/i18n/translations/en-US.json
Normal 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"
|
||||
}
|
||||
}
|
232
src/i18n/translations/zh-CN.json
Normal file
232
src/i18n/translations/zh-CN.json
Normal 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": "缩略图"
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import '@/i18n/config.ts'
|
||||
import App from './App.tsx'
|
||||
import '@/assets/index.scss'
|
||||
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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>)
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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*/}
|
||||
|
@ -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>)
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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
104
src/pages/order/index.tsx
Normal 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
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
@ -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}
|
||||
/>
|
@ -4,6 +4,9 @@
|
||||
:global {
|
||||
.video-bottom {
|
||||
}
|
||||
.video-time-info{
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
@ -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
|
@ -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>
|
||||
</>)
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>)
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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')}>
|
||||
|
@ -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',
|
||||
|
@ -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[]) {
|
||||
|
@ -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
15
src/service/api/order.ts
Normal 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)
|
||||
}
|
11
src/service/api/recycle.ts
Normal file
11
src/service/api/recycle.ts
Normal 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})
|
||||
}
|
@ -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})
|
||||
}
|
||||
|
||||
|
||||
|
@ -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
33
src/types/api.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
2
src/types/config.d.ts
vendored
2
src/types/config.d.ts
vendored
@ -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
27
src/types/core.d.ts
vendored
@ -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
61
src/types/tcplayer.d.ts
vendored
Normal 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;
|
||||
}
|
@ -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
3
src/vite-env.d.ts
vendored
@ -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';
|
||||
|
||||
|
@ -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'))
|
||||
|
Loading…
x
Reference in New Issue
Block a user