Compare commits
10 Commits
03c1449268
...
a1bae30e2d
Author | SHA1 | Date | |
---|---|---|---|
a1bae30e2d | |||
fae2e9c4ae | |||
c7d964965d | |||
daba38f188 | |||
1026c35c08 | |||
b9212c14de | |||
779b366062 | |||
2da4527ab8 | |||
c383aff66a | |||
3eeb6a4c8e |
@ -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>
|
||||
|
@ -26,10 +26,12 @@
|
||||
"dayjs": "^1.11.11",
|
||||
"file-saver": "^2.0.5",
|
||||
"flv.js": "^1.6.2",
|
||||
"i18next": "^24.2.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",
|
||||
|
@ -292,7 +292,34 @@
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.video-history-list-container{
|
||||
height: calc(100vh - var(--app-header-header) - 130px);
|
||||
}
|
||||
.checkbox{
|
||||
@apply bg-black/10 backdrop-blur border border-white hover:border-blue-500 cursor-pointer relative;
|
||||
--size: 22px;
|
||||
border-width: 2px;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 2px;
|
||||
&::before {
|
||||
@apply absolute hidden;
|
||||
border-left:solid 2px white;
|
||||
border-bottom:solid 2px white;
|
||||
left: 3px;
|
||||
top: 4px;
|
||||
content: ' ';
|
||||
width: calc(var(--size) - 8px);
|
||||
height: 6px;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
&.checked{
|
||||
@apply border-blue-500 bg-blue-500;
|
||||
&:before{
|
||||
@apply block;
|
||||
}
|
||||
}
|
||||
}
|
||||
// override antd style
|
||||
.data-list-load-spin{
|
||||
.ant-spin-container::after{
|
||||
@ -408,7 +435,7 @@
|
||||
|
||||
// 全局按钮
|
||||
.page-action {
|
||||
@apply fixed right-10 bottom-10 flex flex-col gap-4;
|
||||
@apply fixed right-10 bottom-10 flex flex-col gap-4 z-10;
|
||||
button {
|
||||
@apply border-0 min-w-[120px] h-[40px] rounded-3xl pr-4 flex items-center justify-between drop-shadow;
|
||||
.text {
|
||||
|
@ -71,6 +71,46 @@ body {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
.news-detail{
|
||||
video{
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.userinfo-drop-menu{
|
||||
@apply drop-shadow bg-gray-50 pb-2 rounded px-4;
|
||||
.user-profile{
|
||||
@apply py-3;
|
||||
.info{
|
||||
min-width: 100px;
|
||||
}
|
||||
.user-avatar{
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
.menu-list-container{
|
||||
.ant-dropdown-menu{
|
||||
@apply shadow-none bg-transparent p-0 rounded-none;
|
||||
.ant-dropdown-menu-item{
|
||||
@apply hover:bg-gray-200 text-gray-600 hover:text-gray-800 rounded-none text-base py-2;
|
||||
}
|
||||
.nav-item{
|
||||
@apply flex items-center;
|
||||
.svg-icon{
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.nav-text{
|
||||
@apply ml-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.logout{
|
||||
@apply text-center py-2 hover:bg-gray-200 text-gray-600 hover:text-gray-800 cursor-pointer;
|
||||
}
|
||||
}
|
||||
.news-detail-content-container{
|
||||
margin-right: -30px;
|
||||
|
@ -2,7 +2,7 @@ import React from "react";
|
||||
import clsx from "clsx";
|
||||
import {Divider, Popconfirm} from "antd";
|
||||
|
||||
import {IconAdd, IconAddCircle, IconDelete} from "@/components/icons";
|
||||
import {IconAdd, IconAddCircle, IconDelete, IconWarningCircle} from "@/components/icons";
|
||||
import ImageList from "@/components/article/list.tsx";
|
||||
|
||||
import { BlockText} from "./item.tsx";
|
||||
@ -79,6 +79,10 @@ export default function ArticleBlock(
|
||||
{editable && <div className="ml-2 flex items-center">
|
||||
{
|
||||
index > 0 ? <Popconfirm
|
||||
rootClassName={'popconfirm-main'}
|
||||
placement={'left'}
|
||||
arrow={false}
|
||||
icon={<IconWarningCircle/>}
|
||||
title={<div style={{minWidth: 150}}><span>请确认删除此分组?</span></div>}
|
||||
onConfirm={onRemove}
|
||||
okText="删除"
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Input, Modal, Space} from "antd";
|
||||
import {Modal} from "antd";
|
||||
import ArticleGroup from "@/components/article/group.tsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useSetState} from "ahooks";
|
||||
@ -157,7 +157,7 @@ export default function ArticleEditModal(props: Props) {
|
||||
{state.error && <div className="text-red-500">{state.error}</div>}
|
||||
</div>
|
||||
<div className="modal-control-footer flex justify-end">
|
||||
<div className="text-lg flex gap-10 ">
|
||||
<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>
|
||||
|
@ -6,7 +6,7 @@ import {clsx} from "clsx";
|
||||
import styles from './article.module.scss'
|
||||
import {getOssPolicy} from "@/service/api/common.ts";
|
||||
import {showToast} from "@/components/message.ts";
|
||||
import {IconAddImage} from "@/components/icons";
|
||||
import {IconAddImage, IconWarningCircle} from "@/components/icons";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@ -77,6 +77,8 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
|
||||
<img src={data.content}/>
|
||||
<div className={styles.uploadTips}>
|
||||
{!onlyUpload && <Popconfirm
|
||||
rootClassName={'popconfirm-main'}
|
||||
placement={'right'}
|
||||
title={<div style={{minWidth: 150}}><span>请确认删除此删除此图片?</span></div>}
|
||||
onConfirm={onRemove}
|
||||
okText="删除"
|
||||
@ -98,7 +100,11 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
|
||||
<img src={data.content}/>
|
||||
<div className={styles.uploadTips}>
|
||||
{!onlyUpload && <Popconfirm
|
||||
title={<div style={{minWidth: 150}}><span>请确认删除此删除此图片?</span></div>}
|
||||
rootClassName={'popconfirm-main'}
|
||||
placement={'right'}
|
||||
arrow={false}
|
||||
icon={<IconWarningCircle/>}
|
||||
title={<div style={{minWidth: 150}}><span>请确认删除此图片?</span></div>}
|
||||
onConfirm={onRemove}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
|
@ -1,21 +1,25 @@
|
||||
import React, {useState} from "react";
|
||||
import {Modal} from "antd";
|
||||
import {App} from "antd";
|
||||
import {ButtonType} from "antd/es/button";
|
||||
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
selected: any[],
|
||||
type?: ButtonType;
|
||||
emptyMessage: string,
|
||||
confirmMessage: React.ReactNode,
|
||||
onProcess: (ids: Id[]) => Promise<any|void>
|
||||
confirmMessage?: React.ReactNode,
|
||||
icon?: React.ReactNode,
|
||||
onProcess: (ids: Id[]) => Promise<any | void>
|
||||
successMessage?: string;
|
||||
onSuccess?: () => void;
|
||||
children?: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
className?:string;
|
||||
className?: string;
|
||||
|
||||
}
|
||||
/**
|
||||
@ -23,10 +27,12 @@ type Props = {
|
||||
*/
|
||||
export default function ButtonBatch(
|
||||
{
|
||||
selected, emptyMessage, successMessage, children,
|
||||
title, confirmMessage, onProcess,onSuccess,className
|
||||
selected, emptyMessage, successMessage, children, icon,
|
||||
title, confirmMessage, onProcess, onSuccess, className
|
||||
}: Props) {
|
||||
const {t} = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const {modal} = App.useApp()
|
||||
const onBatchProcess = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@ -42,22 +48,29 @@ export default function ButtonBatch(
|
||||
}
|
||||
}
|
||||
const handleBtnClick = () => {
|
||||
if(loading) return;
|
||||
if (loading) return;
|
||||
if (selected.length == 0) {
|
||||
showToast(emptyMessage, 'warning')
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
wrapClassName:'root-modal-confirm',
|
||||
title: title || '操作提示',
|
||||
centered: true,
|
||||
icon: <span className="anticon anticon-exclamation-circle"><IconWarningCircle/></span>,
|
||||
content: confirmMessage,
|
||||
onOk: onBatchProcess
|
||||
})
|
||||
if(confirmMessage){
|
||||
modal.confirm({
|
||||
wrapClassName: 'root-modal-confirm',
|
||||
title: title || t('notice.title'),
|
||||
centered: true,
|
||||
icon: <span className="anticon anticon-exclamation-circle"><IconWarningCircle/></span>,
|
||||
content: confirmMessage,
|
||||
onOk: onBatchProcess
|
||||
})
|
||||
}else{
|
||||
onBatchProcess().catch(showErrorToast);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button disabled={loading} className={className} onClick={handleBtnClick}>{children}</button>
|
||||
<button disabled={loading} className={className} onClick={handleBtnClick}>
|
||||
{icon ? <span className="text">{children}</span>:children}
|
||||
{loading ? <LoadingOutlined/> : icon}
|
||||
</button>
|
||||
)
|
||||
}
|
@ -2,12 +2,12 @@ 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 || '';
|
||||
}
|
||||
}, []);
|
||||
return <></>
|
||||
|
@ -90,6 +90,12 @@ export const IconAddText = ({style, className}: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const IconVideo = ({style, className}: IconProps) => (
|
||||
<svg className={`svg-icon ${className || ''} icon-video`} style={style} xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em" height="1em" viewBox="0 0 21 20" version="1.1">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M18 1.00268e-07C18.5046 -0.000159579 18.9906 0.190406 19.3605 0.533497C19.7305 0.876588 19.9572 1.34684 19.995 1.85L20 2V16C20.0002 16.5046 19.8096 16.9906 19.4665 17.3605C19.1234 17.7305 18.6532 17.9572 18.15 17.995L18 18H2C1.49542 18.0002 1.00943 17.8096 0.639452 17.4665C0.269471 17.1234 0.0428434 16.6532 0.00500021 16.15L1.00268e-07 16V2C-0.000159579 1.49542 0.190406 1.00943 0.533497 0.639452C0.876588 0.269471 1.34684 0.0428434 1.85 0.00500021L2 1.00268e-07H18ZM18 2H2V16H18V2ZM8.34 4.638L8.858 4.868L9.196 5.028L9.583 5.218L10.013 5.436L10.483 5.686L10.99 5.966L11.256 6.118L11.774 6.423L12.248 6.715L12.678 6.988L13.058 7.241L13.538 7.571L13.902 7.834L13.997 7.904C14.1513 8.01883 14.2767 8.16816 14.363 8.34005C14.4494 8.51194 14.4943 8.70164 14.4943 8.894C14.4943 9.08636 14.4494 9.27606 14.363 9.44795C14.2767 9.61984 14.1513 9.76917 13.997 9.884L13.674 10.119L13.234 10.427L12.878 10.666L12.473 10.929L12.02 11.212L11.521 11.512L10.987 11.821L10.478 12.103L10.007 12.353L9.577 12.573L9.191 12.761L8.569 13.049L8.339 13.149C8.16242 13.2251 7.97051 13.2589 7.77856 13.2476C7.58662 13.2364 7.39995 13.1805 7.23346 13.0843C7.06696 12.9881 6.92524 12.8544 6.8196 12.6937C6.71396 12.5331 6.64732 12.35 6.625 12.159L6.567 11.594L6.535 11.22L6.493 10.556L6.47 10.048L6.455 9.493L6.451 9.199L6.449 8.894C6.449 8.68733 6.451 8.48733 6.455 8.294L6.47 7.739L6.493 7.232L6.52 6.775L6.55 6.374L6.625 5.63C6.64719 5.43882 6.71376 5.25547 6.81939 5.09458C6.92502 4.93369 7.0668 4.79972 7.2334 4.70335C7.4 4.60698 7.58682 4.55089 7.77896 4.53954C7.97109 4.5282 8.16321 4.56191 8.34 4.638ZM8.951 7.139L8.515 6.921L8.486 7.408L8.464 7.959L8.451 8.569L8.449 8.894L8.451 9.219L8.464 9.828L8.474 10.111L8.5 10.631L8.515 10.866L8.949 10.648L9.436 10.392L9.971 10.098L10.255 9.936L10.806 9.61L11.3 9.304L11.736 9.024L11.932 8.894L11.525 8.624L11.059 8.33C10.7938 8.16584 10.5261 8.00582 10.256 7.85L9.973 7.689L9.439 7.395L8.951 7.139Z" fill="black"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const IconAddImage = ({style, className}: IconProps) => (
|
||||
<svg className={`svg-icon ${className || ''} icon-delete`} style={style} xmlns="http://www.w3.org/2000/svg"
|
||||
|
@ -1,4 +1,3 @@
|
||||
import {Button} from "antd";
|
||||
import {ArrowUpOutlined} from "@ant-design/icons";
|
||||
|
||||
|
||||
@ -11,7 +10,6 @@ export default function ButtonToTop(props: ButtonToTopProps) {
|
||||
return (
|
||||
<div className={'page-action-to-top'}>
|
||||
{props.visible && <button className="btn-to-top text-white" onClick={()=>{
|
||||
console.log(props)
|
||||
if(props.onClick){
|
||||
props.onClick()
|
||||
}else if(props.container){
|
||||
|
@ -24,6 +24,7 @@ type Props = {
|
||||
onChange?: (state: State) => void;
|
||||
onProgress?: (current:number,duration:number) => void;
|
||||
muted?: boolean;
|
||||
autoPlay?: boolean;
|
||||
}
|
||||
export type PlayerInstance = {
|
||||
play: (url: string, currentTime: number) => void;
|
||||
@ -79,7 +80,7 @@ export const Player = React.forwardRef<PlayerInstance, Props>((props, ref) => {
|
||||
controls: props.showControls,
|
||||
// muted:props.muted,
|
||||
poster: props.poster,
|
||||
autoplay: true,
|
||||
autoplay: typeof(props.autoPlay) != 'undefined' ? props.autoPlay : true,
|
||||
licenseUrl: 'https://license.vod2.myqcloud.com/license/v2/1328581896_1/v_cube.license'
|
||||
}
|
||||
)
|
||||
|
@ -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({
|
||||
|
14
src/i18n/config.ts
Normal file
14
src/i18n/config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
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)
|
||||
i18next.use(initReactI18next).init({
|
||||
debug: true,
|
||||
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
10
src/i18n/translations/en-US.json
Normal file
10
src/i18n/translations/en-US.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"AppTitle": "Digital Human Live",
|
||||
"Hello": "Hello",
|
||||
"login": {
|
||||
"text": "Login"
|
||||
},
|
||||
"notice": {
|
||||
"title": "操作提示"
|
||||
}
|
||||
}
|
10
src/i18n/translations/zh-CN.json
Normal file
10
src/i18n/translations/zh-CN.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"AppTitle": "数字人直播",
|
||||
"Hello": "你好",
|
||||
"login": {
|
||||
"text": "登录"
|
||||
},
|
||||
"notice": {
|
||||
"title": "Notice"
|
||||
}
|
||||
}
|
@ -1,56 +1,57 @@
|
||||
import {Button, Form, Input, Select, Space} from "antd";
|
||||
import {Input} from "antd";
|
||||
import {useSetState} from "ahooks";
|
||||
import {PlayCircleOutlined} from "@ant-design/icons";
|
||||
import {SearchListTimes} from "@/pages/news/components/news-source.ts";
|
||||
import {SearchOutlined} from "@ant-design/icons";
|
||||
import React from "react";
|
||||
import TimeSelect from "@/components/form/time-select.tsx";
|
||||
|
||||
type Props = {
|
||||
onSearch?: (params: VideoSearchParams) => void;
|
||||
onBtnStartClick?: () => Promise<void>;
|
||||
loading?:boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
export default function SearchForm({onSearch, onBtnStartClick,loading}: Props) {
|
||||
|
||||
export default function SearchForm({onSearch}: Props) {
|
||||
const [state, setState] = useSetState<{
|
||||
pushing?: boolean;
|
||||
}>({})
|
||||
const onFinish = (values) => {
|
||||
time_flag: number;
|
||||
title?: string;
|
||||
}>({
|
||||
time_flag: 0
|
||||
})
|
||||
|
||||
const onFinish = (params: Partial<VideoSearchParams>) => {
|
||||
onSearch?.({
|
||||
...values,
|
||||
pagination: {page: 1, limit: 10}
|
||||
time_flag: params.time_flag,
|
||||
title: params.title,
|
||||
pagination: {page: 1, limit: 12}
|
||||
})
|
||||
//console.log(values)
|
||||
}
|
||||
|
||||
return (<div className={'search-panel'}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="search-form">
|
||||
<Form<VideoSearchParams> className={""} layout="inline" onFinish={onFinish} initialValues={{title:'',time_flag:0}}>
|
||||
<Form.Item name="title">
|
||||
<Input className="w-[200px]" allowClear placeholder={'请输入搜索信息'}/>
|
||||
</Form.Item>
|
||||
<Form.Item label={'更新时间'} name="time_flag" className="w-[250px]">
|
||||
<Select
|
||||
options={SearchListTimes}
|
||||
optionRender={(option) => (
|
||||
<div className="flex items-center">
|
||||
<span role="icon" className={`radio-icon`}></span>
|
||||
<span role="listitem" aria-label={String(option.label)}>{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space size={10}>
|
||||
<Button loading={loading} type={'primary'} htmlType={'submit'}>搜索</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
const handleTimeFilter = (time_flag: number) => {
|
||||
setState({time_flag})
|
||||
onFinish({
|
||||
title: state.title, time_flag
|
||||
})
|
||||
}
|
||||
|
||||
return (<div className={'search-panel pt-6 pb-2'}>
|
||||
<div className="search-form">
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
className="w-[270px] rounded-3xl"
|
||||
prefix={<SearchOutlined/>}
|
||||
onChange={e => setState({title: e.target.value})}
|
||||
onPressEnter={() => onFinish(state)}
|
||||
onBlur={() => onFinish(state)}
|
||||
allowClear
|
||||
placeholder={'请输入视频标题关键字进行信息'}
|
||||
/>
|
||||
<TimeSelect
|
||||
className="w-[120px] ml-1"
|
||||
value={state.time_flag}
|
||||
onChange={handleTimeFilter}
|
||||
/>
|
||||
</div>
|
||||
<Space size={10}>
|
||||
<Button
|
||||
loading={state.pushing} type={'primary'}
|
||||
onClick={onBtnStartClick} icon={<PlayCircleOutlined/>}
|
||||
>一键推流</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
30
src/pages/library/components/style.module.scss
Normal file
30
src/pages/library/components/style.module.scss
Normal file
@ -0,0 +1,30 @@
|
||||
.videoItem {
|
||||
border: solid 3px transparent;
|
||||
|
||||
:global {
|
||||
.video-bottom {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.videoChecked {
|
||||
@apply border-blue-500;
|
||||
}
|
||||
|
||||
.playIcon {
|
||||
--size: 40px;
|
||||
@apply bg-black/70 flex items-center justify-center;
|
||||
border: solid 2px rgba(255, 255, 255, 0.5);
|
||||
border-radius: var(--size);
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
&:hover{
|
||||
@apply bg-blue-500;
|
||||
}
|
||||
svg{
|
||||
font-size: 24px;
|
||||
transform: translate(2px);
|
||||
}
|
||||
}
|
@ -1,21 +1,17 @@
|
||||
import {Button, Input, Modal} from "antd";
|
||||
import {Modal} from "antd";
|
||||
import {saveAs} from "file-saver";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useSetState} from "ahooks";
|
||||
import {Player} from "@/components/video/player.tsx";
|
||||
|
||||
import ArticleGroup from "@/components/article/group";
|
||||
import * as article from "@/service/api/article.ts";
|
||||
import {push2room} from "@/service/api/video.ts";
|
||||
import {showErrorToast, showToast} from "@/components/message.ts";
|
||||
|
||||
type Props = {
|
||||
video?: VideoInfo;
|
||||
autoPlay?: boolean;
|
||||
onClose?: () => void
|
||||
}
|
||||
export default function VideoDetail({video, onClose}: Props) {
|
||||
const [groups, setGroups] = useState<BlockContent[][]>([]);
|
||||
|
||||
export default function VideoDetail({video, onClose,autoPlay}: Props) {
|
||||
const [state, setState] = useSetState({
|
||||
exporting: false,
|
||||
pushing: false,
|
||||
@ -40,53 +36,26 @@ export default function VideoDetail({video, onClose}: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (video) {
|
||||
if (video.id > 0) {
|
||||
article.getById(video.id).then(res => {
|
||||
setGroups(res.content_group)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [video])
|
||||
|
||||
return (<>
|
||||
<Modal open={!!video} title="新闻视频详情" width={1000} footer={null} onCancel={onClose}>
|
||||
<div className="flex gap-2 my-5">
|
||||
<div className="news-video w-[350px]">
|
||||
<div className="video-container bg-gray-100 rounded overflow-hidden h-[640px]">
|
||||
<Player url={video?.oss_video_url} poster={video?.cover} showControls={true}
|
||||
className="w-[360px] h-[640px] bg-white"/>
|
||||
</div>
|
||||
<div className="video-info text-right text-sm text-gray-600 mt-3">
|
||||
<span>创建时间: 5小时前</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail flex-1 ml-5">
|
||||
<div className="text-lg">新闻内容</div>
|
||||
<div className="article-title mt-5 items-center flex">
|
||||
<span className="text text-base">标题</span>
|
||||
<span className="ml-4 flex-1">
|
||||
<Input value={video?.title}/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="aricle-body mt-3">
|
||||
<div className="title">
|
||||
<span className="text text-base">正文</span>
|
||||
</div>
|
||||
<div className="box mt-1">
|
||||
<ArticleGroup groups={groups}/>
|
||||
</div>
|
||||
<Modal
|
||||
open={!!video} width={390} closeIcon={null} title={null} footer={null} onCancel={onClose}
|
||||
rootClassName={"article-edit-modal"}
|
||||
>
|
||||
<div className="flex gap-2 px-6 pt-6">
|
||||
<div className="news-video w-[340px]">
|
||||
<div className="video-container bg-gray-100 rounded overflow-hidden">
|
||||
<Player autoPlay={autoPlay} url={video?.oss_video_url} poster={video?.cover} showControls={true}
|
||||
className="w-[340px] h-[600px] bg-white"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer flex justify-between">
|
||||
<div className="action flex gap-2">
|
||||
<Button loading={state.pushing} type="primary" onClick={pushToRoom}>一键推流</Button>
|
||||
<Button onClick={downloadVideo}>下载视频</Button>
|
||||
</div>
|
||||
<div className="close">
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
<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.exporting} className="text-gray-400 hover:text-gray-800 " onClick={downloadVideo}
|
||||
type="text">下载视频
|
||||
</button>
|
||||
<button onClick={onClose} type="text" className="text-gray-800 hover:text-blue-500">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
@ -1,49 +1,41 @@
|
||||
import {Checkbox, Image, Tag} from "antd";
|
||||
import {IconDelete} from "@/components/icons";
|
||||
import {useState} from "react";
|
||||
import clsx from "clsx";
|
||||
import {CaretRightOutlined} from "@ant-design/icons"
|
||||
import {timeFromNow} from "@/util/strings.ts";
|
||||
|
||||
|
||||
import ImageCover from './cover.png'
|
||||
import {formatDuration, timeFromNow} from "@/util/strings.ts";
|
||||
import dayjs from "dayjs";
|
||||
import styles from './style.module.scss'
|
||||
|
||||
type VideoItemProps = {
|
||||
videoInfo: VideoInfo;
|
||||
onLive?: boolean;
|
||||
onClick?: () => void;
|
||||
onClick?: (autoPlay:boolean) => void;
|
||||
onRemove?: () => void;
|
||||
onCheckedChange?: (checked:boolean) => void;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
checked?: boolean;
|
||||
}
|
||||
export default function VideoItem(props: VideoItemProps) {
|
||||
const [state, setState] = useState({
|
||||
checked: false
|
||||
})
|
||||
const handleCheckedChange = (checked:boolean) => {
|
||||
setState({checked})
|
||||
if (props.onCheckedChange) {
|
||||
props.onCheckedChange(checked)
|
||||
}
|
||||
}
|
||||
|
||||
return <div className={'video-item bg-gray-100 hover:drop-shadow-md rounded overflow-hidden relative group'}>
|
||||
<div className={`controls absolute top-1 right-1 z-[2] p-1 rounded items-center gap-2 bg-white/80 ${state.checked?'flex':'hidden'} group-hover:flex`}>
|
||||
<span onClick={props.onRemove} className={'cursor-pointer text-blue-500 text-2xl cursor-pointer'}><IconDelete /></span>
|
||||
{!props.onLive && <Checkbox onChange={e=>handleCheckedChange(e.target.checked)} />}
|
||||
export default function VideoItem(props: VideoItemProps) {
|
||||
|
||||
return <div
|
||||
className={clsx(styles.videoItem, `rounded-lg h-[240px] overflow-hidden relative group ${props.checked ? styles.videoChecked : ''}`)}>
|
||||
<div className={`controls absolute top-1 right-1 z-[2] rounded items-center gap-2`}>
|
||||
{/*<span onClick={props.onRemove} className={'cursor-pointer text-blue-500 text-2xl cursor-pointer'}><IconDelete /></span>*/}
|
||||
<div className={clsx("checkbox", {checked: props.checked})}
|
||||
onClick={() => props.onCheckedChange?.(!props.checked)}></div>
|
||||
</div>
|
||||
<div className="cover" onClick={props.onClick}>
|
||||
<img className={'w-full cursor-pointer h-[180px] object-cover'} src={props.videoInfo.cover}/>
|
||||
</div>
|
||||
<div className="text-sm py-2 px-3">
|
||||
<div className="title my-1 cursor-pointer" onClick={props.onClick}>{props.videoInfo.title}</div>
|
||||
<div className="info flex justify-between gap-2 text-sm">
|
||||
<div className="video-time-info text-gray-500">
|
||||
<span>时长: {formatDuration(Math.ceil(props.videoInfo.duration / 1000))}</span>
|
||||
<span className="ml-1">{timeFromNow(props.videoInfo.publish_time)}</span>
|
||||
</div>
|
||||
{props.videoInfo.status == 3 && <div className="live-info">
|
||||
<Tag color="processing" className="mr-0">已在直播间</Tag>
|
||||
</div>}
|
||||
<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>
|
||||
</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>
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
}
|
@ -1,25 +1,52 @@
|
||||
import {useState} from "react";
|
||||
import {Empty, Modal, Pagination} from "antd";
|
||||
import {useRequest} from "ahooks";
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {Checkbox, Modal, Space} from "antd";
|
||||
import {useRequest, useSetState} from "ahooks";
|
||||
|
||||
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 {search} from "@/service/api/video.ts";
|
||||
import {deleteHistories, push2room, search} from "@/service/api/video.ts";
|
||||
import {getList} from "@/service/api/live.ts";
|
||||
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
|
||||
import ButtonBatch from "@/components/button-batch.tsx";
|
||||
import ButtonToTop from "@/components/scoller/button-to-top.tsx";
|
||||
import {IconArrowRight, IconDelete} from "@/components/icons";
|
||||
|
||||
const DEFAULT_PAGE_LIMIT = {
|
||||
|
||||
page: 1,
|
||||
limit: 12
|
||||
}
|
||||
export default function LibraryIndex() {
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
|
||||
const [params, setParams] = useState<VideoSearchParams>({
|
||||
time_flag: 0,
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 10
|
||||
pagination: {...DEFAULT_PAGE_LIMIT}
|
||||
})
|
||||
const [state, setState] = useSetState({
|
||||
checkedAll: false,
|
||||
loading: false,
|
||||
pushedCount: 0,
|
||||
showToTop: false
|
||||
})
|
||||
const [data, setData] = useState<DataList<VideoInfo>>()
|
||||
const scrollerRef = useRef<InfiniteScrollerRef | null>(null)
|
||||
|
||||
const {loading} = useRequest(() => search(params), {
|
||||
refreshDeps: [params],
|
||||
onSuccess: (data) => {
|
||||
setData(prev => {
|
||||
// 判断页码是否是第1页
|
||||
if (data.pagination.page == 1) return data;
|
||||
return {
|
||||
list: [...(prev?.list || []), ...(data?.list || [])],
|
||||
pagination: data.pagination
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
const {data,loading} = useRequest(() => search(params), {
|
||||
refreshDeps: [params]
|
||||
})
|
||||
|
||||
const handleRemove = (video: VideoInfo) => {
|
||||
modal.confirm({
|
||||
title: '删除提示',
|
||||
@ -39,53 +66,108 @@ export default function LibraryIndex() {
|
||||
}
|
||||
})
|
||||
}
|
||||
const [detailVideo, setDetailVideo] = useState<VideoInfo>()
|
||||
const [detailVideo, setDetailVideo] = useState<{
|
||||
video: VideoInfo,
|
||||
autoPlay: boolean
|
||||
}>()
|
||||
const handleAllCheckedChange = (checked: boolean) => {
|
||||
setCheckedIdArray(checked ? data.list.map(v => v.id) : [])
|
||||
setState({
|
||||
checkedAll: !state.checkedAll
|
||||
})
|
||||
}
|
||||
const loadPushedState = () => {
|
||||
getList().then((ret) => {
|
||||
if (ret.list) {
|
||||
setState({pushedCount: ret.list.length})
|
||||
}
|
||||
})
|
||||
}
|
||||
const refresh = () => {
|
||||
loadPushedState();
|
||||
setParams(prev => ({...prev, pagination: {page: 1, limit: DEFAULT_PAGE_LIMIT.limit}, request_time: Date.now()}))
|
||||
}
|
||||
|
||||
useEffect(loadPushedState, [])
|
||||
|
||||
return (<>
|
||||
<div className={'container py-20'}>
|
||||
<div className={'container pb-5'}>
|
||||
{contextHolder}
|
||||
<div className="search-form-container mb-5">
|
||||
<div className="search-form-container">
|
||||
<SearchForm
|
||||
onSearch={setParams}
|
||||
onBtnStartClick={handleLive}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white rounded p-5">
|
||||
<div className={'video-list-container grid gap-5 grid-cols-4'}>
|
||||
{data?.list?.map((it, idx) => (
|
||||
<VideoItem
|
||||
onLive={idx == 2} key={it.id}
|
||||
videoInfo={it}
|
||||
onRemove={() => handleRemove(it)}
|
||||
onClick={() => setDetailVideo(it)}
|
||||
onCheckedChange={(checked) => {
|
||||
setCheckedIdArray(idArray => {
|
||||
return checked ? idArray.concat(it.id) : idArray.filter(id => id != it.id);
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="video-page-container flex justify-center mt-5">
|
||||
{data?.pagination && data?.pagination.total > 0 ? <div className="flex justify-center mt-10">
|
||||
<Pagination
|
||||
current={params.pagination.page}
|
||||
total={data?.pagination.total}
|
||||
pageSize={data?.pagination.limit}
|
||||
showSizeChanger={false}
|
||||
simple={true}
|
||||
rootClassName={'simple-pagination'}
|
||||
onChange={(page) => setParams(prev=>({...prev,pagination: {page, limit: 10}}))}
|
||||
/>
|
||||
</div> : <div className="py-10">
|
||||
<Empty />
|
||||
<div className="">
|
||||
<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>
|
||||
<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>
|
||||
{/*<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>*/}
|
||||
</button>
|
||||
<Checkbox checked={checkedIdArray.length == data?.list.length}
|
||||
onChange={e => handleAllCheckedChange(e.target.checked)}/>
|
||||
</div>
|
||||
}
|
||||
{/*<Pagination defaultCurrent={1} total={50}/>*/}
|
||||
</div>
|
||||
<InfiniteScroller
|
||||
ref={scrollerRef} loading={loading} rootClassName="video-history-list-container"
|
||||
pagination={data?.pagination} onCallback={(page) => {
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
pagination: {page, limit: DEFAULT_PAGE_LIMIT.limit}
|
||||
}))
|
||||
}} onScroll={(top) => setState({showToTop: top > 30})}
|
||||
>
|
||||
<div className={'video-list-container grid gap-4 grid-cols-3 xl:grid-cols-4'}>
|
||||
{data?.list?.map((it, idx) => (
|
||||
<VideoItem
|
||||
onLive={idx == 2}
|
||||
key={idx}
|
||||
videoInfo={it}
|
||||
onRemove={() => handleRemove(it)}
|
||||
onClick={(autoPlay) => setDetailVideo({video: it, autoPlay})}
|
||||
checked={checkedIdArray.includes(it.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setCheckedIdArray(idArray => {
|
||||
return checked ? idArray.concat(it.id) : idArray.filter(id => id != it.id);
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</InfiniteScroller>
|
||||
</div>
|
||||
</div>
|
||||
{detailVideo && <VideoDetail video={detailVideo} onClose={() => setDetailVideo(undefined)}/>}
|
||||
{detailVideo && <VideoDetail video={detailVideo.video} autoPlay={detailVideo.autoPlay}
|
||||
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} 条视频吗?`}
|
||||
confirmMessage={'删除后需重新生成视频'}
|
||||
onProcess={deleteHistories}
|
||||
>批量删除</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>}
|
||||
</div>
|
||||
</>)
|
||||
}
|
@ -232,8 +232,8 @@ export default function LiveIndex() {
|
||||
<div className="live-control flex justify-between mb-1">
|
||||
<div>
|
||||
<Space>
|
||||
<span className={"text-blue-500"}>当前{state.activeIndex == -1 ? '暂未播放' : `播放到 ${state.activeIndex + 1} 条`}</span>
|
||||
<span>共 {videoData.length} 条</span>
|
||||
{/*<span className={"text-blue-500"}>视频正在播放{state.activeIndex == -1 ? '' : `到 ${state.activeIndex + 1} 条`}</span>*/}
|
||||
<span>当前播放列表共 {videoData.length} 条</span>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
@ -10,7 +10,7 @@ import ButtonPush2Video 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, IconWarningCircle} from "@/components/icons";
|
||||
import {IconDelete, IconEdit, IconWarningCircle} 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";
|
||||
@ -57,6 +57,7 @@ export default function NewEdit() {
|
||||
}
|
||||
const handleCheckAll = (checked: boolean) => {
|
||||
setState({checkAll: checked})
|
||||
if(!data?.list) return;
|
||||
if (checked) {
|
||||
setSelectedRowKeys(data?.list?.map(item => item.id) || [])
|
||||
} else {
|
||||
@ -88,7 +89,7 @@ export default function NewEdit() {
|
||||
<span className={'inline-block cursor-pointer mr-2'} onClick={() => {
|
||||
handleCheckAll(!state.checkAll)
|
||||
}}>全选</span>
|
||||
<Checkbox checked={state.checkAll && selectedRowKeys.length == data?.list.length}
|
||||
<Checkbox checked={state.checkAll && (!data?.list || selectedRowKeys.length == data?.list?.length)}
|
||||
onChange={e => {
|
||||
handleCheckAll(e.target.checked)
|
||||
}}/>
|
||||
@ -135,7 +136,7 @@ export default function NewEdit() {
|
||||
className="text-sm">{formatTime(item.publish_time, 'YYYY-MM-DD HH:mm')}</div>
|
||||
</div>
|
||||
<div className="col operations">
|
||||
{/*<span className="icon-btn"><IconEdit/></span>*/}
|
||||
<span className="icon-btn" onClick={()=>setEditId(item.id)}><IconEdit/></span>
|
||||
<Popconfirm
|
||||
rootClassName={'popconfirm-main'}
|
||||
placement={'left'}
|
||||
@ -165,7 +166,8 @@ export default function NewEdit() {
|
||||
</div>
|
||||
</div>
|
||||
<ArticleEditModal
|
||||
type="news" id={editId}
|
||||
type="news"
|
||||
id={editId}
|
||||
onClose={(saved) => {
|
||||
setEditId(-1)
|
||||
if (saved) refresh()
|
||||
|
@ -2,6 +2,7 @@ import React, {useMemo, useRef, useState} from "react";
|
||||
import {Checkbox, Divider, Empty, Modal, Space} from "antd";
|
||||
import {useRequest} from "ahooks";
|
||||
import {CloseOutlined} from "@ant-design/icons"
|
||||
import {clsx} from "clsx";
|
||||
|
||||
import SearchPanel from "@/pages/news/components/search-panel.tsx";
|
||||
import {getById, getList} from "@/service/api/news.ts";
|
||||
@ -9,7 +10,6 @@ import {showLoading} from "@/components/message.ts";
|
||||
import {formatTime} from "@/util/strings.ts";
|
||||
import ButtonPushNews2Article from "@/pages/news/components/button-push-news2article.tsx";
|
||||
import ButtonNewsDownload from "@/pages/news/components/button-news-download.tsx";
|
||||
import {clsx} from "clsx";
|
||||
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
|
||||
import ButtonToTop from "@/components/scoller/button-to-top.tsx";
|
||||
import {useIndexArrayCache} from "@/hooks/useCache.ts";
|
||||
@ -94,7 +94,7 @@ export default function NewsIndex() {
|
||||
footer={null} onCancel={() => setActiveNews(undefined)}
|
||||
>
|
||||
<div className="news-detail pl-16 pr-1 flex pb-5">
|
||||
<div className="px-4 py-6 bg-white">
|
||||
<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">
|
||||
<span className="source text-blue-400">{activeNews?.media_name}</span>
|
||||
@ -104,7 +104,7 @@ 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-2">
|
||||
<div className="actions ml-3">
|
||||
<div className="close">
|
||||
<CloseOutlined className="text-xl text-gray-400 hover:text-gray-800"
|
||||
onClick={() => setActiveNews(undefined)}/>
|
||||
@ -113,7 +113,7 @@ export default function NewsIndex() {
|
||||
<Checkbox
|
||||
checked={checkedId.includes(activeNews!.id)}
|
||||
onChange={() => handleCheckChange(activeNews!.id)}
|
||||
><span>选择</span></Checkbox>
|
||||
><span className="ml-[-4px]">选择</span></Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -134,7 +134,7 @@ export default function NewsIndex() {
|
||||
</div>
|
||||
</div>
|
||||
<InfiniteScroller
|
||||
className="grid grid-cols-3 gap-4 lg:grid-cols-4 pb-2"
|
||||
className="grid grid-cols-2 gap-4 xl:grid-cols-4 md:grid-cols-3 pb-2"
|
||||
pagination={data?.pagination}
|
||||
loading={loading}
|
||||
ref={scrollerRef}
|
||||
@ -175,7 +175,7 @@ export default function NewsIndex() {
|
||||
<div><span>图片数: {item.img_num}</span></div>
|
||||
<div><span>字数: {item.content_word_count}</span></div>
|
||||
<div
|
||||
className={`checkbox mt-1`}>
|
||||
className={` mt-1`}>
|
||||
{item.internal_article_id > 0 ?
|
||||
<span className={"inline-block text-gray-600"}>已推送</span> :
|
||||
<Checkbox checked={checkedId.includes(item.id)} onChange={() => {
|
||||
|
@ -2,7 +2,7 @@ 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} from "ahooks";
|
||||
import {useSetState, useTimeout} from "ahooks";
|
||||
|
||||
import {VideoListItem} from "@/components/video/video-list-item.tsx";
|
||||
import ArticleEditModal from "@/components/article/edit-modal.tsx";
|
||||
@ -35,9 +35,15 @@ export default function VideoIndex() {
|
||||
loading:false
|
||||
})
|
||||
const [checkedIdArray, setCheckedIdArray] = useState<Id[]>([])
|
||||
const [refreshTimer,setTimer] = useState(0)
|
||||
|
||||
// 加载列表
|
||||
const loadList = (needReset = true) => {
|
||||
if(state.loading) return;
|
||||
if(refreshTimer) {
|
||||
clearTimeout(refreshTimer)
|
||||
setTimer(0)
|
||||
}
|
||||
setState({loading: true})
|
||||
getList().then((ret) => {
|
||||
const list = ret.list || []
|
||||
@ -49,12 +55,19 @@ export default function VideoIndex() {
|
||||
// 判断是否有生成中的视频
|
||||
if (list.filter(s => s.status == VideoStatus.Generating).length > 0) {
|
||||
// 每5s重新获取一次最新数据
|
||||
setTimeout(() => loadList(false), 5000)
|
||||
setTimer(()=>setTimeout(() => loadList(false), 5000) as number);
|
||||
}
|
||||
}).catch(showErrorToast)
|
||||
.finally(()=>{
|
||||
setState({loading: false})
|
||||
})
|
||||
return ()=>{
|
||||
if(refreshTimer){
|
||||
|
||||
clearTimeout(refreshTimer)
|
||||
}
|
||||
console.log('go out',refreshTimer)
|
||||
}
|
||||
}
|
||||
|
||||
// 播放视频
|
||||
|
@ -1,13 +1,16 @@
|
||||
import {createBrowserRouter, RouterProvider,} from "react-router-dom";
|
||||
import {Suspense,} from "react";
|
||||
import {ConfigProvider,App} from "antd";
|
||||
import {Suspense, useEffect,} from "react";
|
||||
import {ConfigProvider, App} from "antd";
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
// for date-picker i18n
|
||||
import dayjs from "dayjs";
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import ErrorBoundary from "./error.tsx";
|
||||
import Loader from "@/components/loader.tsx";
|
||||
import routes from "@/routes/routes.tsx";
|
||||
import {DocumentTitle} from "@/components/document.tsx";
|
||||
import useConfig from "@/hooks/useConfig.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
|
||||
const router = createBrowserRouter([
|
||||
@ -26,16 +29,23 @@ const router = createBrowserRouter([
|
||||
|
||||
// future={{v7_startTransition: true,v7_relativeSplatPath: true}}
|
||||
const AppRouter = () => {
|
||||
const {t} = useTranslation();
|
||||
const {i18n} = useConfig();
|
||||
useEffect(() => {
|
||||
if (i18n && i18n == 'zh-CN') {
|
||||
dayjs.locale('zh-cn');
|
||||
}
|
||||
}, [i18n])
|
||||
|
||||
return (<ConfigProvider
|
||||
locale={zhCN}
|
||||
locale={i18n == 'zh-CN' ? zhCN : undefined}
|
||||
theme={{
|
||||
token: {
|
||||
borderRadius: 4,
|
||||
|
||||
},
|
||||
|
||||
}}
|
||||
>
|
||||
<DocumentTitle title={t('AppTitle')}/>
|
||||
<App>
|
||||
<Suspense fallback={<Loader/>}>
|
||||
<RouterProvider future={{v7_startTransition: true}} router={router}/>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {Outlet, useLocation, useNavigate} from "react-router-dom";
|
||||
import {Dropdown, MenuProps} from "antd";
|
||||
import {Divider, Dropdown, MenuProps} from "antd";
|
||||
import React, {useEffect} from "react";
|
||||
|
||||
import AuthGuard from "@/routes/layout/auth-guard.tsx";
|
||||
@ -11,6 +11,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 {useTranslation} from "react-i18next";
|
||||
|
||||
|
||||
type LayoutProps = {
|
||||
@ -18,32 +20,57 @@ type LayoutProps = {
|
||||
}
|
||||
|
||||
const NavigationUserContainer = () => {
|
||||
const {t } = useTranslation()
|
||||
const {logout, user} = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const handleLogout = ()=>{
|
||||
logout().then(() => navigate('/user'))
|
||||
}
|
||||
const items: MenuProps['items'] = [
|
||||
// {
|
||||
// key: 'profile',
|
||||
// label: <div onClick={() => {
|
||||
// navigate('/history')
|
||||
// }}>视频库</div>,
|
||||
// },
|
||||
{
|
||||
key: 'logout',
|
||||
label: <div onClick={() => {
|
||||
logout().then(() => navigate('/user'))
|
||||
}}>退出</div>,
|
||||
key: 'profile',
|
||||
label: <div className="nav-item" onClick={() => navigate('/history')}>
|
||||
<IconVideo />
|
||||
<span className={"nav-text"}>视频库</span>
|
||||
</div>,
|
||||
},
|
||||
// {
|
||||
// key: 'logout',
|
||||
// label: <div onClick={handleLogout}>退出</div>,
|
||||
// },
|
||||
];
|
||||
const UserButton = () => (<div
|
||||
className={`flex items-center rounded-3xl ${user ? 'bg-[#e3eeff]' : 'bg-primary-blue'} p-1 pr-2 cursor-pointer rounded`}>
|
||||
<UserAvatar className="user-avatar size-7"/>
|
||||
{user ? <span className={"username ml-2 text-sm"}>{hidePhone(user.nickname)}</span> : (
|
||||
<span className="text-sm mx-2 text-white">登录</span>
|
||||
<span className="text-sm mx-2 text-white">{t('login.text')}</span>
|
||||
)}
|
||||
</div>)
|
||||
|
||||
return (<div className={"flex items-center justify-between gap-2 ml-10"}>
|
||||
{user ? <Dropdown rootClassName={'z-[99999]'} menu={{items}} placement="bottom" arrow>
|
||||
{user ? <Dropdown
|
||||
rootClassName={'z-[999999] userinfo-drop-menu'}
|
||||
menu={{items}} placement="bottomRight"
|
||||
dropdownRender={(menu)=>(
|
||||
<div>
|
||||
<div className="user-profile flex gap-4">
|
||||
<div className="avatar"><UserAvatar className="user-avatar"/></div>
|
||||
<div className="info">
|
||||
<div>{user?.nickname}</div>
|
||||
<div>ID: {user?.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
<div className="menu-list-container">
|
||||
{menu}
|
||||
</div>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
<div className="logout">
|
||||
<div onClick={handleLogout}>退出登录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
>
|
||||
<div><UserButton/></div>
|
||||
</Dropdown> : <UserButton/>}
|
||||
</div>)
|
||||
|
@ -6,6 +6,15 @@ export function getList() {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频列表的文章编辑(需要重新生成视频)
|
||||
* @param title
|
||||
|
3
src/types/api.d.ts
vendored
3
src/types/api.d.ts
vendored
@ -2,7 +2,8 @@ declare interface ApiRequestPageParams {
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
};
|
||||
request_time?: number;
|
||||
}
|
||||
|
||||
declare interface ApiArticleSearchParams extends ApiRequestPageParams{
|
||||
|
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;
|
||||
|
29
yarn.lock
29
yarn.lock
@ -201,7 +201,7 @@
|
||||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.25.9"
|
||||
|
||||
"@babel/runtime@^7.10.1", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.16.7", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.6", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.24.8", "@babel/runtime@^7.25.7":
|
||||
"@babel/runtime@^7.10.1", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.16.7", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.6", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.24.8", "@babel/runtime@^7.25.0", "@babel/runtime@^7.25.7":
|
||||
version "7.26.0"
|
||||
resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1"
|
||||
integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==
|
||||
@ -1870,6 +1870,20 @@ hasown@^2.0.0, hasown@^2.0.2:
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
html-parse-stringify@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2"
|
||||
integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==
|
||||
dependencies:
|
||||
void-elements "3.1.0"
|
||||
|
||||
i18next@^24.2.1:
|
||||
version "24.2.1"
|
||||
resolved "https://registry.npmmirror.com/i18next/-/i18next-24.2.1.tgz#91e8f11fc9bd7042ec0bd36bed2dd0457aaa35fa"
|
||||
integrity sha512-Q2wC1TjWcSikn1VAJg13UGIjc+okpFxQTxjVAymOnSA3RpttBQNMPf2ovcgoFVsV4QNxTfNZMAxorXZXsk4fBA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.2"
|
||||
|
||||
ignore@^5.2.0, ignore@^5.3.1:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
|
||||
@ -2905,6 +2919,14 @@ react-fast-compare@^3.0.1, react-fast-compare@^3.2.2:
|
||||
resolved "https://registry.npmmirror.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
|
||||
integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
|
||||
|
||||
react-i18next@^15.4.0:
|
||||
version "15.4.0"
|
||||
resolved "https://registry.npmmirror.com/react-i18next/-/react-i18next-15.4.0.tgz#87c755fb6d7a567eec134e4759b022a0baacb19e"
|
||||
integrity sha512-Py6UkX3zV08RTvL6ZANRoBh9sL/ne6rQq79XlkHEdd82cZr2H9usbWpUNVadJntIZP2pu3M2rL1CN+5rQYfYFw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.25.0"
|
||||
html-parse-stringify "^3.0.1"
|
||||
|
||||
react-is@^16.13.1:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
@ -3444,6 +3466,11 @@ vite@^5.2.0:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.3"
|
||||
|
||||
void-elements@3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
|
||||
integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==
|
||||
|
||||
webworkify-webpack@^2.1.5:
|
||||
version "2.1.5"
|
||||
resolved "https://registry.npmmirror.com/webworkify-webpack/-/webworkify-webpack-2.1.5.tgz#bf4336624c0626cbe85cf1ffde157f7aa90b1d1c"
|
||||
|
Loading…
x
Reference in New Issue
Block a user