/** * 剪辑计划片段管理页面 * 对接后端 PR#389 片段 CRUD API * 功能:列表查看、创建、编辑、删除、批量删除、拖拽排序、从素材导入 */ import { useState, useCallback } from "react" import { useParams, useNavigate } from "react-router-dom" import { useQuery, useQueryClient } from "@tanstack/react-query" import { Table, Button, Space, message, Popconfirm, Modal, Form, Input, InputNumber, Select, Tag, Drawer, Empty, Card, } from "antd" import { ArrowLeftOutlined, PlusOutlined, DeleteOutlined, EditOutlined, UploadOutlined, OrderedListOutlined, SaveOutlined, } from "@ant-design/icons" import type { ColumnsType } from "antd/es/table" import { getEditPlan, getEditPlanClips, createEditPlanClip, updateEditPlanClip, deleteEditPlanClip, batchDeleteEditPlanClips, reorderEditPlanClips, createClipsFromAssets, getMediaAssets, type EditPlanClip, type EditPlanClipStatus, } from "@/api/editPlans" import "./plan-clips.css" /* ──────────── 常量 ──────────── */ const CLIP_TYPE_OPTIONS = [ { value: "main", label: "主片段" }, { value: "intro", label: "片头" }, { value: "outro", label: "片尾" }, { value: "overlay", label: "叠加层" }, { value: "background", label: "背景" }, { value: "b_roll", label: "B-roll" }, ] const STATUS_COLORS: Record = { pending: "default", processing: "processing", ready: "success", failed: "error", } const STATUS_LABELS: Record = { pending: "待处理", processing: "处理中", ready: "就绪", failed: "失败", } const TRANSITION_OPTIONS = [ { value: "cut", label: "硬切" }, { value: "fade", label: "淡入淡出" }, { value: "dissolve", label: "溶解" }, { value: "zoom", label: "缩放" }, { value: "slide_left", label: "左滑" }, { value: "slide_right", label: "右滑" }, { value: "slide_up", label: "上滑" }, { value: "slide_down", label: "下滑" }, { value: "wipe_left", label: "左擦除" }, { value: "wipe_right", label: "右擦除" }, { value: "wipe_up", label: "上擦除" }, { value: "wipe_down", label: "下擦除" }, { value: "circlecrop", label: "圆形裁切" }, { value: "rectcrop", label: "矩形裁切" }, ] /* ──────────── 组件 ──────────── */ const PlanClipsManager: React.FC = () => { const { planId } = useParams<{ planId: string }>() const navigate = useNavigate() const queryClient = useQueryClient() /* ── 计划信息 ── */ const { data: plan, isLoading: planLoading } = useQuery({ queryKey: ["editPlan", planId], queryFn: () => getEditPlan(planId!), enabled: !!planId, }) /* ── 片段列表 ── */ const { data: clipsData, isLoading: clipsLoading } = useQuery({ queryKey: ["editPlanClips", planId], queryFn: () => getEditPlanClips(planId!, { limit: 500 }), enabled: !!planId, }) const clips = clipsData?.items ?? [] /* ── 选中的片段(批量操作) ── */ const [selectedRowKeys, setSelectedRowKeys] = useState([]) /* ── 编辑弹窗 ── */ const [editModalOpen, setEditModalOpen] = useState(false) const [editingClip, setEditingClip] = useState(null) const [editForm] = Form.useForm() const [editLoading, setEditLoading] = useState(false) /* ── 素材导入抽屉 ── */ const [importDrawerOpen, setImportDrawerOpen] = useState(false) const [selectedAssetIds, setSelectedAssetIds] = useState([]) const [importLoading, setImportLoading] = useState(false) const { data: assets } = useQuery({ queryKey: ["mediaAssets"], queryFn: () => getMediaAssets(), enabled: importDrawerOpen, }) /* ── 重新排序模式 ── */ const [reorderMode, setReorderMode] = useState(false) const [reorderItems, setReorderItems] = useState([]) /* ── 列定义 ── */ const columns: ColumnsType = [ { title: "序号", dataIndex: "order", width: 70, render: (_, __, index) => index + 1, }, { title: "类型", dataIndex: "clip_type", width: 100, render: (type: string) => { const opt = CLIP_TYPE_OPTIONS.find((o) => o.value === type) return {opt?.label || type} }, }, { title: "素材", dataIndex: "asset_id", width: 150, ellipsis: true, render: (assetId: string) => assetId ? ( {assetId.slice(0, 12)}... ) : ( 无素材 ), }, { title: "文本内容", dataIndex: "text_content", ellipsis: true, render: (text: string) => text || -, }, { title: "时长", dataIndex: "duration", width: 90, render: (d: number) => `${d?.toFixed(1) || 0}s`, }, { title: "转场", dataIndex: "transition_effect", width: 100, render: (effect: string) => { const opt = TRANSITION_OPTIONS.find((o) => o.value === effect) return opt?.label || effect || "硬切" }, }, { title: "播放速度", dataIndex: "playback_speed", width: 90, render: (s: number) => `${s || 1.0}x`, }, { title: "状态", dataIndex: "status", width: 90, render: (status: EditPlanClipStatus) => ( {STATUS_LABELS[status] || status} ), }, { title: "操作", key: "action", width: 140, fixed: "right", render: (_, record) => ( handleDeleteClip(record.id)} okText="确定" cancelText="取消" okButtonProps={{ danger: true }} > ), }, ] /* ── 编辑片段 ── */ const handleEditClip = useCallback( (clip: EditPlanClip) => { setEditingClip(clip) editForm.setFieldsValue({ clip_type: clip.clip_type, asset_id: clip.asset_id, text_content: clip.text_content, duration: clip.duration, start_time: clip.start_time, transition_effect: clip.transition_effect, transition_duration: clip.transition_duration, playback_speed: clip.playback_speed, }) setEditModalOpen(true) }, [editForm], ) const handleNewClip = useCallback(() => { setEditingClip(null) editForm.resetFields() editForm.setFieldsValue({ clip_type: "main", duration: 5, transition_effect: "cut", transition_duration: 0, playback_speed: 1.0, }) setEditModalOpen(true) }, [editForm]) const handleSaveClip = async () => { if (!planId) return try { const values = await editForm.validateFields() setEditLoading(true) if (editingClip) { // 更新 await updateEditPlanClip(planId, editingClip.id, values) message.success("片段已更新") } else { // 新建 const maxOrder = clips.length > 0 ? Math.max(...clips.map((c) => c.order)) : -1 await createEditPlanClip(planId, { ...values, order: maxOrder + 1, }) message.success("片段已创建") } queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] }) setEditModalOpen(false) } catch (err) { console.error(err) message.error(editingClip ? "更新失败" : "创建失败") } finally { setEditLoading(false) } } /* ── 删除片段 ── */ const handleDeleteClip = async (clipId: string) => { if (!planId) return try { await deleteEditPlanClip(planId, clipId) message.success("已删除") queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] }) setSelectedRowKeys((prev) => prev.filter((k) => k !== clipId)) } catch { message.error("删除失败") } } /* ── 批量删除 ── */ const handleBatchDelete = async () => { if (!planId || selectedRowKeys.length === 0) return try { await batchDeleteEditPlanClips( planId, selectedRowKeys.map((k) => String(k)), ) message.success(`已删除 ${selectedRowKeys.length} 个片段`) queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] }) setSelectedRowKeys([]) } catch { message.error("批量删除失败") } } /* ── 从素材导入 ── */ const handleImportFromAssets = async () => { if (!planId || selectedAssetIds.length === 0) return try { setImportLoading(true) const res = await createClipsFromAssets(planId, selectedAssetIds) message.success(`已导入 ${res.created_count} 个片段`) queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] }) setImportDrawerOpen(false) setSelectedAssetIds([]) } catch { message.error("导入失败") } finally { setImportLoading(false) } } /* ── 排序模式 ── */ const enterReorderMode = () => { setReorderItems([...clips].sort((a, b) => a.order - b.order)) setReorderMode(true) } const moveClip = (fromIndex: number, toIndex: number) => { if (toIndex < 0 || toIndex >= reorderItems.length) return const newItems = [...reorderItems] const [moved] = newItems.splice(fromIndex, 1) newItems.splice(toIndex, 0, moved) setReorderItems(newItems) } const saveReorder = async () => { if (!planId) return const items = reorderItems.map((clip, index) => ({ clip_id: clip.id, new_order: index, })) try { await reorderEditPlanClips(planId, items) message.success("排序已保存") queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] }) setReorderMode(false) } catch { message.error("排序保存失败") } } const cancelReorder = () => { setReorderMode(false) setReorderItems([]) } /* ── 渲染 ── */ const displayClips = reorderMode ? reorderItems : [...clips].sort((a, b) => a.order - b.order) return (
{/* 顶部 */}

{plan?.name || "加载中..."}

{planLoading ? "加载中..." : `共 ${clipsData?.total || 0} 个片段 · ${plan?.status || ""}`}

{reorderMode ? ( <> ) : ( <> )}
{/* 批量操作栏 */} {!reorderMode && selectedRowKeys.length > 0 && (
已选择 {selectedRowKeys.length} 个片段
)} {/* 排序列表 */} {reorderMode && (
{reorderItems.map((clip, index) => (
{index + 1} {CLIP_TYPE_OPTIONS.find((o) => o.value === clip.clip_type)?.label || clip.clip_type} {clip.text_content || clip.asset_id || "无内容"} {clip.duration.toFixed(1)}s
))}
)} {/* 片段列表 */} {!reorderMode && (
), }} scroll={{ x: 1000 }} /> )} {/* 编辑弹窗 */} setEditModalOpen(false)} onOk={handleSaveClip} confirmLoading={editLoading} okText="保存" cancelText="取消" width={560} >