fix: 修复 PR#106 代码审计问题
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user