feat: 剪辑计划编辑器前端实现
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled

- 新增剪辑计划编辑器页面 (三栏布局)
  - 左侧:模板面板(搜索/筛选/加载模板)
  - 中间:预览区 + 时间线(片段拖拽排序)
  - 右侧:标题/字幕/BGM 设置面板
- 支持 4 种模式切换(画中画/人物口播/一镜到底/口播+混剪)
- 新增「我的模板」页面(卡片视图/编辑/复制/删除/生成)
- 新增 API 模块(mock 数据,后端就绪后替换)
- 更新路由和导航菜单

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Audit Bot
2026-06-29 14:35:27 +08:00
parent 2849aef47e
commit fff47a7a3d
7 changed files with 1654 additions and 0 deletions
+307
View File
@@ -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,
};
};
@@ -18,6 +18,8 @@ import {
HistoryOutlined,
TrophyOutlined,
ScanOutlined,
EditOutlined,
FolderOutlined,
} from '@ant-design/icons';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/store/authStore';
@@ -40,6 +42,8 @@ const NAV_ITEMS: NavItem[] = [
{ key: 'titles', label: '标题库', path: '/titles', icon: <FileTextOutlined /> },
{ key: 'voices', label: '配音库', path: '/voices', icon: <AudioOutlined /> },
{ key: 'templates', label: '模板库', path: '/templates', icon: <AppstoreOutlined /> },
{ key: 'editing-planner', label: '剪辑编辑器', path: '/editing-planner', icon: <EditOutlined /> },
{ key: 'my-templates', label: '我的模板', path: '/my-templates', icon: <FolderOutlined /> },
{ key: 'generate', label: '一键生成', path: '/generate', icon: <VideoCameraOutlined /> },
{ key: 'history', label: '任务历史', path: '/history', icon: <HistoryOutlined /> },
{ key: 'products', label: '成品库', path: '/products', icon: <TrophyOutlined /> },
@@ -0,0 +1,164 @@
/* ═══════════════════════════════════════════════════
* 剪辑计划编辑器样式
* 三栏布局:左侧模板面板 / 中间预览+时间线 / 右侧设置面板
* ═══════════════════════════════════════════════════ */
.ep-editor {
display: flex;
flex-direction: column;
height: 100%;
gap: 0;
}
/* ─── 顶部工具栏 ─── */
.ep-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
/* ─── 三栏主体 ─── */
.ep-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ─── 左侧:模板面板 ─── */
.ep-left {
width: 260px;
flex-shrink: 0;
background: #fafafa;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ep-tpl-card {
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.ep-tpl-card-active {
border-color: var(--ant-color-primary, #4f46e5) !important;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
/* ─── 中间:预览 + 时间线 ─── */
.ep-center {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding: 20px;
overflow-y: auto;
background: #fff;
}
/* 预览行 */
.ep-preview-row {
display: flex;
gap: 24px;
align-items: flex-start;
margin-bottom: 24px;
}
.ep-preview-box {
display: flex;
flex-direction: column;
align-items: center;
}
.ep-preview-frame {
width: 160px;
height: 284px;
background: #f5f5f5;
border: 2px dashed #d9d9d9;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.ep-cover-btns {
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
}
/* 时间线 */
.ep-timeline {
background: #fafafa;
border-radius: 12px;
padding: 16px;
border: 1px solid #f0f0f0;
}
.ep-seg-card {
transition: box-shadow 0.2s, border-color 0.2s;
cursor: grab;
}
.ep-seg-card:active {
cursor: grabbing;
}
.ep-seg-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
/* ─── 右侧:设置面板 ─── */
.ep-right {
width: 280px;
flex-shrink: 0;
background: #fafafa;
border-left: 1px solid #f0f0f0;
padding: 16px;
overflow-y: auto;
}
.ep-settings-group {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.ep-settings-group:last-child {
border-bottom: none;
margin-bottom: 0;
}
/* ─── 响应式 ─── */
@media (max-width: 1200px) {
.ep-left {
width: 220px;
}
.ep-right {
width: 240px;
}
}
@media (max-width: 900px) {
.ep-body {
flex-direction: column;
}
.ep-left,
.ep-right {
width: 100%;
max-height: 300px;
border-right: none;
border-left: none;
border-bottom: 1px solid #f0f0f0;
}
.ep-preview-row {
flex-wrap: wrap;
}
}
@@ -0,0 +1,848 @@
/**
* 剪辑计划编辑器
* 三栏布局:左侧模板面板 / 中间预览+时间线 / 右侧设置面板
* 支持 4 种模式切换(画中画 / 人物口播 / 一镜到底 / 口播+混剪)
*/
import React, { useState } from 'react';
import './EditingPlanner.css';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Button,
Typography,
Space,
Card,
Input,
Switch,
Select,
Slider,
Tag,
Empty,
Spin,
Modal,
message,
} from 'antd';
import {
SaveOutlined,
VideoCameraOutlined,
PlusOutlined,
DeleteOutlined,
DragOutlined,
PictureOutlined,
UserOutlined,
AppstoreOutlined,
SoundOutlined,
FontSizeOutlined,
DashboardOutlined,
} from '@ant-design/icons';
import {
getEditingTemplates,
getTemplateCategories,
createEditingTemplate,
updateEditingTemplate,
generateFromTemplate,
type EditingTemplate,
type TemplateSegment,
type TemplateMode,
type TitleConfig,
type SubtitleConfig,
type BgmConfig,
} from '@/api/editingPlanner';
const { Text } = Typography;
/* ──────────── 常量 ──────────── */
const MODES: { key: TemplateMode; icon: React.ReactNode; desc: string }[] = [
{ key: '画中画', icon: <AppstoreOutlined />, desc: '多画面叠加' },
{ key: '人物口播', icon: <UserOutlined />, desc: '人物讲解为主' },
{ key: '一镜到底', icon: <VideoCameraOutlined />, desc: '连续不中断' },
{ key: '口播+混剪', icon: <DashboardOutlined />, desc: '口播搭配混剪素材' },
];
const FONT_PRESETS = ['思源黑体', '站酷快乐体', '方正兰亭', '汉仪旗黑'];
const POSITIONS = [
{ value: 'top', label: '顶部' },
{ value: 'center', label: '居中' },
{ value: 'bottom', label: '底部' },
];
const SUBTITLE_FONTS = ['思源黑体', '微软雅黑', '苹方'];
const SUBTITLE_ANIMATIONS = [
{ value: 'none', label: '无' },
{ value: 'fade', label: '淡入' },
{ value: 'typewriter', label: '打字机' },
{ value: 'slide', label: '滑动' },
];
const DEFAULT_TITLE: TitleConfig = {
ai_auto_select: true,
content: '',
font_preset: '思源黑体',
font_color: '#ffffff',
font_size: 32,
position: 'top',
};
const DEFAULT_SUBTITLE: SubtitleConfig = {
enabled: true,
position: 'bottom',
font: '思源黑体',
color: '#ffffff',
size: 24,
animation: 'fade',
};
const DEFAULT_BGM: BgmConfig = { enabled: false, music_id: '' };
/** 计算预估时长 = Σ 片段时长范围中值 */
const calcEstimatedDuration = (segs: TemplateSegment[]) =>
Math.round(segs.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0));
let _segId = 0;
const newSegId = () => `seg-new-${++_segId}`;
/* ──────────── 组件 ──────────── */
const EditingPlanner: React.FC = () => {
const queryClient = useQueryClient();
/* ── 数据查询 ── */
const [searchText, setSearchText] = useState('');
const [filterCategory, setFilterCategory] = useState('');
const { data: templates = [], isLoading: tplLoading } = useQuery({
queryKey: ['editing-templates', filterCategory, searchText],
queryFn: () =>
getEditingTemplates({
category: filterCategory || undefined,
tag: searchText || undefined,
}),
});
const { data: categories = [] } = useQuery({
queryKey: ['template-categories'],
queryFn: getTemplateCategories,
});
/* ── 编辑器状态 ── */
const [currentMode, setCurrentMode] = useState<TemplateMode>('画中画');
const [segments, setSegments] = useState<TemplateSegment[]>([
{ id: newSegId(), segment_order: 1, duration_min: 5, duration_max: 15, material_type: null },
]);
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(null);
const [titleConfig, setTitleConfig] = useState<TitleConfig>({ ...DEFAULT_TITLE });
const [subtitleConfig, setSubtitleConfig] = useState<SubtitleConfig>({ ...DEFAULT_SUBTITLE });
const [bgmConfig, setBgmConfig] = useState<BgmConfig>({ ...DEFAULT_BGM });
/* ── UI 状态 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [generateModalOpen, setGenerateModalOpen] = useState(false);
const [draftName, setDraftName] = useState('');
const [draftCategory, setDraftCategory] = useState('');
const [draftTags, setDraftTags] = useState('');
const [voiceoverId, setVoiceoverId] = useState('');
const [dragIdx, setDragIdx] = useState<number | null>(null);
/* ── Mutations ── */
const createMutation = useMutation({
mutationFn: createEditingTemplate,
onSuccess: () => {
message.success('模板已保存');
queryClient.invalidateQueries({ queryKey: ['editing-templates'] });
setSaveModalOpen(false);
},
onError: (err: any) => {
if (!err?.__msgShown) message.error('保存失败');
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => updateEditingTemplate(id, data),
onSuccess: () => {
message.success('模板已更新');
queryClient.invalidateQueries({ queryKey: ['editing-templates'] });
setSaveModalOpen(false);
},
onError: (err: any) => {
if (!err?.__msgShown) message.error('保存失败');
},
});
const generateMutation = useMutation({
mutationFn: ({ templateId, voiceoverId }: { templateId: string; voiceoverId: string }) =>
generateFromTemplate(templateId, { voiceover_id: voiceoverId }),
onSuccess: (data) => {
const msg = data.warning ? `生成任务已提交(${data.warning})` : '生成任务已提交';
message.success(msg);
setGenerateModalOpen(false);
},
onError: (err: any) => {
if (!err?.__msgShown) message.error('生成失败');
},
});
const saving = createMutation.isPending || updateMutation.isPending;
/* ──────────── 片段操作 ──────────── */
const addSegment = () => {
if (currentMode === '一镜到底') return;
setSegments((prev) => [
...prev,
{
id: newSegId(),
segment_order: prev.length + 1,
duration_min: 5,
duration_max: 15,
material_type: currentMode === '口播+混剪' ? '人物' : null,
},
]);
};
const removeSegment = (id: string) => {
if (currentMode === '一镜到底') return;
setSegments((prev) =>
prev.filter((s) => s.id !== id).map((s, i) => ({ ...s, segment_order: i + 1 })),
);
};
const updateSegment = (id: string, patch: Partial<TemplateSegment>) => {
setSegments((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s)));
};
const handleDragStart = (idx: number) => setDragIdx(idx);
const handleDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault();
if (dragIdx === null || dragIdx === idx) return;
setSegments((prev) => {
const next = [...prev];
const [moved] = next.splice(dragIdx, 1);
next.splice(idx, 0, moved);
return next.map((s, i) => ({ ...s, segment_order: i + 1 }));
});
setDragIdx(idx);
};
const handleDragEnd = () => setDragIdx(null);
/* ──────────── 模式切换 ──────────── */
const handleModeChange = (mode: TemplateMode) => {
setCurrentMode(mode);
if (mode === '一镜到底') {
// 锁定为 1 个片段
setSegments([
{
id: newSegId(),
segment_order: 1,
duration_min: 10,
duration_max: 20,
material_type: null,
},
]);
} else if (mode === '口播+混剪') {
// 确保每个片段有 material_type
setSegments((prev) =>
prev.map((s) => ({
...s,
material_type: s.material_type || '人物',
})),
);
}
};
/* ──────────── 模板操作 ──────────── */
const loadTemplate = (tpl: EditingTemplate) => {
setLoadedTemplateId(tpl.id);
setCurrentMode(tpl.mode);
setSegments(tpl.segments.map((s) => ({ ...s })));
setTitleConfig({ ...tpl.title_config });
setSubtitleConfig({ ...tpl.subtitle_config });
setBgmConfig({ ...tpl.bgm_config });
};
const resetEditor = () => {
setLoadedTemplateId(null);
setCurrentMode('画中画');
setSegments([
{ id: newSegId(), segment_order: 1, duration_min: 5, duration_max: 15, material_type: null },
]);
setTitleConfig({ ...DEFAULT_TITLE });
setSubtitleConfig({ ...DEFAULT_SUBTITLE });
setBgmConfig({ ...DEFAULT_BGM });
};
const openSaveModal = () => {
if (segments.length === 0) {
message.warning('请至少添加一个片段');
return;
}
setDraftName(loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.name || '' : '');
setDraftCategory(
loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.category || '' : '',
);
setDraftTags(
loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.tags.join(', ') || '' : '',
);
setSaveModalOpen(true);
};
const handleSave = () => {
if (!draftName.trim()) {
message.warning('请输入模板名称');
return;
}
const payload = {
name: draftName.trim(),
mode: currentMode,
category: draftCategory,
tags: draftTags
.split(/[,,]/)
.map((t) => t.trim())
.filter(Boolean),
title_config: titleConfig,
subtitle_config: subtitleConfig,
bgm_config: bgmConfig,
segments: segments.map(({ id: _id, ...rest }) => rest),
};
if (loadedTemplateId) {
updateMutation.mutate({ id: loadedTemplateId, data: payload });
} else {
createMutation.mutate(payload);
}
};
const handleGenerate = () => {
if (!loadedTemplateId) {
message.warning('请先保存模板');
return;
}
setGenerateModalOpen(true);
};
const doGenerate = () => {
if (!voiceoverId.trim()) {
message.warning('请输入配音 ID');
return;
}
generateMutation.mutate({ templateId: loadedTemplateId!, voiceoverId: voiceoverId.trim() });
};
const estimatedDuration = calcEstimatedDuration(segments);
const isOneShot = currentMode === '一镜到底';
const isMixedCut = currentMode === '口播+混剪';
/* ──────────── 渲染 ──────────── */
return (
<div className="ep-editor">
{/* ═══ 顶部工具栏 ═══ */}
<div className="ep-toolbar">
<Space wrap>
{MODES.map((m) => (
<Button
key={m.key}
type={currentMode === m.key ? 'primary' : 'default'}
icon={m.icon}
onClick={() => handleModeChange(m.key)}
>
{m.key}
</Button>
))}
</Space>
<Space>
<Button icon={<SaveOutlined />} onClick={openSaveModal}>
保存模板
</Button>
<Button
type="primary"
icon={<VideoCameraOutlined />}
onClick={handleGenerate}
disabled={!loadedTemplateId}
>
使用此模板生成
</Button>
</Space>
</div>
{/* ═══ 三栏主体 ═══ */}
<div className="ep-body">
{/* ─── 左侧:模板面板 ─── */}
<div className="ep-left">
<div style={{ padding: '0 12px', marginBottom: 12 }}>
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 8 }}>
我的模板
</Text>
<Input
placeholder="搜索模板..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
size="small"
style={{ marginBottom: 8 }}
/>
<Select
placeholder="按分类筛选"
value={filterCategory || undefined}
onChange={(v) => setFilterCategory(v || '')}
allowClear
size="small"
style={{ width: '100%' }}
options={categories.map((c) => ({ value: c.name, label: c.name }))}
/>
</div>
<div style={{ padding: '0 12px', flex: 1, overflowY: 'auto' }}>
{tplLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin />
</div>
) : templates.length === 0 ? (
<Empty
description="暂无已保存的模板,请先编辑并保存模板"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: 20 }}
/>
) : (
templates.map((tpl) => (
<Card
key={tpl.id}
size="small"
hoverable
className={`ep-tpl-card ${loadedTemplateId === tpl.id ? 'ep-tpl-card-active' : ''}`}
onClick={() => loadTemplate(tpl)}
style={{ marginBottom: 8 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong ellipsis style={{ maxWidth: 140 }}>
{tpl.name}
</Text>
<Tag color="blue" style={{ marginRight: 0 }}>
{tpl.mode}
</Tag>
</div>
<div style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{tpl.segments.length} 片段 · ~{tpl.estimated_duration}s
</Text>
{tpl.tags.length > 0 && (
<div style={{ marginTop: 4 }}>
{tpl.tags.slice(0, 3).map((tag) => (
<Tag key={tag} style={{ fontSize: 11, marginRight: 4 }}>
{tag}
</Tag>
))}
</div>
)}
</div>
</Card>
))
)}
</div>
{loadedTemplateId && (
<div style={{ padding: 12, borderTop: '1px solid #f0f0f0' }}>
<Button size="small" block onClick={resetEditor}>
新建空白模板
</Button>
</div>
)}
</div>
{/* ─── 中间:预览 + 时间线 ─── */}
<div className="ep-center">
{/* 预览区 */}
<div className="ep-preview-row">
{/* 视频预览 */}
<div className="ep-preview-box">
<div className="ep-preview-frame">
<VideoCameraOutlined style={{ fontSize: 40, color: '#bbb' }} />
<Text type="secondary" style={{ marginTop: 8 }}>
视频预览
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12, marginTop: 4 }}>
9:16 竖屏
</Text>
</div>
{/* 封面预览 + 方案按钮 */}
<div style={{ display: 'flex', gap: 12, flex: '0 0 auto' }}>
<div className="ep-preview-box">
<div className="ep-preview-frame">
<PictureOutlined style={{ fontSize: 40, color: '#bbb' }} />
<Text type="secondary" style={{ marginTop: 8 }}>
封面预览
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12, marginTop: 4 }}>
9:16 竖屏
</Text>
</div>
<div className="ep-cover-btns">
<Button size="small" block>
AI 选帧
</Button>
<Button size="small" block>
手动选
</Button>
<Button size="small" block>
上传
</Button>
<Button size="small" block>
AI 重选
</Button>
</div>
</div>
</div>
{/* 时间线 */}
<div className="ep-timeline">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>
时间线{' '}
<Text type="secondary" style={{ fontWeight: 'normal', fontSize: 12 }}>
(预估总时长:~{estimatedDuration}s)
</Text>
</Text>
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={addSegment}
disabled={isOneShot}
>
添加片段
</Button>
</div>
<div style={{ display: 'flex', gap: 12, overflowX: 'auto', paddingBottom: 8 }}>
{segments.map((seg, idx) => (
<Card
key={seg.id}
size="small"
className="ep-seg-card"
draggable={!isOneShot}
onDragStart={() => handleDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
style={{ minWidth: 180, maxWidth: 220, flexShrink: 0 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span
style={{ cursor: isOneShot ? 'default' : 'grab', color: '#999' }}
>
<DragOutlined />
</span>
<Tag color="blue">#{seg.segment_order}</Tag>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => removeSegment(seg.id)}
disabled={isOneShot}
style={{ marginLeft: 'auto' }}
/>
</div>
{isOneShot ? (
<Text type="secondary" style={{ fontSize: 12 }}>
时长由配音自动决定
</Text>
) : (
<>
<div style={{ marginBottom: 4 }}>
<Text style={{ fontSize: 12 }}>最短 (秒)</Text>
<Slider
min={1}
max={60}
value={seg.duration_min}
onChange={(v) => updateSegment(seg.id, { duration_min: v })}
/>
</div>
<div>
<Text style={{ fontSize: 12 }}>最长 (秒)</Text>
<Slider
min={1}
max={60}
value={seg.duration_max}
onChange={(v) => updateSegment(seg.id, { duration_max: v })}
/>
</div>
</>
)}
{isMixedCut && (
<div style={{ marginTop: 8 }}>
<Text style={{ fontSize: 12 }}>素材类型</Text>
<Select
size="small"
value={seg.material_type || '人物'}
onChange={(v) => updateSegment(seg.id, { material_type: v })}
style={{ width: '100%', marginTop: 4 }}
options={[
{ value: '人物', label: '人物' },
{ value: '场景', label: '场景' },
]}
/>
</div>
)}
</Card>
))}
</div>
</div>
</div>
{/* ─── 右侧:设置面板 ─── */}
<div className="ep-right">
{/* 标题设置 */}
<div className="ep-settings-group">
<Text strong style={{ display: 'block', marginBottom: 12 }}>
<FontSizeOutlined style={{ marginRight: 6 }} />
标题设置
</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text style={{ fontSize: 13 }}>AI 自动选择</Text>
<Switch
size="small"
checked={titleConfig.ai_auto_select}
onChange={(checked) => setTitleConfig((prev) => ({ ...prev, ai_auto_select: checked }))}
checkedChildren="ON"
unCheckedChildren="OFF"
/>
</div>
{!titleConfig.ai_auto_select && (
<Input.TextArea
placeholder="手动输入标题内容"
value={titleConfig.content}
onChange={(e) => setTitleConfig((prev) => ({ ...prev, content: e.target.value }))}
rows={2}
size="small"
style={{ marginBottom: 12 }}
/>
)}
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>字体预设</Text>
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
{FONT_PRESETS.map((font) => (
<Tag
key={font}
color={titleConfig.font_preset === font ? 'blue' : 'default'}
style={{ cursor: 'pointer' }}
onClick={() => setTitleConfig((prev) => ({ ...prev, font_preset: font }))}
>
{font}
</Tag>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}>颜色</Text>
<Input
size="small"
value={titleConfig.font_color}
onChange={(e) => setTitleConfig((prev) => ({ ...prev, font_color: e.target.value }))}
style={{ marginTop: 4 }}
/>
</div>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}>位置</Text>
<Select
size="small"
value={titleConfig.position}
onChange={(v) => setTitleConfig((prev) => ({ ...prev, position: v }))}
options={POSITIONS}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
</div>
<div>
<Text style={{ fontSize: 12 }}>字号:{titleConfig.font_size}</Text>
<Slider
min={16}
max={72}
value={titleConfig.font_size}
onChange={(v) => setTitleConfig((prev) => ({ ...prev, font_size: v }))}
/>
</div>
</div>
{/* 字幕设置 */}
<div className="ep-settings-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>
<FontSizeOutlined style={{ marginRight: 6 }} />
字幕设置
</Text>
<Switch
size="small"
checked={subtitleConfig.enabled}
onChange={(checked) => setSubtitleConfig((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
{subtitleConfig.enabled && (
<>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>位置</Text>
<Select
size="small"
value={subtitleConfig.position}
onChange={(v) => setSubtitleConfig((prev) => ({ ...prev, position: v }))}
options={POSITIONS}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>字体</Text>
<Select
size="small"
value={subtitleConfig.font}
onChange={(v) => setSubtitleConfig((prev) => ({ ...prev, font: v }))}
options={SUBTITLE_FONTS.map((f) => ({ value: f, label: f }))}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}>颜色</Text>
<Input
size="small"
value={subtitleConfig.color}
onChange={(e) => setSubtitleConfig((prev) => ({ ...prev, color: e.target.value }))}
style={{ marginTop: 4 }}
/>
</div>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}>动画</Text>
<Select
size="small"
value={subtitleConfig.animation}
onChange={(v) => setSubtitleConfig((prev) => ({ ...prev, animation: v }))}
options={SUBTITLE_ANIMATIONS}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
</div>
<div>
<Text style={{ fontSize: 12 }}>字号:{subtitleConfig.size}</Text>
<Slider
min={12}
max={48}
value={subtitleConfig.size}
onChange={(v) => setSubtitleConfig((prev) => ({ ...prev, size: v }))}
/>
</div>
</>
)}
</div>
{/* BGM 设置 */}
<div className="ep-settings-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>
<SoundOutlined style={{ marginRight: 6 }} />
BGM 设置
</Text>
<Switch
size="small"
checked={bgmConfig.enabled}
onChange={(checked) => setBgmConfig((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
{bgmConfig.enabled && (
<div>
<Text style={{ fontSize: 12 }}>选择音乐</Text>
<Select
size="small"
placeholder="选择背景音乐"
value={bgmConfig.music_id || undefined}
onChange={(v) => setBgmConfig((prev) => ({ ...prev, music_id: v }))}
style={{ width: '100%', marginTop: 4 }}
options={[
{ value: 'bgm-1', label: '轻快节奏' },
{ value: 'bgm-2', label: '舒缓氛围' },
{ value: 'bgm-3', label: '动感活力' },
]}
/>
</div>
)}
</div>
</div>
</div>
{/* ═══ 保存模板弹窗 ═══ */}
<Modal
title={loadedTemplateId ? '更新模板' : '保存模板'}
open={saveModalOpen}
onCancel={() => setSaveModalOpen(false)}
onOk={handleSave}
confirmLoading={saving}
okText="保存"
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<div>
<Text style={{ fontSize: 13 }}>模板名称 *</Text>
<Input
placeholder="输入模板名称"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
/>
</div>
<div>
<Text style={{ fontSize: 13 }}>分类</Text>
<Input
placeholder="例如:生活、美食、旅行"
value={draftCategory}
onChange={(e) => setDraftCategory(e.target.value)}
/>
</div>
<div>
<Text style={{ fontSize: 13 }}>标签(逗号分隔)</Text>
<Input
placeholder="例如:vlog, 日常"
value={draftTags}
onChange={(e) => setDraftTags(e.target.value)}
/>
</div>
</Space>
</Modal>
{/* ═══ 使用模板生成弹窗 ═══ */}
<Modal
title="使用模板生成视频"
open={generateModalOpen}
onCancel={() => setGenerateModalOpen(false)}
onOk={doGenerate}
confirmLoading={generateMutation.isPending}
okText="开始生成"
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<div>
<Text style={{ fontSize: 13 }}>配音 ID *</Text>
<Input
placeholder="输入配音 ID"
value={voiceoverId}
onChange={(e) => setVoiceoverId(e.target.value)}
/>
</div>
{loadedTemplateId && (
<Text type="secondary" style={{ fontSize: 12 }}>
预估时长:~{estimatedDuration}s,配音时长偏差超过 ±30% 时将收到警告
</Text>
)}
</Space>
</Modal>
</div>
);
};
export default EditingPlanner;
@@ -0,0 +1,70 @@
/* ═══════════════════════════════════════════════════
* 我的模板页面样式
* ═══════════════════════════════════════════════════ */
.mt-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.mt-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.mt-filters {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.mt-content {
min-height: 400px;
}
/* 卡片样式 */
.mt-card {
height: 100%;
border-radius: 12px;
transition: box-shadow 0.2s, transform 0.2s;
}
.mt-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.mt-card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
.mt-card-meta {
margin-bottom: 8px;
}
.mt-card-tags {
margin-bottom: 8px;
}
.mt-card-config {
margin-top: 4px;
}
/* 响应式 */
@media (max-width: 768px) {
.mt-head {
flex-direction: column;
gap: 12px;
}
.mt-filters {
flex-direction: column;
}
}
@@ -0,0 +1,253 @@
/**
* 我的模板页面
* 卡片视图展示用户已保存的剪辑模板
* 支持搜索、分类筛选、编辑/复制/删除/使用模板生成
*/
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Typography,
Card,
Input,
Select,
Tag,
Button,
Space,
Empty,
Spin,
Tooltip,
message,
Popconfirm,
Row,
Col,
} from 'antd';
import {
SearchOutlined,
EditOutlined,
CopyOutlined,
DeleteOutlined,
VideoCameraOutlined,
AppstoreOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import {
getEditingTemplates,
getTemplateCategories,
deleteEditingTemplate,
createEditingTemplate,
type EditingTemplate,
} from '@/api/editingPlanner';
import './MyTemplates.css';
const { Title, Text } = Typography;
const MyTemplates: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchText, setSearchText] = useState('');
const [filterCategory, setFilterCategory] = useState('');
/* ── 数据查询 ── */
const { data: templates = [], isLoading } = useQuery({
queryKey: ['editing-templates', filterCategory, searchText],
queryFn: () =>
getEditingTemplates({
category: filterCategory || undefined,
tag: searchText || undefined,
}),
});
const { data: categories = [] } = useQuery({
queryKey: ['template-categories'],
queryFn: getTemplateCategories,
});
/* ── Mutations ── */
const deleteMutation = useMutation({
mutationFn: deleteEditingTemplate,
onSuccess: () => {
message.success('模板已删除');
queryClient.invalidateQueries({ queryKey: ['editing-templates'] });
},
onError: (err: any) => {
if (!err?.__msgShown) message.error('删除失败');
},
});
const copyMutation = useMutation({
mutationFn: (tpl: EditingTemplate) =>
createEditingTemplate({
name: `${tpl.name}(副本)`,
mode: tpl.mode,
category: tpl.category,
tags: tpl.tags,
title_config: tpl.title_config,
subtitle_config: tpl.subtitle_config,
bgm_config: tpl.bgm_config,
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
}),
onSuccess: () => {
message.success('模板已复制');
queryClient.invalidateQueries({ queryKey: ['editing-templates'] });
},
onError: (err: any) => {
if (!err?.__msgShown) message.error('复制失败');
},
});
/* ── 操作 ── */
const handleEdit = (tpl: EditingTemplate) => {
navigate(`/editing-planner?template=${tpl.id}`);
};
const handleGenerate = (tpl: EditingTemplate) => {
navigate(`/editing-planner?template=${tpl.id}&generate=1`);
};
const handleCopy = (tpl: EditingTemplate) => {
copyMutation.mutate(tpl);
};
const handleDelete = (id: string) => {
deleteMutation.mutate(id);
};
/* ── 模式颜色 ── */
const modeColor: Record<string, string> = {
画中画: 'blue',
人物口播: 'green',
一镜到底: 'orange',
'口播+混剪': 'purple',
};
return (
<div className="mt-page">
{/* 页面头部 */}
<div className="mt-head">
<div>
<Title level={4} style={{ margin: 0 }}>
<AppstoreOutlined style={{ marginRight: 8 }} />
我的模板
</Title>
<Text type="secondary">管理你创建的剪辑模板,快速复用生成视频</Text>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/editing-planner')}
>
新建模板
</Button>
</div>
{/* 筛选栏 */}
<div className="mt-filters">
<Input
prefix={<SearchOutlined />}
placeholder="搜索模板名称或标签..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 280 }}
/>
<Select
placeholder="按分类筛选"
value={filterCategory || undefined}
onChange={(v) => setFilterCategory(v || '')}
allowClear
style={{ width: 160 }}
options={categories.map((c) => ({ value: c.name, label: c.name }))}
/>
</div>
{/* 模板卡片列表 */}
<div className="mt-content">
{isLoading ? (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
) : templates.length === 0 ? (
<Empty
description="还没有模板,点击右上角「新建模板」开始创建"
style={{ padding: 80 }}
>
<Button type="primary" onClick={() => navigate('/editing-planner')}>
新建模板
</Button>
</Empty>
) : (
<Row gutter={[16, 16]}>
{templates.map((tpl) => (
<Col key={tpl.id} xs={24} sm={12} md={8} lg={6}>
<Card
className="mt-card"
hoverable
actions={[
<Tooltip title="编辑" key="edit">
<EditOutlined onClick={() => handleEdit(tpl)} />
</Tooltip>,
<Tooltip title="复制" key="copy">
<CopyOutlined onClick={() => handleCopy(tpl)} />
</Tooltip>,
<Tooltip title="使用模板生成" key="generate">
<VideoCameraOutlined onClick={() => handleGenerate(tpl)} />
</Tooltip>,
<Popconfirm
key="delete"
title="确定删除此模板?"
onConfirm={() => handleDelete(tpl.id)}
okText="删除"
cancelText="取消"
>
<Tooltip title="删除">
<DeleteOutlined style={{ color: '#ff4d4f' }} />
</Tooltip>
</Popconfirm>,
]}
>
<div className="mt-card-head">
<Text strong ellipsis style={{ fontSize: 15 }}>
{tpl.name}
</Text>
<Tag color={modeColor[tpl.mode] || 'default'}>{tpl.mode}</Tag>
</div>
<div className="mt-card-meta">
<Text type="secondary" style={{ fontSize: 12 }}>
{tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s
</Text>
{tpl.category && (
<Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>
)}
</div>
{tpl.tags.length > 0 && (
<div className="mt-card-tags">
{tpl.tags.map((tag) => (
<Tag key={tag} style={{ fontSize: 11 }}>
{tag}
</Tag>
))}
</div>
)}
<div className="mt-card-config">
<Space size={4} wrap>
{tpl.title_config.ai_auto_select && <Tag color="cyan">AI标题</Tag>}
{tpl.subtitle_config.enabled && <Tag color="geekblue">字幕</Tag>}
{tpl.bgm_config.enabled && <Tag color="pink">BGM</Tag>}
</Space>
</div>
</Card>
</Col>
))}
</Row>
)}
</div>
</div>
);
};
export default MyTemplates;
+8
View File
@@ -85,6 +85,14 @@ export const router = createBrowserRouter([
path: 'products',
lazy: () => import('@/pages/products/ProductLibrary').then(m => ({ Component: m.default })),
},
{
path: 'editing-planner',
lazy: () => import('@/pages/editing-planner/EditingPlanner').then(m => ({ Component: m.default })),
},
{
path: 'my-templates',
lazy: () => import('@/pages/my-templates/MyTemplates').then(m => ({ Component: m.default })),
},
{
path: 'duplication',
lazy: () => import('@/pages/duplication/DuplicationUpload').then(m => ({ Component: m.default })),