feat: 剪辑计划编辑器前端实现 #106

Merged
xiaoxia merged 2 commits from feat/editing-planner-frontend into develop 2026-06-29 15:28:45 +08:00
12 changed files with 1947 additions and 0 deletions
+314
View File
@@ -0,0 +1,314 @@
/**
* 剪辑计划编辑器 API
* 当前使用 mock 数据,后端 API 就绪后替换
*/
// import apiClient from './client'; // TODO: 后端 API 就绪后启用
/* ──────────── 类型定义 ──────────── */
/** 模板模式(后端枚举值) */
export type TemplateMode = 'pip' | 'voice_over' | 'one_take' | 'voice_pip';
/** 模式显示名称映射 */
export const MODE_LABELS: Record<TemplateMode, string> = {
pip: '画中画',
voice_over: '人物口播',
one_take: '一镜到底',
voice_pip: '口播+混剪',
};
/** 模式颜色映射 */
export const MODE_COLORS: Record<TemplateMode, string> = {
pip: 'blue',
voice_over: 'green',
one_take: 'orange',
voice_pip: 'purple',
};
/** 标题配置 */
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;
estimated_duration: number;
segments: Omit<TemplateSegment, 'id'>[];
}
/** 使用模板生成请求体 */
export interface GenerateFromTemplatePayload {
voiceover_duration: number;
}
/** 使用模板生成响应 */
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: 'pip',
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: 'voice_over',
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: 'one_take',
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 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: data.estimated_duration,
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 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: data.estimated_duration,
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,438 @@
/**
* 剪辑计划编辑器
* 三栏布局:左侧模板面板 / 中间预览+时间线 / 右侧设置面板
* 支持 4 种模式切换(画中画 / 人物口播 / 一镜到底 / 口播+混剪)
*
* P0-2: 读取 URL 参数 ?template=xxx&generate=1
* P1-3: 拆分为子组件
* P1-4: voiceover_id → voiceover_duration
* P1-5: 分类 Input → Select(在 SaveModal 中实现)
* P1-6: SaveTemplatePayload 补充 estimated_duration
*/
import React, { useState, useEffect } from 'react';
import './EditingPlanner.css';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Space, message } from 'antd';
import {
SaveOutlined,
VideoCameraOutlined,
AppstoreOutlined,
UserOutlined,
DashboardOutlined,
} from '@ant-design/icons';
import { useSearchParams } from 'react-router-dom';
import {
getEditingTemplates,
getTemplateCategories,
createEditingTemplate,
updateEditingTemplate,
generateFromTemplate,
MODE_LABELS,
type EditingTemplate,
type TemplateSegment,
type TemplateMode,
type TitleConfig,
type SubtitleConfig,
type BgmConfig,
} from '@/api/editingPlanner';
/* ── 子组件 ── */
import TemplatePanel from './components/TemplatePanel';
import TimelinePanel from './components/TimelinePanel';
import SettingsPanel from './components/SettingsPanel';
import SaveModal from './components/SaveModal';
import GenerateModal from './components/GenerateModal';
/* ──────────── 常量 ──────────── */
const MODES: { key: TemplateMode; icon: React.ReactNode; desc: string }[] = [
{ key: 'pip', icon: <AppstoreOutlined />, desc: '多画面叠加' },
{ key: 'voice_over', icon: <UserOutlined />, desc: '人物讲解为主' },
{ key: 'one_take', icon: <VideoCameraOutlined />, desc: '连续不中断' },
{ key: 'voice_pip', icon: <DashboardOutlined />, desc: '口播搭配混剪素材' },
];
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 [searchParams] = useSearchParams();
/* ── P0-2: URL 参数 ── */
const urlTemplateId = searchParams.get('template');
const urlGenerate = searchParams.get('generate');
/* ── 数据查询 ── */
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>('pip');
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 [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(null);
const [dragIdx, setDragIdx] = useState<number | null>(null);
/* ── P0-2: 自动加载 URL 指定的模板 ── */
useEffect(() => {
if (urlTemplateId && templates.length > 0 && !loadedTemplateId) {
const tpl = templates.find((t) => t.id === urlTemplateId);
if (tpl) {
loadTemplate(tpl);
// 如果 URL 有 generate=1,自动打开发成弹窗
if (urlGenerate === '1') {
setGenerateModalOpen(true);
}
}
}
}, [urlTemplateId, templates, loadedTemplateId, urlGenerate]);
/* ── 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, duration }: { templateId: string; duration: number }) =>
generateFromTemplate(templateId, { voiceover_duration: duration }),
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 === 'one_take') return;
setSegments((prev) => [
...prev,
{
id: newSegId(),
segment_order: prev.length + 1,
duration_min: 5,
duration_max: 15,
material_type: currentMode === 'voice_pip' ? '人物' : null,
},
]);
};
const removeSegment = (id: string) => {
if (currentMode === 'one_take') 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 === 'one_take') {
// 锁定为 1 个片段
setSegments([
{
id: newSegId(),
segment_order: 1,
duration_min: 10,
duration_max: 20,
material_type: null,
},
]);
} else if (mode === 'voice_pip') {
// 确保每个片段有 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('pip');
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 estimatedDuration = calcEstimatedDuration(segments);
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,
estimated_duration: estimatedDuration,
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 (!voiceoverDuration || voiceoverDuration <= 0) {
message.warning('请输入配音时长');
return;
}
generateMutation.mutate({ templateId: loadedTemplateId!, duration: voiceoverDuration });
};
const estimatedDuration = calcEstimatedDuration(segments);
/* ──────────── 渲染 ──────────── */
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)}
>
{MODE_LABELS[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">
{/* 左侧:模板面板 */}
<TemplatePanel
templates={templates}
categories={categories}
isLoading={tplLoading}
searchText={searchText}
filterCategory={filterCategory}
loadedTemplateId={loadedTemplateId}
onSearchChange={setSearchText}
onCategoryChange={setFilterCategory}
onTemplateSelect={loadTemplate}
onNewTemplate={resetEditor}
/>
{/* 中间:预览 + 时间线 */}
<TimelinePanel
segments={segments}
currentMode={currentMode}
estimatedDuration={estimatedDuration}
onAddSegment={addSegment}
onRemoveSegment={removeSegment}
onUpdateSegment={updateSegment}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
/>
{/* 右侧:设置面板 */}
<SettingsPanel
titleConfig={titleConfig}
subtitleConfig={subtitleConfig}
bgmConfig={bgmConfig}
onTitleChange={setTitleConfig}
onSubtitleChange={setSubtitleConfig}
onBgmChange={setBgmConfig}
/>
</div>
{/* 保存模板弹窗 */}
<SaveModal
open={saveModalOpen}
loading={saving}
isUpdate={!!loadedTemplateId}
draftName={draftName}
draftCategory={draftCategory}
draftTags={draftTags}
categories={categories}
estimatedDuration={estimatedDuration}
onNameChange={setDraftName}
onCategoryChange={setDraftCategory}
onTagsChange={setDraftTags}
onSave={handleSave}
onCancel={() => setSaveModalOpen(false)}
/>
{/* 使用模板生成弹窗 */}
<GenerateModal
open={generateModalOpen}
loading={generateMutation.isPending}
voiceoverDuration={voiceoverDuration}
estimatedDuration={estimatedDuration}
onDurationChange={setVoiceoverDuration}
onGenerate={doGenerate}
onCancel={() => setGenerateModalOpen(false)}
/>
</div>
);
};
export default EditingPlanner;
@@ -0,0 +1,58 @@
/**
* 使用模板生成视频弹窗
* P1-4: voiceover_id → voiceover_duration (number)
*/
import React from 'react';
import { Modal, InputNumber, Space, Typography } from 'antd';
const { Text } = Typography;
interface GenerateModalProps {
open: boolean;
loading: boolean;
voiceoverDuration: number | null;
estimatedDuration: number;
onDurationChange: (v: number | null) => void;
onGenerate: () => void;
onCancel: () => void;
}
const GenerateModal: React.FC<GenerateModalProps> = ({
open,
loading,
voiceoverDuration,
estimatedDuration,
onDurationChange,
onGenerate,
onCancel,
}) => {
return (
<Modal
title="使用模板生成视频"
open={open}
onCancel={onCancel}
onOk={onGenerate}
confirmLoading={loading}
okText="开始生成"
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<div>
<Text style={{ fontSize: 13 }}>*</Text>
<InputNumber
placeholder="输入配音时长"
value={voiceoverDuration}
onChange={onDurationChange}
min={1}
max={600}
style={{ width: '100%' }}
/>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
~{estimatedDuration}s ±30%
</Text>
</Space>
</Modal>
);
};
export default GenerateModal;
@@ -0,0 +1,88 @@
/**
* 保存/更新模板弹窗
* 分类使用 Select 关联后端分类 APIP1-5
*/
import React from 'react';
import { Modal, Input, Select, Space, Typography } from 'antd';
import type { TemplateCategory } from '@/api/editingPlanner';
const { Text } = Typography;
interface SaveModalProps {
open: boolean;
loading: boolean;
isUpdate: boolean;
draftName: string;
draftCategory: string;
draftTags: string;
categories: TemplateCategory[];
estimatedDuration: number;
onNameChange: (v: string) => void;
onCategoryChange: (v: string) => void;
onTagsChange: (v: string) => void;
onSave: () => void;
onCancel: () => void;
}
const SaveModal: React.FC<SaveModalProps> = ({
open,
loading,
isUpdate,
draftName,
draftCategory,
draftTags,
categories,
estimatedDuration,
onNameChange,
onCategoryChange,
onTagsChange,
onSave,
onCancel,
}) => {
return (
<Modal
title={isUpdate ? '更新模板' : '保存模板'}
open={open}
onCancel={onCancel}
onOk={onSave}
confirmLoading={loading}
okText="保存"
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<div>
<Text style={{ fontSize: 13 }}> *</Text>
<Input
placeholder="输入模板名称"
value={draftName}
onChange={(e) => onNameChange(e.target.value)}
/>
</div>
<div>
<Text style={{ fontSize: 13 }}></Text>
<Select
placeholder="选择分类"
value={draftCategory || undefined}
onChange={(v) => onCategoryChange(v || '')}
allowClear
showSearch
style={{ width: '100%' }}
options={categories.map((c) => ({ value: c.name, label: c.name }))}
/>
</div>
<div>
<Text style={{ fontSize: 13 }}></Text>
<Input
placeholder="例如:vlog, 日常"
value={draftTags}
onChange={(e) => onTagsChange(e.target.value)}
/>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
~{estimatedDuration}s
</Text>
</Space>
</Modal>
);
};
export default SaveModal;
@@ -0,0 +1,229 @@
/**
* 右侧设置面板
* 标题设置 / 字幕设置 / BGM 设置
*/
import React from 'react';
import { Typography, Input, Switch, Select, Slider, Tag } from 'antd';
import { SoundOutlined, FontSizeOutlined } from '@ant-design/icons';
import type { TitleConfig, SubtitleConfig, BgmConfig } from '@/api/editingPlanner';
const { Text } = Typography;
/* ── 常量 ── */
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: '滑动' },
];
interface SettingsPanelProps {
titleConfig: TitleConfig;
subtitleConfig: SubtitleConfig;
bgmConfig: BgmConfig;
onTitleChange: (config: TitleConfig) => void;
onSubtitleChange: (config: SubtitleConfig) => void;
onBgmChange: (config: BgmConfig) => void;
}
const SettingsPanel: React.FC<SettingsPanelProps> = ({
titleConfig,
subtitleConfig,
bgmConfig,
onTitleChange,
onSubtitleChange,
onBgmChange,
}) => {
return (
<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) => onTitleChange({ ...titleConfig, ai_auto_select: checked })}
checkedChildren="ON"
unCheckedChildren="OFF"
/>
</div>
{!titleConfig.ai_auto_select && (
<Input.TextArea
placeholder="手动输入标题内容"
value={titleConfig.content}
onChange={(e) => onTitleChange({ ...titleConfig, 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={() => onTitleChange({ ...titleConfig, 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) => onTitleChange({ ...titleConfig, 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) => onTitleChange({ ...titleConfig, 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) => onTitleChange({ ...titleConfig, 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) => onSubtitleChange({ ...subtitleConfig, enabled: checked })}
/>
</div>
{subtitleConfig.enabled && (
<>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}></Text>
<Select
size="small"
value={subtitleConfig.position}
onChange={(v) => onSubtitleChange({ ...subtitleConfig, 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) => onSubtitleChange({ ...subtitleConfig, 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) => onSubtitleChange({ ...subtitleConfig, 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) => onSubtitleChange({ ...subtitleConfig, 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) => onSubtitleChange({ ...subtitleConfig, 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) => onBgmChange({ ...bgmConfig, enabled: checked })}
/>
</div>
{bgmConfig.enabled && (
<div>
<Text style={{ fontSize: 12 }}></Text>
<Select
size="small"
placeholder="选择背景音乐"
value={bgmConfig.music_id || undefined}
onChange={(v) => onBgmChange({ ...bgmConfig, music_id: v })}
style={{ width: '100%', marginTop: 4 }}
options={[
{ value: 'bgm-1', label: '轻快节奏' },
{ value: 'bgm-2', label: '舒缓氛围' },
{ value: 'bgm-3', label: '动感活力' },
]}
/>
</div>
)}
</div>
</div>
);
};
export default SettingsPanel;
@@ -0,0 +1,130 @@
/**
* 左侧模板面板
* 搜索、分类筛选、模板卡片列表
*/
import React from 'react';
import { Input, Select, Card, Tag, Empty, Spin, Button, Typography } from 'antd';
import {
SearchOutlined,
} from '@ant-design/icons';
import {
MODE_LABELS,
MODE_COLORS,
type EditingTemplate,
type TemplateCategory,
type TemplateMode,
} from '@/api/editingPlanner';
const { Text } = Typography;
interface TemplatePanelProps {
templates: EditingTemplate[];
categories: TemplateCategory[];
isLoading: boolean;
searchText: string;
filterCategory: string;
loadedTemplateId: string | null;
onSearchChange: (v: string) => void;
onCategoryChange: (v: string) => void;
onTemplateSelect: (tpl: EditingTemplate) => void;
onNewTemplate: () => void;
}
const TemplatePanel: React.FC<TemplatePanelProps> = ({
templates,
categories,
isLoading,
searchText,
filterCategory,
loadedTemplateId,
onSearchChange,
onCategoryChange,
onTemplateSelect,
onNewTemplate,
}) => {
return (
<div className="ep-left">
<div style={{ padding: '0 12px', marginBottom: 12 }}>
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 8 }}>
</Text>
<Input
prefix={<SearchOutlined />}
placeholder="搜索模板..."
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
size="small"
style={{ marginBottom: 8 }}
/>
<Select
placeholder="按分类筛选"
value={filterCategory || undefined}
onChange={(v) => onCategoryChange(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' }}>
{isLoading ? (
<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={() => onTemplateSelect(tpl)}
style={{ marginBottom: 8 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong ellipsis style={{ maxWidth: 140 }}>
{tpl.name}
</Text>
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || 'blue'} style={{ marginRight: 0 }}>
{MODE_LABELS[tpl.mode as TemplateMode] || 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={onNewTemplate}>
</Button>
</div>
)}
</div>
);
};
export default TemplatePanel;
@@ -0,0 +1,195 @@
/**
* 中间预览 + 时间线面板
* 视频/封面预览区 + 片段卡片时间线
*/
import React from 'react';
import { Card, Button, Tag, Typography, Select, Slider } from 'antd';
import {
PlusOutlined,
DeleteOutlined,
DragOutlined,
VideoCameraOutlined,
PictureOutlined,
} from '@ant-design/icons';
import type { TemplateSegment, TemplateMode } from '@/api/editingPlanner';
const { Text } = Typography;
interface TimelinePanelProps {
segments: TemplateSegment[];
currentMode: TemplateMode;
estimatedDuration: number;
onAddSegment: () => void;
onRemoveSegment: (id: string) => void;
onUpdateSegment: (id: string, patch: Partial<TemplateSegment>) => void;
onDragStart: (idx: number) => void;
onDragOver: (e: React.DragEvent, idx: number) => void;
onDragEnd: () => void;
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
segments,
currentMode,
estimatedDuration,
onAddSegment,
onRemoveSegment,
onUpdateSegment,
onDragStart,
onDragOver,
onDragEnd,
}) => {
const isOneShot = currentMode === 'one_take';
const isMixedCut = currentMode === 'voice_pip';
const handleDragOver = (e: React.DragEvent, idx: number) => {
onDragOver(e, idx);
};
return (
<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={onAddSegment}
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={() => onDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={onDragEnd}
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={() => onRemoveSegment(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={seg.duration_max}
value={seg.duration_min}
onChange={(v) => onUpdateSegment(seg.id, { duration_min: v })}
/>
</div>
<div>
<Text style={{ fontSize: 12 }}> ()</Text>
<Slider
min={seg.duration_min}
max={60}
value={seg.duration_max}
onChange={(v) => onUpdateSegment(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) => onUpdateSegment(seg.id, { material_type: v })}
style={{ width: '100%', marginTop: 4 }}
options={[
{ value: '人物', label: '人物' },
{ value: '场景', label: '场景' },
]}
/>
</div>
)}
</Card>
))}
</div>
</div>
</div>
);
};
export default TimelinePanel;
@@ -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,249 @@
/**
* 我的模板页面
* 卡片视图展示用户已保存的剪辑模板
* 支持搜索、分类筛选、编辑/复制/删除/使用模板生成
*/
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,
MODE_LABELS,
MODE_COLORS,
type EditingTemplate,
type TemplateMode,
} 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,
estimated_duration: tpl.estimated_duration ?? Math.round(tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0)),
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);
};
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={MODE_COLORS[tpl.mode as TemplateMode] || 'default'}>{MODE_LABELS[tpl.mode as TemplateMode] || 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 })),