feat: 添加批量操作按钮;优化视频展示;

This commit is contained in:
LittleBoy 2024-12-15 18:00:48 +08:00
parent b07f336bd5
commit be22fc387a
7 changed files with 189 additions and 132 deletions

View File

@ -19,6 +19,22 @@
@tailwind utilities; @tailwind utilities;
::-webkit-scrollbar {
width: 4px;
border-radius: 5px;
}
::-webkit-scrollbar-thumb {
background: #999;
height: 10px;
border-radius: 5px;
&:hover {
background: #666;
cursor: pointer;
}
}
.btn { .btn {
@apply px-5 py-2 rounded-md bg-white border text-sm; @apply px-5 py-2 rounded-md bg-white border text-sm;
&:hover { &:hover {
@ -124,6 +140,7 @@
min-height: 300px; min-height: 300px;
max-height: calc(100vh - var(--app-header-header) - 300px); max-height: calc(100vh - var(--app-header-header) - 300px);
overflow: auto; overflow: auto;
padding-right: 10px;
} }
.live-video-list-sort-container{ .live-video-list-sort-container{

View File

@ -11,20 +11,24 @@ type Props = {
onClose?: (saved?: boolean) => void; onClose?: (saved?: boolean) => void;
} }
const DEFAULT_STATE = {
loading: false,
open: false,
msgTitle: '',
msgGroup: '',
error:''
}
export default function ArticleEditModal(props: Props) { export default function ArticleEditModal(props: Props) {
const [groups, setGroups] = useState<BlockContent[][]>([]); const [groups, setGroups] = useState<BlockContent[][]>([]);
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [state, setState] = useSetState({ const [state, setState] = useSetState({
loading: false, ...DEFAULT_STATE
open: false,
msgTitle: '',
msgGroup: '',
}) })
// 保存数据 // 保存数据
const handleSave = () => { const handleSave = () => {
console.log(groups, title) setState({error: ''})
if (!title) { if (!title) {
// setState({msgTitle: '请输入标题内容'}); // setState({msgTitle: '请输入标题内容'});
return; return;
@ -37,39 +41,24 @@ export default function ArticleEditModal(props: Props) {
setState({loading: true}) setState({loading: true})
save(title, groups, props.id > 0 ? props.id : undefined).then(() => { save(title, groups, props.id > 0 ? props.id : undefined).then(() => {
props.onClose?.(true) props.onClose?.(true)
}).catch(e=>{
setState({error: e.data || '保存失败,请重试!'})
}).finally(() => { }).finally(() => {
setState({loading: false}) setState({loading: false})
}); });
} }
useEffect(() => { useEffect(() => {
setState({...DEFAULT_STATE})
if (props.id) { if (props.id) {
if (props.id > 0) { if (props.id > 0) {
if (props.type == 'news') { article.getById(props.id).then(res => {
article.getById(props.id).then(res => { setGroups(res.content_group)
setGroups(res.content_group) setTitle(res.title)
setTitle(res.title) })
})
}
} else { } else {
// 新增 // 新增
setGroups([ setGroups([])
[{ setTitle('')
type: 'text',
content: '韩国国会当地时间14日16时举行全体会议就在野党阵营第二次提出的尹锡悦总统弹劾案进行表决。根据投票结果有204票赞成85票反对3票弃权8票无效弹劾案最终获得通过尹锡悦的总统职务立即停止。'
}],
[
{
type: 'text',
content: '韩国宪法法院将在180天内完成弹劾案审判程序。如果宪法法院作出弹劾案不成立的裁决尹锡悦将立即恢复总统职务如果宪法法院认可弹劾案成立尹锡悦将立即被罢免预计韩国将在明年4月至6月间举行大选。'
},
{
type: 'image',
content: 'https://zverse-on.oss-cn-shanghai.aliyuncs.com/metahuman/workbench/20241214/193c442df75.jpeg'
},
],
])
setTitle('韩国国会通过总统弹劾案 尹锡悦职务立即停止')
} }
} }
}, [props.id]) }, [props.id])
@ -83,6 +72,7 @@ export default function ArticleEditModal(props: Props) {
onCancel={()=>props.onClose?.()} onCancel={()=>props.onClose?.()}
okButtonProps={{loading: state.loading}} okButtonProps={{loading: state.loading}}
onOk={handleSave} onOk={handleSave}
okText={props.type == 'news' ? '确定' : '重新生成'}
> >
<div className="article-title mt-5"> <div className="article-title mt-5">
<div className="title"> <div className="title">
@ -111,6 +101,7 @@ export default function ArticleEditModal(props: Props) {
}} }}
/> />
</div> </div>
{state.error && <div className="text-red-500">{state.error}</div>}
</div> </div>
</Modal>); </Modal>);
} }

View File

@ -0,0 +1,56 @@
import React, {useState} from "react";
import {Button, Modal} from "antd";
import {ButtonType} from "antd/es/button";
import {showErrorToast, showToast} from "@/components/message.ts";
type Props = {
selected: any[],
type?: ButtonType;
emptyMessage: string,
confirmMessage: React.ReactNode,
onProcess: (ids: Id[]) => Promise<void>
successMessage?: string;
onSuccess?: () => void;
children?: React.ReactNode
}
/**
*
*/
export default function ButtonBatch(
{
selected, emptyMessage, successMessage, children,
type, confirmMessage, onProcess,onSuccess
}: Props) {
const [loading, setLoading] = useState(false)
const onBatchProcess = async () => {
setLoading(true)
try {
await onProcess(selected)
if (successMessage) showToast(successMessage, 'success')
if (onSuccess) {
onSuccess()
}
} catch (e) {
showErrorToast(e)
} finally {
setLoading(false)
}
}
const handleBtnClick = () => {
if (selected.length == 0) {
showToast(emptyMessage, 'warning')
return;
}
Modal.confirm({
title: '操作提示',
centered: true,
content: confirmMessage,
onOk: onBatchProcess
})
}
return (
<Button loading={loading} type={type} onClick={handleBtnClick}>{children}</Button>
)
}

View File

@ -1,16 +1,18 @@
import {message} from "antd"; import {message} from "antd";
import {BizError} from "@/service/types.ts"; import {BizError} from "@/service/types.ts";
export function showToast(content: string, type?: 'success' | 'info' | 'warning' | 'error') { export function showToast(content: string, type?: 'success' | 'info' | 'warning' | 'error', duration?: number) {
message.open({ message.open({
type, type,
content, content,
duration,
className: 'aui-toast' className: 'aui-toast'
}).then(); }).then();
} }
export function showErrorToast(e:Error|BizError) {
showToast(String(((e instanceof BizError)?e.data:'') || e.message),'error') export function showErrorToast(e: Error | BizError) {
showToast(String(((e instanceof BizError) ? e.data : '') || e.message), 'error')
} }
@ -22,14 +24,14 @@ export function showLoading(content = 'Loading...') {
content, content,
}).then(); }).then();
return { return {
update(content: string,type?: 'success' | 'info' | 'warning' | 'error'){ update(content: string, type?: 'success' | 'info' | 'warning' | 'error') {
message.open({ message.open({
key, key,
content, content,
type type
}).then(); }).then();
}, },
close(){ close() {
message.destroy(key); message.destroy(key);
} }
} }

View File

@ -185,10 +185,6 @@ export default function LiveIndex() {
</> : <div> </> : <div>
<Button type="primary" onClick={() => setEditable(true)}></Button> <Button type="primary" onClick={() => setEditable(true)}></Button>
</div>} </div>}
<div className="flex gap-2">
<Button type="primary" onClick={showFirst}>showFirst</Button>
<Button type="primary" onClick={activeToNext}>Next</Button>
</div>
</div> </div>
<div className="live-video-list-sort-container"> <div className="live-video-list-sort-container">
<div className="flex"> <div className="flex">

View File

@ -1,4 +1,4 @@
import {Empty, message, Modal} from "antd"; import {Empty, Modal} from "antd";
import React, {useEffect, useMemo, useRef, useState} from "react"; import React, {useEffect, useMemo, useRef, useState} from "react";
import {DndContext} from "@dnd-kit/core"; import {DndContext} from "@dnd-kit/core";
import {arrayMove, SortableContext} from "@dnd-kit/sortable"; import {arrayMove, SortableContext} from "@dnd-kit/sortable";
@ -8,17 +8,15 @@ import {clsx} from "clsx";
import {VideoListItem} from "@/components/video/video-list-item.tsx"; import {VideoListItem} from "@/components/video/video-list-item.tsx";
import ArticleEditModal from "@/components/article/edit-modal.tsx"; import ArticleEditModal from "@/components/article/edit-modal.tsx";
import {getList} from "@/service/api/video.ts"; import {deleteByIds, getList} from "@/service/api/video.ts";
import {formatDuration} from "@/util/strings.ts"; import {formatDuration} from "@/util/strings.ts";
import ButtonPush2Room from "@/pages/video/components/button-push2room.tsx"; import ButtonPush2Room from "@/pages/video/components/button-push2room.tsx";
import ButtonBatch from "@/components/button-batch.tsx";
export default function VideoIndex() { export default function VideoIndex() {
const [editId, setEditId] = useState(-1) const [editId, setEditId] = useState(-1)
const [videoData, setVideoData] = useState<VideoInfo[]>([]) const [videoData, setVideoData] = useState<VideoInfo[]>([])
const [modal, contextHolder] = Modal.useModal() const [modal, contextHolder] = Modal.useModal()
const videoRef = useRef<HTMLVideoElement | null>(null) const videoRef = useRef<HTMLVideoElement | null>(null)
const [state, setState] = useSetState({ const [state, setState] = useSetState({
@ -26,45 +24,31 @@ export default function VideoIndex() {
playingIndex: -1, playingIndex: -1,
}) })
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([]) const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
const processDeleteVideo = async (_idArray: number[]) => {
message.info('删除成功!!!' + _idArray.join('')); // 加载列表
} const loadList = () => {
useEffect(() => {
getList().then((ret) => { getList().then((ret) => {
setVideoData(ret.list || []) setVideoData(ret.list || [])
}) setState({checkedAll: false, playingIndex: -1})
}, [])
const handleDeleteBatch = () => {
modal.confirm({
title: '提示',
content: '是否要删除选择的视频?',
onOk: () => processDeleteVideo(checkedIdArray)
}) })
} }
// 播放视频
const playVideo = (video: VideoInfo, playingIndex: number) => { const playVideo = (video: VideoInfo, playingIndex: number) => {
setState({ setState({playingIndex})
playingIndex if (videoRef.current && video.oss_video_url) {
}) videoRef.current!.src = video.oss_video_url
console.log('play', video) }
// if (videoRef.current) {
// videoRef.current!.src = video.play_url
// }
} }
// 处理全选
const handleAllCheckedChange = () => { const handleAllCheckedChange = () => {
// setVideoData(list=>{
// list.map(s=>{
// s.checked = !state.checkedAll
// })
// return list
// })
setCheckedIdArray(state.checkedAll ? [] : videoData.map(v => v.id)) setCheckedIdArray(state.checkedAll ? [] : videoData.map(v => v.id))
setState({ setState({
checkedAll: !state.checkedAll checkedAll: !state.checkedAll
}) })
} }
//
useEffect(loadList, [])
const totalDuration = useMemo(() => { const totalDuration = useMemo(() => {
if (!videoData || videoData.length == 0) return 0; if (!videoData || videoData.length == 0) return 0;
// 计算总时长 // 计算总时长
@ -75,76 +59,86 @@ export default function VideoIndex() {
{contextHolder} {contextHolder}
<div className="flex"> <div className="flex">
<div className="video-list-container bg-white p-10 rounded flex-1"> <div className="video-list-container bg-white p-10 rounded flex-1">
<div className="live-control flex justify-between mb-8"> <div className="live-control flex justify-between mb-5">
<div className="pl-[70px]"> <div className="pl-[70px]">
<span>: {formatDuration(totalDuration)}</span> <span>: {formatDuration(totalDuration)}</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 items-center pr-[10px]">
<span className="cursor-pointer" onClick={handleDeleteBatch}></span> <ButtonBatch
onProcess={deleteByIds}
selected={checkedIdArray}
emptyMessage={`请选择要删除的新闻视频`}
confirmMessage={`是否删除当前的${checkedIdArray.length}个新闻视频?`}
onSuccess={loadList}
></ButtonBatch>
<button className="ml-5 hover:text-blue-300 text-gray-400 text-lg" <button className="ml-5 hover:text-blue-300 text-gray-400 text-lg"
onClick={handleAllCheckedChange}> onClick={handleAllCheckedChange}>
<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/> <CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>
</button> </button>
</div> </div>
</div> </div>
<div className={'flex video-list-sort-container'}> <div className={'video-list-sort-container'}>
{videoData.length == 0 ? <div className="m-auto"><Empty/></div> : <> <div className="flex my-2">
<div className="sort-number-container mr-2"> {videoData.length == 0 ? <div className="m-auto"><Empty/></div> : <>
{videoData.map((v, index) => ( <div className="sort-number-container mr-2">
<div key={index} className="flex items-center px-2 h-[80px] mb-5"> {videoData.map((v, index) => (
<div <div key={index} className="flex items-center px-2 h-[80px] mt-3 mb-2">
className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{index + 1}</div> <div
</div> className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{index + 1}</div>
))} </div>
</div> ))}
<div className="sort-list-container"> </div>
<DndContext onDragEnd={(e) => { <div className="sort-list-container flex-1">
const {active, over} = e; <DndContext onDragEnd={(e) => {
if (over && active.id !== over.id) { const {active, over} = e;
let oldIndex = -1, newIndex = -1; if (over && active.id !== over.id) {
const originArr = [...videoData] let oldIndex = -1, newIndex = -1;
setVideoData((items) => { const originArr = [...videoData]
oldIndex = items.findIndex(s => s.id == active.id); setVideoData((items) => {
newIndex = items.findIndex(s => s.id == over.id); oldIndex = items.findIndex(s => s.id == active.id);
return arrayMove(items, oldIndex, newIndex); newIndex = items.findIndex(s => s.id == over.id);
}); return arrayMove(items, oldIndex, newIndex);
modal.confirm({ });
title: '提示', modal.confirm({
content: '是否要移动到指定位置', title: '提示',
onCancel: () => { content: '是否要移动到指定位置',
setVideoData(originArr); onCancel: () => {
} setVideoData(originArr);
}) }
} })
}}> }
<SortableContext items={videoData}> }}>
{videoData.map((v, index) => ( <SortableContext items={videoData}>
<VideoListItem {videoData.map((v, index) => (
video={v} <VideoListItem
index={index + 1} video={v}
id={v.id} index={index + 1}
key={index} id={v.id}
active={state.playingIndex == index} key={index}
checked={checkedIdArray.includes(v.id)} active={state.playingIndex == index}
onCheckedChange={(checked) => { checked={checkedIdArray.includes(v.id)}
setCheckedIdArray(idArray => { className={`list-item-${index} mt-3 mb-2`}
const newArr = checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id); onCheckedChange={(checked) => {
setState({checkedAll: newArr.length == videoData.length}) setCheckedIdArray(idArray => {
return newArr; const newArr = checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
}) setState({checkedAll: newArr.length == videoData.length})
}} return newArr;
onPlay={() => playVideo(v, index)} })
onEdit={() => { }}
setEditId(v.article_id) onPlay={() => playVideo(v, index)}
}} onEdit={() => {
editable setEditId(v.article_id)
/>))} }}
</SortableContext> editable
</DndContext> />))}
</div> </SortableContext>
</>} </DndContext>
</div>
</>}
</div>
</div> </div>
<div className="text-right mt-10"> <div className="text-right mt-5">
<ButtonPush2Room ids={checkedIdArray}/> <ButtonPush2Room ids={checkedIdArray}/>
</div> </div>
</div> </div>
@ -157,6 +151,6 @@ export default function VideoIndex() {
</div> </div>
</div> </div>
</div> </div>
<ArticleEditModal id={editId} onClose={() => setEditId(-1)}/> <ArticleEditModal type={'video'} id={editId} onClose={() => setEditId(-1)}/>
</div>) </div>)
} }

View File

@ -25,10 +25,11 @@ export function getById(id: Id) {
return post<VideoInfo>({url: '/video/detail/' + id}) return post<VideoInfo>({url: '/video/detail/' + id})
} }
export function deleteById(id: Id) { export function deleteByIds(ids: Id[]) {
return post({url: '/video/detail/' + id}) return post('/video/remove', {ids})
} }
export function modifyOrder(ids: Id[]) { export function modifyOrder(ids: Id[]) {
return post('/video/modifyorder', {ids}) return post('/video/modifyorder', {ids})
} }