feat: ️ 新增订单/回收站导航

fix:直播间回退
style: 📚️调整相关UI
This commit is contained in:
LittleBoy 2025-04-16 11:29:54 +08:00
parent 42e2d3fcc0
commit 99323df02b
12 changed files with 93 additions and 69 deletions

View File

@ -170,7 +170,7 @@
@apply text-sm; @apply text-sm;
background: none; background: none;
.col{ .col{
@apply text-sm text-gray-800; @apply text-base text-gray-800;
height: 42px; height: 42px;
} }
} }
@ -180,7 +180,7 @@
} }
.col { .col {
@apply flex items-center relative pl-4 text-center justify-center; @apply flex items-center relative pl-4 text-center justify-center text-sm;
height: 60px; height: 60px;
&:after { &:after {
@ -209,7 +209,7 @@
} }
.title { .title {
@apply flex-1 text-base; @apply flex-1;
} }
.generated-time { .generated-time {

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -137,7 +137,7 @@ export const VideoListItem = (
{/* <button className="hover:text-blue-500 cursor-move">*/} {/* <button className="hover:text-blue-500 cursor-move">*/}
{/* <MenuOutlined/>*/} {/* <MenuOutlined/>*/}
{/* </button> : <button disabled className="cursor-not-allowed"><MenuOutlined/></button>)}*/} {/* </button> : <button disabled className="cursor-not-allowed"><MenuOutlined/></button>)}*/}
<div className={"flex items-center justify-center gap-6"}> <div className={"flex items-center justify-center gap-5"}>
{downloadUrl && video.status == VideoStatus.Generated && {downloadUrl && video.status == VideoStatus.Generated &&
<button className="hover:text-blue-500" onClick={e => { <button className="hover:text-blue-500" onClick={e => {
e.preventDefault() e.preventDefault()
@ -168,15 +168,13 @@ export const VideoListItem = (
{onRemove && !failed && <DeleteItemPopoverConfirm {onRemove && !failed && <DeleteItemPopoverConfirm
description={failed ? t('video.rollback_confirm_title') : undefined} description={failed ? t('video.rollback_confirm_title') : undefined}
onConfirm={() => onRemove(failed ? 'rollback' : 'delete')}> onConfirm={() => onRemove(failed ? 'rollback' : 'delete')}>
<span className="icon-btn"> <button className="hover:text-blue-500" title={
<button className="hover:text-blue-500" title={ failed ? (i18n.language == 'zh-CN'?'重新生成':'Regenerate') : i18n.language == 'zh-CN'?'删除':'Delete'
i18n.language == 'zh-CN'?'重新生成':'Regenerate' } style={{fontSize:20}}>
}> {removeIcon ? removeIcon : (failed ?
{removeIcon ? removeIcon : (failed ? <IconRollbackCircle/> :
<IconRollbackCircle/> : <IconDelete/>)}
<IconDelete/>)} </button>
</button>
</span>
</DeleteItemPopoverConfirm>} </DeleteItemPopoverConfirm>}
{hideCheckBox ? <></> : {hideCheckBox ? <></> :
<Checkbox checked={state.checked} onChange={() => { <Checkbox checked={state.checked} onChange={() => {

View File

@ -1,5 +1,5 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {Button, Checkbox, Empty, Modal, Popconfirm, Space} from "antd"; import {Checkbox, Empty, Popconfirm, Space} from "antd";
import {SortableContext, arrayMove} from '@dnd-kit/sortable'; import {SortableContext, arrayMove} from '@dnd-kit/sortable';
import {DndContext} from "@dnd-kit/core"; import {DndContext} from "@dnd-kit/core";
import FlvJs from "flv.js"; import FlvJs from "flv.js";
@ -7,16 +7,17 @@ import {useTranslation} from "react-i18next";
import {useSetState} from "ahooks"; import {useSetState} from "ahooks";
import {VideoListItem} from "@/components/video/video-list-item.tsx"; import {VideoListItem} from "@/components/video/video-list-item.tsx";
import {deleteByIds, getList, modifyOrder, playState} from "@/service/api/live.ts"; import {deleteByIds, getList, modifyOrder, playState, restoreByIds} from "@/service/api/live.ts";
import {showErrorToast, showToast} from "@/components/message.ts"; import {showErrorToast, showToast} from "@/components/message.ts";
import ButtonBatch from "@/components/button-batch.tsx"; import ButtonBatch from "@/components/button-batch.tsx";
import {formatDuration} from "@/util/strings.ts"; import {formatDuration} from "@/util/strings.ts";
import {Player, PlayerInstance} from "@/components/video/player.tsx"; import {Player, PlayerInstance} from "@/components/video/player.tsx";
import {IconDelete, IconLocked, IconRollbackCircle, IconWarningCircle} from "@/components/icons"; import {IconDelete, IconLocked, IconRollbackCircle} from "@/components/icons";
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx"; import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
import ButtonToTop from "@/components/scoller/button-to-top.tsx"; import ButtonToTop from "@/components/scoller/button-to-top.tsx";
import styles from "./style.module.scss" import styles from "./style.module.scss"
import {ModalWarningIcon, ModalWarningTitle} from "@/components/icons/ModalWarning.tsx";
const cache: { flvPlayer?: FlvJs.Player, timerPlayNext?: any, timerLoadState?: any, prevUrl?: string } = {} const cache: { flvPlayer?: FlvJs.Player, timerPlayNext?: any, timerLoadState?: any, prevUrl?: string } = {}
@ -25,7 +26,6 @@ export default function LiveIndex() {
const player = useRef<PlayerInstance | null>(null) const player = useRef<PlayerInstance | null>(null)
const [videoData, setVideoData] = useState<LiveVideoInfo[]>([]) const [videoData, setVideoData] = useState<LiveVideoInfo[]>([])
const [modal, contextHolder] = Modal.useModal()
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([]) const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
const [editable, setEditable] = useState<boolean>(false) const [editable, setEditable] = useState<boolean>(false)
const scrollerRef = useRef<InfiniteScrollerRef | null>(null) const scrollerRef = useRef<InfiniteScrollerRef | null>(null)
@ -195,7 +195,7 @@ export default function LiveIndex() {
await deleteByIds(delIds) await deleteByIds(delIds)
} }
if(rollbackIds.length > 0) { if(rollbackIds.length > 0) {
showToast('回退暂未实现') await restoreByIds(rollbackIds)
} }
// 调整排序 // 调整排序
await modifyOrder(ids); await modifyOrder(ids);
@ -356,8 +356,9 @@ export default function LiveIndex() {
rootClassName={'popconfirm-main'} rootClassName={'popconfirm-main'}
placement={'left'} placement={'left'}
arrow={false} arrow={false}
icon={<IconWarningCircle/>} icon={<ModalWarningIcon/>}
title={t('video.live_rollback_confirm_title')} title={<ModalWarningTitle />}
description={t('video.live_rollback_confirm_title')}
onConfirm={() => handleRollback(v)} onConfirm={() => handleRollback(v)}
><button className="hover:text-blue-500"><IconRollbackCircle /></button></Popconfirm>} ><button className="hover:text-blue-500"><IconRollbackCircle /></button></Popconfirm>}
</>} </>}
@ -388,6 +389,5 @@ export default function LiveIndex() {
<IconDelete/> <IconDelete/>
</ButtonBatch>} </ButtonBatch>}
</div> </div>
{contextHolder}
</div>) </div>)
} }

View File

@ -12,6 +12,7 @@ import {IconPin} from "@/components/icons";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
type SearchPanelProps = { type SearchPanelProps = {
rootClassName?: string;
onSearch?: (params: ApiArticleSearchParams) => void; onSearch?: (params: ApiArticleSearchParams) => void;
defaultParams?: Partial<ApiArticleSearchParams>; defaultParams?: Partial<ApiArticleSearchParams>;
hideNewsSource?: boolean; hideNewsSource?: boolean;
@ -25,7 +26,14 @@ const DEFAULT_STATE = {
tag_level_2_id: -1, tag_level_2_id: -1,
subOptions: [] subOptions: []
} }
export default function SearchPanel({onSearch, defaultParams, hideNewsSource,rightRender}: SearchPanelProps) { export default function SearchPanel(
{
onSearch,
defaultParams,
hideNewsSource,
rightRender,
rootClassName
}: SearchPanelProps) {
const tags = useArticleTags(); const tags = useArticleTags();
const {t} = useTranslation() const {t} = useTranslation()
const [params, setParams] = useSetState<ApiArticleSearchParams>({ const [params, setParams] = useSetState<ApiArticleSearchParams>({
@ -143,7 +151,7 @@ export default function SearchPanel({onSearch, defaultParams, hideNewsSource,rig
const setFalse = () => togglePinnedManagePanel(false) const setFalse = () => togglePinnedManagePanel(false)
useClickAway(() => setFalse(), pinnedManagePanel) useClickAway(() => setFalse(), pinnedManagePanel)
return (<div className={`${styles.searchPanel} pt-6 pb-2`}> return (<div className={`${styles.searchPanel} ${rootClassName??'pt-6 pb-2'}`}>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="search-form flex items-center gap-4"> <div className="search-form flex items-center gap-4">
<Input <Input

View File

@ -44,7 +44,7 @@
} }
} }
.col{ .col{
@apply flex items-center justify-center relative pl-6; @apply flex items-center justify-center relative pl-6 text-sm;
height: 54px; height: 54px;
&:after{ &:after{
@apply absolute; @apply absolute;
@ -85,9 +85,8 @@
.header{ .header{
@apply bg-primary-bg; @apply bg-primary-bg;
.col{ .col{
@apply text-sm; @apply text-base;
height: 42px; height: 42px;
} }
.operations{ .operations{
} }
@ -104,21 +103,22 @@
:global { :global {
.title{ .title{
justify-content: left; text-align: center;
} }
.id{ .id{
@apply pl-0; @apply pl-0;
width: 130px; width: 140px;
line-height: 1.2em; line-height: 1.2em;
&:after{ &:after{
display: none; display: none;
} }
} }
.cover{ .cover{
img{ width: 140px;
width: 120px; //img{
max-height: 50px; // max-width: 100px;
} // max-height: 56px;
//}
} }
.title { .title {
@apply flex-1 pl-4; @apply flex-1 pl-4;

View File

@ -1,6 +1,6 @@
import React, {useState} from "react"; import React, {useState} from "react";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {Empty, Image, Pagination} from "antd"; import {Empty, Pagination} from "antd";
import {useRequest} from "ahooks"; import {useRequest} from "ahooks";
import SearchPanel from "@/pages/news/components/search-panel.tsx"; import SearchPanel from "@/pages/news/components/search-panel.tsx";
@ -8,7 +8,7 @@ import styles from "@/pages/news/components/style.module.scss";
import {formatDurationToTime, formatTime} from "@/util/strings.ts"; import {formatDurationToTime, formatTime} from "@/util/strings.ts";
import {getList} from "@/service/api/order.ts"; import {getList} from "@/service/api/order.ts";
import ImageErr from "@/assets/images/error/error_img.svg" import ImageErr from "@/assets/images/error/ic_broken_image.png"
function OrderIndex() { function OrderIndex() {
const {t} = useTranslation() const {t} = useTranslation()
@ -20,16 +20,20 @@ function OrderIndex() {
refreshDeps: [params], refreshDeps: [params],
}) })
return <div className="container pb-5 page-order-index"> return <div className="pb-5 page-order-index">
<SearchPanel <div className=" mb-5" style={{backgroundColor:'#dae8fc'}}>
hideNewsSource={true} <div className="container" style={{padding:0}}>
defaultParams={params} <SearchPanel
onSearch={setParams} rootClassName="py-6"
rightRender={<div>{t('order.left_time')}: <span hideNewsSource={true}
className={`${!data?.remaining_duration || Number(data?.remaining_duration) < 3600 ? 'text-red-600' : ''}`}>{formatDurationToTime(data?.remaining_duration)}</span> defaultParams={params}
</div>} onSearch={setParams}
/> rightRender={<div>{t('order.left_time')}: <span
<div className="mt-2"> className={`${!data?.remaining_duration || Number(data?.remaining_duration) < 3600 ? 'text-red-600' : ''}`}>{formatDurationToTime(data?.remaining_duration)}</span>
</div>}
/>
</div></div>
<div className="mt-5 container" style={{padding:"20px 0"}}>
<div className={`${styles.newListTable} ${styles.orderDataList} `}> <div className={`${styles.newListTable} ${styles.orderDataList} `}>
<div className="header row flex"> <div className="header row flex">
<div className="col id w-[160px]">{t('order.list.id')}</div> <div className="col id w-[160px]">{t('order.list.id')}</div>
@ -39,7 +43,7 @@ function OrderIndex() {
<div className="col w-[120px]">{t('order.list.consume_time')}</div> <div className="col w-[120px]">{t('order.list.consume_time')}</div>
<div className="col w-[180px]">{t('order.list.operator')}</div> <div className="col w-[180px]">{t('order.list.operator')}</div>
</div> </div>
<div > <div>
{data?.list.length === 0 && <div style={{marginTop: 50}}> {data?.list.length === 0 && <div style={{marginTop: 50}}>
<Empty/> <Empty/>
</div>} </div>}
@ -51,24 +55,23 @@ function OrderIndex() {
</div> </div>
</div> </div>
<div className="col cover"> <div className="col cover">
<Image <div
src={item.img_url} preview={false} className="rounded object-cover" className="w-[100px] h-[56px] flex items-center rounded overflow-hidden border border-gray-50"
fallback={ImageErr} >
/> <img
src={item.img_url || ImageErr}
className="w-[100px] object-cover"
/>
</div>
</div> </div>
<div className="col title order-title flex-1 w-min-60px"> <div className="col title order-title flex-1 w-min-60px">
<div className="line-clamp-2 ">{item.title}</div> <div className="line-clamp-2">{item.title}</div>
</div> </div>
<div className="col w-[180px]"> <div className="col w-[180px]">{formatTime(item.order_time, 'YYYY-MM-DD HH:mm')}
<div className="text-sm">{formatTime(item.order_time, 'YYYY-MM-DD HH:mm')}</div>
</div> </div>
<div className="col w-[120px]"> <div className="col w-[120px]">{formatDurationToTime(item.consumption_duration)}
<div
className="text-sm">{formatDurationToTime(item.consumption_duration)}</div>
</div>
<div className="col w-[180px]">
<div className="text-sm">{item.operator}</div>
</div> </div>
<div className="col w-[180px]">{item.operator}</div>
</div> </div>
})} })}

View File

@ -7,7 +7,7 @@ import styles from './style.module.scss'
type VideoItemProps = { type VideoItemProps = {
videoInfo: VideoInfo; videoInfo: VideoInfo;
onLive?: boolean; onLive?: boolean;
onClick?: (autoPlay:boolean) => void; onClick?: (autoPlay: boolean) => void;
onRemove?: () => void; onRemove?: () => void;
onCheckedChange?: (checked: boolean) => void; onCheckedChange?: (checked: boolean) => void;
checked?: boolean; checked?: boolean;
@ -25,14 +25,14 @@ export default function VideoItem(props: VideoItemProps) {
<div className="cover"> <div className="cover">
<img className={'w-full cursor-pointer object-cover'} src={props.videoInfo.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={'absolute inset-x-0 top-0 flex items-center justify-center bottom-[36px]'}>
<div className={styles.playIcon} onClick={()=>props.onClick?.(true)}><CaretRightOutlined /></div> <div className={styles.playIcon} onClick={() => props.onClick?.(true)}><CaretRightOutlined/></div>
</div> </div>
</div> </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"> 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" <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> onClick={() => props.onClick?.(false)}>{props.videoInfo.title}</div>
<div className="video-time-info">{timeFromNow(props.videoInfo.ctime)}</div> <div className="video-time-info w-[60px] text-right">{timeFromNow(props.videoInfo.d_time)}</div>
</div> </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 className={"absolute top-1 left-1 bg-black/50 rounded-3xl text-white px-3 py-0.5"}>{Math.ceil(props.videoInfo.duration / 1000)}s

View File

@ -1,6 +1,6 @@
import {Outlet, useLocation, useNavigate, useSearchParams} from "react-router-dom"; import {Outlet, useLocation, useNavigate} from "react-router-dom";
import {Button, Divider, Dropdown, MenuProps} from "antd"; import {Divider, Dropdown, MenuProps} from "antd";
import React, {useEffect} from "react"; import React, {useEffect, useMemo} from "react";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import AuthGuard from "@/routes/layout/auth-guard.tsx"; import AuthGuard from "@/routes/layout/auth-guard.tsx";
@ -13,7 +13,6 @@ import useAuth from "@/hooks/useAuth.ts";
import {hidePhone} from "@/util/strings.ts"; import {hidePhone} from "@/util/strings.ts";
import {defaultCache} from "@/hooks/useCache.ts"; import {defaultCache} from "@/hooks/useCache.ts";
import {IconOrderFill, IconRecycleFill} from "@/components/icons"; import {IconOrderFill, IconRecycleFill} from "@/components/icons";
import useGlobalConfig from "@/hooks/useGlobalConfig.ts";
import LanguageSwitcher from "@/components/icons/language-switcher.tsx"; import LanguageSwitcher from "@/components/icons/language-switcher.tsx";
@ -84,13 +83,25 @@ const NavigationUserContainer = () => {
</Dropdown> : <UserButton/>} </Dropdown> : <UserButton/>}
</div>) </div>)
} }
const ExtraNavItems = {
'/order':'order.text',
'/recycle':'history.text',
}
export const BaseLayout: React.FC<LayoutProps> = ({children}) => { export const BaseLayout: React.FC<LayoutProps> = ({children}) => {
const {pathname} = useLocation()
const {t,i18n} = useTranslation()
const extraNav = useMemo(()=>{
if(!pathname || !ExtraNavItems[pathname]) return null
return t(ExtraNavItems[pathname])
},[pathname,i18n.language])
return (<div className={'dashboard-layout min-h-screen'}> return (<div className={'dashboard-layout min-h-screen'}>
<div className="min-h-screen w-full"> <div className="min-h-screen w-full">
<div className="app-header"> <div className="app-header">
<div className="logo-container"> <div className="logo-container flex items-center">
<LogoText style={{fontSize: 30}}/> <LogoText style={{fontSize: 30}}/>
{extraNav && <div className="extra-nav-name ml-2">
<span className="nav-item active">{extraNav}</span>
</div>}
</div> </div>
<DashboardNavigation/> <DashboardNavigation/>
<div className="flex items-center"> <div className="flex items-center">

View File

@ -15,6 +15,9 @@ export function modifyOrder(ids: Id[]) {
export function deleteByIds(ids: Id[]) { export function deleteByIds(ids: Id[]) {
return post('/room/remove', {ids}) return post('/room/remove', {ids})
} }
export function restoreByIds(ids: Id[]) {
return post('/room/restore', {ids})
}
export function getLiveUrl() { export function getLiveUrl() {
return get<{flv_url:string}>({ return get<{flv_url:string}>({

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

@ -108,6 +108,7 @@ declare interface VideoInfo {
status: number; status: number;
publish_time?: number|string; publish_time?: number|string;
ctime?: number|string; ctime?: number|string;
d_time?: number|string;
} }
declare interface VideoListItem extends VideoInfo { declare interface VideoListItem extends VideoInfo {
playing?: boolean; playing?: boolean;

View File

@ -58,7 +58,7 @@ function getDayjs(time:any){
export function formatDurationToTime(duration?: number|string) { export function formatDurationToTime(duration?: number|string) {
duration = duration ? Number(duration) : 0; duration = duration ? Number(duration) : 0;
if (!duration || isNaN(duration) || duration < 0) return '00:00'; if (!duration || isNaN(duration) || duration < 0) return '00:00';
duration = Math.ceil(duration); duration = Math.ceil(duration / 1000);
// 计算 // 计算
const hour = Math.floor(duration / 3600); const hour = Math.floor(duration / 3600);
const minute = Math.floor((duration - hour * 3600) / 60); const minute = Math.floor((duration - hour * 3600) / 60);