feat: 剪辑计划片段CRUD前端全链路对接 #403
+214
-18
@@ -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<void> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/** 更新片段请求 */
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/** 片段列表响应 */
|
||||
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<EditPlanClipListResponse> {
|
||||
const response = await apiClient.get<EditPlanClipListResponse>(
|
||||
`/edit-plans/${planId}/clips`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 获取单个片段详情 */
|
||||
export async function getEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.get<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips/${clipId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 创建片段 */
|
||||
export async function createEditPlanClip(
|
||||
planId: string,
|
||||
data: CreateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.post<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 更新片段 */
|
||||
export async function updateEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
data: UpdateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.put<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips/${clipId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 删除片段 */
|
||||
export async function deleteEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
): Promise<void> {
|
||||
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<ClipReorderResponse> {
|
||||
const response = await apiClient.post<ClipReorderResponse>(
|
||||
`/edit-plans/${planId}/clips/reorder`,
|
||||
{ items },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 批量删除片段 */
|
||||
export async function batchDeleteEditPlanClips(
|
||||
planId: string,
|
||||
clipIds: string[],
|
||||
): Promise<ClipBatchDeleteResponse> {
|
||||
const response = await apiClient.post<ClipBatchDeleteResponse>(
|
||||
`/edit-plans/${planId}/clips/batch-delete`,
|
||||
{ clip_ids: clipIds },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 从素材批量创建片段(追加到时间线末尾) */
|
||||
export async function createClipsFromAssets(
|
||||
planId: string,
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/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<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(
|
||||
`/edit-plans/${planId}/copy`,
|
||||
data || {},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
||||
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
||||
|
||||
@@ -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) => (
|
||||
<div className="plan-actions">
|
||||
<Tooltip title="片段管理">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => navigate(`/app/edit-plans/${record.id}/clips`)}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
片段
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -360,6 +389,28 @@ export default function EditPlans() {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="复制计划"
|
||||
description="确定要复制这个剪辑计划吗?将创建一个编辑中的新副本。"
|
||||
onConfirm={() =>
|
||||
copyMutation.mutate({
|
||||
planId: record.id,
|
||||
name: `${record.name} 副本`,
|
||||
})
|
||||
}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyMutation.isPending}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="确定要删除这个剪辑计划吗?此操作不可恢复。"
|
||||
|
||||
+658
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* 剪辑计划片段管理页面
|
||||
* 对接后端 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<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;
|
||||
Executable
+203
@@ -0,0 +1,203 @@
|
||||
/* 剪辑计划片段管理页面 */
|
||||
|
||||
.plan-clips-page {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.plan-clips-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.plan-clips-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plan-clips-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.plan-clips-title p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.plan-clips-batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 16px;
|
||||
background: #e6f4ff;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.plan-clips-table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.clip-asset-id {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 排序模式 */
|
||||
.plan-clips-reorder-card {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.plan-clips-reorder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-clips-reorder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plan-clips-reorder-item:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.reorder-index {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reorder-type {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
padding: 2px 8px;
|
||||
background: #eef2ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reorder-content {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reorder-duration {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 素材导入 */
|
||||
.asset-import-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.asset-import-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.asset-import-item:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.asset-import-item.selected {
|
||||
border-color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.asset-thumb {
|
||||
width: 56px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asset-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.asset-thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.asset-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-meta {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -6074,3 +6074,249 @@
|
||||
.ep-gh-action-placeholder {
|
||||
color: var(--text-tertiary, #bfbfbf);
|
||||
}
|
||||
|
||||
/* ═══ 生成进度 - 片段状态列表 ═══ */
|
||||
|
||||
.ep-gen-clip-list {
|
||||
margin-top: 16px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ep-gen-clip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ep-gen-clip-item + .ep-gen-clip-item {
|
||||
border-top: 1px solid var(--border-color-light, #f3f4f6);
|
||||
}
|
||||
|
||||
.ep-gen-clip-index {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary, #f3f4f6);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-gen-clip-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.ep-gen-clip-status {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ep-gen-clip-status.status-completed {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.ep-gen-clip-status.status-failed {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.ep-gen-clip-status.status-processing {
|
||||
color: #3b82f6;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.ep-gen-clip-status.status-pending,
|
||||
.ep-gen-clip-status.status-queued {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ═══ 右侧栏 Tab ═══ */
|
||||
.ep-right-panel {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.ep-right-tabs {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.ep-right-tab {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.ep-right-tab:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.ep-right-tab.active {
|
||||
color: var(--primary-color, #3b82f6);
|
||||
border-bottom-color: var(--primary-color, #3b82f6);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ep-right-tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ═══ 编辑器内片段列表 ═══ */
|
||||
.ep-clip-list {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ep-clip-list-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.ep-clip-list-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.ep-clip-list-count b {
|
||||
color: var(--text-primary, #111827);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ep-clip-list-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.ep-clip-list-item {
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.ep-clip-list-item:hover {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.ep-clip-list-item.selected {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
background: rgba(59, 130, 246, 0.04);
|
||||
}
|
||||
|
||||
.ep-clip-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ep-clip-item-index {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-tertiary, #f3f4f6);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-clip-item-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-clip-item-type-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ep-clip-item-duration {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-clip-item-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.ep-clip-item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
justify-content: flex-end;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.ep-clip-list-item:hover .ep-clip-item-actions,
|
||||
.ep-clip-list-item.selected .ep-clip-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ep-clip-item-btn {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.ep-clip-list-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ import {
|
||||
getGenerationStatus,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
batchDeleteEditPlanClips,
|
||||
type EditPlanClip,
|
||||
type CreateEditPlanClipRequest,
|
||||
type ClipStatusItem,
|
||||
} from "@/api/editPlans";
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo";
|
||||
import type {
|
||||
@@ -80,6 +86,7 @@ import MediaPanel from "./components/MediaPanel";
|
||||
import PreviewPlayer from "./components/PreviewPlayer";
|
||||
import TimelinePanel from "./components/TimelinePanel";
|
||||
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
|
||||
import EditorClipList from "./components/EditorClipList";
|
||||
import BgmSelector from "./components/BgmSelector";
|
||||
import SubtitleStylePanel from "./components/SubtitleStylePanel";
|
||||
import type { SubtitleStyleConfig } from "./types/subtitle";
|
||||
@@ -237,6 +244,10 @@ const EditingPlanner: React.FC = () => {
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
});
|
||||
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false);
|
||||
/* ── 右侧栏 Tab ── */
|
||||
const [rightTab, setRightTab] = useState<"properties" | "clips">(
|
||||
"properties",
|
||||
);
|
||||
|
||||
/* ── 保存弹窗 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
@@ -272,6 +283,8 @@ const EditingPlanner: React.FC = () => {
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||||
const [genError, setGenError] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [genCancelled, setGenCancelled] = useState(false);
|
||||
const [genClipStatuses, setGenClipStatuses] = useState<ClipStatusItem[]>([]);
|
||||
const genTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
/* ── 播放 ── */
|
||||
@@ -410,8 +423,16 @@ const EditingPlanner: React.FC = () => {
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!loadedPlanId) return;
|
||||
getEditPlan(loadedPlanId)
|
||||
.then((plan) => {
|
||||
|
||||
// 并行加载计划基本信息 + 片段列表
|
||||
Promise.all([
|
||||
getEditPlan(loadedPlanId),
|
||||
getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
})),
|
||||
])
|
||||
.then(([plan, clipsRes]) => {
|
||||
// 设置关联的模板(触发模板加载 effect)
|
||||
setLoadedTemplateId(plan.template_id);
|
||||
|
||||
@@ -450,9 +471,54 @@ const EditingPlanner: React.FC = () => {
|
||||
music_id: cfg.bgm_config!.music_id || "",
|
||||
}));
|
||||
}
|
||||
// 还原封面配置
|
||||
if (cfg.cover_config) {
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cfg.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
|
||||
thumbnail_url:
|
||||
cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
ai_suggested_time:
|
||||
cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
}));
|
||||
}
|
||||
|
||||
// 还原片段 — 延迟设置,等模板加载 effect 先执行 resetClips
|
||||
if (cfg.segments && cfg.segments.length > 0) {
|
||||
// 还原片段:优先从后端 clips 表,其次从 config.segments 兜底
|
||||
const backendClips = clipsRes?.items || [];
|
||||
if (backendClips.length > 0) {
|
||||
// 从后端 clips 表还原
|
||||
const sorted = [...backendClips].sort((a, b) => a.order - b.order);
|
||||
const mapped: ClipData[] = sorted.map((clip) => ({
|
||||
id: clip.id,
|
||||
template_segment_id:
|
||||
(clip.config?.template_segment_id as string) || "",
|
||||
type: (clip.clip_type === "voiceover"
|
||||
? "voice"
|
||||
: "pip") as ClipType,
|
||||
duration: clip.duration || 3,
|
||||
startOffset: 0,
|
||||
script_text: clip.text_content || "",
|
||||
order: clip.order,
|
||||
media_asset_id: clip.asset_id || undefined,
|
||||
transition:
|
||||
clip.transition_effect && clip.transition_effect !== "none"
|
||||
? {
|
||||
type: clip.transition_effect as TransitionEffect["type"],
|
||||
duration: clip.transition_duration || 0.3,
|
||||
}
|
||||
: undefined,
|
||||
speed: clip.playback_speed
|
||||
? { rate: clip.playback_speed, pitchCorrection: true }
|
||||
: undefined,
|
||||
tts_config: (clip.config?.tts_config as TtsConfig) || undefined,
|
||||
trim_config: (clip.config?.trim_config as TrimConfig) || undefined,
|
||||
}));
|
||||
setTimeout(() => resetClips(mapped), 100);
|
||||
} else if (cfg.segments && cfg.segments.length > 0) {
|
||||
// 兜底:从 config.segments 还原(老数据兼容)
|
||||
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
|
||||
id: `seg-${idx}`,
|
||||
template_segment_id: `seg-${idx}`,
|
||||
@@ -936,10 +1002,62 @@ const EditingPlanner: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将本地编辑的片段同步到后端 clips 表
|
||||
* 策略:先删除后端所有片段,再批量创建(简单可靠,生成前使用)
|
||||
*/
|
||||
const syncClipsToBackend = async (planId: string): Promise<void> => {
|
||||
if (clips.length === 0) return;
|
||||
|
||||
// 1. 获取后端现有片段 ID
|
||||
try {
|
||||
const existing = await getEditPlanClips(planId, { limit: 500 });
|
||||
if (existing.items.length > 0) {
|
||||
await batchDeleteEditPlanClips(
|
||||
planId,
|
||||
existing.items.map((c) => c.id),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[获取后端片段失败,跳过删除]", err);
|
||||
}
|
||||
|
||||
// 2. 批量创建新片段(并发 3 个)
|
||||
const clipDataList: CreateEditPlanClipRequest[] = clips.map((c, i) => ({
|
||||
clip_type: c.type === "voice" ? "voiceover" : "main",
|
||||
order: i,
|
||||
asset_id: c.media_asset_id || "",
|
||||
text_content: c.script_text || "",
|
||||
start_time: 0,
|
||||
duration: c.duration,
|
||||
transition_effect: c.transition?.type || "cut",
|
||||
transition_duration: c.transition?.duration || 0,
|
||||
playback_speed: c.speed?.rate || 1.0,
|
||||
config: {
|
||||
tts_config: c.tts_config || null,
|
||||
trim_config: c.trim_config || null,
|
||||
template_segment_id: c.template_segment_id || null,
|
||||
},
|
||||
}));
|
||||
|
||||
// 并发控制:最多同时 3 个请求
|
||||
const results: EditPlanClip[] = [];
|
||||
const concurrency = 3;
|
||||
for (let i = 0; i < clipDataList.length; i += concurrency) {
|
||||
const batch = clipDataList.slice(i, i + concurrency);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map((data) => createEditPlanClip(planId, data)),
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
console.log(`[片段同步] 创建了 ${results.length} 个片段`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 剪辑计划生成
|
||||
* 1. 有 planId → 更新计划配置 + 触发生成
|
||||
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 触发生成
|
||||
* 1. 有 planId → 更新计划配置 + 同步片段 + 触发生成
|
||||
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 同步片段 + 触发生成
|
||||
* 3. 触发生成后轮询状态,完成后获取视频结果
|
||||
*/
|
||||
const handleGoToGenerate = async () => {
|
||||
@@ -957,6 +1075,7 @@ const EditingPlanner: React.FC = () => {
|
||||
setGeneratedVideos([]);
|
||||
setGenError(null);
|
||||
setGenProgress(0);
|
||||
setGenCancelled(false);
|
||||
|
||||
try {
|
||||
const config = buildPlanConfig();
|
||||
@@ -994,6 +1113,14 @@ const EditingPlanner: React.FC = () => {
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
}
|
||||
|
||||
// 同步片段到后端 clips 表(生成前必须同步,后端生成从 clips 表读)
|
||||
try {
|
||||
await syncClipsToBackend(planId);
|
||||
} catch (syncErr) {
|
||||
console.warn("[片段同步失败]", syncErr);
|
||||
// 同步失败不阻塞生成,后端有模板兜底
|
||||
}
|
||||
|
||||
// 触发生成
|
||||
const genRes = await generateEditPlan(planId);
|
||||
setGenTotalClips(genRes.clip_count);
|
||||
@@ -1021,6 +1148,7 @@ const EditingPlanner: React.FC = () => {
|
||||
).length;
|
||||
setGenDoneClips(done);
|
||||
setGenTotalClips(total);
|
||||
setGenClipStatuses(status.clips || []);
|
||||
setGenProgress(total > 0 ? Math.round((done / total) * 100) : 5);
|
||||
|
||||
if (status.plan_status === "completed") {
|
||||
@@ -1052,6 +1180,7 @@ const EditingPlanner: React.FC = () => {
|
||||
if (status.plan_status === "cancelled") {
|
||||
setGenerating(false);
|
||||
setGenError("生成已取消");
|
||||
setGenCancelled(true);
|
||||
message.info("生成任务已取消");
|
||||
return; // 停止轮询
|
||||
}
|
||||
@@ -1246,43 +1375,87 @@ const EditingPlanner: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 右栏 260px:设置面板 */}
|
||||
<ClipPropertiesPanel
|
||||
selectedClip={selectedClip}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={subtitleSettings}
|
||||
bgmSettings={bgmSettings}
|
||||
clipsCount={clips.length}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
onTitleSettingsChange={(partial) =>
|
||||
setTitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings(
|
||||
(prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig,
|
||||
)
|
||||
}
|
||||
onBgmSettingsChange={(partial) =>
|
||||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onClipUpdate={handleClipUpdate}
|
||||
onOpenBgmDrawer={() => setBgmDrawerOpen(true)}
|
||||
onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||||
onClipVoiceSelect={handleClipVoiceSelect}
|
||||
onOpenTransitionDrawer={handleOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={handleOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={handleOpenTtsDrawer}
|
||||
onOpenWatermarkDrawer={() => setWatermarkDrawerOpen(true)}
|
||||
onOpenIntroOutroDrawer={() => setIntroOutroDrawerOpen(true)}
|
||||
onOpenPipDrawer={() => setPipDrawerOpen(true)}
|
||||
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
|
||||
/>
|
||||
<div className="ep-right-panel">
|
||||
{/* Tab 切换 */}
|
||||
<div className="ep-right-tabs">
|
||||
<button
|
||||
className={`ep-right-tab${rightTab === "properties" ? " active" : ""}`}
|
||||
onClick={() => setRightTab("properties")}
|
||||
>
|
||||
属性
|
||||
</button>
|
||||
<button
|
||||
className={`ep-right-tab${rightTab === "clips" ? " active" : ""}`}
|
||||
onClick={() => setRightTab("clips")}
|
||||
>
|
||||
片段
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 属性 Tab */}
|
||||
{rightTab === "properties" && (
|
||||
<div className="ep-right-tab-content">
|
||||
<ClipPropertiesPanel
|
||||
selectedClip={selectedClip}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={subtitleSettings}
|
||||
bgmSettings={bgmSettings}
|
||||
clipsCount={clips.length}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
onTitleSettingsChange={(partial) =>
|
||||
setTitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings(
|
||||
(prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig,
|
||||
)
|
||||
}
|
||||
onBgmSettingsChange={(partial) =>
|
||||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onClipUpdate={handleClipUpdate}
|
||||
onOpenBgmDrawer={() => setBgmDrawerOpen(true)}
|
||||
onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||||
onClipVoiceSelect={handleClipVoiceSelect}
|
||||
onOpenTransitionDrawer={handleOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={handleOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={handleOpenTtsDrawer}
|
||||
onOpenWatermarkDrawer={() => setWatermarkDrawerOpen(true)}
|
||||
onOpenIntroOutroDrawer={() => setIntroOutroDrawerOpen(true)}
|
||||
onOpenPipDrawer={() => setPipDrawerOpen(true)}
|
||||
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 片段 Tab */}
|
||||
{rightTab === "clips" && (
|
||||
<div className="ep-right-tab-content">
|
||||
<EditorClipList
|
||||
clips={clips}
|
||||
selectedClipId={selectedClipId}
|
||||
onSelect={handleClipSelect}
|
||||
onMoveUp={(clipId) => {
|
||||
const idx = clips.findIndex((c) => c.id === clipId);
|
||||
if (idx > 0) handleClipReorder(idx, idx - 1);
|
||||
}}
|
||||
onMoveDown={(clipId) => {
|
||||
const idx = clips.findIndex((c) => c.id === clipId);
|
||||
if (idx < clips.length - 1) handleClipReorder(idx, idx + 1);
|
||||
}}
|
||||
onRemove={handleClipRemove}
|
||||
onAdd={() => handleAddClip("pip", 3)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ 第4行:底栏 40px ═══ */}
|
||||
@@ -1352,8 +1525,16 @@ const EditingPlanner: React.FC = () => {
|
||||
|
||||
{/* ═══ 生成进度弹窗 ═══ */}
|
||||
<Modal
|
||||
title={genError ? "生成失败" : generated ? "生成完成" : "正在生成视频"}
|
||||
open={generating || generated || !!genError}
|
||||
title={
|
||||
genError
|
||||
? "生成失败"
|
||||
: genCancelled
|
||||
? "已取消生成"
|
||||
: generated
|
||||
? "生成完成"
|
||||
: "正在生成视频"
|
||||
}
|
||||
open={generating || generated || !!genError || genCancelled}
|
||||
footer={
|
||||
generated
|
||||
? [
|
||||
@@ -1400,19 +1581,32 @@ const EditingPlanner: React.FC = () => {
|
||||
取消生成
|
||||
</Button>,
|
||||
]
|
||||
: genError
|
||||
: genCancelled
|
||||
? [
|
||||
<Button
|
||||
key="close"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setGenError(null);
|
||||
setGenCancelled(false);
|
||||
setGenerating(false);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
: null
|
||||
: genError
|
||||
? [
|
||||
<Button
|
||||
key="close"
|
||||
onClick={() => {
|
||||
setGenError(null);
|
||||
setGenerating(false);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
: null
|
||||
}
|
||||
closable={!generating}
|
||||
maskClosable={false}
|
||||
@@ -1424,11 +1618,50 @@ const EditingPlanner: React.FC = () => {
|
||||
<p style={{ marginTop: 8, color: "var(--text-secondary)" }}>
|
||||
已处理 {genDoneClips}/{genTotalClips} 个片段
|
||||
</p>
|
||||
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{genClipStatuses.length > 0 && (
|
||||
<div className="ep-gen-clip-list">
|
||||
{genClipStatuses.map((clip, index) => (
|
||||
<div key={clip.clip_id || index} className="ep-gen-clip-item">
|
||||
<span className="ep-gen-clip-index">{index + 1}</span>
|
||||
<span className="ep-gen-clip-name">
|
||||
{clip.text_content
|
||||
? clip.text_content.slice(0, 20)
|
||||
: clip.clip_type || `片段${index + 1}`}
|
||||
</span>
|
||||
<span
|
||||
className={`ep-gen-clip-status status-${clip.status}`}
|
||||
>
|
||||
{clip.status === "completed"
|
||||
? "✓ 完成"
|
||||
: clip.status === "failed"
|
||||
? "✗ 失败"
|
||||
: clip.status === "processing"
|
||||
? "⟳ 处理中"
|
||||
: "⏳ 等待中"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
请耐心等待,生成过程中请勿关闭页面
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{genCancelled && (
|
||||
<div style={{ padding: "24px 0", textAlign: "center" }}>
|
||||
<p>生成已取消</p>
|
||||
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
你可以继续编辑后重新生成
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<div style={{ padding: "8px 0" }}>
|
||||
<video
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 编辑器右侧栏 — 片段列表 Tab
|
||||
* 紧凑版片段管理:选中、上下移动、删除、添加
|
||||
*/
|
||||
import React from "react";
|
||||
import { Button, Tooltip, Empty } from "antd";
|
||||
import {
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ScissorOutlined,
|
||||
SoundOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
|
||||
interface EditorClipListProps {
|
||||
clips: ClipData[];
|
||||
selectedClipId: string | null;
|
||||
onSelect: (clipId: string) => void;
|
||||
onMoveUp: (clipId: string) => void;
|
||||
onMoveDown: (clipId: string) => void;
|
||||
onRemove: (clipId: string) => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
const clipTypeIcon: Record<ClipType | string, React.ReactNode> = {
|
||||
video: <VideoCameraOutlined />,
|
||||
image: <PictureOutlined />,
|
||||
voice: <SoundOutlined />,
|
||||
pip: <ScissorOutlined />,
|
||||
};
|
||||
|
||||
const clipTypeLabel: Record<ClipType | string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
voice: "配音",
|
||||
pip: "画中画",
|
||||
};
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = (sec % 60).toFixed(0);
|
||||
return `${m}m${s.padStart(2, "0")}s`;
|
||||
};
|
||||
|
||||
const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
onSelect,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
onAdd,
|
||||
}) => {
|
||||
if (clips.length === 0) {
|
||||
return (
|
||||
<div className="ep-clip-list-empty">
|
||||
<Empty
|
||||
description="暂无片段"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
style={{ margin: "40px 0" }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} block onClick={onAdd}>
|
||||
添加片段
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ep-clip-list">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="ep-clip-list-toolbar">
|
||||
<span className="ep-clip-list-count">
|
||||
共 <b>{clips.length}</b> 个片段
|
||||
</span>
|
||||
<Tooltip title="添加片段">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onAdd}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 片段列表 */}
|
||||
<div className="ep-clip-list-scroll">
|
||||
{clips.map((clip, index) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-clip-list-item${
|
||||
selectedClipId === clip.id ? " selected" : ""
|
||||
}`}
|
||||
onClick={() => onSelect(clip.id)}
|
||||
>
|
||||
{/* 序号 + 类型图标 */}
|
||||
<div className="ep-clip-item-head">
|
||||
<span className="ep-clip-item-index">{index + 1}</span>
|
||||
<span className="ep-clip-item-type">
|
||||
{clipTypeIcon[clip.type] || <ScissorOutlined />}
|
||||
<span className="ep-clip-item-type-label">
|
||||
{clipTypeLabel[clip.type] || "片段"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ep-clip-item-duration">
|
||||
{formatDuration(clip.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文案预览 */}
|
||||
{clip.script_text && (
|
||||
<div className="ep-clip-item-text">
|
||||
{clip.script_text.slice(0, 40)}
|
||||
{clip.script_text.length > 40 ? "..." : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div
|
||||
className="ep-clip-item-actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip title="上移">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<UpOutlined />}
|
||||
disabled={index === 0}
|
||||
onClick={() => onMoveUp(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="下移">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DownOutlined />}
|
||||
disabled={index === clips.length - 1}
|
||||
onClick={() => onMoveDown(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onRemove(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorClipList;
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 剪辑计划片段管理 Hook
|
||||
* 对接后端 PR#389 片段 CRUD API,替代原来的 config.segments 模式
|
||||
*
|
||||
* 功能:
|
||||
* - 加载/刷新片段列表
|
||||
* - 单个增删改查
|
||||
* - 批量删除
|
||||
* - 拖拽重排序
|
||||
* - 从素材批量导入
|
||||
* - 乐观更新 + 撤销重做
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { message } from "antd";
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import type {
|
||||
EditPlanClip,
|
||||
CreateEditPlanClipRequest,
|
||||
UpdateEditPlanClipRequest,
|
||||
ClipReorderItem,
|
||||
} from "@/api/editPlans";
|
||||
import {
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
updateEditPlanClip,
|
||||
deleteEditPlanClip,
|
||||
reorderEditPlanClips,
|
||||
batchDeleteEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
} from "@/api/editPlans";
|
||||
import { useUndoRedo } from "./useUndoRedo";
|
||||
|
||||
const QUERY_KEY = "editPlanClips";
|
||||
|
||||
export function useEditPlanClips(planId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* ── 片段列表查询 ── */
|
||||
const {
|
||||
data: clipListData,
|
||||
isLoading: clipsLoading,
|
||||
refetch: refetchClips,
|
||||
} = useQuery({
|
||||
queryKey: [QUERY_KEY, planId],
|
||||
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
|
||||
enabled: !!planId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const clips: EditPlanClip[] = clipListData?.items ?? [];
|
||||
const clipsTotal = clipListData?.total ?? 0;
|
||||
|
||||
/* ── 选中片段 ── */
|
||||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null;
|
||||
|
||||
/* ── 本地撤销/重做(用于拖拽等即时操作的回退) ── */
|
||||
const {
|
||||
state: localClips,
|
||||
set: setLocalClips,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
reset: resetLocalClips,
|
||||
} = useUndoRedo<EditPlanClip[]>([]);
|
||||
|
||||
// 当服务端数据变化时同步本地
|
||||
// 注意:实际使用时以服务端为准,本地仅用于拖拽等临时操作
|
||||
|
||||
/* ── 创建片段 ── */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateEditPlanClipRequest) =>
|
||||
createEditPlanClip(planId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
message.success("片段已添加");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("添加片段失败");
|
||||
},
|
||||
});
|
||||
|
||||
const addClip = useCallback(
|
||||
(data: Omit<CreateEditPlanClipRequest, "order"> & { order?: number }) => {
|
||||
if (!planId) return;
|
||||
const order = data.order ?? clips.length;
|
||||
createMutation.mutate({ ...data, order });
|
||||
},
|
||||
[planId, clips.length, createMutation],
|
||||
);
|
||||
|
||||
/* ── 更新片段 ── */
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
clipId,
|
||||
data,
|
||||
}: {
|
||||
clipId: string;
|
||||
data: UpdateEditPlanClipRequest;
|
||||
}) => updateEditPlanClip(planId!, clipId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新片段失败");
|
||||
},
|
||||
});
|
||||
|
||||
const updateClip = useCallback(
|
||||
(clipId: string, data: UpdateEditPlanClipRequest) => {
|
||||
if (!planId) return;
|
||||
updateMutation.mutate({ clipId, data });
|
||||
},
|
||||
[planId, updateMutation],
|
||||
);
|
||||
|
||||
/* ── 删除片段 ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
message.success("片段已删除");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除片段失败");
|
||||
},
|
||||
});
|
||||
|
||||
const removeClip = useCallback(
|
||||
(clipId: string) => {
|
||||
if (!planId) return;
|
||||
if (selectedClipId === clipId) {
|
||||
setSelectedClipId(null);
|
||||
}
|
||||
deleteMutation.mutate(clipId);
|
||||
},
|
||||
[planId, selectedClipId, deleteMutation],
|
||||
);
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const batchDeleteMutation = useMutation({
|
||||
mutationFn: (clipIds: string[]) =>
|
||||
batchDeleteEditPlanClips(planId!, clipIds),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
message.success(`已删除 ${res.deleted_count} 个片段`);
|
||||
},
|
||||
onError: () => {
|
||||
message.error("批量删除失败");
|
||||
},
|
||||
});
|
||||
|
||||
const batchRemoveClips = useCallback(
|
||||
(clipIds: string[]) => {
|
||||
if (!planId || clipIds.length === 0) return;
|
||||
if (selectedClipId && clipIds.includes(selectedClipId)) {
|
||||
setSelectedClipId(null);
|
||||
}
|
||||
batchDeleteMutation.mutate(clipIds);
|
||||
},
|
||||
[planId, selectedClipId, batchDeleteMutation],
|
||||
);
|
||||
|
||||
/* ── 重排序(拖拽结束后一次性提交) ── */
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (items: ClipReorderItem[]) =>
|
||||
reorderEditPlanClips(planId!, items),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("排序失败");
|
||||
// 失败后刷新回服务端状态
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
},
|
||||
});
|
||||
|
||||
const reorderClips = useCallback(
|
||||
(items: ClipReorderItem[]) => {
|
||||
if (!planId || items.length === 0) return;
|
||||
reorderMutation.mutate(items);
|
||||
},
|
||||
[planId, reorderMutation],
|
||||
);
|
||||
|
||||
/* ── 从素材批量导入 ── */
|
||||
const importFromAssetsMutation = useMutation({
|
||||
mutationFn: (assetIds: string[]) =>
|
||||
createClipsFromAssets(planId!, assetIds),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
message.success(`已导入 ${res.created_count} 个素材片段`);
|
||||
},
|
||||
onError: () => {
|
||||
message.error("导入素材失败");
|
||||
},
|
||||
});
|
||||
|
||||
const importFromAssets = useCallback(
|
||||
(assetIds: string[]) => {
|
||||
if (!planId || assetIds.length === 0) return;
|
||||
importFromAssetsMutation.mutate(assetIds);
|
||||
},
|
||||
[planId, importFromAssetsMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
// 数据
|
||||
clips,
|
||||
clipsTotal,
|
||||
clipsLoading,
|
||||
selectedClipId,
|
||||
selectedClip,
|
||||
// 选中
|
||||
setSelectedClipId,
|
||||
// 操作
|
||||
addClip,
|
||||
updateClip,
|
||||
removeClip,
|
||||
batchRemoveClips,
|
||||
reorderClips,
|
||||
importFromAssets,
|
||||
refetchClips,
|
||||
// 状态
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isReordering: reorderMutation.isPending,
|
||||
isImporting: importFromAssetsMutation.isPending,
|
||||
// 本地撤销重做(供拖拽等场景使用)
|
||||
localClips,
|
||||
setLocalClips,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
resetLocalClips,
|
||||
};
|
||||
}
|
||||
|
||||
export default useEditPlanClips;
|
||||
Regular → Executable
+2
@@ -512,6 +512,8 @@ export interface ClipData {
|
||||
type: ClipType; // 片段类型:voice(口播)或 pip(画中画)
|
||||
duration: number; // 时长(秒)
|
||||
startOffset: number; // 仅 voice 类型:在口播素材中的起始时间(秒)
|
||||
/** 素材库素材 ID(main/pip 类型片段使用) */
|
||||
media_asset_id?: string;
|
||||
// 保留兼容字段(后端序列化需要)
|
||||
template_segment_id?: string;
|
||||
script_text?: string;
|
||||
|
||||
Regular → Executable
+7
@@ -163,6 +163,13 @@ export const router = createBrowserRouter([
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "edit-plans/:planId/clips",
|
||||
lazy: () =>
|
||||
import("@/pages/edit-plans/PlanClipsManager").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "voice-clone",
|
||||
lazy: () =>
|
||||
|
||||
Reference in New Issue
Block a user