feat: 剪辑计划编辑器前端实现
- 新增剪辑计划编辑器页面 (三栏布局) - 左侧:模板面板(搜索/筛选/加载模板) - 中间:预览区 + 时间线(片段拖拽排序) - 右侧:标题/字幕/BGM 设置面板 - 支持 4 种模式切换(画中画/人物口播/一镜到底/口播+混剪) - 新增「我的模板」页面(卡片视图/编辑/复制/删除/生成) - 新增 API 模块(mock 数据,后端就绪后替换) - 更新路由和导航菜单 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 剪辑计划编辑器 API
|
||||
* 当前使用 mock 数据,后端 API 就绪后替换
|
||||
*/
|
||||
// import apiClient from './client'; // TODO: 后端 API 就绪后启用
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 模板模式 */
|
||||
export type TemplateMode = '画中画' | '人物口播' | '一镜到底' | '口播+混剪';
|
||||
|
||||
/** 标题配置 */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean;
|
||||
content: string;
|
||||
font_preset: string;
|
||||
font_color: string;
|
||||
font_size: number;
|
||||
position: string;
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
color: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
}
|
||||
|
||||
/** 模板片段 */
|
||||
export interface TemplateSegment {
|
||||
id: string;
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string | null; // 仅 口播+混剪 模式:人物/场景
|
||||
}
|
||||
|
||||
/** 剪辑模板 */
|
||||
export interface EditingTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: TemplateMode;
|
||||
category: string;
|
||||
tags: string[];
|
||||
title_config: TitleConfig;
|
||||
subtitle_config: SubtitleConfig;
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: TemplateSegment[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 模板分类 */
|
||||
export interface TemplateCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 创建/更新模板请求体 */
|
||||
export interface SaveTemplatePayload {
|
||||
name: string;
|
||||
mode: TemplateMode;
|
||||
category: string;
|
||||
tags: string[];
|
||||
title_config: TitleConfig;
|
||||
subtitle_config: SubtitleConfig;
|
||||
bgm_config: BgmConfig;
|
||||
segments: Omit<TemplateSegment, 'id'>[];
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
export interface GenerateFromTemplatePayload {
|
||||
voiceover_id: string;
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
task_id: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
/* ──────────── Mock 数据 ──────────── */
|
||||
|
||||
let _nextId = 100;
|
||||
const nextId = () => String(++_nextId);
|
||||
|
||||
const MOCK_CATEGORIES: TemplateCategory[] = [
|
||||
{ id: 'cat-1', name: '生活' },
|
||||
{ id: 'cat-2', name: '美食' },
|
||||
{ id: 'cat-3', name: '旅行' },
|
||||
{ id: 'cat-4', name: '知识' },
|
||||
];
|
||||
|
||||
const MOCK_TEMPLATES: EditingTemplate[] = [
|
||||
{
|
||||
id: 'tpl-1',
|
||||
name: '生活 Vlog 模板',
|
||||
mode: '画中画',
|
||||
category: '生活',
|
||||
tags: ['vlog', '日常'],
|
||||
title_config: {
|
||||
ai_auto_select: true,
|
||||
content: '',
|
||||
font_preset: '思源黑体',
|
||||
font_color: '#ffffff',
|
||||
font_size: 32,
|
||||
position: 'top',
|
||||
},
|
||||
subtitle_config: {
|
||||
enabled: true,
|
||||
position: 'bottom',
|
||||
font: '思源黑体',
|
||||
color: '#ffffff',
|
||||
size: 24,
|
||||
animation: 'fade',
|
||||
},
|
||||
bgm_config: { enabled: true, music_id: 'bgm-1' },
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{ id: 'seg-1', segment_order: 1, duration_min: 5, duration_max: 15, material_type: null },
|
||||
{ id: 'seg-2', segment_order: 2, duration_min: 10, duration_max: 20, material_type: null },
|
||||
],
|
||||
created_at: '2026-06-20T10:00:00Z',
|
||||
updated_at: '2026-06-20T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'tpl-2',
|
||||
name: '知识分享口播',
|
||||
mode: '人物口播',
|
||||
category: '知识',
|
||||
tags: ['口播', '分享'],
|
||||
title_config: {
|
||||
ai_auto_select: false,
|
||||
content: '每日知识分享',
|
||||
font_preset: '站酷快乐体',
|
||||
font_color: '#ffdd00',
|
||||
font_size: 36,
|
||||
position: 'top',
|
||||
},
|
||||
subtitle_config: {
|
||||
enabled: true,
|
||||
position: 'bottom',
|
||||
font: '思源黑体',
|
||||
color: '#ffffff',
|
||||
size: 28,
|
||||
animation: 'typewriter',
|
||||
},
|
||||
bgm_config: { enabled: false, music_id: '' },
|
||||
estimated_duration: 60,
|
||||
segments: [
|
||||
{ id: 'seg-3', segment_order: 1, duration_min: 10, duration_max: 30, material_type: null },
|
||||
{ id: 'seg-4', segment_order: 2, duration_min: 20, duration_max: 40, material_type: null },
|
||||
{ id: 'seg-5', segment_order: 3, duration_min: 10, duration_max: 20, material_type: null },
|
||||
],
|
||||
created_at: '2026-06-21T10:00:00Z',
|
||||
updated_at: '2026-06-21T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'tpl-3',
|
||||
name: '一镜到底展示',
|
||||
mode: '一镜到底',
|
||||
category: '生活',
|
||||
tags: ['一镜到底'],
|
||||
title_config: {
|
||||
ai_auto_select: true,
|
||||
content: '',
|
||||
font_preset: '思源黑体',
|
||||
font_color: '#ffffff',
|
||||
font_size: 32,
|
||||
position: 'center',
|
||||
},
|
||||
subtitle_config: { enabled: false, position: 'bottom', font: '思源黑体', color: '#ffffff', size: 24, animation: 'fade' },
|
||||
bgm_config: { enabled: true, music_id: 'bgm-2' },
|
||||
estimated_duration: 15,
|
||||
segments: [
|
||||
{ id: 'seg-6', segment_order: 1, duration_min: 10, duration_max: 20, material_type: null },
|
||||
],
|
||||
created_at: '2026-06-22T10:00:00Z',
|
||||
updated_at: '2026-06-22T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
/* ──────────── Mock API 函数 ──────────── */
|
||||
|
||||
const delay = (ms = 200) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** 获取模板列表 */
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string;
|
||||
tag?: string;
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
await delay();
|
||||
let list = [...MOCK_TEMPLATES];
|
||||
if (params?.category) list = list.filter((t) => t.category === params.category);
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!));
|
||||
return list;
|
||||
};
|
||||
|
||||
/** 获取模板详情 */
|
||||
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
||||
await delay();
|
||||
const tpl = MOCK_TEMPLATES.find((t) => t.id === id);
|
||||
if (!tpl) throw new Error('模板不存在');
|
||||
return { ...tpl };
|
||||
};
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
await delay(300);
|
||||
const now = new Date().toISOString();
|
||||
const estimated =
|
||||
data.segments.reduce(
|
||||
(sum, s) => sum + (s.duration_min + s.duration_max) / 2,
|
||||
0,
|
||||
);
|
||||
const tpl: EditingTemplate = {
|
||||
id: nextId(),
|
||||
name: data.name,
|
||||
mode: data.mode,
|
||||
category: data.category,
|
||||
tags: data.tags,
|
||||
title_config: data.title_config,
|
||||
subtitle_config: data.subtitle_config,
|
||||
bgm_config: data.bgm_config,
|
||||
estimated_duration: Math.round(estimated),
|
||||
segments: data.segments.map((s, i) => ({
|
||||
...s,
|
||||
id: nextId(),
|
||||
segment_order: i + 1,
|
||||
})),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
MOCK_TEMPLATES.push(tpl);
|
||||
return tpl;
|
||||
};
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
await delay(300);
|
||||
const idx = MOCK_TEMPLATES.findIndex((t) => t.id === id);
|
||||
if (idx === -1) throw new Error('模板不存在');
|
||||
const estimated =
|
||||
data.segments.reduce(
|
||||
(sum, s) => sum + (s.duration_min + s.duration_max) / 2,
|
||||
0,
|
||||
);
|
||||
const updated: EditingTemplate = {
|
||||
...MOCK_TEMPLATES[idx],
|
||||
name: data.name,
|
||||
mode: data.mode,
|
||||
category: data.category,
|
||||
tags: data.tags,
|
||||
title_config: data.title_config,
|
||||
subtitle_config: data.subtitle_config,
|
||||
bgm_config: data.bgm_config,
|
||||
estimated_duration: Math.round(estimated),
|
||||
segments: data.segments.map((s, i) => ({
|
||||
...s,
|
||||
id: nextId(),
|
||||
segment_order: i + 1,
|
||||
})),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
MOCK_TEMPLATES[idx] = updated;
|
||||
return updated;
|
||||
};
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await delay();
|
||||
const idx = MOCK_TEMPLATES.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) MOCK_TEMPLATES.splice(idx, 1);
|
||||
};
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
await delay();
|
||||
return [...MOCK_CATEGORIES];
|
||||
};
|
||||
|
||||
/** 使用模板生成视频 */
|
||||
export const generateFromTemplate = async (
|
||||
_templateId: string,
|
||||
_data: GenerateFromTemplatePayload,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
await delay(500);
|
||||
return {
|
||||
task_id: nextId(),
|
||||
warning: undefined,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user