feat(task-2.14): 剪辑计划编辑器完整版 — 三栏布局 + 拖拽 + 素材面板
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 189h47m20s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 189h47m22s
Deploy / Deploy Staging (push) Failing after 189h47m51s
CI/CD Pipeline / Frontend Lint (push) Failing after 189h48m17s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 189h48m22s
Deploy / Production Browser E2E (push) Failing after 1848h22m59s
Deploy / Deploy Production (push) Failing after 1848h23m2s
Deploy / Build Production Runtime Images (push) Failing after 1848h23m33s

- 新增 editPlans API 模块(Mock 数据,预留真实接口)
- 新增 MediaPanel 组件:左侧 Tab 切换(模板+素材),素材可拖拽
- 重写 TimelinePanel:可视化时长条 + 片段卡片 + 拖拽排序 + 素材接收
- 新增 ClipPropertiesPanel:右侧片段属性编辑(文案/时长/转场/素材关联)
- 升级 EditingPlanner 主页面:集成三栏布局 + 剪辑计划 CRUD
- 重写 EditingPlanner.css:全新 V21 设计系统样式 + 响应式适配
- 修复 TypeScript 类型错误

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
灵应
2026-07-01 15:03:58 +08:00
parent 2730d2ce8d
commit 16bf124a60
6 changed files with 1927 additions and 578 deletions
+336
View File
@@ -0,0 +1,336 @@
/**
* 剪辑计划 API — Mock 数据 + 预留接口
* 任务 2.14:对接后端 GET/POST/PUT /api/v1/edit-plans
* 当前使用 Mock 数据,后续替换为真实 API 调用
*/
import type { TemplateMode } from "./editingPlanner";
/* ============================================================
* 类型定义
* ============================================================ */
/** 剪辑计划中的片段 */
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;
}
/** 转场效果 */
export interface TransitionEffect {
type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide";
duration: number; // 转场时长(秒)
}
/** 剪辑计划 */
export interface EditPlan {
id: string;
name: string;
template_id?: string;
mode: TemplateMode;
clips: EditPlanClip[];
/** 总预估时长 */
total_duration: number;
/** 状态 */
status: "draft" | "ready" | "generating" | "completed" | "failed";
created_at: string;
updated_at: string;
}
/** 创建剪辑计划请求 */
export interface CreateEditPlanRequest {
name: string;
template_id?: string;
mode: TemplateMode;
clips: Omit<EditPlanClip, "id">[];
}
/** 更新剪辑计划请求 */
export interface UpdateEditPlanRequest {
name?: string;
clips?: EditPlanClip[];
status?: EditPlan["status"];
}
/** 素材库资产 */
export interface MediaAsset {
id: string;
name: string;
type: "video" | "image" | "audio";
/** 缩略图 URL */
thumbnail_url?: string;
/** 时长(秒),仅 video/audio */
duration?: number;
/** 文件大小(字节) */
size?: number;
/** 标签 */
tags: string[];
created_at: string;
}
/* ============================================================
* Mock 数据
* ============================================================ */
const MOCK_ASSETS: MediaAsset[] = [
{
id: "asset-001",
name: "产品展示-正面.mp4",
type: "video",
duration: 15,
size: 12_500_000,
tags: ["产品", "展示"],
created_at: "2026-06-20T10:00:00Z",
},
{
id: "asset-002",
name: "使用教程-片段A.mp4",
type: "video",
duration: 20,
size: 18_000_000,
tags: ["教程", "使用"],
created_at: "2026-06-21T14:00:00Z",
},
{
id: "asset-003",
name: "背景音乐-轻快.mp3",
type: "audio",
duration: 120,
size: 3_200_000,
tags: ["BGM", "轻快"],
created_at: "2026-06-18T09:00:00Z",
},
{
id: "asset-004",
name: "封面图-主图.jpg",
type: "image",
size: 850_000,
tags: ["封面", "主图"],
created_at: "2026-06-22T11:00:00Z",
},
{
id: "asset-005",
name: "细节特写-侧面.mp4",
type: "video",
duration: 10,
size: 8_500_000,
tags: ["产品", "细节"],
created_at: "2026-06-23T16:00:00Z",
},
{
id: "asset-006",
name: "开箱视频-片段.mp4",
type: "video",
duration: 25,
size: 22_000_000,
tags: ["开箱", "展示"],
created_at: "2026-06-19T08:00:00Z",
},
{
id: "asset-007",
name: "配音-产品介绍.wav",
type: "audio",
duration: 45,
size: 5_600_000,
tags: ["配音", "产品"],
created_at: "2026-06-24T10:00:00Z",
},
{
id: "asset-008",
name: "场景图-生活场景.jpg",
type: "image",
size: 1_200_000,
tags: ["场景", "生活"],
created_at: "2026-06-25T13:00:00Z",
},
];
let mockPlans: EditPlan[] = [
{
id: "plan-001",
name: "产品展示视频 v1",
template_id: "tpl-001",
mode: "pip",
clips: [
{
id: "clip-001",
template_segment_id: "seg-001",
media_asset_id: "asset-001",
material_type: "video",
script_text: "大家好,今天给大家带来一款超值好物!",
duration: 10,
transition: { type: "fade", duration: 0.5 },
order: 0,
},
{
id: "clip-002",
template_segment_id: "seg-002",
media_asset_id: "asset-005",
material_type: "video",
script_text: "来看看这个细节做工,真的绝了",
duration: 8,
transition: { type: "dissolve", duration: 0.3 },
order: 1,
},
{
id: "clip-003",
template_segment_id: "seg-003",
material_type: "image",
script_text: "多种颜色可选,总有一款适合你",
duration: 6,
transition: { type: "none", duration: 0 },
order: 2,
},
],
total_duration: 24,
status: "draft",
created_at: "2026-06-25T10:00:00Z",
updated_at: "2026-06-25T15:00:00Z",
},
];
/* ============================================================
* Mock 延迟
* ============================================================ */
const delay = (ms = 300) => new Promise((r) => setTimeout(r, ms));
/* ============================================================
* API 函数(Mock 实现,后续替换为真实 API)
* ============================================================ */
/** 获取剪辑计划列表 */
export async function getEditPlans(): Promise<EditPlan[]> {
// TODO: 替换为 apiClient.get('/edit-plans')
await delay();
return [...mockPlans];
}
/** 获取单个剪辑计划 */
export async function getEditPlan(id: string): Promise<EditPlan> {
// TODO: 替换为 apiClient.get(`/edit-plans/${id}`)
await delay();
const plan = mockPlans.find((p) => p.id === id);
if (!plan) throw new Error(`剪辑计划 ${id} 不存在`);
return { ...plan };
}
/** 创建剪辑计划 */
export async function createEditPlan(
data: CreateEditPlanRequest
): Promise<EditPlan> {
// TODO: 替换为 apiClient.post('/edit-plans', data)
await delay();
const now = new Date().toISOString();
const plan: EditPlan = {
id: `plan-${Date.now()}`,
name: data.name,
template_id: data.template_id,
mode: data.mode,
clips: data.clips.map((c, i) => ({
...c,
id: `clip-${Date.now()}-${i}`,
})),
total_duration: data.clips.reduce((sum, c) => sum + c.duration, 0),
status: "draft",
created_at: now,
updated_at: now,
};
mockPlans = [plan, ...mockPlans];
return plan;
}
/** 更新剪辑计划 */
export async function updateEditPlan(
id: string,
data: UpdateEditPlanRequest
): Promise<EditPlan> {
// TODO: 替换为 apiClient.put(`/edit-plans/${id}`, data)
await delay();
const idx = mockPlans.findIndex((p) => p.id === id);
if (idx === -1) throw new Error(`剪辑计划 ${id} 不存在`);
mockPlans[idx] = {
...mockPlans[idx],
...data,
total_duration:
data.clips?.reduce((sum, c) => sum + c.duration, 0) ??
mockPlans[idx].total_duration,
updated_at: new Date().toISOString(),
};
return { ...mockPlans[idx] };
}
/** 删除剪辑计划 */
export async function deleteEditPlan(id: string): Promise<void> {
// TODO: 替换为 apiClient.delete(`/edit-plans/${id}`)
await delay();
mockPlans = mockPlans.filter((p) => p.id !== id);
}
/** 获取素材库列表 */
export async function getMediaAssets(): Promise<MediaAsset[]> {
// TODO: 替换为 apiClient.get('/media-assets')
await delay();
return [...MOCK_ASSETS];
}
/** 获取单个素材 */
export async function getMediaAsset(id: string): Promise<MediaAsset> {
// TODO: 替换为 apiClient.get(`/media-assets/${id}`)
await delay();
const asset = MOCK_ASSETS.find((a) => a.id === id);
if (!asset) throw new Error(`素材 ${id} 不存在`);
return { ...asset };
}
/* ============================================================
* 常量
* ============================================================ */
/** 转场效果选项 */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"];
label: string;
}[] = [
{ value: "none", label: "无转场" },
{ value: "fade", label: "淡入淡出" },
{ value: "dissolve", label: "溶解" },
{ value: "wipe", label: "擦除" },
{ value: "zoom", label: "缩放" },
{ value: "slide", label: "滑动" },
];
/** 素材类型标签 */
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
};
/** 素材类型图标 */
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
};
/** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlan["status"], string> = {
draft: "草稿",
ready: "就绪",
generating: "生成中",
completed: "已完成",
failed: "失败",
};