fix: 修复 PR#106 代码审计问题
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled

P0-1: TemplateMode 改为英文枚举值(pip/voice_over/one_take/voice_pip),显示层通过 MODE_LABELS 映射中文
P0-2: EditingPlanner 通过 useSearchParams 读取 ?template=xxx&generate=1,自动加载模板并打开发成弹窗
P1-3: 848行组件拆分为 5 个子组件(TemplatePanel/TimelinePanel/SettingsPanel/SaveModal/GenerateModal)
P1-4: GenerateFromTemplatePayload voiceover_id(string) → voiceover_duration(number)
P1-5: SaveModal 分类字段 Input → Select,关联后端 categories API
P1-6: SaveTemplatePayload 补充 estimated_duration 字段
P2: TimelinePanel Slider 约束 min≤max

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Audit Bot
2026-06-29 15:18:33 +08:00
parent fff47a7a3d
commit 067439a1dd
8 changed files with 842 additions and 549 deletions
+25 -18
View File
@@ -6,8 +6,24 @@
/* ──────────── 类型定义 ──────────── */
/** 模板模式 */
export type TemplateMode = '画中画' | '人物口播' | '一镜到底' | '口播+混剪';
/** 模板模式(后端枚举值) */
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 {
@@ -75,12 +91,13 @@ export interface SaveTemplatePayload {
title_config: TitleConfig;
subtitle_config: SubtitleConfig;
bgm_config: BgmConfig;
estimated_duration: number;
segments: Omit<TemplateSegment, 'id'>[];
}
/** 使用模板生成请求体 */
export interface GenerateFromTemplatePayload {
voiceover_id: string;
voiceover_duration: number;
}
/** 使用模板生成响应 */
@@ -105,7 +122,7 @@ const MOCK_TEMPLATES: EditingTemplate[] = [
{
id: 'tpl-1',
name: '生活 Vlog 模板',
mode: '画中画',
mode: 'pip',
category: '生活',
tags: ['vlog', '日常'],
title_config: {
@@ -136,7 +153,7 @@ const MOCK_TEMPLATES: EditingTemplate[] = [
{
id: 'tpl-2',
name: '知识分享口播',
mode: '人物口播',
mode: 'voice_over',
category: '知识',
tags: ['口播', '分享'],
title_config: {
@@ -168,7 +185,7 @@ const MOCK_TEMPLATES: EditingTemplate[] = [
{
id: 'tpl-3',
name: '一镜到底展示',
mode: '一镜到底',
mode: 'one_take',
category: '生活',
tags: ['一镜到底'],
title_config: {
@@ -220,11 +237,6 @@ export const createEditingTemplate = async (
): Promise<EditingTemplate> => {
await delay(300);
const now = new Date().toISOString();
const estimated =
data.segments.reduce(
(sum, s) => sum + (s.duration_min + s.duration_max) / 2,
0,
);
const tpl: EditingTemplate = {
id: nextId(),
name: data.name,
@@ -234,7 +246,7 @@ export const createEditingTemplate = async (
title_config: data.title_config,
subtitle_config: data.subtitle_config,
bgm_config: data.bgm_config,
estimated_duration: Math.round(estimated),
estimated_duration: data.estimated_duration,
segments: data.segments.map((s, i) => ({
...s,
id: nextId(),
@@ -255,11 +267,6 @@ export const updateEditingTemplate = async (
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,
@@ -269,7 +276,7 @@ export const updateEditingTemplate = async (
title_config: data.title_config,
subtitle_config: data.subtitle_config,
bgm_config: data.bgm_config,
estimated_duration: Math.round(estimated),
estimated_duration: data.estimated_duration,
segments: data.segments.map((s, i) => ({
...s,
id: nextId(),
@@ -2,44 +2,32 @@
* 剪辑计划编辑器
* 三栏布局:左侧模板面板 / 中间预览+时间线 / 右侧设置面板
* 支持 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 } from 'react';
import React, { useState, useEffect } 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 { Button, Space, message } from 'antd';
import {
SaveOutlined,
VideoCameraOutlined,
PlusOutlined,
DeleteOutlined,
DragOutlined,
PictureOutlined,
UserOutlined,
AppstoreOutlined,
SoundOutlined,
FontSizeOutlined,
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,
@@ -48,29 +36,20 @@ import {
type BgmConfig,
} from '@/api/editingPlanner';
const { Text } = Typography;
/* ── 子组件 ── */
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: '画中画', icon: <AppstoreOutlined />, desc: '多画面叠加' },
{ key: '人物口播', icon: <UserOutlined />, desc: '人物讲解为主' },
{ key: '一镜到底', icon: <VideoCameraOutlined />, desc: '连续不中断' },
{ key: '口播+混剪', icon: <DashboardOutlined />, desc: '口播搭配混剪素材' },
];
const FONT_PRESETS = ['思源黑体', '站酷快乐体', '方正兰亭', '汉仪旗黑'];
const POSITIONS = [
{ value: 'top', label: '顶部' },
{ value: 'center', label: '居中' },
{ value: 'bottom', label: '底部' },
];
const SUBTITLE_FONTS = ['思源黑体', '微软雅黑', '苹方'];
const SUBTITLE_ANIMATIONS = [
{ value: 'none', label: '无' },
{ value: 'fade', label: '淡入' },
{ value: 'typewriter', label: '打字机' },
{ value: 'slide', label: '滑动' },
{ 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 = {
@@ -102,6 +81,11 @@ 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('');
@@ -122,7 +106,7 @@ const EditingPlanner: React.FC = () => {
});
/* ── 编辑器状态 ── */
const [currentMode, setCurrentMode] = useState<TemplateMode>('画中画');
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 },
]);
@@ -138,9 +122,23 @@ const EditingPlanner: React.FC = () => {
const [draftName, setDraftName] = useState('');
const [draftCategory, setDraftCategory] = useState('');
const [draftTags, setDraftTags] = useState('');
const [voiceoverId, setVoiceoverId] = 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,
@@ -167,8 +165,8 @@ const EditingPlanner: React.FC = () => {
});
const generateMutation = useMutation({
mutationFn: ({ templateId, voiceoverId }: { templateId: string; voiceoverId: string }) =>
generateFromTemplate(templateId, { voiceover_id: voiceoverId }),
mutationFn: ({ templateId, duration }: { templateId: string; duration: number }) =>
generateFromTemplate(templateId, { voiceover_duration: duration }),
onSuccess: (data) => {
const msg = data.warning ? `生成任务已提交(${data.warning})` : '生成任务已提交';
message.success(msg);
@@ -184,7 +182,7 @@ const EditingPlanner: React.FC = () => {
/* ──────────── 片段操作 ──────────── */
const addSegment = () => {
if (currentMode === '一镜到底') return;
if (currentMode === 'one_take') return;
setSegments((prev) => [
...prev,
{
@@ -192,13 +190,13 @@ const EditingPlanner: React.FC = () => {
segment_order: prev.length + 1,
duration_min: 5,
duration_max: 15,
material_type: currentMode === '口播+混剪' ? '人物' : null,
material_type: currentMode === 'voice_pip' ? '人物' : null,
},
]);
};
const removeSegment = (id: string) => {
if (currentMode === '一镜到底') return;
if (currentMode === 'one_take') return;
setSegments((prev) =>
prev.filter((s) => s.id !== id).map((s, i) => ({ ...s, segment_order: i + 1 })),
);
@@ -228,7 +226,7 @@ const EditingPlanner: React.FC = () => {
const handleModeChange = (mode: TemplateMode) => {
setCurrentMode(mode);
if (mode === '一镜到底') {
if (mode === 'one_take') {
// 锁定为 1 个片段
setSegments([
{
@@ -239,7 +237,7 @@ const EditingPlanner: React.FC = () => {
material_type: null,
},
]);
} else if (mode === '口播+混剪') {
} else if (mode === 'voice_pip') {
// 确保每个片段有 material_type
setSegments((prev) =>
prev.map((s) => ({
@@ -263,7 +261,7 @@ const EditingPlanner: React.FC = () => {
const resetEditor = () => {
setLoadedTemplateId(null);
setCurrentMode('画中画');
setCurrentMode('pip');
setSegments([
{ id: newSegId(), segment_order: 1, duration_min: 5, duration_max: 15, material_type: null },
]);
@@ -292,6 +290,7 @@ const EditingPlanner: React.FC = () => {
message.warning('请输入模板名称');
return;
}
const estimatedDuration = calcEstimatedDuration(segments);
const payload = {
name: draftName.trim(),
mode: currentMode,
@@ -303,6 +302,7 @@ const EditingPlanner: React.FC = () => {
title_config: titleConfig,
subtitle_config: subtitleConfig,
bgm_config: bgmConfig,
estimated_duration: estimatedDuration,
segments: segments.map(({ id: _id, ...rest }) => rest),
};
@@ -322,16 +322,14 @@ const EditingPlanner: React.FC = () => {
};
const doGenerate = () => {
if (!voiceoverId.trim()) {
message.warning('请输入配音 ID');
if (!voiceoverDuration || voiceoverDuration <= 0) {
message.warning('请输入配音时长');
return;
}
generateMutation.mutate({ templateId: loadedTemplateId!, voiceoverId: voiceoverId.trim() });
generateMutation.mutate({ templateId: loadedTemplateId!, duration: voiceoverDuration });
};
const estimatedDuration = calcEstimatedDuration(segments);
const isOneShot = currentMode === '一镜到底';
const isMixedCut = currentMode === '口播+混剪';
/* ──────────── 渲染 ──────────── */
@@ -347,7 +345,7 @@ const EditingPlanner: React.FC = () => {
icon={m.icon}
onClick={() => handleModeChange(m.key)}
>
{m.key}
{MODE_LABELS[m.key]}
</Button>
))}
</Space>
@@ -368,479 +366,71 @@ const EditingPlanner: React.FC = () => {
{/* ═══ 三栏主体 ═══ */}
<div className="ep-body">
{/* ─── 左侧:模板面板 ─── */}
<div className="ep-left">
<div style={{ padding: '0 12px', marginBottom: 12 }}>
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 8 }}>
我的模板
</Text>
<Input
placeholder="搜索模板..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
size="small"
style={{ marginBottom: 8 }}
/>
<Select
placeholder="按分类筛选"
value={filterCategory || undefined}
onChange={(v) => setFilterCategory(v || '')}
allowClear
size="small"
style={{ width: '100%' }}
options={categories.map((c) => ({ value: c.name, label: c.name }))}
/>
</div>
{/* 左侧:模板面板 */}
<TemplatePanel
templates={templates}
categories={categories}
isLoading={tplLoading}
searchText={searchText}
filterCategory={filterCategory}
loadedTemplateId={loadedTemplateId}
onSearchChange={setSearchText}
onCategoryChange={setFilterCategory}
onTemplateSelect={loadTemplate}
onNewTemplate={resetEditor}
/>
<div style={{ padding: '0 12px', flex: 1, overflowY: 'auto' }}>
{tplLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin />
</div>
) : templates.length === 0 ? (
<Empty
description="暂无已保存的模板,请先编辑并保存模板"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: 20 }}
/>
) : (
templates.map((tpl) => (
<Card
key={tpl.id}
size="small"
hoverable
className={`ep-tpl-card ${loadedTemplateId === tpl.id ? 'ep-tpl-card-active' : ''}`}
onClick={() => loadTemplate(tpl)}
style={{ marginBottom: 8 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong ellipsis style={{ maxWidth: 140 }}>
{tpl.name}
</Text>
<Tag color="blue" style={{ marginRight: 0 }}>
{tpl.mode}
</Tag>
</div>
<div style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{tpl.segments.length} 片段 · ~{tpl.estimated_duration}s
</Text>
{tpl.tags.length > 0 && (
<div style={{ marginTop: 4 }}>
{tpl.tags.slice(0, 3).map((tag) => (
<Tag key={tag} style={{ fontSize: 11, marginRight: 4 }}>
{tag}
</Tag>
))}
</div>
)}
</div>
</Card>
))
)}
</div>
{/* 中间:预览 + 时间线 */}
<TimelinePanel
segments={segments}
currentMode={currentMode}
estimatedDuration={estimatedDuration}
onAddSegment={addSegment}
onRemoveSegment={removeSegment}
onUpdateSegment={updateSegment}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
/>
{loadedTemplateId && (
<div style={{ padding: 12, borderTop: '1px solid #f0f0f0' }}>
<Button size="small" block onClick={resetEditor}>
新建空白模板
</Button>
</div>
)}
</div>
{/* ─── 中间:预览 + 时间线 ─── */}
<div className="ep-center">
{/* 预览区 */}
<div className="ep-preview-row">
{/* 视频预览 */}
<div className="ep-preview-box">
<div className="ep-preview-frame">
<VideoCameraOutlined style={{ fontSize: 40, color: '#bbb' }} />
<Text type="secondary" style={{ marginTop: 8 }}>
视频预览
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12, marginTop: 4 }}>
9:16 竖屏
</Text>
</div>
{/* 封面预览 + 方案按钮 */}
<div style={{ display: 'flex', gap: 12, flex: '0 0 auto' }}>
<div className="ep-preview-box">
<div className="ep-preview-frame">
<PictureOutlined style={{ fontSize: 40, color: '#bbb' }} />
<Text type="secondary" style={{ marginTop: 8 }}>
封面预览
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12, marginTop: 4 }}>
9:16 竖屏
</Text>
</div>
<div className="ep-cover-btns">
<Button size="small" block>
AI 选帧
</Button>
<Button size="small" block>
手动选
</Button>
<Button size="small" block>
上传
</Button>
<Button size="small" block>
AI 重选
</Button>
</div>
</div>
</div>
{/* 时间线 */}
<div className="ep-timeline">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>
时间线{' '}
<Text type="secondary" style={{ fontWeight: 'normal', fontSize: 12 }}>
(预估总时长:~{estimatedDuration}s)
</Text>
</Text>
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={addSegment}
disabled={isOneShot}
>
添加片段
</Button>
</div>
<div style={{ display: 'flex', gap: 12, overflowX: 'auto', paddingBottom: 8 }}>
{segments.map((seg, idx) => (
<Card
key={seg.id}
size="small"
className="ep-seg-card"
draggable={!isOneShot}
onDragStart={() => handleDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
style={{ minWidth: 180, maxWidth: 220, flexShrink: 0 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span
style={{ cursor: isOneShot ? 'default' : 'grab', color: '#999' }}
>
<DragOutlined />
</span>
<Tag color="blue">#{seg.segment_order}</Tag>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => removeSegment(seg.id)}
disabled={isOneShot}
style={{ marginLeft: 'auto' }}
/>
</div>
{isOneShot ? (
<Text type="secondary" style={{ fontSize: 12 }}>
时长由配音自动决定
</Text>
) : (
<>
<div style={{ marginBottom: 4 }}>
<Text style={{ fontSize: 12 }}>最短 (秒)</Text>
<Slider
min={1}
max={60}
value={seg.duration_min}
onChange={(v) => updateSegment(seg.id, { duration_min: v })}
/>
</div>
<div>
<Text style={{ fontSize: 12 }}>最长 (秒)</Text>
<Slider
min={1}
max={60}
value={seg.duration_max}
onChange={(v) => updateSegment(seg.id, { duration_max: v })}
/>
</div>
</>
)}
{isMixedCut && (
<div style={{ marginTop: 8 }}>
<Text style={{ fontSize: 12 }}>素材类型</Text>
<Select
size="small"
value={seg.material_type || '人物'}
onChange={(v) => updateSegment(seg.id, { material_type: v })}
style={{ width: '100%', marginTop: 4 }}
options={[
{ value: '人物', label: '人物' },
{ value: '场景', label: '场景' },
]}
/>
</div>
)}
</Card>
))}
</div>
</div>
</div>
{/* ─── 右侧:设置面板 ─── */}
<div className="ep-right">
{/* 标题设置 */}
<div className="ep-settings-group">
<Text strong style={{ display: 'block', marginBottom: 12 }}>
<FontSizeOutlined style={{ marginRight: 6 }} />
标题设置
</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text style={{ fontSize: 13 }}>AI 自动选择</Text>
<Switch
size="small"
checked={titleConfig.ai_auto_select}
onChange={(checked) => setTitleConfig((prev) => ({ ...prev, ai_auto_select: checked }))}
checkedChildren="ON"
unCheckedChildren="OFF"
/>
</div>
{!titleConfig.ai_auto_select && (
<Input.TextArea
placeholder="手动输入标题内容"
value={titleConfig.content}
onChange={(e) => setTitleConfig((prev) => ({ ...prev, content: e.target.value }))}
rows={2}
size="small"
style={{ marginBottom: 12 }}
/>
)}
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>字体预设</Text>
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
{FONT_PRESETS.map((font) => (
<Tag
key={font}
color={titleConfig.font_preset === font ? 'blue' : 'default'}
style={{ cursor: 'pointer' }}
onClick={() => setTitleConfig((prev) => ({ ...prev, font_preset: font }))}
>
{font}
</Tag>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}>颜色</Text>
<Input
size="small"
value={titleConfig.font_color}
onChange={(e) => setTitleConfig((prev) => ({ ...prev, font_color: e.target.value }))}
style={{ marginTop: 4 }}
/>
</div>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}>位置</Text>
<Select
size="small"
value={titleConfig.position}
onChange={(v) => setTitleConfig((prev) => ({ ...prev, position: v }))}
options={POSITIONS}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
</div>
<div>
<Text style={{ fontSize: 12 }}>字号:{titleConfig.font_size}</Text>
<Slider
min={16}
max={72}
value={titleConfig.font_size}
onChange={(v) => setTitleConfig((prev) => ({ ...prev, font_size: v }))}
/>
</div>
</div>
{/* 字幕设置 */}
<div className="ep-settings-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>
<FontSizeOutlined style={{ marginRight: 6 }} />
字幕设置
</Text>
<Switch
size="small"
checked={subtitleConfig.enabled}
onChange={(checked) => setSubtitleConfig((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
{subtitleConfig.enabled && (
<>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>位置</Text>
<Select
size="small"
value={subtitleConfig.position}
onChange={(v) => setSubtitleConfig((prev) => ({ ...prev, position: v }))}
options={POSITIONS}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>字体</Text>
<Select
size="small"
value={subtitleConfig.font}
onChange={(v) => setSubtitleConfig((prev) => ({ ...prev, font: v }))}
options={SUBTITLE_FONTS.map((f) => ({ value: f, label: f }))}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}>颜色</Text>
<Input
size="small"
value={subtitleConfig.color}
onChange={(e) => setSubtitleConfig((prev) => ({ ...prev, color: e.target.value }))}
style={{ marginTop: 4 }}
/>
</div>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}>动画</Text>
<Select
size="small"
value={subtitleConfig.animation}
onChange={(v) => setSubtitleConfig((prev) => ({ ...prev, animation: v }))}
options={SUBTITLE_ANIMATIONS}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
</div>
<div>
<Text style={{ fontSize: 12 }}>字号:{subtitleConfig.size}</Text>
<Slider
min={12}
max={48}
value={subtitleConfig.size}
onChange={(v) => setSubtitleConfig((prev) => ({ ...prev, size: v }))}
/>
</div>
</>
)}
</div>
{/* BGM 设置 */}
<div className="ep-settings-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>
<SoundOutlined style={{ marginRight: 6 }} />
BGM 设置
</Text>
<Switch
size="small"
checked={bgmConfig.enabled}
onChange={(checked) => setBgmConfig((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
{bgmConfig.enabled && (
<div>
<Text style={{ fontSize: 12 }}>选择音乐</Text>
<Select
size="small"
placeholder="选择背景音乐"
value={bgmConfig.music_id || undefined}
onChange={(v) => setBgmConfig((prev) => ({ ...prev, music_id: v }))}
style={{ width: '100%', marginTop: 4 }}
options={[
{ value: 'bgm-1', label: '轻快节奏' },
{ value: 'bgm-2', label: '舒缓氛围' },
{ value: 'bgm-3', label: '动感活力' },
]}
/>
</div>
)}
</div>
</div>
{/* 右侧:设置面板 */}
<SettingsPanel
titleConfig={titleConfig}
subtitleConfig={subtitleConfig}
bgmConfig={bgmConfig}
onTitleChange={setTitleConfig}
onSubtitleChange={setSubtitleConfig}
onBgmChange={setBgmConfig}
/>
</div>
{/* ═══ 保存模板弹窗 ═══ */}
<Modal
title={loadedTemplateId ? '更新模板' : '保存模板'}
{/* 保存模板弹窗 */}
<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)}
onOk={handleSave}
confirmLoading={saving}
okText="保存"
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<div>
<Text style={{ fontSize: 13 }}>模板名称 *</Text>
<Input
placeholder="输入模板名称"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
/>
</div>
<div>
<Text style={{ fontSize: 13 }}>分类</Text>
<Input
placeholder="例如:生活、美食、旅行"
value={draftCategory}
onChange={(e) => setDraftCategory(e.target.value)}
/>
</div>
<div>
<Text style={{ fontSize: 13 }}>标签(逗号分隔)</Text>
<Input
placeholder="例如:vlog, 日常"
value={draftTags}
onChange={(e) => setDraftTags(e.target.value)}
/>
</div>
</Space>
</Modal>
/>
{/* ═══ 使用模板生成弹窗 ═══ */}
<Modal
title="使用模板生成视频"
{/* 使用模板生成弹窗 */}
<GenerateModal
open={generateModalOpen}
loading={generateMutation.isPending}
voiceoverDuration={voiceoverDuration}
estimatedDuration={estimatedDuration}
onDurationChange={setVoiceoverDuration}
onGenerate={doGenerate}
onCancel={() => setGenerateModalOpen(false)}
onOk={doGenerate}
confirmLoading={generateMutation.isPending}
okText="开始生成"
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<div>
<Text style={{ fontSize: 13 }}>配音 ID *</Text>
<Input
placeholder="输入配音 ID"
value={voiceoverId}
onChange={(e) => setVoiceoverId(e.target.value)}
/>
</div>
{loadedTemplateId && (
<Text type="secondary" style={{ fontSize: 12 }}>
预估时长:~{estimatedDuration}s,配音时长偏差超过 ±30% 时将收到警告
</Text>
)}
</Space>
</Modal>
/>
</div>
);
};
@@ -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 关联后端分类 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<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;
@@ -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<string, string> = {
画中画: 'blue',
人物口播: 'green',
一镜到底: 'orange',
'口播+混剪': 'purple',
};
return (
<div className="mt-page">
{/* 页面头部 */}
@@ -211,7 +207,7 @@ const MyTemplates: React.FC = () => {
<Text strong ellipsis style={{ fontSize: 15 }}>
{tpl.name}
</Text>
<Tag color={modeColor[tpl.mode] || 'default'}>{tpl.mode}</Tag>
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || 'default'}>{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}</Tag>
</div>
<div className="mt-card-meta">