Compare commits

...

10 Commits

32 changed files with 535 additions and 241 deletions

View File

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

View File

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

View File

@ -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 {

View File

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

View File

@ -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="删除"

View File

@ -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>

View File

@ -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="取消"

View File

@ -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>
)
}

View File

@ -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 <></>

View File

@ -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"

View File

@ -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){

View File

@ -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'
}
)

View File

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

14
src/i18n/config.ts Normal file
View 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
View File

View File

@ -0,0 +1,10 @@
{
"AppTitle": "Digital Human Live",
"Hello": "Hello",
"login": {
"text": "Login"
},
"notice": {
"title": "操作提示"
}
}

View File

@ -0,0 +1,10 @@
{
"AppTitle": "数字人直播",
"Hello": "你好",
"login": {
"text": "登录"
},
"notice": {
"title": "Notice"
}
}

View File

@ -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>)
}

View 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);
}
}

View File

@ -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>

View File

@ -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>
}

View File

@ -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>
</>)
}

View File

@ -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>

View File

@ -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()

View File

@ -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={() => {

View File

@ -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)
}
}
// 播放视频

View File

@ -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}/>

View File

@ -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>)

View File

@ -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
View File

@ -2,7 +2,8 @@ declare interface ApiRequestPageParams {
pagination: {
page: number;
limit: number;
}
};
request_time?: number;
}
declare interface ApiArticleSearchParams extends ApiRequestPageParams{

View File

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

View File

@ -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"