feat: 生成页面剪辑模式选择 + 智能编排时间轴

- 新增剪辑模式选择器(one-take/pip/voiceover/voice_pip)
- 新增智能编排 API(auto-generate, getEditPlans, deleteEditPlan 等)
- 新增时间轴组件展示编排预览
- 新增 AutoArrangePanel 智能编排面板
- GenerationTaskItem 和 GeneratedVideoItem 新增 editing_mode 字段
- 生成弹窗新增剪辑模式选择 UI
This commit is contained in:
Coze Agent
2026-06-26 21:10:04 +08:00
parent 299f42a6aa
commit a37100af95
4 changed files with 828 additions and 45 deletions
+53 -1
View File
@@ -1,4 +1,9 @@
import apiClient from './client';
/**
* 编辑计划 API
*/
import apiClient from "./client";
export type EditingMode = "one-take" | "pip" | "voiceover" | "voice_pip";
export interface EditTemplateItem {
id: string;
@@ -19,6 +24,8 @@ export interface EditPlanClipItem {
start_time: number;
duration: number;
reason: string;
layer?: "main" | "pip" | "broll";
thumbnail_url?: string;
}
export interface EditPlanItem {
@@ -29,8 +36,11 @@ export interface EditPlanItem {
asset_library_id: string;
title_id: string;
status: string;
editing_mode?: EditingMode;
summary: string;
clips: EditPlanClipItem[];
created_at?: string;
updated_at?: string;
}
export const getEditTemplates = async (projectId: string, workspaceId: string): Promise<EditTemplateItem[]> => {
@@ -47,3 +57,45 @@ export const createEditPlan = async ({ projectId, data }: { projectId: string; d
const response = await apiClient.post(`/projects/${projectId}/edit-plans`, data);
return response.data;
};
/**
* 获取编排计划列表
*/
export const getEditPlans = async (projectId: string): Promise<EditPlanItem[]> => {
const response = await apiClient.get(`/projects/${projectId}/edit-plans/`);
return response.data.items || [];
};
/**
* 获取单个编排计划
*/
export const getEditPlan = async (projectId: string, planId: string): Promise<EditPlanItem> => {
const response = await apiClient.get(`/projects/${projectId}/edit-plans/${planId}`);
return response.data;
};
/**
* 智能编排 - 自动生成编辑计划
*/
export const autoGenerateEditPlan = async (projectId: string, params: {
editing_mode: EditingMode;
target_duration?: number;
}): Promise<EditPlanItem> => {
const response = await apiClient.post(`/projects/${projectId}/edit-plans/auto-generate/`, params);
return response.data;
};
/**
* 删除编辑计划
*/
export const deleteEditPlan = async (projectId: string, planId: string): Promise<void> => {
await apiClient.delete(`/projects/${projectId}/edit-plans/${planId}`);
};
/**
* 更新编辑计划
*/
export const updateEditPlan = async (projectId: string, planId: string, data: Partial<EditPlanItem>): Promise<EditPlanItem> => {
const response = await apiClient.patch(`/projects/${projectId}/edit-plans/${planId}`, data);
return response.data;
};
+15 -6
View File
@@ -1,4 +1,8 @@
import apiClient from './client';
/**
* 视频生成 API
*/
import apiClient from "./client";
import type { EditingMode } from "./editPlans";
export interface GenerationTaskItem {
id: string;
@@ -8,10 +12,13 @@ export interface GenerationTaskItem {
strategy_id?: string | null;
voice_library_id?: string | null;
edit_plan_id?: string | null;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
editing_mode?: EditingMode;
status: "pending" | "running" | "completed" | "failed" | "cancelled";
progress: number;
result_count: number;
error_message?: string | null;
created_at?: string;
updated_at?: string;
}
export interface GeneratedVideoItem {
@@ -28,8 +35,9 @@ export interface GeneratedVideoItem {
height: number;
fps: number;
status?: string;
review_status?: 'pending_review' | 'approved' | 'rejected';
review_status?: "pending_review" | "approved" | "rejected";
generation_params?: Record<string, unknown>;
editing_mode?: EditingMode;
}
export const createGenerationTask = async (data: {
@@ -39,9 +47,10 @@ export const createGenerationTask = async (data: {
strategy_id?: string;
voice_library_id?: string;
edit_plan_id?: string;
editing_mode?: EditingMode;
created_by_user_id?: string;
}): Promise<GenerationTaskItem> => {
const response = await apiClient.post('/generation/tasks', data);
const response = await apiClient.post("/generation/tasks", data);
return response.data;
};
@@ -56,11 +65,11 @@ export const getGenerationResults = async (taskId: string): Promise<GeneratedVid
};
export const getGeneratedVideos = async (projectId: string): Promise<GeneratedVideoItem[]> => {
const response = await apiClient.get('/generated-videos', { params: { project_id: projectId } });
const response = await apiClient.get("/generated-videos", { params: { project_id: projectId } });
return response.data.items;
};
export const updateGeneratedVideoReviewStatus = async (videoId: string, reviewStatus: 'pending_review' | 'approved' | 'rejected'): Promise<GeneratedVideoItem> => {
export const updateGeneratedVideoReviewStatus = async (videoId: string, reviewStatus: "pending_review" | "approved" | "rejected"): Promise<GeneratedVideoItem> => {
const response = await apiClient.patch(`/generated-videos/${videoId}/review`, { review_status: reviewStatus });
return response.data;
};
@@ -1,7 +1,8 @@
/* V21 视频剪辑 */
/* V21 视频剪辑 - 增强版 */
.xx-project-generation {
}
/* 模板网格 */
.xx-template-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -76,3 +77,409 @@
font-size: 12px;
line-height: 1.4;
}
/* 智能编排面板 */
.auto-arrange-panel {
padding: 16px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.panel-header {
margin-bottom: 16px;
}
.panel-header h4 {
margin: 0 0 4px;
font-size: 16px;
color: #1e293b;
}
.panel-header p {
margin: 0;
font-size: 13px;
color: #64748b;
}
/* 模式选择器 */
.mode-selector-section {
margin-bottom: 16px;
}
.mode-selector-section label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #475569;
}
.mode-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
@media (max-width: 900px) {
.mode-cards {
grid-template-columns: repeat(2, 1fr);
}
}
.mode-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
background: white;
border: 2px solid #e2e8f0;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.mode-card:hover {
border-color: #94a3b8;
}
.mode-card.selected {
border-width: 2px;
}
.mode-icon {
font-size: 24px;
margin-bottom: 6px;
}
.mode-label {
font-size: 12px;
font-weight: 600;
color: #334155;
}
/* 时长选择器 */
.duration-selector {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.duration-selector label {
font-size: 13px;
font-weight: 500;
color: #475569;
}
/* 生成按钮 */
.generate-btn {
width: 100%;
height: 40px;
font-size: 14px;
font-weight: 600;
border: none;
margin-bottom: 16px;
}
/* 编排列表 */
.plans-list {
border-top: 1px solid #e2e8f0;
padding-top: 16px;
}
.plans-list h5 {
margin: 0 0 12px;
font-size: 14px;
color: #475569;
}
.plan-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: white;
border: 2px solid #e2e8f0;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 8px;
}
.plan-item:hover {
border-color: #94a3b8;
}
.plan-item.selected {
border-color: #4f46e5;
background: #f5f3ff;
}
.plan-info {
flex: 1;
min-width: 0;
}
.plan-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.plan-summary {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.plan-meta {
display: flex;
gap: 12px;
margin-top: 4px;
font-size: 12px;
color: #64748b;
}
.plan-actions {
display: flex;
gap: 4px;
}
/* 时间轴区域 */
.timeline-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-header h4 {
margin: 0;
font-size: 15px;
color: #1e293b;
}
/* 时间轴容器 */
.timeline-container {
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 16px;
}
.timeline-controls {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-size: 13px;
color: #64748b;
}
.timeline-duration {
font-weight: 600;
color: #334155;
}
.timeline-clips-count {
color: #64748b;
}
.timeline-wrapper {
display: flex;
gap: 12px;
}
.timeline-layers {
flex-shrink: 0;
width: 70px;
}
.layer-label {
height: 28px;
line-height: 28px;
font-size: 11px;
color: #64748b;
text-align: right;
padding-right: 8px;
}
.layer-label.main {
color: #4f46e5;
}
.layer-label.pip {
color: #f97316;
}
.layer-label.broll {
color: #10b981;
}
.timeline-body {
flex: 1;
min-width: 0;
}
/* 时间刻度 */
.timeline-ruler {
position: relative;
height: 24px;
border-bottom: 1px solid #e2e8f0;
margin-bottom: 4px;
}
.timeline-tick {
position: absolute;
top: 0;
transform: translateX(-50%);
}
.timeline-tick::before {
content: "";
position: absolute;
left: 50%;
bottom: 0;
width: 1px;
height: 6px;
background: #cbd5e1;
}
.timeline-tick span {
font-size: 10px;
color: #94a3b8;
white-space: nowrap;
}
/* 轨道 */
.timeline-tracks {
position: relative;
height: 88px;
}
.timeline-track {
height: 28px;
border-bottom: 1px dashed #f1f5f9;
}
.timeline-track:last-child {
border-bottom: none;
}
/* 片段 */
.timeline-clip {
position: absolute;
height: 24px;
top: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 6px;
font-size: 10px;
cursor: pointer;
transition: opacity 0.2s;
overflow: hidden;
min-width: 30px;
}
.timeline-clip:hover {
opacity: 0.85;
}
.timeline-clip-main {
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
color: white;
}
.timeline-clip-pip {
background: linear-gradient(135deg, #f97316 0%, #fb923c 100%);
color: white;
}
.timeline-clip-broll {
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
color: white;
}
.clip-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.clip-duration {
flex-shrink: 0;
margin-left: 4px;
opacity: 0.8;
}
/* 弹窗样式 */
.modal-title {
display: flex;
align-items: center;
gap: 12px;
}
/* 剪辑模式选择 */
.editing-mode-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.editing-mode-group .ant-radio-button-wrapper {
flex: 1;
min-width: 130px;
height: auto !important;
padding: 12px !important;
border-radius: 8px !important;
}
.mode-option {
display: flex;
align-items: flex-start;
gap: 8px;
}
.mode-emoji {
font-size: 20px;
line-height: 1;
}
.mode-text {
display: flex;
flex-direction: column;
}
.mode-name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.mode-desc {
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
/* 选中方案提示 */
.selected-plan-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
font-size: 13px;
color: #166534;
}
@@ -1,33 +1,322 @@
/**
* 视频剪辑/生成页面 - V21 完全对标
* 功能:剪辑模式选择器 + 智能编排时间轴
*/
import React, { useState } from 'react';
import { Select, Modal, Form, message } from 'antd';
import './ProjectGeneration.css';
import React, { useState, useEffect } from "react";
import { Select, Modal, Form, message, Radio, Spin, Tooltip, Button, Empty, Tag } from "antd";
import { AutoGenerateOutlined, PlayCircleOutlined, DeleteOutlined, CheckCircleOutlined } from "ant-design/icons";
import "./ProjectGeneration.css";
import { autoGenerateEditPlan, getEditPlans, deleteEditPlan, EditingMode, EditPlanItem, EditPlanClipItem } from "@/api/editPlans";
const TITLE_OPTIONS = [
{ id: '1', name: '产品介绍', content: '完整介绍产品功能和优势' },
{ id: '2', name: '用户评价', content: '展示用户真实反馈和评分' },
{ id: '3', name: '功能演示', content: '详细演示产品各项功能' },
/**
* 剪辑模式配置
*/
const EDITING_MODE_OPTIONS: {
value: EditingMode;
label: string;
icon: string;
description: string;
color: string;
}[] = [
{
value: "one-take",
label: "一镜到底",
icon: "🎬",
description: "所有素材顺序拼接,适合连续场景",
color: "#4f46e5",
},
{
value: "pip",
label: "画中画",
icon: "🖼️",
description: "主画面 + 角落小窗口,适合对比展示",
color: "#f97316",
},
{
value: "voiceover",
label: "口播",
icon: "🎙️",
description: "人物口播为主,穿插 B-roll",
color: "#10b981",
},
{
value: "voice_pip",
label: "口播+画中画",
icon: "🎙️🖼️",
description: "口播主体 + 画中画叠加",
color: "#8b5cf6",
},
];
const TITLE_OPTIONS = [
{ id: "1", name: "产品介绍", content: "完整介绍产品功能和优势" },
{ id: "2", name: "用户评价", content: "展示用户真实反馈和评分" },
{ id: "3", name: "功能演示", content: "详细演示产品各项功能" },
];
/**
* 时间轴片段组件
*/
const TimelineClip: React.FC<{
clip: EditPlanClipItem;
scale: number;
totalDuration: number;
}> = ({ clip, scale, totalDuration }) => {
const left = (clip.start_time / totalDuration) * 100;
const width = (clip.duration / totalDuration) * 100;
const layerIndex = clip.layer === "pip" ? 1 : clip.layer === "broll" ? 2 : 0;
return (
<Tooltip
title={
<div>
<div><strong>{clip.asset_name}</strong></div>
<div>时长: {clip.duration.toFixed(1)}s</div>
<div>理由: {clip.reason}</div>
</div>
}
>
<div
className={`timeline-clip timeline-clip-${clip.layer || "main"}`}
style={{
left: `${left}%`,
width: `${Math.max(width, 2)}%`,
top: `${layerIndex * 28}px`,
}}
>
<span className="clip-label">{clip.asset_name.substring(0, 8)}</span>
<span className="clip-duration">{clip.duration.toFixed(0)}s</span>
</div>
</Tooltip>
);
};
/**
* 时间轴组件
*/
const Timeline: React.FC<{
clips: EditPlanClipItem[];
totalDuration: number;
}> = ({ clips, totalDuration }) => {
const [scale, setScale] = useState(1);
const tickCount = 10;
const ticks = Array.from({ length: tickCount + 1 }, (_, i) => {
const time = (totalDuration / tickCount) * i;
return { time, position: (time / totalDuration) * 100 };
});
return (
<div className="timeline-container">
<div className="timeline-controls">
<span className="timeline-duration">总时长: {totalDuration.toFixed(1)}s</span>
<span className="timeline-clips-count">{clips.length} 个片段</span>
</div>
<div className="timeline-wrapper">
<div className="timeline-layers">
<div className="layer-label main">主画面</div>
<div className="layer-label pip">画中画</div>
<div className="layer-label broll">B-roll</div>
</div>
<div className="timeline-body">
<div className="timeline-ruler">
{ticks.map((tick, i) => (
<div key={i} className="timeline-tick" style={{ left: `${tick.position}%` }}>
<span>{tick.time.toFixed(0)}s</span>
</div>
))}
</div>
<div className="timeline-tracks">
<div className="timeline-track main-track" />
<div className="timeline-track pip-track" />
<div className="timeline-track broll-track" />
{clips.map((clip) => (
<TimelineClip key={clip.id} clip={clip} scale={scale} totalDuration={totalDuration} />
))}
</div>
</div>
</div>
</div>
);
};
/**
* 智能编排面板
*/
const AutoArrangePanel: React.FC<{
projectId: string;
onSelectPlan: (plan: EditPlanItem) => void;
selectedPlanId?: string;
}> = ({ projectId, onSelectPlan, selectedPlanId }) => {
const [plans, setPlans] = useState<EditPlanItem[]>([]);
const [loading, setLoading] = useState(false);
const [generating, setGenerating] = useState(false);
const [selectedMode, setSelectedMode] = useState<EditingMode>("one-take");
const [targetDuration, setTargetDuration] = useState<number | undefined>(60);
useEffect(() => { fetchPlans(); }, [projectId]);
const fetchPlans = async () => {
setLoading(true);
try {
const data = await getEditPlans(projectId);
setPlans(data);
} catch (error) {
console.error("Failed to fetch edit plans:", error);
} finally {
setLoading(false);
}
};
const handleAutoGenerate = async () => {
setGenerating(true);
try {
const plan = await autoGenerateEditPlan(projectId, { editing_mode: selectedMode, target_duration: targetDuration });
message.success("智能编排完成");
setPlans((prev) => [plan, ...prev]);
onSelectPlan(plan);
} catch (error: any) {
message.error(error?.response?.data?.message || "智能编排失败");
} finally {
setGenerating(false);
}
};
const handleDeletePlan = async (planId: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
await deleteEditPlan(projectId, planId);
setPlans((prev) => prev.filter((p) => p.id !== planId));
message.success("删除成功");
} catch (error) {
message.error("删除失败");
}
};
const getModeLabel = (mode?: EditingMode) => {
if (!mode) return null;
const option = EDITING_MODE_OPTIONS.find((o) => o.value === mode);
return option ? `${option.icon} ${option.label}` : mode;
};
const getModeColor = (mode?: EditingMode) => {
if (!mode) return "#64748b";
const option = EDITING_MODE_OPTIONS.find((o) => o.value === mode);
return option?.color || "#64748b";
};
return (
<div className="auto-arrange-panel">
<div className="panel-header">
<h4>🪄 智能编排</h4>
<p>AI 自动分析素材并生成剪辑方案</p>
</div>
<div className="mode-selector-section">
<label>选择剪辑模式:</label>
<div className="mode-cards">
{EDITING_MODE_OPTIONS.map((mode) => (
<div
key={mode.value}
className={`mode-card ${selectedMode === mode.value ? "selected" : ""}`}
onClick={() => setSelectedMode(mode.value)}
style={{
borderColor: selectedMode === mode.value ? mode.color : undefined,
background: selectedMode === mode.value ? `${mode.color}10` : undefined,
}}
>
<span className="mode-icon">{mode.icon}</span>
<span className="mode-label">{mode.label}</span>
</div>
))}
</div>
</div>
<div className="duration-selector">
<label>目标时长:</label>
<Select value={targetDuration} onChange={setTargetDuration} style={{ width: 120 }}>
<Select.Option value={15}>15秒</Select.Option>
<Select.Option value={30}>30秒</Select.Option>
<Select.Option value={60}>60秒</Select.Option>
<Select.Option value={90}>90秒</Select.Option>
<Select.Option value={120}>120秒</Select.Option>
</Select>
</div>
<Button
type="primary"
icon={<AutoGenerateOutlined />}
loading={generating}
onClick={handleAutoGenerate}
className="generate-btn"
style={{ background: EDITING_MODE_OPTIONS.find((m) => m.value === selectedMode)?.color }}
>
智能编排生成
</Button>
<div className="plans-list">
<h5>编排方案列表</h5>
{loading ? (
<Spin size="small" />
) : plans.length === 0 ? (
<Empty description="暂无编排方案" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
plans.map((plan) => (
<div key={plan.id} className={`plan-item ${selectedPlanId === plan.id ? "selected" : ""}`} onClick={() => onSelectPlan(plan)}>
<div className="plan-info">
<div className="plan-header">
<span className="plan-summary">{plan.summary || "未命名方案"}</span>
<Tag color={getModeColor(plan.editing_mode)} style={{ marginLeft: 8 }}>{getModeLabel(plan.editing_mode)}</Tag>
</div>
<div className="plan-meta">
<span>📎 {plan.clips.length} 个片段</span>
<span>⏱️ {plan.clips.reduce((sum, c) => sum + c.duration, 0).toFixed(1)}s</span>
{plan.created_at && <span>{new Date(plan.created_at).toLocaleDateString()}</span>}
</div>
</div>
<div className="plan-actions">
<Tooltip title="使用此方案生成">
<Button type="text" icon={<PlayCircleOutlined />} onClick={(e) => { e.stopPropagation(); onSelectPlan(plan); }} />
</Tooltip>
<Tooltip title="删除方案">
<Button type="text" danger icon={<DeleteOutlined />} onClick={(e) => handleDeletePlan(plan.id, e)} />
</Tooltip>
</div>
</div>
))
)}
</div>
</div>
);
};
const ProjectGeneration: React.FC = () => {
const [selectedTitle, setSelectedTitle] = useState<string>('');
const [selectedTitle, setSelectedTitle] = useState<string>("");
const [generateOpen, setGenerateOpen] = useState(false);
const [editingMode, setEditingMode] = useState<EditingMode>("one-take");
const [selectedPlan, setSelectedPlan] = useState<EditPlanItem | null>(null);
const [form] = Form.useForm();
const projectId = "demo-project-id";
const handleGenerate = async () => {
await form.validateFields();
try {
message.success('成片生成任务已创建');
const values = form.getFieldsValue();
console.log("生成任务参数:", { ...values, editing_mode: editingMode, edit_plan_id: selectedPlan?.id });
message.success("成片生成任务已创建");
setGenerateOpen(false);
form.resetFields();
setSelectedTitle('');
setSelectedTitle("");
setSelectedPlan(null);
} catch (error: any) {
message.error('创建生成任务失败');
message.error("创建生成任务失败");
}
};
const handlePlanSelect = (plan: EditPlanItem) => {
setSelectedPlan(plan);
setEditingMode(plan.editing_mode || "one-take");
};
const getModeOption = (mode: EditingMode) => EDITING_MODE_OPTIONS.find((o) => o.value === mode);
return (
<div className="xx-project-generation">
<div className="xx-page-head">
@@ -35,9 +324,7 @@ const ProjectGeneration: React.FC = () => {
<h2>视频剪辑</h2>
<p>生成您的成片</p>
</div>
<button className="btn primary" onClick={() => setGenerateOpen(true)}>
+ 开始生成
</button>
<button className="btn primary" onClick={() => setGenerateOpen(true)}>+ 开始生成</button>
</div>
<div className="xx-card">
@@ -45,20 +332,14 @@ const ProjectGeneration: React.FC = () => {
<div className="xx-template-grid">
{TITLE_OPTIONS.map((title, idx) => {
const colors = [
'linear-gradient(135deg,#4f46e5,#818cf8)',
'linear-gradient(135deg,#f97316,#ef4444)',
'linear-gradient(135deg,#10b981,#0284c7)',
'linear-gradient(135deg,#64748b,#334155)',
"linear-gradient(135deg,#4f46e5,#818cf8)",
"linear-gradient(135deg,#f97316,#ef4444)",
"linear-gradient(135deg,#10b981,#0284c7)",
"linear-gradient(135deg,#64748b,#334155)",
];
return (
<div
key={title.id}
className={`xx-choice ${selectedTitle === title.id ? 'selected' : ''}`}
onClick={() => setSelectedTitle(title.id)}
>
<div className="cover" style={{ background: colors[idx % 4] }}>
📹
</div>
<div key={title.id} className={`xx-choice ${selectedTitle === title.id ? "selected" : ""}`} onClick={() => setSelectedTitle(title.id)}>
<div className="cover" style={{ background: colors[idx % 4] }}>📹</div>
{selectedTitle === title.id && <div className="check">✓</div>}
<b>{title.name}</b>
<p>{title.content.substring(0, 50)}...</p>
@@ -68,37 +349,71 @@ const ProjectGeneration: React.FC = () => {
</div>
</div>
<div className="xx-card">
<AutoArrangePanel projectId={projectId} onSelectPlan={handlePlanSelect} selectedPlanId={selectedPlan?.id} />
{selectedPlan && selectedPlan.clips.length > 0 && (
<div className="timeline-section">
<div className="section-header">
<h4>📺 编排预览</h4>
<Button type="primary" icon={<PlayCircleOutlined />} onClick={() => setGenerateOpen(true)}>使用此方案生成</Button>
</div>
<Timeline clips={selectedPlan.clips} totalDuration={selectedPlan.clips.reduce((sum, c) => sum + c.duration, 0)} />
</div>
)}
</div>
<Modal
title="生成新成片"
title={
<div className="modal-title">
<span>生成新成片</span>
{editingMode && <Tag color={getModeOption(editingMode)?.color}>{getModeOption(editingMode)?.icon} {getModeOption(editingMode)?.label}</Tag>}
</div>
}
open={generateOpen}
okText="开始生成"
cancelText="取消"
onOk={handleGenerate}
onCancel={() => setGenerateOpen(false)}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item
name="template_id"
label="选择模板"
rules={[{ required: true, message: '请选择模板' }]}
>
<Form.Item label="剪辑模式" required>
<Radio.Group value={editingMode} onChange={(e) => setEditingMode(e.target.value)} className="editing-mode-group">
{EDITING_MODE_OPTIONS.map((mode) => (
<Radio.Button key={mode.value} value={mode.value} className="editing-mode-btn">
<div className="mode-option">
<span className="mode-emoji">{mode.icon}</span>
<div className="mode-text">
<span className="mode-name">{mode.label}</span>
<span className="mode-desc">{mode.description}</span>
</div>
</div>
</Radio.Button>
))}
</Radio.Group>
</Form.Item>
<Form.Item name="template_id" label="选择模板" rules={[{ required: true, message: "请选择模板" }]}>
<Select placeholder="选择剪辑模板">
<Select.Option value="basic">基础模板</Select.Option>
<Select.Option value="product">产品介绍</Select.Option>
<Select.Option value="lifestyle">生活方式</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="duration"
label="视频时长"
rules={[{ required: true, message: '请选择时长' }]}
>
<Form.Item name="duration" label="视频时长" rules={[{ required: true, message: "请选择时长" }]}>
<Select placeholder="选择时长">
<Select.Option value="15">15秒</Select.Option>
<Select.Option value="30">30秒</Select.Option>
<Select.Option value="60">60秒</Select.Option>
</Select>
</Form.Item>
{selectedPlan && (
<Form.Item>
<div className="selected-plan-hint">
<CheckCircleOutlined style={{ color: "#52c41a" }} />
<span>已选择编排方案: <strong>{selectedPlan.summary || "未命名方案"}</strong> ({selectedPlan.clips.length} 个片段)</span>
</div>
</Form.Item>
)}
</Form>
</Modal>
</div>