626 lines
19 KiB
TypeScript
Executable File
626 lines
19 KiB
TypeScript
Executable File
/**
|
|
* 剪辑计划片段管理页面
|
|
* 对接后端 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 EditPlan,
|
|
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<EditPlanClipStatus, string> = {
|
|
pending: "default",
|
|
processing: "processing",
|
|
ready: "success",
|
|
failed: "error",
|
|
};
|
|
|
|
const STATUS_LABELS: Record<EditPlanClipStatus, string> = {
|
|
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<React.Key[]>([]);
|
|
|
|
/* ── 编辑弹窗 ── */
|
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
|
const [editingClip, setEditingClip] = useState<EditPlanClip | null>(null);
|
|
const [editForm] = Form.useForm();
|
|
const [editLoading, setEditLoading] = useState(false);
|
|
|
|
/* ── 素材导入抽屉 ── */
|
|
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
|
|
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
|
const [importLoading, setImportLoading] = useState(false);
|
|
|
|
const { data: assets } = useQuery({
|
|
queryKey: ["mediaAssets"],
|
|
queryFn: () => getMediaAssets(),
|
|
enabled: importDrawerOpen,
|
|
});
|
|
|
|
/* ── 重新排序模式 ── */
|
|
const [reorderMode, setReorderMode] = useState(false);
|
|
const [reorderItems, setReorderItems] = useState<EditPlanClip[]>([]);
|
|
|
|
/* ── 列定义 ── */
|
|
const columns: ColumnsType<EditPlanClip> = [
|
|
{
|
|
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 <Tag>{opt?.label || type}</Tag>;
|
|
},
|
|
},
|
|
{
|
|
title: "素材",
|
|
dataIndex: "asset_id",
|
|
width: 150,
|
|
ellipsis: true,
|
|
render: (assetId: string) =>
|
|
assetId ? <code className="clip-asset-id">{assetId.slice(0, 12)}...</code> : <span style={{ color: "#999" }}>无素材</span>,
|
|
},
|
|
{
|
|
title: "文本内容",
|
|
dataIndex: "text_content",
|
|
ellipsis: true,
|
|
render: (text: string) => text || <span style={{ color: "#999" }}>-</span>,
|
|
},
|
|
{
|
|
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) => (
|
|
<Tag color={STATUS_COLORS[status] || "default"}>
|
|
{STATUS_LABELS[status] || status}
|
|
</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: "操作",
|
|
key: "action",
|
|
width: 140,
|
|
fixed: "right",
|
|
render: (_, record) => (
|
|
<Space size="small">
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<EditOutlined />}
|
|
onClick={() => handleEditClip(record)}
|
|
>
|
|
编辑
|
|
</Button>
|
|
<Popconfirm
|
|
title="删除片段"
|
|
description="确定删除这个片段吗?"
|
|
onConfirm={() => handleDeleteClip(record.id)}
|
|
okText="确定"
|
|
cancelText="取消"
|
|
okButtonProps={{ danger: true }}
|
|
>
|
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
|
删除
|
|
</Button>
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
/* ── 编辑片段 ── */
|
|
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 (
|
|
<div className="plan-clips-page">
|
|
{/* 顶部 */}
|
|
<div className="plan-clips-header">
|
|
<div className="plan-clips-header-left">
|
|
<Button
|
|
type="text"
|
|
icon={<ArrowLeftOutlined />}
|
|
onClick={() => navigate("/app/edit-plans")}
|
|
>
|
|
返回计划列表
|
|
</Button>
|
|
<div className="plan-clips-title">
|
|
<h2>{plan?.name || "加载中..."}</h2>
|
|
<p>
|
|
{planLoading
|
|
? "加载中..."
|
|
: `共 ${clipsData?.total || 0} 个片段 · ${plan?.status || ""}`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="plan-clips-header-right">
|
|
<Space>
|
|
<Button
|
|
icon={<UploadOutlined />}
|
|
onClick={() => setImportDrawerOpen(true)}
|
|
>
|
|
从素材导入
|
|
</Button>
|
|
{reorderMode ? (
|
|
<>
|
|
<Button onClick={cancelReorder}>取消排序</Button>
|
|
<Button type="primary" icon={<SaveOutlined />} onClick={saveReorder}>
|
|
保存排序
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
icon={<OrderedListOutlined />}
|
|
onClick={enterReorderMode}
|
|
disabled={clips.length === 0}
|
|
>
|
|
调整顺序
|
|
</Button>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleNewClip}>
|
|
添加片段
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Space>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 批量操作栏 */}
|
|
{!reorderMode && selectedRowKeys.length > 0 && (
|
|
<div className="plan-clips-batch-bar">
|
|
<span>已选择 {selectedRowKeys.length} 个片段</span>
|
|
<Popconfirm
|
|
title="批量删除"
|
|
description={`确定删除选中的 ${selectedRowKeys.length} 个片段吗?`}
|
|
onConfirm={handleBatchDelete}
|
|
okText="确定"
|
|
cancelText="取消"
|
|
okButtonProps={{ danger: true }}
|
|
>
|
|
<Button danger icon={<DeleteOutlined />}>
|
|
批量删除
|
|
</Button>
|
|
</Popconfirm>
|
|
</div>
|
|
)}
|
|
|
|
{/* 排序列表 */}
|
|
{reorderMode && (
|
|
<Card className="plan-clips-reorder-card" title="拖拽调整顺序">
|
|
<div className="plan-clips-reorder-list">
|
|
{reorderItems.map((clip, index) => (
|
|
<div key={clip.id} className="plan-clips-reorder-item">
|
|
<span className="reorder-index">{index + 1}</span>
|
|
<span className="reorder-type">
|
|
{CLIP_TYPE_OPTIONS.find((o) => o.value === clip.clip_type)
|
|
?.label || clip.clip_type}
|
|
</span>
|
|
<span className="reorder-content">
|
|
{clip.text_content || clip.asset_id || "无内容"}
|
|
</span>
|
|
<span className="reorder-duration">{clip.duration.toFixed(1)}s</span>
|
|
<Space>
|
|
<Button
|
|
size="small"
|
|
onClick={() => moveClip(index, index - 1)}
|
|
disabled={index === 0}
|
|
>
|
|
↑
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
onClick={() => moveClip(index, index + 1)}
|
|
disabled={index === reorderItems.length - 1}
|
|
>
|
|
↓
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 片段列表 */}
|
|
{!reorderMode && (
|
|
<div className="plan-clips-table-wrap">
|
|
<Table
|
|
rowKey="id"
|
|
columns={columns}
|
|
dataSource={displayClips}
|
|
loading={clipsLoading}
|
|
rowSelection={{
|
|
selectedRowKeys,
|
|
onChange: setSelectedRowKeys,
|
|
}}
|
|
pagination={false}
|
|
locale={{
|
|
emptyText: (
|
|
<Empty
|
|
description="暂无片段,点击上方按钮添加或从素材导入"
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
/>
|
|
),
|
|
}}
|
|
scroll={{ x: 1000 }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 编辑弹窗 */}
|
|
<Modal
|
|
title={editingClip ? "编辑片段" : "添加片段"}
|
|
open={editModalOpen}
|
|
onCancel={() => setEditModalOpen(false)}
|
|
onOk={handleSaveClip}
|
|
confirmLoading={editLoading}
|
|
okText="保存"
|
|
cancelText="取消"
|
|
width={560}
|
|
>
|
|
<Form form={editForm} layout="vertical">
|
|
<Form.Item
|
|
label="片段类型"
|
|
name="clip_type"
|
|
rules={[{ required: true, message: "请选择类型" }]}
|
|
>
|
|
<Select options={CLIP_TYPE_OPTIONS} />
|
|
</Form.Item>
|
|
<Form.Item label="素材 ID" name="asset_id">
|
|
<Input placeholder="关联的素材 ID(可选)" />
|
|
</Form.Item>
|
|
<Form.Item label="文本内容" name="text_content">
|
|
<Input.TextArea rows={3} placeholder="字幕/配音文案等" />
|
|
</Form.Item>
|
|
<div style={{ display: "flex", gap: 16 }}>
|
|
<Form.Item label="起始时间(秒)" name="start_time" style={{ flex: 1 }}>
|
|
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
|
</Form.Item>
|
|
<Form.Item label="时长(秒)" name="duration" style={{ flex: 1 }}>
|
|
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
|
</Form.Item>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 16 }}>
|
|
<Form.Item label="转场效果" name="transition_effect" style={{ flex: 1 }}>
|
|
<Select options={TRANSITION_OPTIONS} />
|
|
</Form.Item>
|
|
<Form.Item label="转场时长" name="transition_duration" style={{ flex: 1 }}>
|
|
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
|
</Form.Item>
|
|
</div>
|
|
<Form.Item label="播放速度" name="playback_speed">
|
|
<InputNumber min={0.1} max={10} step={0.1} style={{ width: "100%" }} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* 素材导入抽屉 */}
|
|
<Drawer
|
|
title="从素材库导入"
|
|
open={importDrawerOpen}
|
|
onClose={() => setImportDrawerOpen(false)}
|
|
width={480}
|
|
extra={
|
|
<Button
|
|
type="primary"
|
|
onClick={handleImportFromAssets}
|
|
loading={importLoading}
|
|
disabled={selectedAssetIds.length === 0}
|
|
>
|
|
导入 {selectedAssetIds.length > 0 ? `(${selectedAssetIds.length})` : ""}
|
|
</Button>
|
|
}
|
|
>
|
|
{assets && assets.length > 0 ? (
|
|
<div className="asset-import-list">
|
|
{assets.map((asset) => (
|
|
<div
|
|
key={asset.id}
|
|
className={`asset-import-item ${
|
|
selectedAssetIds.includes(asset.id) ? "selected" : ""
|
|
}`}
|
|
onClick={() => {
|
|
setSelectedAssetIds((prev) =>
|
|
prev.includes(asset.id)
|
|
? prev.filter((id) => id !== asset.id)
|
|
: [...prev, asset.id],
|
|
);
|
|
}}
|
|
>
|
|
<div className="asset-thumb">
|
|
{asset.thumbnail_url ? (
|
|
<img src={asset.thumbnail_url} alt={asset.name} />
|
|
) : (
|
|
<div className="asset-thumb-placeholder">{asset.type}</div>
|
|
)}
|
|
</div>
|
|
<div className="asset-info">
|
|
<div className="asset-name" title={asset.name}>
|
|
{asset.name}
|
|
</div>
|
|
<div className="asset-meta">
|
|
{asset.type}
|
|
{asset.duration ? ` · ${asset.duration.toFixed(1)}s` : ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Empty description="素材库为空" />
|
|
)}
|
|
</Drawer>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PlanClipsManager;
|