Compare commits

...

10 Commits

46 changed files with 723 additions and 271 deletions

View File

@ -17,6 +17,11 @@ npm run build
```
生成的资源在dist目录中将此目录中所有文件放置在待部署web目录即可。
#### 直播页面
1、直接正常部署后使用访问 /live.html 即可
2、单独域名部署设置环境变量ONLY_LIVE=yes使用正常编译即可
**使用docker**
[x] TODO

52
public/live.html Normal file
View File

@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>数字人直播</title>
<link href="https://web.sdk.qcloud.com/player/tcplayer/release/v5.2.0/tcplayer.min.css" rel="stylesheet"/>
<style>
*, :before, :after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
}
body{
margin: 0;
}
.wrapper{}
.wrapper .video-js{
max-width: 480px;
margin: auto;
height: 100vh;
}
.wrapper video{
height: 100vh;
}
</style>
</head>
<body>
<div class="wrapper">
<video id="player-container" width="414" height="270" preload="auto" playsinline webkit-playsinline>
</video>
</div>
<!--播放器脚本文件-->
<script src="https://web.sdk.qcloud.com/player/tcplayer/release/v5.2.0/tcplayer.v5.2.0.min.js"></script>
<script>
function init(){
fetch('/api/v1/tencent/get_pull_url').then(r=>r.json()).then(ret=>{
console.log(ret)
TCPlayer('player-container', {
sources: [{
src: ret.data.flv_url, // 播放地址
}],
licenseUrl: 'https://license.vod2.myqcloud.com/license/v2/1328581896_1/v_cube.license', // license 地址,必传。参考准备工作部分,在视立方控制台申请 license 后可获得 licenseUrl
});
})
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>

View File

@ -1,15 +1,16 @@
import AppRouter from "@/routes";
import {ConfigProvider} from "@/contexts/config";
import {AuthProvider} from "@/contexts/auth";
import LivePlayer from "@/pages/live-player";
console.log(`APP-BUILD-AT: ${AppBuildVersion}`)
function App() {
return (
<ConfigProvider>
<AuthProvider>
{AppConfig.ONLY_LIVE && AppConfig.ONLY_LIVE == 'yes'?<LivePlayer />:<AuthProvider>
<AppRouter/>
</AuthProvider>
</AuthProvider>}
</ConfigProvider>
)
}

View File

@ -164,6 +164,7 @@
@apply text-sm;
background: none;
.col{
@apply text-sm text-gray-800;
height: 42px;
}
}

View File

@ -15,7 +15,6 @@ body {
font-size: 16px;
font-family: -apple-system, "PingFang SC", 'Microsoft YaHei', sans-serif;
background-color: var(--main-bg-color);
min-width: 1000px;
}
.dashboard-layout {

View File

@ -2,11 +2,12 @@ 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";
type Props = {
children?: React.ReactNode;
@ -49,6 +50,7 @@ export default function ArticleBlock(
onChange,
index,
}: Props) {
const {t} = useTranslation()
const blocks = rebuildBlockArray(defaultBlocks)
const handleBlockChange = (index: number, block: BlockContent) => {
@ -59,7 +61,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)} 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')}>
@ -83,12 +85,12 @@ export default function ArticleBlock(
placement={'left'}
arrow={false}
icon={<IconWarningCircle/>}
title={<div style={{minWidth: 150}}><span>?</span></div>}
title={<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>

View File

@ -6,6 +6,7 @@ import * as article from "@/service/api/article.ts";
import {regenerate} from "@/service/api/video.ts";
import {push2video} from "@/service/api/article.ts";
import {showErrorToast, showToast} from "@/components/message.ts";
import {useTranslation} from "react-i18next";
type Props = {
id?: number;
@ -64,7 +65,7 @@ function rebuildGroups(groups: BlockContent[][]) {
}
export default function ArticleEditModal(props: Props) {
const {t} = useTranslation()
const [groups, setGroups] = useState<BlockContent[][]>([]);
const [title, setTitle] = useState('')
@ -85,19 +86,28 @@ export default function ArticleEditModal(props: Props) {
}
const save = props.type == 'news' ? article.save : regenerate
setState({loading: true})
save(title, groups, props.id && props.id > 0 ? props.id : undefined).then(() => {
save(title, groups[0][0].content, groups.slice(1), props.id && props.id > 0 ? props.id : undefined).then(() => {
props.onClose?.(true)
}).catch(e => {
setState({error: e.data || '保存失败,请重试!'})
setState({error: e.data || t('news.edit_save_failed')})
}).finally(() => {
setState({loading: false})
});
}
const handlePush2Video = () =>{
const handlePush2Video = async () =>{
if (!title) {
// setState({msgTitle: '请输入标题内容'});
return;
}
if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) {
// setState({msgGroup: '请输入正文文本内容'});
return;
}
if(!props.id || state.generating) return;
setState({generating:true})
await article.save(title, groups[0][0].content, groups.slice(1), props.id)
push2video([props.id]).then(() => {
showToast('推流成功', 'success')
showToast(t('news.push_stream_success'), 'success')
// navigate('/create?state=push-success',{
// state: 'push-success'
// })
@ -111,7 +121,7 @@ export default function ArticleEditModal(props: Props) {
if (typeof (props.id) != 'undefined') {
if (props.id > 0) {
article.getById(props.id).then(res => {
setGroups(rebuildGroups(res.content_group))
setGroups(rebuildGroups([[{content: res.metahuman_text, type: "text"}], ...res.content_group]))
setTitle(res.title)
})
} else {
@ -135,13 +145,13 @@ export default function ArticleEditModal(props: Props) {
onCancel={() => props.onClose?.()}
okButtonProps={{loading: state.loading}}
onOk={handleSave}
okText={props.type == 'news' ? '确定' : '重新生成'}
okText={props.type == 'news' ? t('confirm') : 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={'请输入文章标题'}/>
setState({msgTitle: e.target.value ? '' : t('news.edit_notice_enter_article_title1')})
}} placeholder={t('news.edit_notice_enter_article_title')}/>
<div className="text-red-500">{state.msgTitle}</div>
</div>
<div className="article-body">
@ -150,7 +160,7 @@ export default function ArticleEditModal(props: Props) {
errorMessage={state.msgGroup} editable groups={groups}
onChange={list => {
setGroups(() => list)
setState({msgGroup: (list.length == 0 || list[0].length == 0 || !list[0][0].content) ? '请输入正文文本内容' : ''});
setState({msgGroup: (list.length == 0 || list[0].length == 0 || !list[0][0].content) ? t('news.edit_notice_enter_article_content') : ''});
}}
/>
</div>
@ -158,9 +168,9 @@ export default function ArticleEditModal(props: Props) {
</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.generating?'推送中...':''}</button> : null}
<button className="text-gray-400 hover:text-gray-800" onClick={() => props.onClose?.()}>{t('cancel')}</button>
<button onClick={handleSave} className="text-gray-800 hover:text-blue-500">{props.type == 'news' ? t('confirm') : t('news.edit_generate_again')}</button>
</div>
</div>
</Modal>);

View File

@ -4,6 +4,7 @@ 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";
type Props = {
groups: BlockContent[][];
@ -14,6 +15,7 @@ type Props = {
export default function ArticleGroup({groups, editable, onChange, errorMessage}: Props) {
const {t,i18n} = useTranslation()
// const groups = rebuildGroups(_groups)
/**
*
@ -24,7 +26,7 @@ export default function ArticleGroup({groups, editable, onChange, errorMessage}:
const triggerGroup = insertIndex == -1 || insertIndex >= groups.length ? groups[groups.length - 1] : groups[insertIndex - 1];
// 判断
if (triggerGroup.length == 0 || triggerGroup.some(s => !s.content)) {
showToast('请先添加内容')
showToast(t('news.edit_notice_enter_text'))
return;
}
}
@ -47,15 +49,15 @@ export default function ArticleGroup({groups, editable, onChange, errorMessage}:
return <div className={styles.group}>
<div className={'panel digital-person'}>
<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">
{/* 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={'请输入文本内容'}
placeholder={t('news.edit_notice_enter_text')}
value={groups && groups.length > 0 ? groups[0][0].content : ''}
autoSize={{minRows: 20, maxRows: 21}}
variant={"borderless"}
@ -63,14 +65,14 @@ export default function ArticleGroup({groups, editable, onChange, errorMessage}:
handleDigitalPersonContentChange(e.target.value)
}}
/>
</div> : <p className="p-2">12123</p>}
</div> : <p className="p-2">{groups && groups.length > 0 ? groups[0][0].content : ''}</p>}
</div>
</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">
@ -91,7 +93,7 @@ export default function ArticleGroup({groups, editable, onChange, errorMessage}:
}}
onRemove={async () => {
if (groups.length == 1) {
message.warning('至少保留一个内容块')
message.warning(t('news.edit_notice_keep_1'))
return;
}
onChange?.(groups.filter((_, idx) => index !== idx))

View File

@ -7,6 +7,7 @@ import styles from './article.module.scss'
import {getOssPolicy} from "@/service/api/common.ts";
import {showToast} from "@/components/message.ts";
import {IconAddImage, IconWarningCircle} from "@/components/icons";
import {useTranslation} from "react-i18next";
type Props = {
children?: React.ReactNode;
@ -25,7 +26,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) => ({
@ -57,7 +58,7 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
onChange?.({type: 'image', content: Data.uploadConfig?.host + '/' + file.url})
} else if (file.status == 'error') {
setLoading(-1)
showToast('上传图片失败,请重试', 'warning')
showToast(t('upload.upload_failed'), 'warning')
} else if (file.status == 'uploading') {
setLoading(file.percent)
}
@ -79,10 +80,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 +91,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>
@ -104,10 +105,10 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
placement={'right'}
arrow={false}
icon={<IconWarningCircle/>}
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>}
@ -117,15 +118,17 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
}
export function BlockText({data, editable, onChange, isFirstBlock}: Props) {
const {t} = useTranslation()
return <div className='flex-1'>
<div
className={clsx(styles.text, isFirstBlock ? 'border-red-400 hover:border-red-500 focus-within:border-red-500' : '')}>
{editable ? <div className="relative">
{/*请输入文本内容*/}
<Input.TextArea
onChange={e => {
onChange?.({type: 'text', content: e.target.value})
}}
placeholder={'请输入文本内容'} value={data.content} autoSize={{minRows: 4, maxRows: 5}}
placeholder={t('news.edit_notice_enter_article_content')} value={data.content} autoSize={{minRows: 4, maxRows: 5}}
variant={"borderless"}/>
</div> : <p className="p-2">{data.content}</p>}
</div>

View File

@ -56,7 +56,7 @@ export default function ButtonBatch(
if(confirmMessage){
modal.confirm({
wrapClassName: 'root-modal-confirm',
title: title || t('notice.title'),
title: title || t('confirm.title'),
centered: true,
icon: <span className="anticon anticon-exclamation-circle"><IconWarningCircle/></span>,
content: confirmMessage,

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import ImageCover from '@/assets/images/cover.png'
import {IconDelete, IconEdit, IconPlaying, IconWarningCircle} from "@/components/icons";
import {VideoStatus} from "@/service/api/video.ts";
import {formatTime} from "@/util/strings.ts";
import {useTranslation} from "react-i18next";
type Props = {
video: VideoInfo | LiveVideoInfo,
@ -37,7 +38,7 @@ export const VideoListItem = (
setNodeRef, transform
} = useSortable({resizeObserverConfig: {}, id})
const {t} = useTranslation()
const [state, setState] = useSetState<{ checked?: boolean }>({})
useEffect(() => {
setState({checked})
@ -60,14 +61,14 @@ export const VideoListItem = (
<img className="w-[100px] h-[56px] object-cover" 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>
<span className="ml-1">{t('video.generating')}</span>
</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>{t('video.playing')}</div>
</div>
</div>}
</div>
@ -107,7 +108,7 @@ export const VideoListItem = (
placement={'left'}
arrow={false}
icon={<IconWarningCircle/>}
title={'你确定要删除此视频吗?'}
title={t('video.delete_confirm_title')}
// description={`删除后需从重新${type == 'create' ? '生成' : '推流'}`}
onConfirm={onRemove}
><button className="hover:text-blue-500"><IconDelete/></button></Popconfirm>}

View File

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

View File

@ -1,10 +1,152 @@
{
"AppTitle": "Digital Human Live",
"AppTitle": "Metahuman Streaming platform",
"Hello": "Hello",
"login": {
"text": "Login"
"close": "Close",
"confirm": {
"push_title": "Push Notice",
"push_video": "Are you sure editing selected news?",
"title": "Notice"
},
"notice": {
"title": "操作提示"
"delete_batch": "Delete Select",
"delete_failed": "Delete failed",
"delete_success": "Delete success",
"download": "Download",
"generating": {
"title": "Preview - Click video item to play"
},
"history": {
"delete_confirm": "Are you sure you want to delete this video?",
"push_success": "Streaming success",
"search_key": "Please enter keywords for video title",
"text": "History video"
},
"live": {
"duration": "Duration",
"edit_locked": "Disable to sort",
"edit_unlock": "Unlock",
"play_first": "Play first video",
"playlist_count": "Current playlist total has {{count}} items",
"title": "Live"
},
"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"
},
"nav": {
"editing": "Editing",
"generating": "Generating",
"live": "Streaming",
"materials": "News Materials"
},
"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_empty": "Please select the items to delete",
"download_empty": "Please select the news to download",
"download_failed": "Download failed!",
"edit_form_search": "Please enter keywords for news title",
"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": "image",
"materials": {
"title": "News Materials"
},
"news_all_source": "source",
"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": "Pushed",
"search_key_title": "Please enter keywords",
"source": "source",
"title": "News content",
"title_image_count": "Number of pictures",
"title_operate": "Operation",
"title_time": "time",
"title_word_count": "Wordcount",
"word_count": "word",
"edit_digital_text": "MetaHuman materiel",
"edit_other_text": "Other media materiel",
"edit_generate_video_again": "Regenerate",
"edit_generate_again": "Regenerate",
"edit_generate_video": "Video generation",
"edit_save_failed": "Save failed!",
"edit_notice_keep_1": "Keep at least one content block",
"edit_notice_enter_text": "Please enter content",
"edit_notice_enter_article_title": "Please enter title",
"edit_notice_enter_article_title1": "Please enter news title",
"edit_notice_enter_article_content": "Please enter content",
"edit_add_group": "Add Group",
"edit_delete_group": "Delete Group",
"edit_delete_group_confirm": "Are you sure delete the group?",
"delete_the_picture": "Are you sure delete the picture?"
},
"upload": {
"upload_failed": "Upload failed",
"delete_confirm": "Are you sure delete the picture?",
"upload_image": "Upload Picture"
},
"delete": "Delete",
"cancel": "Cancel",
"confirm": "Confirm",
"select": {
"pushed": "Pushed {{count}}",
"select_all": "Select all",
"selected": "Selected",
"selected_some": "Selected: {{count}}",
"text": "Select",
"total": "Total: {{count}}"
},
"time_filter": {
"all": "All time",
"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"
},
"user": {
"logout": "Logout"
},
"video": {
"delete_confirm_title": "Are you sure you want to delete this video?",
"delete_confirm": "These videos will be deleted.<br.>They can be recovered from the “news” page. ",
"delete_description": "Are you sure you want to delete these {{count}} videos?",
"delete_empty": "Select the video you want to delete",
"download": "Download",
"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",
"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": "Operation",
"title_thumb": "Cover",
"playing": "Playing",
"generating": "Generating"
}
}

View File

@ -1,10 +1,151 @@
{
"AppTitle": "数字人直播",
"Hello": "你好",
"login": {
"text": "登录"
"close": "关闭",
"confirm": {
"push_title": "推流提示",
"push_video": "是否确定一键推流选中新闻视频?",
"title": "提示"
},
"notice": {
"title": "Notice"
"delete_batch": "批量删除",
"delete_failed": "删除失败",
"delete_success": "删除成功",
"download": "下载",
"generating": {
"title": "预览视频 - 点击视频列表播放"
},
"history": {
"delete_confirm": "是否要删除该视频",
"push_success": "一键推流成功,已推流至数字人直播间,请查看!",
"search_key": "请输入视频标题关键字进行信息",
"text": "历史视频"
},
"live": {
"duration": "时长",
"edit_locked": "锁定状态不可排序",
"edit_unlock": "已解锁",
"play_first": "即将播放第一条视频",
"playlist_count": "当前播放列表共 {{count}} 条",
"title": "直播界面"
},
"login": {
"code_sending": "发送中",
"invalid_username_or_pwd": "账号或密码错误",
"loading": "登录中...",
"password": "请输入验证码",
"send_sms_code": "获取验证码",
"text": "立即登录",
"title": "登录",
"username": "请输入账号",
"welcome": "欢迎登录"
},
"nav": {
"editing": "新闻编辑",
"generating": "视频生成",
"live": "数字人直播间",
"materials": "新闻素材"
},
"news": {
"delete_confirm": "你确定要删除吗?",
"delete_confirm_count": "你确定要删除选择的 {{count}} 条新闻吗?",
"delete_description": "删除后需从新闻素材中重新选择",
"delete_empty": "请选择要删除的新闻",
"download_empty": "请选择要下载的新闻",
"download_failed": "下载新闻失败,请重试!",
"edit_form_search": "请输入新闻标题关键词进行搜索",
"editing": "新闻编辑",
"filter_all": "全部",
"filter_source": "新闻来源",
"generate_video": "生成视频",
"get_detail": "获取新闻详情",
"get_detail_error": "获取新闻详情失败",
"image_count": "图片数",
"materials": {
"title": "新闻素材"
},
"news_all_source": "全部来源",
"push_empty": "请选择要推入编辑的新闻",
"push_failed": "推送失败",
"push_stream_empty": "请选择要开播的新闻",
"push_stream_success": "推流成功",
"push_streaming": "推流中...",
"push_success": "推送成功",
"push_to_edit": "推入编辑",
"pushed": "已推送",
"search_key_title": "请输入新闻标题关键词进行搜索",
"source": "来源",
"title": "标题",
"title_image_count": "图片数",
"title_operate": "操作",
"title_time": "时间",
"title_word_count": "字数",
"word_count": "字数",
"edit_digital_text": "数字人主播台编辑区",
"edit_other_text": "素材融合呈现编辑区",
"edit_generate_video_again": "重新生成",
"edit_generate_again": "重新生成",
"edit_generate_video": "生成视频",
"edit_save_failed": "保存失败,请重试!",
"edit_notice_keep_1": "至少保留一个内容块",
"edit_notice_enter_text": "请先添加内容",
"edit_notice_enter_article_title": "请输入文章标题",
"edit_notice_enter_article_title1": "请输入标题内容",
"edit_notice_enter_article_content": "请输入正文文本内容",
"edit_add_group": "新增分组",
"edit_delete_group": "删除此分组",
"edit_delete_group_confirm": "请确认删除此分组?",
"delete_the_picture": "请确认删除此图片"
},
"upload": {
"upload_failed": "上传图片失败,请重试",
"delete_confirm": "请确认删除此图片?",
"upload_image": "上传图片"
},
"delete": "删除",
"cancel": "取消",
"confirm": "确定",
"select": {
"pushed": "已推送: {{count}} 条",
"select_all": "全选",
"selected": "已选",
"selected_some": "已选 {{count}} 条",
"text": "选择",
"total": "总共 {{count}} 条"
},
"time_filter": {
"all": "所有时间",
"last_week": "近一周",
"past_24_hour": "一天内",
"past_30_min": "半小时内",
"past_4_hour": "四小时内",
"past_hour": "一小时内"
},
"user": {
"logout": "退出登录"
},
"video": {
"delete_confirm_title": "你确定要删除此视频吗 ",
"delete_confirm": "删除后需重新生成视频",
"delete_description": "已选择{{count}}条,确定要全部删除吗?",
"delete_empty": "请选择要删除的视频",
"download": "下载视频",
"push_confirm": "是否确定一键推流选中新闻视频?",
"push_empty": "请选择要推流的新闻视频",
"push_failed": "选择视频中有部分视频还在生成中无法推送,推流成功视频前往数字人直播间页面查看!",
"push_success": "一键推流成功,已推流至数字人直播间,请前往数字人直播间页面查看!",
"push_to_live": "一键推流",
"sort_modify_confirm": "是否采纳移动视频位置操作?",
"sort_modify_failed": "调整视频顺序失败,请重试!",
"sort_modify_live_success": "已完成直播队列的修改",
"sort_modify_rollback": "退出并恢复之前的直播队列!",
"sort_modify_success": "调整视频顺序成功",
"title": "标题",
"title_generated_time": "生成时间",
"title_operation": "操作",
"title_thumb": "缩略图",
"playing": "播放中"
}
}

View File

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

View File

@ -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,7 +46,7 @@ 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"

View File

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

View File

@ -11,13 +11,14 @@ import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infini
import ButtonBatch from "@/components/button-batch.tsx";
import ButtonToTop from "@/components/scoller/button-to-top.tsx";
import {IconArrowRight, IconDelete} from "@/components/icons";
import {useTranslation} from "react-i18next";
const DEFAULT_PAGE_LIMIT = {
page: 1,
limit: 12
}
export default function LibraryIndex() {
const {t} = useTranslation()
const [modal, contextHolder] = Modal.useModal();
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
const [params, setParams] = useState<VideoSearchParams>({
@ -49,8 +50,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);
}
@ -59,8 +60,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');
}
@ -71,6 +72,7 @@ export default function LibraryIndex() {
autoPlay: boolean
}>()
const handleAllCheckedChange = (checked: boolean) => {
if (!data) return;
setCheckedIdArray(checked ? data.list.map(v => v.id) : [])
setState({
checkedAll: !state.checkedAll
@ -105,13 +107,13 @@ export default function LibraryIndex() {
<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>
<span>{t('select.total',{count:data?.list.length || 0})}</span>
<span>{t('select.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"
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}
@ -157,17 +159,19 @@ export default function LibraryIndex() {
onSuccess={refresh}
className='bg-gray-300 hover:bg-gray-400 text-white'
icon={<IconDelete className=""/>}
title={`你确定要删除选择的 ${checkedIdArray.length} 条视频吗?`}
confirmMessage={'删除后需重新生成视频'}
title={t('video.delete_description',{count:checkedIdArray.length})}
emptyMessage={t('video.delete_empty')}
confirmMessage={t('video.delete_confirm')}
onProcess={deleteHistories}
></ButtonBatch>}
>{t('delete_batch')}</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}
></ButtonBatch>}
emptyMessage={t('video.push_empty')}
>{t('video.push_to_live')}</ButtonBatch>}
</div>
</>)
}

View File

@ -0,0 +1,17 @@
import {useMount} from "ahooks";
import {getLiveUrl} from "@/service/api/live.ts";
import React, {useState} from "react";
import {Player} from "@/components/video/player.tsx";
import './style.scss'
export default function LivePlayer() {
const [liveUrl, setLiveUrl] = useState<string>('http://fm.live.starbitech.com/fm/prod_fm.flv')
useMount(async ()=>{
getLiveUrl().then((ret)=>{
setLiveUrl(ret.flv_url)
})
})
return <div className="live-player-wrapper ">
<Player showControls url={liveUrl} className={'h-screen'} />
</div>
}

View File

@ -0,0 +1,9 @@
.live-player-wrapper {
@apply relative m-auto h-screen w-screen max-w-[480px] content-center relative;
.player-container{
@apply bg-gray-500 w-full h-screen ;
}
video {
height: 100vh !important;
}
}

View File

@ -15,10 +15,11 @@ import {Player, PlayerInstance} from "@/components/video/player.tsx";
import {IconDelete, IconLocked, IconUnlock} from "@/components/icons";
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
import ButtonToTop from "@/components/scoller/button-to-top.tsx";
import {useTranslation} from "react-i18next";
const cache: { flvPlayer?: FlvJs.Player, timerPlayNext?: any, timerLoadState?: any, prevUrl?: string } = {}
export default function LiveIndex() {
const {t} = useTranslation()
const player = useRef<PlayerInstance | null>(null)
const [videoData, setVideoData] = useState<LiveVideoInfo[]>([])
@ -65,7 +66,7 @@ export default function LiveIndex() {
const _activeIndex = index != undefined && index > -1 ? index : (endToFirst ? 0 : activeIndex.current + 1)
setState({activeIndex: _activeIndex})
if (endToFirst) {
showToast('即将播放第一条视频');
showToast(t("live.play_first"));
}
// 找到对应video item 并显示在视图可见区域
showVideoItem(_activeIndex)
@ -143,7 +144,7 @@ export default function LiveIndex() {
const processDeleteVideo = async (ids: Id[]) => {
deleteByIds(ids).then(() => {
showToast('删除成功!', 'success')
showToast(t('delete_success'), 'success')
loadList()
}).catch(showErrorToast)
}
@ -158,20 +159,20 @@ export default function LiveIndex() {
return;
}
modal.confirm({
title: '提示',
content: '是否采纳移动视频位置操作?',
title: t('confirm.title'),
content: t('video.sort_modify_confirm'),
centered: true,
onOk: () => {
//showToast('编辑成功!!!', 'info');
modifyOrder(videoData.map(s => s.id)).then(() => {
showToast('已完成直播队列的修改!', 'success')
showToast(t('video.sort_modify_live_success'), 'success')
setEditable(false)
}).catch(() => {
showToast('调整视频顺序失败,请重试!', 'warning')
showToast(t('video.sort_modify_failed'), 'warning')
})
},
onCancel: () => {
showToast('退出并恢复之前的直播队列!', 'info');
showToast(t('video.sort_modify_rollback'), 'info');
loadList()
setEditable(false)
}
@ -210,7 +211,7 @@ export default function LiveIndex() {
<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">{t('live.title')}</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,7 +225,7 @@ 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(currentTotalDuration)} / {formatDuration(totalDuration)}</span>
</div>
</div>
</div>
@ -233,14 +234,14 @@ export default function LiveIndex() {
<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>{editable ? t('live.edit_unlock') : t('live.edit_locked')}</span>
<span className="ml-2 text-sm">
{editable ? <IconUnlock/> : <IconLocked/>}
</span>
@ -248,7 +249,7 @@ export default function LiveIndex() {
<div className="check-all ml-10">
<button className="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()}/>
@ -258,10 +259,10 @@ export default function LiveIndex() {
<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="">
@ -322,12 +323,12 @@ export default function LiveIndex() {
{checkedIdArray.length > 0 && <ButtonBatch
className='bg-gray-300 hover:bg-gray-400 text-white'
selected={checkedIdArray}
emptyMessage={`请选择要删除的视频`}
confirmMessage={`是否删除当前的${checkedIdArray.length}条视频?`}
emptyMessage={t('video.delete_empty')}
confirmMessage={t('video.delete_description',{count:checkedIdArray.length})}
onSuccess={loadList}
onProcess={processDeleteVideo}
>
<span className={'text'}></span>
<span className={'text'}>{t('delete_batch')}</span>
<IconDelete/>
</ButtonBatch>}
</div>

View File

@ -3,17 +3,20 @@ 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";
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 +24,14 @@ 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: '删除后需从新闻素材中重新选择',
title: t('news.delete_confirm_count',{count:props.ids.length}),
content: <span dangerouslySetInnerHTML={{__html:t('news.delete_description')}}></span>,
onOk: handlePush,
centered: true
})
@ -40,7 +43,7 @@ export default function ButtonDeleteBatch(props: { ids: Id[];onSuccess?: () => v
onClick={onPushClick}
className='bg-gray-300 hover:bg-gray-400 text-white'
>
<span className={'text'}></span>
<span className={'text'}>{t('delete_batch')}</span>
<IconDelete className=""/>
</button>
</div>

View File

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

View File

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

View File

@ -3,16 +3,18 @@ import {showErrorToast, showToast} from "@/components/message.ts";
import {push2video} from "@/service/api/article.ts";
import {IconArrowRight} from "@/components/icons";
import {useNavigate} from "react-router-dom";
import {useTranslation} from "react-i18next";
export default function ButtonPush2Video(props: { ids: Id[]; onSuccess?: () => void; }) {
const [loading, setLoading] = useState(false)
const {t} = useTranslation()
const navigate = useNavigate()
const handlePush = () => {
setLoading(true)
push2video(props.ids).then(() => {
showToast('推流成功', 'success')
navigate('/create?state=push-success',{
showToast(t('news.push_stream_success'), 'success')
navigate('/create?state=push-success', {
state: 'push-success'
})
// props.onSuccess?.()
@ -23,7 +25,7 @@ export default function ButtonPush2Video(props: { ids: Id[]; onSuccess?: () => v
const onPushClick = () => {
if (loading) return;
if (props.ids.length === 0) {
showToast('请选择要开播的新闻', 'warning')
showToast(t('news.push_stream_empty'), 'warning')
return
}
// Modal.confirm({
@ -40,7 +42,7 @@ export default function ButtonPush2Video(props: { ids: Id[]; onSuccess?: () => v
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>
</div>

View File

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

View File

@ -9,6 +9,7 @@ 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 = {
onSearch?: (params: ApiArticleSearchParams) => void;
@ -24,6 +25,7 @@ const DEFAULT_STATE = {
}
export default function SearchPanel({onSearch,defaultParams}: SearchPanelProps) {
const tags = useArticleTags();
const {t} = useTranslation()
const [params, setParams] = useSetState<ApiArticleSearchParams>({
pagination,
time_flag:1,
@ -146,13 +148,13 @@ export default function SearchPanel({onSearch,defaultParams}: SearchPanelProps)
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"
className="w-[140px] ml-1"
value={typeof(params.time_flag) != "undefined" ? params.time_flag : 1}
onChange={handleTimeFilter}
/>
@ -167,7 +169,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
@ -194,7 +196,7 @@ export default function SearchPanel({onSearch,defaultParams}: SearchPanelProps)
{/* 固定新闻来源 */}
<div className={clsx(styles.pinnedManagePanel)}>
<div className="header flex justify-between">
<div className="title font-bold"></div>
<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>

View File

@ -56,7 +56,7 @@
}
}
.title{
@apply flex-1 pl-0 justify-start;
@apply flex-1 pl-0;
&:after{
display: none;
}
@ -64,7 +64,11 @@
.source{
width: 180px;
}
.count-picture,.count-words{
.count-picture{
width: 160px;
text-align: center;
}
.count-words{
width: 120px;
text-align: center;
}

View File

@ -15,11 +15,13 @@ 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";
const FilterCache: Partial<ApiArticleSearchParams> = {
tags: [],
}
export default function NewEdit() {
const {t} = useTranslation()
const [state, setState] = useState<{
checkAll?: boolean;
showToTop?: boolean;
@ -68,7 +70,7 @@ export default function NewEdit() {
const handleDelete = (id) => {
deleteByIds([id]).then(() => {
refresh()
showToast('删除成功', 'success')
showToast(t('delete_success'), 'success')
}).catch(showErrorToast)
}
@ -80,15 +82,15 @@ 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>
}}>{t('select.select_all')}</span>
<Checkbox checked={state.checkAll && (!data?.list || selectedRowKeys.length == data?.list?.length)}
onChange={e => {
handleCheckAll(e.target.checked)
@ -97,12 +99,12 @@ export default function NewEdit() {
</div>
<div className={styles.newListTable}>
<div className="header row flex">
<div className="col title"></div>
<div className="col source 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 => ({
@ -142,8 +144,8 @@ export default function NewEdit() {
placement={'left'}
arrow={false}
icon={<IconWarningCircle/>}
title={'你确定要删除吗?'}
description={'删除后需从新闻素材中重新选择'}
title={t('news.delete_confirm')}
description={<span dangerouslySetInnerHTML={{__html:t('news.delete_description')}}></span>}
onConfirm={() => {
handleDelete(item.id)
}}

View File

@ -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
@ -55,12 +57,12 @@ export default function NewsIndex() {
})
const handleViewNewsDetail = (id: number) => {
const {update, close} = showLoading('获取新闻详情...')
const {update, close} = showLoading(`${t('news.get_detail')}...`)
getById(id).then(res => {
close()
setActiveNews({...res, id})
}).catch(() => {
update('获取新闻详情失败', 'info')
update(t('news.get_detail_error'), 'info')
})
}
@ -113,21 +115,21 @@ export default function NewsIndex() {
<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>
@ -169,15 +171,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)
}}/>}

View File

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

View File

@ -3,10 +3,12 @@ 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 {useTranslation} from "react-i18next";
export default function ButtonPush2Room(props: { ids: Id[]; list: VideoInfo[];onSuccess?:()=>void; }) {
const [loading, setLoading] = useState(false)
const {t} = useTranslation()
const handlePush = () => {
setLoading(true)
// 只需要已经生成视频的数据id
@ -14,9 +16,9 @@ export default function ButtonPush2Room(props: { ids: Id[]; list: VideoInfo[];on
push2room(vids).then(() => {
props.onSuccess?.()
if(props.ids.length == vids.length){
showToast('一键推流成功,已推流至数字人直播间,请前往数字人直播间页面查看!', 'success')
showToast(t("video.push_success"), 'success')
}else{
showToast('选择视频中有部分视频还在生成中无法推送,推流成功视频前往数字人直播间页面查看!', 'success')
showToast(t("video.push_failed"), 'success')
}
}).catch(showErrorToast).finally(() => {
setLoading(false)
@ -25,14 +27,14 @@ export default function ButtonPush2Room(props: { ids: Id[]; list: VideoInfo[];on
const onPushClick = () => {
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: '是否确定一键推流选中新闻视频??',
content: t("video.push_confirm"),
onOk: handlePush
})
}
@ -44,7 +46,7 @@ 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>
<span className={'text'}>{t("video.push_to_live")}</span>
<IconArrowRight />
</button>
</div>

View File

@ -2,11 +2,11 @@ import {Checkbox, Empty, Space} from "antd";
import React, {useEffect, useMemo, useRef, useState} from "react";
import {DndContext} from "@dnd-kit/core";
import {arrayMove, SortableContext} from "@dnd-kit/sortable";
import {useSetState, useTimeout} from "ahooks";
import {useSetState} from "ahooks";
import {VideoListItem} from "@/components/video/video-list-item.tsx";
import ArticleEditModal from "@/components/article/edit-modal.tsx";
import {deleteByIds, getList, modifyOrder, VideoStatus} from "@/service/api/video.ts";
import {deleteFromList, getList, modifyOrder, 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";
@ -16,8 +16,10 @@ 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";
import {useTranslation} from "react-i18next";
export default function VideoIndex() {
const {t} = useTranslation()
const [editId, setEditId] = useState(-1)
const loc = useLocation()
const [videoData, setVideoData] = useState<VideoInfo[]>([])
@ -55,7 +57,7 @@ export default function VideoIndex() {
// 判断是否有生成中的视频
if (list.filter(s => s.status == VideoStatus.Generating).length > 0) {
// 每5s重新获取一次最新数据
setTimer(()=>setTimeout(() => loadList(false), 5000) as number);
setTimer(()=>setTimeout(() => loadList(false), 5000) as any);
}
}).catch(showErrorToast)
.finally(()=>{
@ -96,10 +98,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 ()=>{
@ -131,8 +133,8 @@ export default function VideoIndex() {
}
}, [videoData, scrollerRef])
const processDeleteVideo = async (ids: Id[]) => {
deleteByIds(ids).then(() => {
showToast('删除成功!', 'success')
deleteFromList(ids).then(() => {
showToast(t('delete_success'), 'success')
loadList()
}).catch(showErrorToast)
}
@ -142,7 +144,7 @@ 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">
<Player
@ -170,12 +172,12 @@ export default function VideoIndex() {
<div className="pl-[70px]"></div>
<div className="flex items-center">
<Space>
<span> {videoData.length || 0} </span>
<span className={'text-blue-500'}> {checkedIdArray.length} </span>
<span>{t('select.selected_some',{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"
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 +187,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})}>
@ -256,18 +258,18 @@ export default function VideoIndex() {
<div className="page-action">
<ButtonToTop visible={state.showToTop} onClick={() => scrollerRef.current?.scrollToPosition(0)}/>
{checkedIdArray.length > 0 && <ButtonBatch
onProcess={deleteByIds}
onProcess={deleteFromList}
selected={checkedIdArray}
emptyMessage={`请选择要删除的新闻视频`}
title={`已选择${checkedIdArray.length}条,确定要全部删除吗?`}
emptyMessage={t('video.delete_empty')}
title={t('video.delete_description',{count:checkedIdArray.length})}
className='bg-gray-300 hover:bg-gray-400 text-white'
confirmMessage={`删除后需重新生成视频`}
confirmMessage={t('video.delete_confirm')}
onSuccess={() => {
showToast('删除成功!', 'success')
showToast(t('delete_success'), 'success')
loadList()
}}
>
<span className="text"></span>
<span className="text">{t('delete_batch')}</span>
<IconDelete/>
</ButtonBatch>}
<ButtonPush2Room ids={checkedIdArray} list={videoData} onSuccess={loadList}/>

View File

@ -29,12 +29,15 @@ const router = createBrowserRouter([
// future={{v7_startTransition: true,v7_relativeSplatPath: true}}
const AppRouter = () => {
const {t} = useTranslation();
const {t,i18n:langConfig} = useTranslation();
const {i18n} = useConfig();
useEffect(() => {
if (i18n && i18n == 'zh-CN') {
dayjs.locale('zh-cn');
}else{
dayjs.locale('en')
}
langConfig.changeLanguage(i18n).then(()=>console.log('change lang to ',i18n))
}, [i18n])
return (<ConfigProvider

View File

@ -1,5 +1,5 @@
import {Outlet, useLocation, useNavigate} from "react-router-dom";
import {Divider, Dropdown, MenuProps} from "antd";
import {Button, Divider, Dropdown, MenuProps} from "antd";
import React, {useEffect} from "react";
import AuthGuard from "@/routes/layout/auth-guard.tsx";
@ -13,6 +13,7 @@ import {hidePhone} from "@/util/strings.ts";
import {defaultCache} from "@/hooks/useCache.ts";
import {IconVideo} from "@/components/icons";
import {useTranslation} from "react-i18next";
import useConfig from "@/hooks/useConfig.ts";
type LayoutProps = {
@ -31,7 +32,7 @@ const NavigationUserContainer = () => {
key: 'profile',
label: <div className="nav-item" onClick={() => navigate('/history')}>
<IconVideo />
<span className={"nav-text"}></span>
<span className={"nav-text"}>{t('history.text')}</span>
</div>,
},
// {
@ -43,7 +44,7 @@ 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">{t('login.text')}</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"}>
@ -65,7 +66,7 @@ const NavigationUserContainer = () => {
</div>
<Divider style={{ margin: 0 }} />
<div className="logout">
<div onClick={handleLogout}>退</div>
<div onClick={handleLogout}>{t('user.logout')}</div>
</div>
</div>
@ -76,6 +77,7 @@ const NavigationUserContainer = () => {
</div>)
}
export const BaseLayout: React.FC<LayoutProps> = ({children}) => {
const {i18n,onChangeLocalization} = useConfig();
return (<div className={'dashboard-layout min-h-screen'}>
<div className="min-h-screen w-full">
<div className="app-header">
@ -84,6 +86,13 @@ export const BaseLayout: React.FC<LayoutProps> = ({children}) => {
</div>
<DashboardNavigation/>
<div className="flex items-center">
{
i18n == 'zh-CN'?(
<Button className="ml-2" onClick={()=>onChangeLocalization('en-US')}>Change To EN</Button>
):(
<Button className="ml-2" onClick={()=>onChangeLocalization('zh-CN')}></Button>
)
}
<NavigationUserContainer/>
</div>
</div>

View File

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

View File

@ -20,9 +20,10 @@ export function getById(id: Id) {
return post<ArticleDetail>({url: '/article/detail/' + id})
}
export function save(title: string, content_group: BlockContent[][], id?: number) {
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
})

View File

@ -1,4 +1,4 @@
import {post} from "@/service/request.ts";
import {get, post} from "@/service/request.ts";
export function playState() {
return post<LiveState>({url: '/room/playing'})
@ -14,4 +14,11 @@ export function modifyOrder(ids: Id[]) {
export function deleteByIds(ids: Id[]) {
return post('/room/remove', {ids})
}
export function getLiveUrl() {
return get<{flv_url:string}>({
url: '/tencent/get_pull_url',
baseURL: '/api/v1'
})
}

View File

@ -1,4 +1,4 @@
import {post} from "@/service/request.ts";
import {get, post} from "@/service/request.ts";
export function getList() {
return post<DataList<VideoInfo>>('/video/list')
@ -7,12 +7,7 @@ export function search(params:VideoSearchParams) {
return post<DataList<VideoInfo>>('/video/search',params)
}
export function deleteHistories(ids: Id[]) {
console.log('deleteHistories',ids)
return new Promise<number>((resolve)=>{
setTimeout(()=>{
resolve(1)
},2000)
})
return post('/video/history/remove', {ids})
}
/**
@ -21,11 +16,12 @@ export function deleteHistories(ids: Id[]) {
* @param content_group
* @param article_id
*/
export function regenerate(title: string, content_group: BlockContent[][], article_id?: Id) {
export function regenerate(title: string, metahuman_text: string, content_group: BlockContent[][], article_id?: Id) {
return post<{ content: string }>({
url: '/video/regenerate',
data: {
title,
metahuman_text,
content_group,
article_id
}
@ -36,8 +32,8 @@ export function getById(id: Id) {
return post<VideoInfo>({url: '/video/detail/' + id})
}
export function deleteByIds(ids: Id[]) {
return post('/video/remove', {ids})
export function deleteFromList(ids: Id[]) {
return post('/video/outside', {ids})
}

View File

@ -71,12 +71,13 @@ export function post<T>(params: RequestOption | string, _data?: AllType) {
})
}
export function get<T>(params: RequestOption) {
if (params.data) {
params.url += (params.url.indexOf('?') === -1 ? '?' : '&') + stringify(params.data)
export function get<T>(params: RequestOption|string) {
const options = typeof params === 'string' ? {url: params} : params;
if (options.data) {
options.url += (options.url.indexOf('?') === -1 ? '?' : '&') + stringify(options.data)
}
return request<T>({
...params,
...options,
method: 'get'
})
}

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

@ -31,6 +31,7 @@ declare interface ArticleContentGroup {
declare interface ArticleDetail {
id: number;
title: string;
metahuman_text: string;
content_group: BlockContent[][]
}

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

@ -12,6 +12,7 @@ declare const AppConfig: {
// 登录用户信息 key
AUTHED_PERSON_DATA_KEY: string;
API_PREFIX: string;
ONLY_LIVE: string;
};
declare const AppMode: 'test' | 'production' | 'development';

View File

@ -21,6 +21,7 @@ export default defineConfig(({mode}) => {
API_PREFIX: process.env.APP_API_PREFIX || '/mgmt/v1/metahuman',
AUTH_TOKEN_KEY: process.env.AUTH_TOKEN_KEY || AUTH_TOKEN_KEY,
AUTHED_PERSON_DATA_KEY: process.env.AUTHED_PERSON_DATA_KEY || 'digital-person-user-info',
ONLY_LIVE: process.env.ONLY_LIVE || 'no',
}),
AppMode: JSON.stringify(mode),
AppBuildVersion: JSON.stringify(AppPackage.name + '-' + AppPackage.version + '-' + dayjs().format('YYYYMMDDHH_mmss'))
@ -46,7 +47,7 @@ export default defineConfig(({mode}) => {
// rewrite: (path) => path.replace(/^\/api/, '')
},
'/api': {
target: `http://${devServerHost}`,
target: `${devServerHost}`,
changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api/, '')
}