diff --git a/apps/web/src/api/editingPlanner.ts b/apps/web/src/api/editingPlanner.ts new file mode 100644 index 000000000..063acbc9f --- /dev/null +++ b/apps/web/src/api/editingPlanner.ts @@ -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 = { + pip: '画中画', + voice_over: '人物口播', + one_take: '一镜到底', + voice_pip: '口播+混剪', +}; + +/** 模式颜色映射 */ +export const MODE_COLORS: Record = { + 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[]; +} + +/** 使用模板生成请求体 */ +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 => { + 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 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 => { + 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 => { + 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..4dd1e7b8f --- /dev/null +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -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: , desc: '多画面叠加' }, + { key: 'voice_over', icon: , desc: '人物讲解为主' }, + { key: 'one_take', icon: , desc: '连续不中断' }, + { key: 'voice_pip', icon: , 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('pip'); + 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 [voiceoverDuration, setVoiceoverDuration] = useState(null); + const [dragIdx, setDragIdx] = useState(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) => { + 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 ( +
+ {/* ═══ 顶部工具栏 ═══ */} +
+ + {MODES.map((m) => ( + + ))} + + + + + +
+ + {/* ═══ 三栏主体 ═══ */} +
+ {/* 左侧:模板面板 */} + + + {/* 中间:预览 + 时间线 */} + + + {/* 右侧:设置面板 */} + +
+ + {/* 保存模板弹窗 */} + setSaveModalOpen(false)} + /> + + {/* 使用模板生成弹窗 */} + setGenerateModalOpen(false)} + /> +
+ ); +}; + +export default EditingPlanner; 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.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..c1a8b16d7 --- /dev/null +++ b/apps/web/src/pages/my-templates/MyTemplates.tsx @@ -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 ( +
+ {/* 页面头部 */} +
+
+ + <AppstoreOutlined style={{ marginRight: 8 }} /> + 我的模板 + + 管理你创建的剪辑模板,快速复用生成视频 +
+ +
+ + {/* 筛选栏 */} +
+ } + placeholder="搜索模板名称或标签..." + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ width: 280 }} + /> +