Files
xiaoxia-saas/apps/web/src/pages/edit-plans/PlanClipsManager.tsx
T

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;