diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts index 414f563e8..90704264f 100755 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -276,24 +276,6 @@ export interface CoverResult { * 前端 UI 类型(EditingPlanner 组件依赖,保留兼容) * ============================================================ */ -/** 剪辑计划中的片段(UI 层类型) */ -export interface EditPlanClip { - id: string; - template_segment_id: string; - /** 素材库中的素材 ID */ - media_asset_id?: string; - /** 素材类型 */ - material_type: "video" | "image" | "audio" | "voiceover"; - /** 片段文案 */ - script_text: string; - /** 实际时长(秒) */ - duration: number; - /** 转场效果 */ - transition?: TransitionEffect; - /** 排序 */ - order: number; -} - /** 转场效果(14 种预设) */ export interface TransitionEffect { type: @@ -458,6 +440,220 @@ export async function cancelGeneration(planId: string): Promise { await apiClient.post(`/edit-plans/${planId}/cancel`); } +/* ============================================================ + * 片段 CRUD(后端 EditPlanClip 独立表) + * ============================================================ */ + +/** 片段状态 */ +export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed"; + +/** 剪辑片段(后端响应) */ +export interface EditPlanClip { + id: string; + plan_id: string; + clip_type: string; // main / intro / outro / overlay / background / b_roll 等 + order: number; + asset_id: string; + text_content: string; + start_time: number; + duration: number; + transition_effect: string; + transition_duration: number; + playback_speed: number; + status: EditPlanClipStatus; + config: Record; + created_at?: string; + updated_at?: string; +} + +/** 创建片段请求 */ +export interface CreateEditPlanClipRequest { + clip_type: string; + order: number; + asset_id?: string; + text_content?: string; + start_time?: number; + duration?: number; + transition_effect?: string; + transition_duration?: number; + playback_speed?: number; + config?: Record; +} + +/** 更新片段请求 */ +export interface UpdateEditPlanClipRequest { + clip_type?: string; + order?: number; + asset_id?: string; + text_content?: string; + start_time?: number; + duration?: number; + transition_effect?: string; + transition_duration?: number; + playback_speed?: number; + config?: Record; +} + +/** 片段列表响应 */ +export interface EditPlanClipListResponse { + items: EditPlanClip[]; + total: number; +} + +/** 片段列表查询参数 */ +export interface EditPlanClipListParams { + status?: string; + skip?: number; + limit?: number; +} + +/** 获取片段列表 */ +export async function getEditPlanClips( + planId: string, + params?: EditPlanClipListParams, +): Promise { + const response = await apiClient.get( + `/edit-plans/${planId}/clips`, + { params }, + ); + return response.data; +} + +/** 获取单个片段详情 */ +export async function getEditPlanClip( + planId: string, + clipId: string, +): Promise { + const response = await apiClient.get( + `/edit-plans/${planId}/clips/${clipId}`, + ); + return response.data; +} + +/** 创建片段 */ +export async function createEditPlanClip( + planId: string, + data: CreateEditPlanClipRequest, +): Promise { + const response = await apiClient.post( + `/edit-plans/${planId}/clips`, + data, + ); + return response.data; +} + +/** 更新片段 */ +export async function updateEditPlanClip( + planId: string, + clipId: string, + data: UpdateEditPlanClipRequest, +): Promise { + const response = await apiClient.put( + `/edit-plans/${planId}/clips/${clipId}`, + data, + ); + return response.data; +} + +/** 删除片段 */ +export async function deleteEditPlanClip( + planId: string, + clipId: string, +): Promise { + await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`); +} + +/* ============================================================ + * 片段批量操作 + * ============================================================ */ + +/** 重排序条目 */ +export interface ClipReorderItem { + clip_id: string; + new_order: number; +} + +/** 重排序响应 */ +export interface ClipReorderResponse { + success: boolean; + updated_count: number; + message: string; +} + +/** 批量删除响应 */ +export interface ClipBatchDeleteResponse { + success: boolean; + deleted_count: number; + message: string; +} + +/** 从素材批量创建响应 */ +export interface ClipsFromAssetsResponse { + success: boolean; + created_count: number; + message: string; + clip_ids: string[]; +} + +/** 片段重排序(拖拽排序后一次性提交) */ +export async function reorderEditPlanClips( + planId: string, + items: ClipReorderItem[], +): Promise { + const response = await apiClient.post( + `/edit-plans/${planId}/clips/reorder`, + { items }, + ); + return response.data; +} + +/** 批量删除片段 */ +export async function batchDeleteEditPlanClips( + planId: string, + clipIds: string[], +): Promise { + const response = await apiClient.post( + `/edit-plans/${planId}/clips/batch-delete`, + { clip_ids: clipIds }, + ); + return response.data; +} + +/** 从素材批量创建片段(追加到时间线末尾) */ +export async function createClipsFromAssets( + planId: string, + assetIds: string[], + clipType = "main", +): Promise { + const response = await apiClient.post( + `/edit-plans/${planId}/clips/from-assets`, + { asset_ids: assetIds, clip_type: clipType }, + ); + return response.data; +} + +/* ============================================================ + * 复制计划 + * ============================================================ */ + +/** 复制计划请求 */ +export interface CopyEditPlanRequest { + name?: string; + project_id?: string; +} + +/** 复制剪辑计划(含所有片段配置) */ +export async function copyEditPlan( + planId: string, + data?: CopyEditPlanRequest, +): Promise { + const response = await apiClient.post( + `/edit-plans/${planId}/copy`, + data || {}, + ); + return response.data; +} + /** * 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx * 将后端 AssetResponse 映射为前端 MediaAsset 类型 diff --git a/apps/web/src/pages/edit-plans/EditPlans.tsx b/apps/web/src/pages/edit-plans/EditPlans.tsx index 899a2f28e..ce68bc71e 100755 --- a/apps/web/src/pages/edit-plans/EditPlans.tsx +++ b/apps/web/src/pages/edit-plans/EditPlans.tsx @@ -24,6 +24,8 @@ import { DeleteOutlined, FileTextOutlined, ThunderboltOutlined, + CopyOutlined, + UnorderedListOutlined, StopOutlined, } from "@ant-design/icons"; import type { ColumnsType } from "antd/es/table"; @@ -32,6 +34,7 @@ import { deleteEditPlan, generateEditPlan, cancelGeneration, + copyEditPlan, type EditPlan, type EditPlanStatus, type EditPlanListParams, @@ -204,6 +207,21 @@ export default function EditPlans() { }, }); + // 复制计划 + const copyMutation = useMutation({ + mutationFn: ({ planId, name }: { planId: string; name?: string }) => + copyEditPlan(planId, name ? { name } : undefined), + onSuccess: (newPlan) => { + message.success("计划已复制"); + queryClient.invalidateQueries({ queryKey: ["edit-plans"] }); + // 自动跳转到新计划的编辑器 + navigate(`/app/editing-planner?planId=${newPlan.id}`); + }, + onError: () => { + message.error("复制失败,请稍后重试"); + }, + }); + // 跳转到剪辑编辑器 const handleEdit = useCallback( (plan: EditPlan) => { @@ -309,6 +327,17 @@ export default function EditPlans() { fixed: "right", render: (_: unknown, record: EditPlan) => (
+ + + + = { + 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} + > +
+ + + + + + +
+ + + + + + +
+
+ +