feat: 剪辑计划编辑器前端实现 #106
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 剪辑计划编辑器 API
|
||||
* 当前使用 mock 数据,后端 API 就绪后替换
|
||||
*/
|
||||
// import apiClient from './client'; // TODO: 后端 API 就绪后启用
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 模板模式(后端枚举值) */
|
||||
export type TemplateMode = 'pip' | 'voice_over' | 'one_take' | 'voice_pip';
|
||||
|
||||
/** 模式显示名称映射 */
|
||||
export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
pip: '画中画',
|
||||
voice_over: '人物口播',
|
||||
one_take: '一镜到底',
|
||||
voice_pip: '口播+混剪',
|
||||
};
|
||||
|
||||
/** 模式颜色映射 */
|
||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
pip: 'blue',
|
||||
voice_over: 'green',
|
||||
one_take: 'orange',
|
||||
voice_pip: 'purple',
|
||||
};
|
||||
|
||||
/** 标题配置 */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean;
|
||||
content: string;
|
||||
font_preset: string;
|
||||
font_color: string;
|
||||
font_size: number;
|
||||
position: string;
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
color: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
}
|
||||
|
||||
/** 模板片段 */
|
||||
export interface TemplateSegment {
|
||||
id: string;
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string | null; // 仅 口播+混剪 模式:人物/场景
|
||||
}
|
||||
|
||||
/** 剪辑模板 */
|
||||
export interface EditingTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: TemplateMode;
|
||||
category: string;
|
||||
tags: string[];
|
||||
title_config: TitleConfig;
|
||||
subtitle_config: SubtitleConfig;
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: TemplateSegment[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 模板分类 */
|
||||
export interface TemplateCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 创建/更新模板请求体 */
|
||||
export interface SaveTemplatePayload {
|
||||
name: string;
|
||||
mode: TemplateMode;
|
||||
category: string;
|
||||
tags: string[];
|
||||
title_config: TitleConfig;
|
||||
subtitle_config: SubtitleConfig;
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: Omit<TemplateSegment, 'id'>[];
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
export interface GenerateFromTemplatePayload {
|
||||
voiceover_duration: number;
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
task_id: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
/* ──────────── Mock 数据 ──────────── */
|
||||
|
||||
let _nextId = 100;
|
||||
const nextId = () => String(++_nextId);
|
||||
|
||||
const MOCK_CATEGORIES: TemplateCategory[] = [
|
||||
{ id: 'cat-1', name: '生活' },
|
||||
{ id: 'cat-2', name: '美食' },
|
||||
{ id: 'cat-3', name: '旅行' },
|
||||
{ id: 'cat-4', name: '知识' },
|
||||
];
|
||||
|
||||
const MOCK_TEMPLATES: EditingTemplate[] = [
|
||||
{
|
||||
id: 'tpl-1',
|
||||
name: '生活 Vlog 模板',
|
||||
mode: 'pip',
|
||||
category: '生活',
|
||||
tags: ['vlog', '日常'],
|
||||
title_config: {
|
||||
ai_auto_select: true,
|
||||
content: '',
|
||||
font_preset: '思源黑体',
|
||||
font_color: '#ffffff',
|
||||
font_size: 32,
|
||||
position: 'top',
|
||||
},
|
||||
subtitle_config: {
|
||||
enabled: true,
|
||||
position: 'bottom',
|
||||
font: '思源黑体',
|
||||
color: '#ffffff',
|
||||
size: 24,
|
||||
animation: 'fade',
|
||||
},
|
||||
bgm_config: { enabled: true, music_id: 'bgm-1' },
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{ id: 'seg-1', segment_order: 1, duration_min: 5, duration_max: 15, material_type: null },
|
||||
{ id: 'seg-2', segment_order: 2, duration_min: 10, duration_max: 20, material_type: null },
|
||||
],
|
||||
created_at: '2026-06-20T10:00:00Z',
|
||||
updated_at: '2026-06-20T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'tpl-2',
|
||||
name: '知识分享口播',
|
||||
mode: 'voice_over',
|
||||
category: '知识',
|
||||
tags: ['口播', '分享'],
|
||||
title_config: {
|
||||
ai_auto_select: false,
|
||||
content: '每日知识分享',
|
||||
font_preset: '站酷快乐体',
|
||||
font_color: '#ffdd00',
|
||||
font_size: 36,
|
||||
position: 'top',
|
||||
},
|
||||
subtitle_config: {
|
||||
enabled: true,
|
||||
position: 'bottom',
|
||||
font: '思源黑体',
|
||||
color: '#ffffff',
|
||||
size: 28,
|
||||
animation: 'typewriter',
|
||||
},
|
||||
bgm_config: { enabled: false, music_id: '' },
|
||||
estimated_duration: 60,
|
||||
segments: [
|
||||
{ id: 'seg-3', segment_order: 1, duration_min: 10, duration_max: 30, material_type: null },
|
||||
{ id: 'seg-4', segment_order: 2, duration_min: 20, duration_max: 40, material_type: null },
|
||||
{ id: 'seg-5', segment_order: 3, duration_min: 10, duration_max: 20, material_type: null },
|
||||
],
|
||||
created_at: '2026-06-21T10:00:00Z',
|
||||
updated_at: '2026-06-21T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'tpl-3',
|
||||
name: '一镜到底展示',
|
||||
mode: 'one_take',
|
||||
category: '生活',
|
||||
tags: ['一镜到底'],
|
||||
title_config: {
|
||||
ai_auto_select: true,
|
||||
content: '',
|
||||
font_preset: '思源黑体',
|
||||
font_color: '#ffffff',
|
||||
font_size: 32,
|
||||
position: 'center',
|
||||
},
|
||||
subtitle_config: { enabled: false, position: 'bottom', font: '思源黑体', color: '#ffffff', size: 24, animation: 'fade' },
|
||||
bgm_config: { enabled: true, music_id: 'bgm-2' },
|
||||
estimated_duration: 15,
|
||||
segments: [
|
||||
{ id: 'seg-6', segment_order: 1, duration_min: 10, duration_max: 20, material_type: null },
|
||||
],
|
||||
created_at: '2026-06-22T10:00:00Z',
|
||||
updated_at: '2026-06-22T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
/* ──────────── Mock API 函数 ──────────── */
|
||||
|
||||
const delay = (ms = 200) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** 获取模板列表 */
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string;
|
||||
tag?: string;
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
await delay();
|
||||
let list = [...MOCK_TEMPLATES];
|
||||
if (params?.category) list = list.filter((t) => t.category === params.category);
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!));
|
||||
return list;
|
||||
};
|
||||
|
||||
/** 获取模板详情 */
|
||||
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
||||
await delay();
|
||||
const tpl = MOCK_TEMPLATES.find((t) => t.id === id);
|
||||
if (!tpl) throw new Error('模板不存在');
|
||||
return { ...tpl };
|
||||
};
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
await delay(300);
|
||||
const now = new Date().toISOString();
|
||||
const tpl: EditingTemplate = {
|
||||
id: nextId(),
|
||||
name: data.name,
|
||||
mode: data.mode,
|
||||
category: data.category,
|
||||
tags: data.tags,
|
||||
title_config: data.title_config,
|
||||
subtitle_config: data.subtitle_config,
|
||||
bgm_config: data.bgm_config,
|
||||
estimated_duration: data.estimated_duration,
|
||||
segments: data.segments.map((s, i) => ({
|
||||
...s,
|
||||
id: nextId(),
|
||||
segment_order: i + 1,
|
||||
})),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
MOCK_TEMPLATES.push(tpl);
|
||||
return tpl;
|
||||
};
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
await delay(300);
|
||||
const idx = MOCK_TEMPLATES.findIndex((t) => t.id === id);
|
||||
if (idx === -1) throw new Error('模板不存在');
|
||||
const updated: EditingTemplate = {
|
||||
...MOCK_TEMPLATES[idx],
|
||||
name: data.name,
|
||||
mode: data.mode,
|
||||
category: data.category,
|
||||
tags: data.tags,
|
||||
title_config: data.title_config,
|
||||
subtitle_config: data.subtitle_config,
|
||||
bgm_config: data.bgm_config,
|
||||
estimated_duration: data.estimated_duration,
|
||||
segments: data.segments.map((s, i) => ({
|
||||
...s,
|
||||
id: nextId(),
|
||||
segment_order: i + 1,
|
||||
})),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
MOCK_TEMPLATES[idx] = updated;
|
||||
return updated;
|
||||
};
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await delay();
|
||||
const idx = MOCK_TEMPLATES.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) MOCK_TEMPLATES.splice(idx, 1);
|
||||
};
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
await delay();
|
||||
return [...MOCK_CATEGORIES];
|
||||
};
|
||||
|
||||
/** 使用模板生成视频 */
|
||||
export const generateFromTemplate = async (
|
||||
_templateId: string,
|
||||
_data: GenerateFromTemplatePayload,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
await delay(500);
|
||||
return {
|
||||
task_id: nextId(),
|
||||
warning: undefined,
|
||||
};
|
||||
};
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
HistoryOutlined,
|
||||
TrophyOutlined,
|
||||
ScanOutlined,
|
||||
EditOutlined,
|
||||
FolderOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
@@ -40,6 +42,8 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ key: 'titles', label: '标题库', path: '/titles', icon: <FileTextOutlined /> },
|
||||
{ key: 'voices', label: '配音库', path: '/voices', icon: <AudioOutlined /> },
|
||||
{ key: 'templates', label: '模板库', path: '/templates', icon: <AppstoreOutlined /> },
|
||||
{ key: 'editing-planner', label: '剪辑编辑器', path: '/editing-planner', icon: <EditOutlined /> },
|
||||
{ key: 'my-templates', label: '我的模板', path: '/my-templates', icon: <FolderOutlined /> },
|
||||
{ key: 'generate', label: '一键生成', path: '/generate', icon: <VideoCameraOutlined /> },
|
||||
{ key: 'history', label: '任务历史', path: '/history', icon: <HistoryOutlined /> },
|
||||
{ key: 'products', label: '成品库', path: '/products', icon: <TrophyOutlined /> },
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
* 剪辑计划编辑器样式
|
||||
* 三栏布局:左侧模板面板 / 中间预览+时间线 / 右侧设置面板
|
||||
* ═══════════════════════════════════════════════════ */
|
||||
|
||||
.ep-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ─── 顶部工具栏 ─── */
|
||||
.ep-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── 三栏主体 ─── */
|
||||
.ep-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── 左侧:模板面板 ─── */
|
||||
.ep-left {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
background: #fafafa;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ep-tpl-card {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.ep-tpl-card-active {
|
||||
border-color: var(--ant-color-primary, #4f46e5) !important;
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
/* ─── 中间:预览 + 时间线 ─── */
|
||||
.ep-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 预览行 */
|
||||
.ep-preview-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.ep-preview-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ep-preview-frame {
|
||||
width: 160px;
|
||||
height: 284px;
|
||||
background: #f5f5f5;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ep-cover-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 时间线 */
|
||||
.ep-timeline {
|
||||
background: #fafafa;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ep-seg-card {
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.ep-seg-card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.ep-seg-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ─── 右侧:设置面板 ─── */
|
||||
.ep-right {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
background: #fafafa;
|
||||
border-left: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ep-settings-group {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ep-settings-group:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ─── 响应式 ─── */
|
||||
@media (max-width: 1200px) {
|
||||
.ep-left {
|
||||
width: 220px;
|
||||
}
|
||||
.ep-right {
|
||||
width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ep-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
.ep-left,
|
||||
.ep-right {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.ep-preview-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 剪辑计划编辑器
|
||||
* 三栏布局:左侧模板面板 / 中间预览+时间线 / 右侧设置面板
|
||||
* 支持 4 种模式切换(画中画 / 人物口播 / 一镜到底 / 口播+混剪)
|
||||
*
|
||||
* P0-2: 读取 URL 参数 ?template=xxx&generate=1
|
||||
* P1-3: 拆分为子组件
|
||||
* P1-4: voiceover_id → voiceover_duration
|
||||
* P1-5: 分类 Input → Select(在 SaveModal 中实现)
|
||||
* P1-6: SaveTemplatePayload 补充 estimated_duration
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './EditingPlanner.css';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Space, message } from 'antd';
|
||||
import {
|
||||
SaveOutlined,
|
||||
VideoCameraOutlined,
|
||||
AppstoreOutlined,
|
||||
UserOutlined,
|
||||
DashboardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
getEditingTemplates,
|
||||
getTemplateCategories,
|
||||
createEditingTemplate,
|
||||
updateEditingTemplate,
|
||||
generateFromTemplate,
|
||||
MODE_LABELS,
|
||||
type EditingTemplate,
|
||||
type TemplateSegment,
|
||||
type TemplateMode,
|
||||
type TitleConfig,
|
||||
type SubtitleConfig,
|
||||
type BgmConfig,
|
||||
} from '@/api/editingPlanner';
|
||||
|
||||
/* ── 子组件 ── */
|
||||
import TemplatePanel from './components/TemplatePanel';
|
||||
import TimelinePanel from './components/TimelinePanel';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import SaveModal from './components/SaveModal';
|
||||
import GenerateModal from './components/GenerateModal';
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
const MODES: { key: TemplateMode; icon: React.ReactNode; desc: string }[] = [
|
||||
{ key: 'pip', icon: <AppstoreOutlined />, desc: '多画面叠加' },
|
||||
{ key: 'voice_over', icon: <UserOutlined />, desc: '人物讲解为主' },
|
||||
{ key: 'one_take', icon: <VideoCameraOutlined />, desc: '连续不中断' },
|
||||
{ key: 'voice_pip', icon: <DashboardOutlined />, desc: '口播搭配混剪素材' },
|
||||
];
|
||||
|
||||
const DEFAULT_TITLE: TitleConfig = {
|
||||
ai_auto_select: true,
|
||||
content: '',
|
||||
font_preset: '思源黑体',
|
||||
font_color: '#ffffff',
|
||||
font_size: 32,
|
||||
position: 'top',
|
||||
};
|
||||
const DEFAULT_SUBTITLE: SubtitleConfig = {
|
||||
enabled: true,
|
||||
position: 'bottom',
|
||||
font: '思源黑体',
|
||||
color: '#ffffff',
|
||||
size: 24,
|
||||
animation: 'fade',
|
||||
};
|
||||
const DEFAULT_BGM: BgmConfig = { enabled: false, music_id: '' };
|
||||
|
||||
/** 计算预估时长 = Σ 片段时长范围中值 */
|
||||
const calcEstimatedDuration = (segs: TemplateSegment[]) =>
|
||||
Math.round(segs.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0));
|
||||
|
||||
let _segId = 0;
|
||||
const newSegId = () => `seg-new-${++_segId}`;
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const EditingPlanner: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
/* ── P0-2: URL 参数 ── */
|
||||
const urlTemplateId = searchParams.get('template');
|
||||
const urlGenerate = searchParams.get('generate');
|
||||
|
||||
/* ── 数据查询 ── */
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
|
||||
const { data: templates = [], isLoading: tplLoading } = useQuery({
|
||||
queryKey: ['editing-templates', filterCategory, searchText],
|
||||
queryFn: () =>
|
||||
getEditingTemplates({
|
||||
category: filterCategory || undefined,
|
||||
tag: searchText || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['template-categories'],
|
||||
queryFn: getTemplateCategories,
|
||||
});
|
||||
|
||||
/* ── 编辑器状态 ── */
|
||||
const [currentMode, setCurrentMode] = useState<TemplateMode>('pip');
|
||||
const [segments, setSegments] = useState<TemplateSegment[]>([
|
||||
{ id: newSegId(), segment_order: 1, duration_min: 5, duration_max: 15, material_type: null },
|
||||
]);
|
||||
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(null);
|
||||
|
||||
const [titleConfig, setTitleConfig] = useState<TitleConfig>({ ...DEFAULT_TITLE });
|
||||
const [subtitleConfig, setSubtitleConfig] = useState<SubtitleConfig>({ ...DEFAULT_SUBTITLE });
|
||||
const [bgmConfig, setBgmConfig] = useState<BgmConfig>({ ...DEFAULT_BGM });
|
||||
|
||||
/* ── UI 状态 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [generateModalOpen, setGenerateModalOpen] = useState(false);
|
||||
const [draftName, setDraftName] = useState('');
|
||||
const [draftCategory, setDraftCategory] = useState('');
|
||||
const [draftTags, setDraftTags] = useState('');
|
||||
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(null);
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
|
||||
/* ── P0-2: 自动加载 URL 指定的模板 ── */
|
||||
useEffect(() => {
|
||||
if (urlTemplateId && templates.length > 0 && !loadedTemplateId) {
|
||||
const tpl = templates.find((t) => t.id === urlTemplateId);
|
||||
if (tpl) {
|
||||
loadTemplate(tpl);
|
||||
// 如果 URL 有 generate=1,自动打开发成弹窗
|
||||
if (urlGenerate === '1') {
|
||||
setGenerateModalOpen(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [urlTemplateId, templates, loadedTemplateId, urlGenerate]);
|
||||
|
||||
/* ── Mutations ── */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createEditingTemplate,
|
||||
onSuccess: () => {
|
||||
message.success('模板已保存');
|
||||
queryClient.invalidateQueries({ queryKey: ['editing-templates'] });
|
||||
setSaveModalOpen(false);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (!err?.__msgShown) message.error('保存失败');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => updateEditingTemplate(id, data),
|
||||
onSuccess: () => {
|
||||
message.success('模板已更新');
|
||||
queryClient.invalidateQueries({ queryKey: ['editing-templates'] });
|
||||
setSaveModalOpen(false);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (!err?.__msgShown) message.error('保存失败');
|
||||
},
|
||||
});
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: ({ templateId, duration }: { templateId: string; duration: number }) =>
|
||||
generateFromTemplate(templateId, { voiceover_duration: duration }),
|
||||
onSuccess: (data) => {
|
||||
const msg = data.warning ? `生成任务已提交(${data.warning})` : '生成任务已提交';
|
||||
message.success(msg);
|
||||
setGenerateModalOpen(false);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (!err?.__msgShown) message.error('生成失败');
|
||||
},
|
||||
});
|
||||
|
||||
const saving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
/* ──────────── 片段操作 ──────────── */
|
||||
|
||||
const addSegment = () => {
|
||||
if (currentMode === 'one_take') return;
|
||||
setSegments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: newSegId(),
|
||||
segment_order: prev.length + 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: currentMode === 'voice_pip' ? '人物' : null,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeSegment = (id: string) => {
|
||||
if (currentMode === 'one_take') return;
|
||||
setSegments((prev) =>
|
||||
prev.filter((s) => s.id !== id).map((s, i) => ({ ...s, segment_order: i + 1 })),
|
||||
);
|
||||
};
|
||||
|
||||
const updateSegment = (id: string, patch: Partial<TemplateSegment>) => {
|
||||
setSegments((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s)));
|
||||
};
|
||||
|
||||
const handleDragStart = (idx: number) => setDragIdx(idx);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault();
|
||||
if (dragIdx === null || dragIdx === idx) return;
|
||||
setSegments((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(dragIdx, 1);
|
||||
next.splice(idx, 0, moved);
|
||||
return next.map((s, i) => ({ ...s, segment_order: i + 1 }));
|
||||
});
|
||||
setDragIdx(idx);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => setDragIdx(null);
|
||||
|
||||
/* ──────────── 模式切换 ──────────── */
|
||||
|
||||
const handleModeChange = (mode: TemplateMode) => {
|
||||
setCurrentMode(mode);
|
||||
if (mode === 'one_take') {
|
||||
// 锁定为 1 个片段
|
||||
setSegments([
|
||||
{
|
||||
id: newSegId(),
|
||||
segment_order: 1,
|
||||
duration_min: 10,
|
||||
duration_max: 20,
|
||||
material_type: null,
|
||||
},
|
||||
]);
|
||||
} else if (mode === 'voice_pip') {
|
||||
// 确保每个片段有 material_type
|
||||
setSegments((prev) =>
|
||||
prev.map((s) => ({
|
||||
...s,
|
||||
material_type: s.material_type || '人物',
|
||||
})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/* ──────────── 模板操作 ──────────── */
|
||||
|
||||
const loadTemplate = (tpl: EditingTemplate) => {
|
||||
setLoadedTemplateId(tpl.id);
|
||||
setCurrentMode(tpl.mode);
|
||||
setSegments(tpl.segments.map((s) => ({ ...s })));
|
||||
setTitleConfig({ ...tpl.title_config });
|
||||
setSubtitleConfig({ ...tpl.subtitle_config });
|
||||
setBgmConfig({ ...tpl.bgm_config });
|
||||
};
|
||||
|
||||
const resetEditor = () => {
|
||||
setLoadedTemplateId(null);
|
||||
setCurrentMode('pip');
|
||||
setSegments([
|
||||
{ id: newSegId(), segment_order: 1, duration_min: 5, duration_max: 15, material_type: null },
|
||||
]);
|
||||
setTitleConfig({ ...DEFAULT_TITLE });
|
||||
setSubtitleConfig({ ...DEFAULT_SUBTITLE });
|
||||
setBgmConfig({ ...DEFAULT_BGM });
|
||||
};
|
||||
|
||||
const openSaveModal = () => {
|
||||
if (segments.length === 0) {
|
||||
message.warning('请至少添加一个片段');
|
||||
return;
|
||||
}
|
||||
setDraftName(loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.name || '' : '');
|
||||
setDraftCategory(
|
||||
loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.category || '' : '',
|
||||
);
|
||||
setDraftTags(
|
||||
loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.tags.join(', ') || '' : '',
|
||||
);
|
||||
setSaveModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!draftName.trim()) {
|
||||
message.warning('请输入模板名称');
|
||||
return;
|
||||
}
|
||||
const estimatedDuration = calcEstimatedDuration(segments);
|
||||
const payload = {
|
||||
name: draftName.trim(),
|
||||
mode: currentMode,
|
||||
category: draftCategory,
|
||||
tags: draftTags
|
||||
.split(/[,,]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
title_config: titleConfig,
|
||||
subtitle_config: subtitleConfig,
|
||||
bgm_config: bgmConfig,
|
||||
estimated_duration: estimatedDuration,
|
||||
segments: segments.map(({ id: _id, ...rest }) => rest),
|
||||
};
|
||||
|
||||
if (loadedTemplateId) {
|
||||
updateMutation.mutate({ id: loadedTemplateId, data: payload });
|
||||
} else {
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
if (!loadedTemplateId) {
|
||||
message.warning('请先保存模板');
|
||||
return;
|
||||
}
|
||||
setGenerateModalOpen(true);
|
||||
};
|
||||
|
||||
const doGenerate = () => {
|
||||
if (!voiceoverDuration || voiceoverDuration <= 0) {
|
||||
message.warning('请输入配音时长');
|
||||
return;
|
||||
}
|
||||
generateMutation.mutate({ templateId: loadedTemplateId!, duration: voiceoverDuration });
|
||||
};
|
||||
|
||||
const estimatedDuration = calcEstimatedDuration(segments);
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
|
||||
return (
|
||||
<div className="ep-editor">
|
||||
{/* ═══ 顶部工具栏 ═══ */}
|
||||
<div className="ep-toolbar">
|
||||
<Space wrap>
|
||||
{MODES.map((m) => (
|
||||
<Button
|
||||
key={m.key}
|
||||
type={currentMode === m.key ? 'primary' : 'default'}
|
||||
icon={m.icon}
|
||||
onClick={() => handleModeChange(m.key)}
|
||||
>
|
||||
{MODE_LABELS[m.key]}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
<Space>
|
||||
<Button icon={<SaveOutlined />} onClick={openSaveModal}>
|
||||
保存模板
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<VideoCameraOutlined />}
|
||||
onClick={handleGenerate}
|
||||
disabled={!loadedTemplateId}
|
||||
>
|
||||
使用此模板生成
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* ═══ 三栏主体 ═══ */}
|
||||
<div className="ep-body">
|
||||
{/* 左侧:模板面板 */}
|
||||
<TemplatePanel
|
||||
templates={templates}
|
||||
categories={categories}
|
||||
isLoading={tplLoading}
|
||||
searchText={searchText}
|
||||
filterCategory={filterCategory}
|
||||
loadedTemplateId={loadedTemplateId}
|
||||
onSearchChange={setSearchText}
|
||||
onCategoryChange={setFilterCategory}
|
||||
onTemplateSelect={loadTemplate}
|
||||
onNewTemplate={resetEditor}
|
||||
/>
|
||||
|
||||
{/* 中间:预览 + 时间线 */}
|
||||
<TimelinePanel
|
||||
segments={segments}
|
||||
currentMode={currentMode}
|
||||
estimatedDuration={estimatedDuration}
|
||||
onAddSegment={addSegment}
|
||||
onRemoveSegment={removeSegment}
|
||||
onUpdateSegment={updateSegment}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
|
||||
{/* 右侧:设置面板 */}
|
||||
<SettingsPanel
|
||||
titleConfig={titleConfig}
|
||||
subtitleConfig={subtitleConfig}
|
||||
bgmConfig={bgmConfig}
|
||||
onTitleChange={setTitleConfig}
|
||||
onSubtitleChange={setSubtitleConfig}
|
||||
onBgmChange={setBgmConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 保存模板弹窗 */}
|
||||
<SaveModal
|
||||
open={saveModalOpen}
|
||||
loading={saving}
|
||||
isUpdate={!!loadedTemplateId}
|
||||
draftName={draftName}
|
||||
draftCategory={draftCategory}
|
||||
draftTags={draftTags}
|
||||
categories={categories}
|
||||
estimatedDuration={estimatedDuration}
|
||||
onNameChange={setDraftName}
|
||||
onCategoryChange={setDraftCategory}
|
||||
onTagsChange={setDraftTags}
|
||||
onSave={handleSave}
|
||||
onCancel={() => setSaveModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 使用模板生成弹窗 */}
|
||||
<GenerateModal
|
||||
open={generateModalOpen}
|
||||
loading={generateMutation.isPending}
|
||||
voiceoverDuration={voiceoverDuration}
|
||||
estimatedDuration={estimatedDuration}
|
||||
onDurationChange={setVoiceoverDuration}
|
||||
onGenerate={doGenerate}
|
||||
onCancel={() => setGenerateModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditingPlanner;
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 使用模板生成视频弹窗
|
||||
* P1-4: voiceover_id → voiceover_duration (number)
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Modal, InputNumber, Space, Typography } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface GenerateModalProps {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
voiceoverDuration: number | null;
|
||||
estimatedDuration: number;
|
||||
onDurationChange: (v: number | null) => void;
|
||||
onGenerate: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const GenerateModal: React.FC<GenerateModalProps> = ({
|
||||
open,
|
||||
loading,
|
||||
voiceoverDuration,
|
||||
estimatedDuration,
|
||||
onDurationChange,
|
||||
onGenerate,
|
||||
onCancel,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title="使用模板生成视频"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onGenerate}
|
||||
confirmLoading={loading}
|
||||
okText="开始生成"
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
<div>
|
||||
<Text style={{ fontSize: 13 }}>配音时长(秒)*</Text>
|
||||
<InputNumber
|
||||
placeholder="输入配音时长"
|
||||
value={voiceoverDuration}
|
||||
onChange={onDurationChange}
|
||||
min={1}
|
||||
max={600}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
预估时长:~{estimatedDuration}s,配音时长偏差超过 ±30% 时将收到警告
|
||||
</Text>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerateModal;
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 保存/更新模板弹窗
|
||||
* 分类使用 Select 关联后端分类 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;
|
||||
@@ -0,0 +1,70 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
* 我的模板页面样式
|
||||
* ═══════════════════════════════════════════════════ */
|
||||
|
||||
.mt-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mt-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mt-filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mt-content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.mt-card {
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.mt-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.mt-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mt-card-meta {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mt-card-tags {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mt-card-config {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.mt-head {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mt-filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 我的模板页面
|
||||
* 卡片视图展示用户已保存的剪辑模板
|
||||
* 支持搜索、分类筛选、编辑/复制/删除/使用模板生成
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Empty,
|
||||
Spin,
|
||||
Tooltip,
|
||||
message,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
VideoCameraOutlined,
|
||||
AppstoreOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
getEditingTemplates,
|
||||
getTemplateCategories,
|
||||
deleteEditingTemplate,
|
||||
createEditingTemplate,
|
||||
MODE_LABELS,
|
||||
MODE_COLORS,
|
||||
type EditingTemplate,
|
||||
type TemplateMode,
|
||||
} from '@/api/editingPlanner';
|
||||
import './MyTemplates.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const MyTemplates: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
|
||||
/* ── 数据查询 ── */
|
||||
const { data: templates = [], isLoading } = useQuery({
|
||||
queryKey: ['editing-templates', filterCategory, searchText],
|
||||
queryFn: () =>
|
||||
getEditingTemplates({
|
||||
category: filterCategory || undefined,
|
||||
tag: searchText || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['template-categories'],
|
||||
queryFn: getTemplateCategories,
|
||||
});
|
||||
|
||||
/* ── Mutations ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteEditingTemplate,
|
||||
onSuccess: () => {
|
||||
message.success('模板已删除');
|
||||
queryClient.invalidateQueries({ queryKey: ['editing-templates'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (!err?.__msgShown) message.error('删除失败');
|
||||
},
|
||||
});
|
||||
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: (tpl: EditingTemplate) =>
|
||||
createEditingTemplate({
|
||||
name: `${tpl.name}(副本)`,
|
||||
mode: tpl.mode,
|
||||
category: tpl.category,
|
||||
tags: tpl.tags,
|
||||
title_config: tpl.title_config,
|
||||
subtitle_config: tpl.subtitle_config,
|
||||
bgm_config: tpl.bgm_config,
|
||||
estimated_duration: tpl.estimated_duration ?? Math.round(tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0)),
|
||||
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
message.success('模板已复制');
|
||||
queryClient.invalidateQueries({ queryKey: ['editing-templates'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (!err?.__msgShown) message.error('复制失败');
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 操作 ── */
|
||||
const handleEdit = (tpl: EditingTemplate) => {
|
||||
navigate(`/editing-planner?template=${tpl.id}`);
|
||||
};
|
||||
|
||||
const handleGenerate = (tpl: EditingTemplate) => {
|
||||
navigate(`/editing-planner?template=${tpl.id}&generate=1`);
|
||||
};
|
||||
|
||||
const handleCopy = (tpl: EditingTemplate) => {
|
||||
copyMutation.mutate(tpl);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-page">
|
||||
{/* 页面头部 */}
|
||||
<div className="mt-head">
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<AppstoreOutlined style={{ marginRight: 8 }} />
|
||||
我的模板
|
||||
</Title>
|
||||
<Text type="secondary">管理你创建的剪辑模板,快速复用生成视频</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate('/editing-planner')}
|
||||
>
|
||||
新建模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="mt-filters">
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索模板名称或标签..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="按分类筛选"
|
||||
value={filterCategory || undefined}
|
||||
onChange={(v) => setFilterCategory(v || '')}
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
options={categories.map((c) => ({ value: c.name, label: c.name }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模板卡片列表 */}
|
||||
<div className="mt-content">
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<Empty
|
||||
description="还没有模板,点击右上角「新建模板」开始创建"
|
||||
style={{ padding: 80 }}
|
||||
>
|
||||
<Button type="primary" onClick={() => navigate('/editing-planner')}>
|
||||
新建模板
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{templates.map((tpl) => (
|
||||
<Col key={tpl.id} xs={24} sm={12} md={8} lg={6}>
|
||||
<Card
|
||||
className="mt-card"
|
||||
hoverable
|
||||
actions={[
|
||||
<Tooltip title="编辑" key="edit">
|
||||
<EditOutlined onClick={() => handleEdit(tpl)} />
|
||||
</Tooltip>,
|
||||
<Tooltip title="复制" key="copy">
|
||||
<CopyOutlined onClick={() => handleCopy(tpl)} />
|
||||
</Tooltip>,
|
||||
<Tooltip title="使用模板生成" key="generate">
|
||||
<VideoCameraOutlined onClick={() => handleGenerate(tpl)} />
|
||||
</Tooltip>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确定删除此模板?"
|
||||
onConfirm={() => handleDelete(tpl.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<DeleteOutlined style={{ color: '#ff4d4f' }} />
|
||||
</Tooltip>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<div className="mt-card-head">
|
||||
<Text strong ellipsis style={{ fontSize: 15 }}>
|
||||
{tpl.name}
|
||||
</Text>
|
||||
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || 'default'}>{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}</Tag>
|
||||
</div>
|
||||
|
||||
<div className="mt-card-meta">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s
|
||||
</Text>
|
||||
{tpl.category && (
|
||||
<Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="mt-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<Tag key={tag} style={{ fontSize: 11 }}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-card-config">
|
||||
<Space size={4} wrap>
|
||||
{tpl.title_config.ai_auto_select && <Tag color="cyan">AI标题</Tag>}
|
||||
{tpl.subtitle_config.enabled && <Tag color="geekblue">字幕</Tag>}
|
||||
{tpl.bgm_config.enabled && <Tag color="pink">BGM</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyTemplates;
|
||||
@@ -85,6 +85,14 @@ export const router = createBrowserRouter([
|
||||
path: 'products',
|
||||
lazy: () => import('@/pages/products/ProductLibrary').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'editing-planner',
|
||||
lazy: () => import('@/pages/editing-planner/EditingPlanner').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'my-templates',
|
||||
lazy: () => import('@/pages/my-templates/MyTemplates').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'duplication',
|
||||
lazy: () => import('@/pages/duplication/DuplicationUpload').then(m => ({ Component: m.default })),
|
||||
|
||||
Reference in New Issue
Block a user