From fff47a7a3d1d71dcd78272ecd4060ae17be21586 Mon Sep 17 00:00:00 2001 From: Audit Bot Date: Mon, 29 Jun 2026 14:35:27 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=89=AA=E8=BE=91=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E7=BC=96=E8=BE=91=E5=99=A8=E5=89=8D=E7=AB=AF=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增剪辑计划编辑器页面 (三栏布局) - 左侧:模板面板(搜索/筛选/加载模板) - 中间:预览区 + 时间线(片段拖拽排序) - 右侧:标题/字幕/BGM 设置面板 - 支持 4 种模式切换(画中画/人物口播/一镜到底/口播+混剪) - 新增「我的模板」页面(卡片视图/编辑/复制/删除/生成) - 新增 API 模块(mock 数据,后端就绪后替换) - 更新路由和导航菜单 Co-Authored-By: Claude Fable 5 --- apps/web/src/api/editingPlanner.ts | 307 +++++++ apps/web/src/components/layout/Header.tsx | 4 + .../pages/editing-planner/EditingPlanner.css | 164 ++++ .../pages/editing-planner/EditingPlanner.tsx | 848 ++++++++++++++++++ .../src/pages/my-templates/MyTemplates.css | 70 ++ .../src/pages/my-templates/MyTemplates.tsx | 253 ++++++ apps/web/src/router/index.tsx | 8 + 7 files changed, 1654 insertions(+) create mode 100644 apps/web/src/api/editingPlanner.ts create mode 100644 apps/web/src/pages/editing-planner/EditingPlanner.css create mode 100644 apps/web/src/pages/editing-planner/EditingPlanner.tsx create mode 100644 apps/web/src/pages/my-templates/MyTemplates.css create mode 100644 apps/web/src/pages/my-templates/MyTemplates.tsx diff --git a/apps/web/src/api/editingPlanner.ts b/apps/web/src/api/editingPlanner.ts new file mode 100644 index 000000000..b6763c0de --- /dev/null +++ b/apps/web/src/api/editingPlanner.ts @@ -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[]; +} + +/** 使用模板生成请求体 */ +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 => { + 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 => { + 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 => { + 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 => { + 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 => { + await delay(); + const idx = MOCK_TEMPLATES.findIndex((t) => t.id === id); + if (idx !== -1) MOCK_TEMPLATES.splice(idx, 1); +}; + +/** 获取模板分类列表 */ +export const getTemplateCategories = async (): Promise => { + await delay(); + return [...MOCK_CATEGORIES]; +}; + +/** 使用模板生成视频 */ +export const generateFromTemplate = async ( + _templateId: string, + _data: GenerateFromTemplatePayload, +): Promise => { + await delay(500); + return { + task_id: nextId(), + warning: undefined, + }; +}; diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index 3ad0be292..a2f7ec1c9 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -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: }, { key: 'voices', label: '配音库', path: '/voices', icon: }, { key: 'templates', label: '模板库', path: '/templates', icon: }, + { key: 'editing-planner', label: '剪辑编辑器', path: '/editing-planner', icon: }, + { key: 'my-templates', label: '我的模板', path: '/my-templates', icon: }, { key: 'generate', label: '一键生成', path: '/generate', icon: }, { key: 'history', label: '任务历史', path: '/history', icon: }, { key: 'products', label: '成品库', path: '/products', icon: }, diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css new file mode 100644 index 000000000..661ac8c64 --- /dev/null +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -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; + } +} diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx new file mode 100644 index 000000000..fca9ca085 --- /dev/null +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -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: , desc: '多画面叠加' }, + { key: '人物口播', icon: , desc: '人物讲解为主' }, + { key: '一镜到底', icon: , desc: '连续不中断' }, + { key: '口播+混剪', icon: , 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('画中画'); + const [segments, setSegments] = useState([ + { id: newSegId(), segment_order: 1, duration_min: 5, duration_max: 15, material_type: null }, + ]); + const [loadedTemplateId, setLoadedTemplateId] = useState(null); + + const [titleConfig, setTitleConfig] = useState({ ...DEFAULT_TITLE }); + const [subtitleConfig, setSubtitleConfig] = useState({ ...DEFAULT_SUBTITLE }); + const [bgmConfig, setBgmConfig] = useState({ ...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(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) => { + 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 ( +
+ {/* ═══ 顶部工具栏 ═══ */} +
+ + {MODES.map((m) => ( + + ))} + + + + + +
+ + {/* ═══ 三栏主体 ═══ */} +
+ {/* ─── 左侧:模板面板 ─── */} +
+
+ + 我的模板 + + setSearchText(e.target.value)} + allowClear + size="small" + style={{ marginBottom: 8 }} + /> + updateSegment(seg.id, { material_type: v })} + style={{ width: '100%', marginTop: 4 }} + options={[ + { value: '人物', label: '人物' }, + { value: '场景', label: '场景' }, + ]} + /> +
+ )} + + ))} +
+
+
+ + {/* ─── 右侧:设置面板 ─── */} +
+ {/* 标题设置 */} +
+ + + 标题设置 + + +
+ AI 自动选择 + setTitleConfig((prev) => ({ ...prev, ai_auto_select: checked }))} + checkedChildren="ON" + unCheckedChildren="OFF" + /> +
+ + {!titleConfig.ai_auto_select && ( + setTitleConfig((prev) => ({ ...prev, content: e.target.value }))} + rows={2} + size="small" + style={{ marginBottom: 12 }} + /> + )} + +
+ 字体预设 +
+ {FONT_PRESETS.map((font) => ( + setTitleConfig((prev) => ({ ...prev, font_preset: font }))} + > + {font} + + ))} +
+
+ +
+
+ 颜色 + setTitleConfig((prev) => ({ ...prev, font_color: e.target.value }))} + style={{ marginTop: 4 }} + /> +
+
+ 位置 + setSubtitleConfig((prev) => ({ ...prev, position: v }))} + options={POSITIONS} + style={{ width: '100%', marginTop: 4 }} + /> +
+
+ 字体 + setSubtitleConfig((prev) => ({ ...prev, color: e.target.value }))} + style={{ marginTop: 4 }} + /> +
+
+ 动画 + setBgmConfig((prev) => ({ ...prev, music_id: v }))} + style={{ width: '100%', marginTop: 4 }} + options={[ + { value: 'bgm-1', label: '轻快节奏' }, + { value: 'bgm-2', label: '舒缓氛围' }, + { value: 'bgm-3', label: '动感活力' }, + ]} + /> +
+ )} +
+
+
+ + {/* ═══ 保存模板弹窗 ═══ */} + setSaveModalOpen(false)} + onOk={handleSave} + confirmLoading={saving} + okText="保存" + > + +
+ 模板名称 * + setDraftName(e.target.value)} + /> +
+
+ 分类 + setDraftCategory(e.target.value)} + /> +
+
+ 标签(逗号分隔) + setDraftTags(e.target.value)} + /> +
+
+
+ + {/* ═══ 使用模板生成弹窗 ═══ */} + setGenerateModalOpen(false)} + onOk={doGenerate} + confirmLoading={generateMutation.isPending} + okText="开始生成" + > + +
+ 配音 ID * + setVoiceoverId(e.target.value)} + /> +
+ {loadedTemplateId && ( + + 预估时长:~{estimatedDuration}s,配音时长偏差超过 ±30% 时将收到警告 + + )} +
+
+ + ); +}; + +export default EditingPlanner; diff --git a/apps/web/src/pages/my-templates/MyTemplates.css b/apps/web/src/pages/my-templates/MyTemplates.css new file mode 100644 index 000000000..2cf5a6c85 --- /dev/null +++ b/apps/web/src/pages/my-templates/MyTemplates.css @@ -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; + } +} diff --git a/apps/web/src/pages/my-templates/MyTemplates.tsx b/apps/web/src/pages/my-templates/MyTemplates.tsx new file mode 100644 index 000000000..9bcf5f10e --- /dev/null +++ b/apps/web/src/pages/my-templates/MyTemplates.tsx @@ -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 = { + 画中画: 'blue', + 人物口播: 'green', + 一镜到底: 'orange', + '口播+混剪': 'purple', + }; + + return ( +
+ {/* 页面头部 */} +
+
+ + <AppstoreOutlined style={{ marginRight: 8 }} /> + 我的模板 + + 管理你创建的剪辑模板,快速复用生成视频 +
+ +
+ + {/* 筛选栏 */} +
+ } + placeholder="搜索模板名称或标签..." + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ width: 280 }} + /> + setSearchText(e.target.value)} - allowClear - size="small" - style={{ marginBottom: 8 }} - /> - updateSegment(seg.id, { material_type: v })} - style={{ width: '100%', marginTop: 4 }} - options={[ - { value: '人物', label: '人物' }, - { value: '场景', label: '场景' }, - ]} - /> -
- )} - - ))} -
- - - - {/* ─── 右侧:设置面板 ─── */} -
- {/* 标题设置 */} -
- - - 标题设置 - - -
- AI 自动选择 - setTitleConfig((prev) => ({ ...prev, ai_auto_select: checked }))} - checkedChildren="ON" - unCheckedChildren="OFF" - /> -
- - {!titleConfig.ai_auto_select && ( - setTitleConfig((prev) => ({ ...prev, content: e.target.value }))} - rows={2} - size="small" - style={{ marginBottom: 12 }} - /> - )} - -
- 字体预设 -
- {FONT_PRESETS.map((font) => ( - setTitleConfig((prev) => ({ ...prev, font_preset: font }))} - > - {font} - - ))} -
-
- -
-
- 颜色 - setTitleConfig((prev) => ({ ...prev, font_color: e.target.value }))} - style={{ marginTop: 4 }} - /> -
-
- 位置 - setSubtitleConfig((prev) => ({ ...prev, position: v }))} - options={POSITIONS} - style={{ width: '100%', marginTop: 4 }} - /> -
-
- 字体 - setSubtitleConfig((prev) => ({ ...prev, color: e.target.value }))} - style={{ marginTop: 4 }} - /> -
-
- 动画 - setBgmConfig((prev) => ({ ...prev, music_id: v }))} - style={{ width: '100%', marginTop: 4 }} - options={[ - { value: 'bgm-1', label: '轻快节奏' }, - { value: 'bgm-2', label: '舒缓氛围' }, - { value: 'bgm-3', label: '动感活力' }, - ]} - /> -
- )} -
-
+ {/* 右侧:设置面板 */} +
- {/* ═══ 保存模板弹窗 ═══ */} - setSaveModalOpen(false)} - onOk={handleSave} - confirmLoading={saving} - okText="保存" - > - -
- 模板名称 * - setDraftName(e.target.value)} - /> -
-
- 分类 - setDraftCategory(e.target.value)} - /> -
-
- 标签(逗号分隔) - setDraftTags(e.target.value)} - /> -
-
-
+ /> - {/* ═══ 使用模板生成弹窗 ═══ */} - setGenerateModalOpen(false)} - onOk={doGenerate} - confirmLoading={generateMutation.isPending} - okText="开始生成" - > - -
- 配音 ID * - setVoiceoverId(e.target.value)} - /> -
- {loadedTemplateId && ( - - 预估时长:~{estimatedDuration}s,配音时长偏差超过 ±30% 时将收到警告 - - )} -
-
+ /> ); }; diff --git a/apps/web/src/pages/editing-planner/components/GenerateModal.tsx b/apps/web/src/pages/editing-planner/components/GenerateModal.tsx new file mode 100644 index 000000000..6fd02beb2 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/GenerateModal.tsx @@ -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 = ({ + open, + loading, + voiceoverDuration, + estimatedDuration, + onDurationChange, + onGenerate, + onCancel, +}) => { + return ( + + +
+ 配音时长(秒)* + +
+ + 预估时长:~{estimatedDuration}s,配音时长偏差超过 ±30% 时将收到警告 + +
+
+ ); +}; + +export default GenerateModal; diff --git a/apps/web/src/pages/editing-planner/components/SaveModal.tsx b/apps/web/src/pages/editing-planner/components/SaveModal.tsx new file mode 100644 index 000000000..aa60d2c81 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/SaveModal.tsx @@ -0,0 +1,88 @@ +/** + * 保存/更新模板弹窗 + * 分类使用 Select 关联后端分类 API(P1-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 = ({ + open, + loading, + isUpdate, + draftName, + draftCategory, + draftTags, + categories, + estimatedDuration, + onNameChange, + onCategoryChange, + onTagsChange, + onSave, + onCancel, +}) => { + return ( + + +
+ 模板名称 * + onNameChange(e.target.value)} + /> +
+
+ 分类 + onTagsChange(e.target.value)} + /> +
+ + 预估时长:~{estimatedDuration}s + +
+
+ ); +}; + +export default SaveModal; diff --git a/apps/web/src/pages/editing-planner/components/SettingsPanel.tsx b/apps/web/src/pages/editing-planner/components/SettingsPanel.tsx new file mode 100644 index 000000000..2c1da27d2 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/SettingsPanel.tsx @@ -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 = ({ + titleConfig, + subtitleConfig, + bgmConfig, + onTitleChange, + onSubtitleChange, + onBgmChange, +}) => { + return ( +
+ {/* 标题设置 */} +
+ + + 标题设置 + + +
+ AI 自动选择 + onTitleChange({ ...titleConfig, ai_auto_select: checked })} + checkedChildren="ON" + unCheckedChildren="OFF" + /> +
+ + {!titleConfig.ai_auto_select && ( + onTitleChange({ ...titleConfig, content: e.target.value })} + rows={2} + size="small" + style={{ marginBottom: 12 }} + /> + )} + +
+ 字体预设 +
+ {FONT_PRESETS.map((font) => ( + onTitleChange({ ...titleConfig, font_preset: font })} + > + {font} + + ))} +
+
+ +
+
+ 颜色 + onTitleChange({ ...titleConfig, font_color: e.target.value })} + style={{ marginTop: 4 }} + /> +
+
+ 位置 + onSubtitleChange({ ...subtitleConfig, position: v })} + options={POSITIONS} + style={{ width: '100%', marginTop: 4 }} + /> +
+
+ 字体 + onSubtitleChange({ ...subtitleConfig, color: e.target.value })} + style={{ marginTop: 4 }} + /> +
+
+ 动画 + onBgmChange({ ...bgmConfig, music_id: v })} + style={{ width: '100%', marginTop: 4 }} + options={[ + { value: 'bgm-1', label: '轻快节奏' }, + { value: 'bgm-2', label: '舒缓氛围' }, + { value: 'bgm-3', label: '动感活力' }, + ]} + /> +
+ )} +
+
+ ); +}; + +export default SettingsPanel; diff --git a/apps/web/src/pages/editing-planner/components/TemplatePanel.tsx b/apps/web/src/pages/editing-planner/components/TemplatePanel.tsx new file mode 100644 index 000000000..91be782a1 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/TemplatePanel.tsx @@ -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 = ({ + templates, + categories, + isLoading, + searchText, + filterCategory, + loadedTemplateId, + onSearchChange, + onCategoryChange, + onTemplateSelect, + onNewTemplate, +}) => { + return ( +
+
+ + 我的模板 + + } + placeholder="搜索模板..." + value={searchText} + onChange={(e) => onSearchChange(e.target.value)} + allowClear + size="small" + style={{ marginBottom: 8 }} + /> + onUpdateSegment(seg.id, { material_type: v })} + style={{ width: '100%', marginTop: 4 }} + options={[ + { value: '人物', label: '人物' }, + { value: '场景', label: '场景' }, + ]} + /> +
+ )} + + ))} +
+
+ + ); +}; + +export default TimelinePanel; diff --git a/apps/web/src/pages/my-templates/MyTemplates.tsx b/apps/web/src/pages/my-templates/MyTemplates.tsx index 9bcf5f10e..c1a8b16d7 100644 --- a/apps/web/src/pages/my-templates/MyTemplates.tsx +++ b/apps/web/src/pages/my-templates/MyTemplates.tsx @@ -36,7 +36,10 @@ import { getTemplateCategories, deleteEditingTemplate, createEditingTemplate, + MODE_LABELS, + MODE_COLORS, type EditingTemplate, + type TemplateMode, } from '@/api/editingPlanner'; import './MyTemplates.css'; @@ -86,6 +89,7 @@ const MyTemplates: React.FC = () => { 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: () => { @@ -114,14 +118,6 @@ const MyTemplates: React.FC = () => { deleteMutation.mutate(id); }; - /* ── 模式颜色 ── */ - const modeColor: Record = { - 画中画: 'blue', - 人物口播: 'green', - 一镜到底: 'orange', - '口播+混剪': 'purple', - }; - return (
{/* 页面头部 */} @@ -211,7 +207,7 @@ const MyTemplates: React.FC = () => { {tpl.name} - {tpl.mode} + {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
-- 2.54.0