feat(task-2.14): 剪辑计划编辑器完整版 — 三栏布局 + 拖拽 + 素材面板 #145

Merged
xiaoxia merged 2 commits from feature/task-2.14-editing-planner-full into develop 2026-07-01 16:15:34 +08:00
9 changed files with 2733 additions and 578 deletions
+6
View File
@@ -6,6 +6,7 @@ from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.dashboard import router as dashboard_router
from app.api.routes.duplication import router as duplication_router
from app.api.routes.edit_plans import router as edit_plans_router
from app.api.routes.edit_templates import router as edit_templates_router
from app.api.routes.generated_videos import router as generated_videos_router
from app.api.routes.generation_tasks import router as generation_tasks_router
@@ -122,3 +123,8 @@ api_router.include_router(
prefix="/edit-templates",
tags=["EditTemplate"],
)
api_router.include_router(
edit_plans_router,
prefix="/edit-plans",
tags=["EditPlan"],
)
+304
View File
@@ -0,0 +1,304 @@
"""剪辑计划管理 API — Phase 8 模板编排引擎.
RESTful CRUD for EditPlan:
- GET /api/v1/edit-plans 列表(分页 + 状态/模板筛选)
- GET /api/v1/edit-plans/{id} 详情
- POST /api/v1/edit-plans 创建
- PUT /api/v1/edit-plans/{id} 更新(含状态机流转)
- DELETE /api/v1/edit-plans/{id} 删除
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import SQLAlchemyEditPlanRepository
from packages.domain.edit_plan import EditPlan, EditPlanStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
class EditPlanCreateRequest(BaseModel):
"""创建剪辑计划请求体"""
template_id: str = Field(..., min_length=1, max_length=32, description="关联模板 ID")
name: str = Field(..., min_length=1, max_length=200, description="计划名称")
config: dict[str, Any] = Field(default_factory=dict, description="计划配置 (JSON)")
total_duration: float = Field(default=0.0, ge=0.0, description="总时长 (秒)")
class EditPlanUpdateRequest(BaseModel):
"""更新剪辑计划请求体"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="计划名称")
config: Optional[dict[str, Any]] = Field(default=None, description="计划配置 (JSON)")
total_duration: Optional[float] = Field(default=None, ge=0.0, description="总时长 (秒)")
status: Optional[str] = Field(
default=None,
description="目标状态 (通过状态机流转): editing / rendering / completed / failed / draft",
)
class EditPlanResponse(BaseModel):
"""剪辑计划响应体"""
id: str
template_id: str
name: str
status: str
total_duration: float
config: dict[str, Any]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class EditPlanListResponse(BaseModel):
"""剪辑计划列表响应体"""
items: List[EditPlanResponse]
total: int
page: int
page_size: int
# ── Helpers ───────────────────────────────────────────────────────────────────
def _to_response(p: EditPlan) -> EditPlanResponse:
return EditPlanResponse(
id=p.id,
template_id=p.template_id,
name=p.name,
status=p.status.value if hasattr(p.status, "value") else p.status,
total_duration=p.total_duration,
config=p.config,
created_at=p.created_at,
updated_at=p.updated_at,
)
def _apply_status_transition(plan: EditPlan, target_status_str: str) -> None:
"""通过状态机方法流转状态,非法流转抛出 ValueError"""
try:
target = EditPlanStatus(target_status_str)
except ValueError:
raise ValueError(
f"无效的状态值: {target_status_str}"
f"可选值: draft, editing, rendering, completed, failed"
)
if target == plan.status:
return # 已是目标状态,无需流转
# 根据目标状态选择对应的状态机方法
transition_map = {
EditPlanStatus.EDITING: plan.start_editing,
EditPlanStatus.RENDERING: plan.start_rendering,
EditPlanStatus.COMPLETED: plan.mark_completed,
EditPlanStatus.FAILED: plan.mark_failed,
EditPlanStatus.DRAFT: plan.reset_to_draft,
}
transition_map[target]()
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanListResponse)
def list_plans(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
template_id: Optional[str] = Query(default=None, description="按模板 ID 筛选"),
status_filter: Optional[str] = Query(
default=None,
alias="status",
description="按状态筛选: draft / editing / rendering / completed / failed",
),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanListResponse:
"""获取剪辑计划列表(支持分页、按模板/状态筛选)"""
repo = SQLAlchemyEditPlanRepository(db)
# 解析状态筛选
status_enum: Optional[EditPlanStatus] = None
if status_filter:
try:
status_enum = EditPlanStatus(status_filter)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"无效的状态值: {status_filter}"
f"可选值: draft, editing, rendering, completed, failed"
),
)
skip = (page - 1) * page_size
# 根据是否有 template_id 选择查询方法
if template_id:
plans = repo.list_by_template(
template_id,
status=status_enum,
skip=skip,
limit=page_size,
)
# count() 不支持 template_id 筛选,通过全量查询计算 total
all_matching = repo.list_by_template(
template_id,
status=status_enum,
skip=0,
limit=10000,
)
total = len(all_matching)
else:
plans = repo.list_all(status=status_enum, skip=skip, limit=page_size)
total = repo.count(status=status_enum)
return EditPlanListResponse(
items=[_to_response(p) for p in plans],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{plan_id}", response_model=EditPlanResponse)
def get_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanResponse:
"""获取单个剪辑计划详情"""
repo = SQLAlchemyEditPlanRepository(db)
plan = repo.get(plan_id)
if plan is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
return _to_response(plan)
@router.post("", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED)
def create_plan(
body: EditPlanCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanResponse:
"""创建剪辑计划"""
repo = SQLAlchemyEditPlanRepository(db)
try:
plan = EditPlan.create(
template_id=body.template_id,
name=body.name,
config=body.config,
total_duration=body.total_duration,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
created = repo.create(plan)
logger.info(
"创建剪辑计划: id=%s name=%s by user=%s",
created.id,
created.name,
current_user.user.id,
)
return _to_response(created)
@router.put("/{plan_id}", response_model=EditPlanResponse)
def update_plan(
plan_id: str,
body: EditPlanUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanResponse:
"""更新剪辑计划(支持状态机流转)"""
repo = SQLAlchemyEditPlanRepository(db)
existing = repo.get(plan_id)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
# 基础字段更新
new_name = body.name.strip() if body.name is not None else existing.name
new_config = body.config if body.config is not None else existing.config
new_total_duration = body.total_duration if body.total_duration is not None else existing.total_duration
# 状态机流转
new_status = existing.status
if body.status is not None:
try:
_apply_status_transition(existing, body.status)
new_status = existing.status
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
try:
updated = EditPlan(
id=existing.id,
template_id=existing.template_id,
name=new_name,
status=new_status,
total_duration=new_total_duration,
config=new_config,
created_at=existing.created_at,
updated_at=existing.updated_at,
)
except (ValueError, TypeError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
result = repo.update(updated)
logger.info("更新剪辑计划: id=%s by user=%s", plan_id, current_user.user.id)
return _to_response(result)
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> None:
"""删除剪辑计划"""
repo = SQLAlchemyEditPlanRepository(db)
existing = repo.get(plan_id)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
repo.delete(plan_id)
logger.info(
"删除剪辑计划: id=%s by user=%s",
plan_id,
current_user.user.id,
)
+336
View File
@@ -0,0 +1,336 @@
/**
* 剪辑计划 API — Mock 数据 + 预留接口
* 任务 2.14:对接后端 GET/POST/PUT /api/v1/edit-plans
* 当前使用 Mock 数据,后续替换为真实 API 调用
*/
import type { TemplateMode } from "./editingPlanner";
/* ============================================================
* 类型定义
* ============================================================ */
/** 剪辑计划中的片段 */
export interface EditPlanClip {
id: string;
template_segment_id: string;
/** 素材库中的素材 ID */
media_asset_id?: string;
/** 素材类型 */
material_type: "video" | "image" | "audio" | "voiceover";
/** 片段文案 */
script_text: string;
/** 实际时长(秒) */
duration: number;
/** 转场效果 */
transition?: TransitionEffect;
/** 排序 */
order: number;
}
/** 转场效果 */
export interface TransitionEffect {
type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide";
duration: number; // 转场时长(秒)
}
/** 剪辑计划 */
export interface EditPlan {
id: string;
name: string;
template_id?: string;
mode: TemplateMode;
clips: EditPlanClip[];
/** 总预估时长 */
total_duration: number;
/** 状态 */
status: "draft" | "ready" | "generating" | "completed" | "failed";
created_at: string;
updated_at: string;
}
/** 创建剪辑计划请求 */
export interface CreateEditPlanRequest {
name: string;
template_id?: string;
mode: TemplateMode;
clips: Omit<EditPlanClip, "id">[];
}
/** 更新剪辑计划请求 */
export interface UpdateEditPlanRequest {
name?: string;
clips?: EditPlanClip[];
status?: EditPlan["status"];
}
/** 素材库资产 */
export interface MediaAsset {
id: string;
name: string;
type: "video" | "image" | "audio";
/** 缩略图 URL */
thumbnail_url?: string;
/** 时长(秒),仅 video/audio */
duration?: number;
/** 文件大小(字节) */
size?: number;
/** 标签 */
tags: string[];
created_at: string;
}
/* ============================================================
* Mock 数据
* ============================================================ */
const MOCK_ASSETS: MediaAsset[] = [
{
id: "asset-001",
name: "产品展示-正面.mp4",
type: "video",
duration: 15,
size: 12_500_000,
tags: ["产品", "展示"],
created_at: "2026-06-20T10:00:00Z",
},
{
id: "asset-002",
name: "使用教程-片段A.mp4",
type: "video",
duration: 20,
size: 18_000_000,
tags: ["教程", "使用"],
created_at: "2026-06-21T14:00:00Z",
},
{
id: "asset-003",
name: "背景音乐-轻快.mp3",
type: "audio",
duration: 120,
size: 3_200_000,
tags: ["BGM", "轻快"],
created_at: "2026-06-18T09:00:00Z",
},
{
id: "asset-004",
name: "封面图-主图.jpg",
type: "image",
size: 850_000,
tags: ["封面", "主图"],
created_at: "2026-06-22T11:00:00Z",
},
{
id: "asset-005",
name: "细节特写-侧面.mp4",
type: "video",
duration: 10,
size: 8_500_000,
tags: ["产品", "细节"],
created_at: "2026-06-23T16:00:00Z",
},
{
id: "asset-006",
name: "开箱视频-片段.mp4",
type: "video",
duration: 25,
size: 22_000_000,
tags: ["开箱", "展示"],
created_at: "2026-06-19T08:00:00Z",
},
{
id: "asset-007",
name: "配音-产品介绍.wav",
type: "audio",
duration: 45,
size: 5_600_000,
tags: ["配音", "产品"],
created_at: "2026-06-24T10:00:00Z",
},
{
id: "asset-008",
name: "场景图-生活场景.jpg",
type: "image",
size: 1_200_000,
tags: ["场景", "生活"],
created_at: "2026-06-25T13:00:00Z",
},
];
let mockPlans: EditPlan[] = [
{
id: "plan-001",
name: "产品展示视频 v1",
template_id: "tpl-001",
mode: "pip",
clips: [
{
id: "clip-001",
template_segment_id: "seg-001",
media_asset_id: "asset-001",
material_type: "video",
script_text: "大家好,今天给大家带来一款超值好物!",
duration: 10,
transition: { type: "fade", duration: 0.5 },
order: 0,
},
{
id: "clip-002",
template_segment_id: "seg-002",
media_asset_id: "asset-005",
material_type: "video",
script_text: "来看看这个细节做工,真的绝了",
duration: 8,
transition: { type: "dissolve", duration: 0.3 },
order: 1,
},
{
id: "clip-003",
template_segment_id: "seg-003",
material_type: "image",
script_text: "多种颜色可选,总有一款适合你",
duration: 6,
transition: { type: "none", duration: 0 },
order: 2,
},
],
total_duration: 24,
status: "draft",
created_at: "2026-06-25T10:00:00Z",
updated_at: "2026-06-25T15:00:00Z",
},
];
/* ============================================================
* Mock 延迟
* ============================================================ */
const delay = (ms = 300) => new Promise((r) => setTimeout(r, ms));
/* ============================================================
* API 函数(Mock 实现,后续替换为真实 API)
* ============================================================ */
/** 获取剪辑计划列表 */
export async function getEditPlans(): Promise<EditPlan[]> {
// TODO: 替换为 apiClient.get('/edit-plans')
await delay();
return [...mockPlans];
}
/** 获取单个剪辑计划 */
export async function getEditPlan(id: string): Promise<EditPlan> {
// TODO: 替换为 apiClient.get(`/edit-plans/${id}`)
await delay();
const plan = mockPlans.find((p) => p.id === id);
if (!plan) throw new Error(`剪辑计划 ${id} 不存在`);
return { ...plan };
}
/** 创建剪辑计划 */
export async function createEditPlan(
data: CreateEditPlanRequest
): Promise<EditPlan> {
// TODO: 替换为 apiClient.post('/edit-plans', data)
await delay();
const now = new Date().toISOString();
const plan: EditPlan = {
id: `plan-${Date.now()}`,
name: data.name,
template_id: data.template_id,
mode: data.mode,
clips: data.clips.map((c, i) => ({
...c,
id: `clip-${Date.now()}-${i}`,
})),
total_duration: data.clips.reduce((sum, c) => sum + c.duration, 0),
status: "draft",
created_at: now,
updated_at: now,
};
mockPlans = [plan, ...mockPlans];
return plan;
}
/** 更新剪辑计划 */
export async function updateEditPlan(
id: string,
data: UpdateEditPlanRequest
): Promise<EditPlan> {
// TODO: 替换为 apiClient.put(`/edit-plans/${id}`, data)
await delay();
const idx = mockPlans.findIndex((p) => p.id === id);
if (idx === -1) throw new Error(`剪辑计划 ${id} 不存在`);
mockPlans[idx] = {
...mockPlans[idx],
...data,
total_duration:
data.clips?.reduce((sum, c) => sum + c.duration, 0) ??
mockPlans[idx].total_duration,
updated_at: new Date().toISOString(),
};
return { ...mockPlans[idx] };
}
/** 删除剪辑计划 */
export async function deleteEditPlan(id: string): Promise<void> {
// TODO: 替换为 apiClient.delete(`/edit-plans/${id}`)
await delay();
mockPlans = mockPlans.filter((p) => p.id !== id);
}
/** 获取素材库列表 */
export async function getMediaAssets(): Promise<MediaAsset[]> {
// TODO: 替换为 apiClient.get('/media-assets')
await delay();
return [...MOCK_ASSETS];
}
/** 获取单个素材 */
export async function getMediaAsset(id: string): Promise<MediaAsset> {
// TODO: 替换为 apiClient.get(`/media-assets/${id}`)
await delay();
const asset = MOCK_ASSETS.find((a) => a.id === id);
if (!asset) throw new Error(`素材 ${id} 不存在`);
return { ...asset };
}
/* ============================================================
* 常量
* ============================================================ */
/** 转场效果选项 */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"];
label: string;
}[] = [
{ value: "none", label: "无转场" },
{ value: "fade", label: "淡入淡出" },
{ value: "dissolve", label: "溶解" },
{ value: "wipe", label: "擦除" },
{ value: "zoom", label: "缩放" },
{ value: "slide", label: "滑动" },
];
/** 素材类型标签 */
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
};
/** 素材类型图标 */
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
};
/** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlan["status"], string> = {
draft: "草稿",
ready: "就绪",
generating: "生成中",
completed: "已完成",
failed: "失败",
};
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,7 @@
/**
* 剪辑计划编辑器 — V21 设计系统
* 三栏布局:左侧模板面板 / 中间预览+时间线 / 右侧设置面板
* 支持 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
* 剪辑计划编辑器 — V21 设计系统(完整版)
* 任务 2.14:三栏布局 — 素材面板 / 时间线 / 片段属性
* 支持拖拽排序、素材关联、转场编辑、剪辑计划 CRUD
*/
import React, { useState, useEffect, useCallback } from "react";
import "./EditingPlanner.css";
@@ -22,53 +16,28 @@ import {
generateFromTemplate,
MODE_LABELS,
type EditingTemplate,
type TemplateSegment,
type TemplateMode,
type TitleConfig,
type SubtitleConfig,
type BgmConfig,
type SaveTemplatePayload,
} from "@/api/editingPlanner";
import {
getMediaAssets,
createEditPlan,
updateEditPlan,
type EditPlanClip,
type MediaAsset,
} from "@/api/editPlans";
/* ── 子组件 ── */
import TemplatePanel from "./components/TemplatePanel";
import MediaPanel from "./components/MediaPanel";
import TimelinePanel from "./components/TimelinePanel";
import SettingsPanel from "./components/SettingsPanel";
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
import SaveModal from "./components/SaveModal";
import GenerateModal from "./components/GenerateModal";
/* ──────────── 常量 ──────────── */
const MODES: { key: TemplateMode; icon: string; desc: string }[] = [
{ key: "pip", icon: "🖼️", desc: "多画面叠加" },
{ key: "voice_over", icon: "🎙️", desc: "人物讲解为主" },
{ key: "one_take", icon: "🎬", desc: "连续不中断" },
{ key: "voice_pip", icon: "🎞️", desc: "口播搭配混剪素材" },
];
const DEFAULT_TITLE: TitleConfig = {
ai_auto_select: true,
content: "",
font_preset: "思源黑体",
font_color: "#ffffff",
font_size: 32,
position: "top",
};
const DEFAULT_SUBTITLE: SubtitleConfig = {
enabled: true,
position: "bottom",
font: "思源黑体",
color: "#ffffff",
size: 24,
animation: "fade",
};
const DEFAULT_BGM: BgmConfig = { enabled: false, music_id: "" };
/** 计算预估时长 = Σ 片段时长范围中值 */
const calcEstimatedDuration = (segs: TemplateSegment[]) =>
Math.round(
segs.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
);
let _clipId = 0;
const newClipId = () => `clip-new-${++_clipId}`;
let _segId = 0;
const newSegId = () => `seg-new-${++_segId}`;
@@ -101,21 +70,16 @@ const EditingPlanner: React.FC = () => {
const [searchParams] = useSearchParams();
const { show: showToast, ToastNode } = useToast();
/* ── P0-2: URL 参数 ── */
/* ── 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: templates = [], isLoading: tplLoading } = useQuery<
EditingTemplate[]
>({
queryKey: ["editing-templates"],
queryFn: () => getEditingTemplates(),
});
const { data: categories = [] } = useQuery({
@@ -123,26 +87,28 @@ const EditingPlanner: React.FC = () => {
queryFn: getTemplateCategories,
});
/* ── 数据查询:素材库 ── */
const { data: assets = [], isLoading: assetsLoading } = useQuery({
queryKey: ["media-assets"],
queryFn: getMediaAssets,
});
/* ── 编辑器状态 ── */
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip");
const [segments, setSegments] = useState<TemplateSegment[]>([
const [clips, setClips] = useState<EditPlanClip[]>([
{
id: newSegId(),
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: null,
id: newClipId(),
template_segment_id: newSegId(),
material_type: "video",
script_text: "",
duration: 10,
transition: { type: "none", duration: 0 },
order: 0,
},
]);
const [selectedClipId, setSelectedClipId] = useState<string | null>(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 });
const [editPlanId, setEditPlanId] = useState<string | null>(null);
/* ── UI 状态 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false);
@@ -153,15 +119,13 @@ const EditingPlanner: React.FC = () => {
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(
null,
);
const [dragIdx, setDragIdx] = useState<number | null>(null);
/* ── P0-2: 自动加载 URL 指定的模板 ── */
/* ── 自动加载 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);
}
@@ -169,7 +133,7 @@ const EditingPlanner: React.FC = () => {
}
}, [urlTemplateId, templates, loadedTemplateId, urlGenerate]);
/* ── Mutations ── */
/* ── Mutations:模板 ── */
const createMutation = useMutation({
mutationFn: createEditingTemplate,
onSuccess: () => {
@@ -197,6 +161,31 @@ const EditingPlanner: React.FC = () => {
},
});
/* ── Mutations:剪辑计划 ── */
const savePlanMutation = useMutation({
mutationFn: createEditPlan,
onSuccess: (plan) => {
showToast("剪辑计划已保存", "success");
setEditPlanId(plan.id);
setSaveModalOpen(false);
},
onError: () => showToast("保存失败", "error"),
});
const updatePlanMutation = useMutation({
mutationFn: ({
id,
data,
}: {
id: string;
data: { name?: string; clips?: EditPlanClip[] };
}) => updateEditPlan(id, data),
onSuccess: () => {
showToast("剪辑计划已更新", "success");
},
onError: () => showToast("更新失败", "error"),
});
const generateMutation = useMutation({
mutationFn: ({
templateId,
@@ -219,79 +208,99 @@ const EditingPlanner: React.FC = () => {
},
});
const saving = createMutation.isPending || updateMutation.isPending;
const saving =
createMutation.isPending ||
updateMutation.isPending ||
savePlanMutation.isPending ||
updatePlanMutation.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 handleSelectClip = useCallback((clipId: string | null) => {
setSelectedClipId(clipId);
}, []);
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 handleUpdateClip = useCallback(
(clipId: string, updates: Partial<EditPlanClip>) => {
setClips((prev) =>
prev.map((c) => (c.id === clipId ? { ...c, ...updates } : c)),
);
},
[],
);
const updateSegment = (id: string, patch: Partial<TemplateSegment>) => {
setSegments((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...patch } : s)),
);
};
const handleRemoveClip = useCallback(
(clipId: string) => {
setClips((prev) => {
const next = prev
.filter((c) => c.id !== clipId)
.map((c, i) => ({ ...c, order: i }));
return next;
});
if (selectedClipId === clipId) {
setSelectedClipId(null);
}
},
[selectedClipId],
);
const handleDragStart = (idx: number) => setDragIdx(idx);
const handleReorderClips = useCallback(
(fromIdx: number, toIdx: number) => {
setClips((prev) => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, moved);
return next.map((c, i) => ({ ...c, order: i }));
});
},
[],
);
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 handleAssetDrop = useCallback(
(asset: MediaAsset, insertIdx: number) => {
const newClip: EditPlanClip = {
id: newClipId(),
template_segment_id: newSegId(),
media_asset_id: asset.id,
material_type: asset.type as EditPlanClip["material_type"],
script_text: "",
duration: asset.duration || 10,
transition: { type: "none", duration: 0 },
order: insertIdx,
};
setClips((prev) => {
const next = [...prev];
next.splice(insertIdx, 0, newClip);
return next.map((c, i) => ({ ...c, order: i }));
});
setSelectedClipId(newClip.id);
showToast(`已添加素材: ${asset.name}`, "success");
},
[showToast],
);
const handleDragEnd = () => setDragIdx(null);
const handleAddClip = useCallback(() => {
const newClip: EditPlanClip = {
id: newClipId(),
template_segment_id: newSegId(),
material_type: "video",
script_text: "",
duration: 10,
transition: { type: "none", duration: 0 },
order: clips.length,
};
setClips((prev) => [...prev, newClip]);
setSelectedClipId(newClip.id);
}, [clips.length]);
const handleAssetDragStart = useCallback((_asset: MediaAsset) => {
// 素材拖拽开始时的回调(可用于高亮时间线等)
}, []);
/* ──────────── 模式切换 ──────────── */
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 || "人物",
})),
);
}
};
/* ──────────── 模板操作 ──────────── */
@@ -299,31 +308,42 @@ const EditingPlanner: React.FC = () => {
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 newClips: EditPlanClip[] = tpl.segments.map((seg, i) => ({
id: newClipId(),
template_segment_id: seg.id || `seg-${i}`,
material_type: (seg.material_type as EditPlanClip["material_type"]) || "video",
script_text: "",
duration: Math.round((seg.duration_min + seg.duration_max) / 2),
transition: { type: "none", duration: 0 },
order: i,
}));
setClips(newClips);
setSelectedClipId(null);
};
const resetEditor = () => {
setLoadedTemplateId(null);
setEditPlanId(null);
setCurrentMode("pip");
setSegments([
setClips([
{
id: newSegId(),
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: null,
id: newClipId(),
template_segment_id: newSegId(),
material_type: "video",
script_text: "",
duration: 10,
transition: { type: "none", duration: 0 },
order: 0,
},
]);
setTitleConfig({ ...DEFAULT_TITLE });
setSubtitleConfig({ ...DEFAULT_SUBTITLE });
setBgmConfig({ ...DEFAULT_BGM });
setSelectedClipId(null);
};
/* ──────────── 保存/生成 ──────────── */
const openSaveModal = () => {
if (segments.length === 0) {
if (clips.length === 0) {
showToast("请至少添加一个片段", "warning");
return;
}
@@ -348,35 +368,69 @@ const EditingPlanner: React.FC = () => {
const handleSave = () => {
if (!draftName.trim()) {
showToast("请输入模板名称", "warning");
showToast("请输入名称", "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 });
// 保存剪辑计划
if (editPlanId) {
updatePlanMutation.mutate({
id: editPlanId,
data: { name: draftName.trim(), clips },
});
} else {
createMutation.mutate(payload);
savePlanMutation.mutate({
name: draftName.trim(),
template_id: loadedTemplateId || undefined,
mode: currentMode,
clips: clips.map(({ id: _id, ...rest }) => rest),
});
}
// 同时保存模板(如果有 loadedTemplateId
if (loadedTemplateId) {
const estimatedDuration = clips.reduce((s, c) => s + c.duration, 0);
const payload: SaveTemplatePayload = {
name: draftName.trim(),
mode: currentMode,
category: draftCategory,
tags: draftTags
.split(/[,]/)
.map((t) => t.trim())
.filter(Boolean),
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: false, music_id: "" },
estimated_duration: estimatedDuration,
segments: clips.map((c) => ({
id: c.template_segment_id,
segment_order: c.order + 1,
duration_min: Math.max(1, c.duration - 3),
duration_max: c.duration + 3,
material_type: c.material_type === "voiceover" ? null : c.material_type,
})),
};
updateMutation.mutate({ id: loadedTemplateId, data: payload });
}
};
const handleGenerate = () => {
if (!loadedTemplateId) {
showToast("请先保存模板", "warning");
showToast("请先选择模板", "warning");
return;
}
setGenerateModalOpen(true);
@@ -393,7 +447,8 @@ const EditingPlanner: React.FC = () => {
});
};
const estimatedDuration = calcEstimatedDuration(segments);
const totalDuration = clips.reduce((s, c) => s + c.duration, 0);
const selectedClip = clips.find((c) => c.id === selectedClipId) || null;
/* ──────────── 渲染 ──────────── */
@@ -405,22 +460,35 @@ const EditingPlanner: React.FC = () => {
<div className="ep-toolbar">
<div className="ep-toolbar-left">
<div className="ep-mode-switch">
{MODES.map((m) => (
<button
key={m.key}
className={`ep-mode-btn${currentMode === m.key ? " active" : ""}`}
onClick={() => handleModeChange(m.key)}
title={m.desc}
>
<span className="ep-mode-icon">{m.icon}</span>
{MODE_LABELS[m.key]}
</button>
))}
{(["pip", "voice_over", "one_take", "voice_pip"] as TemplateMode[]).map(
(mode) => (
<button
key={mode}
className={`ep-mode-btn${currentMode === mode ? " active" : ""}`}
onClick={() => handleModeChange(mode)}
title={MODE_LABELS[mode]}
>
{MODE_LABELS[mode]}
</button>
),
)}
</div>
</div>
<div className="ep-toolbar-center">
{loadedTemplateId && (
<span className="ep-toolbar-template-name">
📋{" "}
{templates.find((t) => t.id === loadedTemplateId)?.name ||
"未命名模板"}
</span>
)}
{editPlanId && (
<span className="ep-toolbar-plan-badge"></span>
)}
</div>
<div className="ep-toolbar-right">
<Button buttonType="ghost" buttonSize="sm" onClick={openSaveModal}>
💾
💾
</Button>
<Button
buttonType="primary"
@@ -428,48 +496,43 @@ const EditingPlanner: React.FC = () => {
onClick={handleGenerate}
disabled={!loadedTemplateId}
>
🎬 使
🎬
</Button>
</div>
</div>
{/* ═══ 三栏主体 ═══ */}
<div className="ep-body">
{/* 左侧:模板面板 */}
<TemplatePanel
{/* 左侧:素材面板(模板+素材 Tab */}
<MediaPanel
templates={templates}
categories={categories}
isLoading={tplLoading}
searchText={searchText}
filterCategory={filterCategory}
isLoadingTemplates={tplLoading}
loadedTemplateId={loadedTemplateId}
onSearchChange={setSearchText}
onCategoryChange={setFilterCategory}
onTemplateSelect={loadTemplate}
onNewTemplate={resetEditor}
assets={assets}
isLoadingAssets={assetsLoading}
onAssetDragStart={handleAssetDragStart}
/>
{/* 中间:预览 + 时间线 */}
{/* 中间:时间线 */}
<TimelinePanel
segments={segments}
currentMode={currentMode}
estimatedDuration={estimatedDuration}
onAddSegment={addSegment}
onRemoveSegment={removeSegment}
onUpdateSegment={updateSegment}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
clips={clips}
selectedClipId={selectedClipId}
onSelectClip={handleSelectClip}
onRemoveClip={handleRemoveClip}
onReorderClips={handleReorderClips}
onAssetDrop={handleAssetDrop}
onAddClip={handleAddClip}
totalDuration={totalDuration}
/>
{/* 右侧:设置面板 */}
<SettingsPanel
titleConfig={titleConfig}
subtitleConfig={subtitleConfig}
bgmConfig={bgmConfig}
onTitleChange={setTitleConfig}
onSubtitleChange={setSubtitleConfig}
onBgmChange={setBgmConfig}
{/* 右侧:片段属性 */}
<ClipPropertiesPanel
selectedClip={selectedClip}
onUpdateClip={handleUpdateClip}
clips={clips}
/>
</div>
@@ -477,12 +540,12 @@ const EditingPlanner: React.FC = () => {
<SaveModal
open={saveModalOpen}
loading={saving}
isUpdate={!!loadedTemplateId}
isUpdate={!!editPlanId}
draftName={draftName}
draftCategory={draftCategory}
draftTags={draftTags}
categories={categories}
estimatedDuration={estimatedDuration}
estimatedDuration={totalDuration}
onNameChange={setDraftName}
onCategoryChange={setDraftCategory}
onTagsChange={setDraftTags}
@@ -495,7 +558,7 @@ const EditingPlanner: React.FC = () => {
open={generateModalOpen}
loading={generateMutation.isPending}
voiceoverDuration={voiceoverDuration}
estimatedDuration={estimatedDuration}
estimatedDuration={totalDuration}
onDurationChange={setVoiceoverDuration}
onGenerate={doGenerate}
onCancel={() => setGenerateModalOpen(false)}
@@ -0,0 +1,204 @@
/**
* 右侧片段属性面板 — V21 设计系统
* 选中片段后编辑:文案、时长、转场效果
* 未选中时显示全局设置(标题/字幕/BGM)
*/
import React from "react";
import type { EditPlanClip, TransitionEffect } from "@/api/editPlans";
import { TRANSITION_OPTIONS, MATERIAL_TYPE_ICONS } from "@/api/editPlans";
interface ClipPropertiesPanelProps {
/** 当前选中的片段 */
selectedClip: EditPlanClip | null;
/** 更新片段属性 */
onUpdateClip: (clipId: string, updates: Partial<EditPlanClip>) => void;
/** 所有片段列表(用于显示上下文) */
clips: EditPlanClip[];
}
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
selectedClip,
onUpdateClip,
clips,
}) => {
if (!selectedClip) {
return (
<div className="ep-right">
<div className="ep-clip-props-empty">
<div className="ep-clip-props-empty-icon">👆</div>
<h3></h3>
<p>线</p>
<div className="ep-clip-props-summary">
<div className="ep-clip-props-summary-item">
<span className="ep-clip-props-summary-label"></span>
<span className="ep-clip-props-summary-value">{clips.length}</span>
</div>
<div className="ep-clip-props-summary-item">
<span className="ep-clip-props-summary-label"></span>
<span className="ep-clip-props-summary-value">
{clips.reduce((s, c) => s + c.duration, 0)}s
</span>
</div>
</div>
</div>
</div>
);
}
const transition = selectedClip.transition ?? {
type: "none" as const,
duration: 0,
};
const handleTransitionTypeChange = (type: TransitionEffect["type"]) => {
const duration = type === "none" ? 0 : transition.duration || 0.5;
onUpdateClip(selectedClip.id, {
transition: { type, duration },
});
};
const handleTransitionDurationChange = (duration: number) => {
onUpdateClip(selectedClip.id, {
transition: { ...transition, duration },
});
};
return (
<div className="ep-right">
{/* 片段信息头 */}
<div className="ep-right-section">
<div className="ep-clip-props-header">
<span className="ep-clip-props-header-icon">
{MATERIAL_TYPE_ICONS[selectedClip.material_type] || "📄"}
</span>
<div>
<h3> {selectedClip.order + 1}</h3>
<span className="ep-clip-props-header-type">
{selectedClip.material_type} · {selectedClip.duration}s
</span>
</div>
</div>
</div>
{/* 文案编辑 */}
<div className="ep-right-section">
<h3>📝 </h3>
<div className="ep-clip-props-field">
<textarea
className="ep-clip-props-textarea"
placeholder="输入片段文案..."
value={selectedClip.script_text}
onChange={(e) =>
onUpdateClip(selectedClip.id, {
script_text: e.target.value,
})
}
rows={4}
/>
<div className="ep-clip-props-field-hint">
{selectedClip.script_text.length}
</div>
</div>
</div>
{/* 时长调整 */}
<div className="ep-right-section">
<h3> </h3>
<div className="ep-clip-props-field">
<div className="ep-clip-props-duration-control">
<input
type="range"
className="ep-clip-props-range"
min={1}
max={60}
step={1}
value={selectedClip.duration}
onChange={(e) =>
onUpdateClip(selectedClip.id, {
duration: Number(e.target.value),
})
}
/>
<span className="ep-clip-props-duration-value">
{selectedClip.duration}s
</span>
</div>
</div>
</div>
{/* 转场效果 */}
<div className="ep-right-section">
<h3> </h3>
<div className="ep-clip-props-field">
<label className="ep-clip-props-label"></label>
<div className="ep-clip-props-transition-grid">
{TRANSITION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`ep-clip-props-transition-btn${transition.type === opt.value ? " active" : ""}`}
onClick={() => handleTransitionTypeChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
{transition.type !== "none" && (
<div className="ep-clip-props-field">
<label className="ep-clip-props-label"></label>
<div className="ep-clip-props-duration-control">
<input
type="range"
className="ep-clip-props-range"
min={0.1}
max={2}
step={0.1}
value={transition.duration}
onChange={(e) =>
handleTransitionDurationChange(Number(e.target.value))
}
/>
<span className="ep-clip-props-duration-value">
{transition.duration.toFixed(1)}s
</span>
</div>
</div>
)}
</div>
{/* 素材关联 */}
<div className="ep-right-section">
<h3>🔗 </h3>
<div className="ep-clip-props-field">
{selectedClip.media_asset_id ? (
<div className="ep-clip-props-asset-linked">
<span className="ep-clip-props-asset-icon">
{MATERIAL_TYPE_ICONS[selectedClip.material_type]}
</span>
<span className="ep-clip-props-asset-name">
{selectedClip.media_asset_id}
</span>
<button
className="ep-clip-props-asset-unlink"
onClick={() =>
onUpdateClip(selectedClip.id, {
media_asset_id: undefined,
})
}
title="取消关联"
>
</button>
</div>
) : (
<div className="ep-clip-props-asset-empty">
<p></p>
</div>
)}
</div>
</div>
</div>
);
};
export default ClipPropertiesPanel;
@@ -0,0 +1,237 @@
/**
* 左侧素材面板 — V21 设计系统
* Tab 切换:模板列表 / 素材库
* 素材支持拖拽到时间线
*/
import React, { useState } from "react";
import { Input, Select, Tag } from "@/components/ui";
import {
MODE_LABELS,
MODE_COLORS,
type EditingTemplate,
type TemplateCategory,
type TemplateMode,
} from "@/api/editingPlanner";
import type { MediaAsset } from "@/api/editPlans";
import { MATERIAL_TYPE_ICONS } from "@/api/editPlans";
/** antd Tag color → V21 Tag variant */
const modeVariantMap: Record<
string,
"primary" | "success" | "warning" | "info"
> = {
blue: "primary",
green: "success",
orange: "warning",
purple: "info",
};
type LeftTab = "templates" | "assets";
interface MediaPanelProps {
/* 模板相关 */
templates: EditingTemplate[];
categories: TemplateCategory[];
isLoadingTemplates: boolean;
loadedTemplateId: string | null;
onTemplateSelect: (tpl: EditingTemplate) => void;
onNewTemplate: () => void;
/* 素材相关 */
assets: MediaAsset[];
isLoadingAssets: boolean;
onAssetDragStart: (asset: MediaAsset) => void;
}
const MediaPanel: React.FC<MediaPanelProps> = ({
templates,
categories,
isLoadingTemplates,
loadedTemplateId,
onTemplateSelect,
onNewTemplate,
assets,
isLoadingAssets,
onAssetDragStart,
}) => {
const [activeTab, setActiveTab] = useState<LeftTab>("templates");
const [searchText, setSearchText] = useState("");
const [filterCategory, setFilterCategory] = useState("");
const [assetFilter, setAssetFilter] = useState<string>("");
/* 过滤模板 */
const filteredTemplates = templates.filter((tpl) => {
if (searchText && !tpl.name.toLowerCase().includes(searchText.toLowerCase()))
return false;
if (filterCategory && tpl.category !== filterCategory) return false;
return true;
});
/* 过滤素材 */
const filteredAssets = assets.filter((a) => {
if (searchText && !a.name.toLowerCase().includes(searchText.toLowerCase()))
return false;
if (assetFilter && a.type !== assetFilter) return false;
return true;
});
const handleDragStart = (e: React.DragEvent, asset: MediaAsset) => {
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(asset));
e.dataTransfer.effectAllowed = "copy";
onAssetDragStart(asset);
};
return (
<div className="ep-left">
{/* Tab 切换 */}
<div className="ep-left-tabs">
<button
className={`ep-left-tab${activeTab === "templates" ? " active" : ""}`}
onClick={() => setActiveTab("templates")}
>
📂
</button>
<button
className={`ep-left-tab${activeTab === "assets" ? " active" : ""}`}
onClick={() => setActiveTab("assets")}
>
🎬
</button>
</div>
{/* 搜索栏 */}
<div className="ep-left-header">
<Input.Search
placeholder={
activeTab === "templates" ? "搜索模板..." : "搜索素材..."
}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
/>
{activeTab === "templates" && (
<Select
placeholder="按分类筛选"
value={filterCategory || undefined}
onChange={(v: string) => setFilterCategory(v || "")}
allowClear
options={categories.map((c) => ({ value: c.name, label: c.name }))}
/>
)}
{activeTab === "assets" && (
<Select
placeholder="素材类型"
value={assetFilter || undefined}
onChange={(v: string) => setAssetFilter(v || "")}
allowClear
options={[
{ value: "video", label: "🎬 视频" },
{ value: "image", label: "🖼️ 图片" },
{ value: "audio", label: "🎵 音频" },
]}
/>
)}
</div>
{/* 内容区 */}
<div className="ep-left-list">
{activeTab === "templates" ? (
/* ── 模板列表 ── */
isLoadingTemplates ? (
<div className="ep-left-empty">
<div className="ep-left-empty-icon"></div>
<p>...</p>
</div>
) : filteredTemplates.length === 0 ? (
<div className="ep-left-empty">
<div className="ep-left-empty-icon">📭</div>
<p></p>
</div>
) : (
filteredTemplates.map((tpl) => {
const modeColor =
MODE_COLORS[tpl.mode as TemplateMode] || "blue";
const variant = modeVariantMap[modeColor] || "primary";
return (
<div
key={tpl.id}
className={`ep-template-card${loadedTemplateId === tpl.id ? " selected" : ""}`}
onClick={() => onTemplateSelect(tpl)}
>
<div className="ep-template-card-name">{tpl.name}</div>
<div className="ep-template-card-tags">
<Tag variant={variant}>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
</Tag>
{tpl.tags.slice(0, 2).map((tag) => (
<Tag key={tag} variant="info">
{tag}
</Tag>
))}
</div>
<div className="ep-template-card-meta">
{tpl.segments.length} · ~{tpl.estimated_duration}s
</div>
</div>
);
})
)
) : /* ── 素材列表(可拖拽) ── */
isLoadingAssets ? (
<div className="ep-left-empty">
<div className="ep-left-empty-icon"></div>
<p>...</p>
</div>
) : filteredAssets.length === 0 ? (
<div className="ep-left-empty">
<div className="ep-left-empty-icon">📭</div>
<p></p>
</div>
) : (
filteredAssets.map((asset) => (
<div
key={asset.id}
className="ep-asset-card"
draggable
onDragStart={(e) => handleDragStart(e, asset)}
>
<div className="ep-asset-card-icon">
{MATERIAL_TYPE_ICONS[asset.type] || "📄"}
</div>
<div className="ep-asset-card-info">
<div className="ep-asset-card-name">{asset.name}</div>
<div className="ep-asset-card-meta">
{asset.duration ? `${asset.duration}s · ` : ""}
{asset.type}
{asset.tags.length > 0 && (
<span className="ep-asset-card-tags">
{asset.tags.map((t) => (
<span key={t} className="ep-asset-card-tag">
{t}
</span>
))}
</span>
)}
</div>
</div>
<div className="ep-asset-card-drag-hint"></div>
</div>
))
)}
</div>
{/* 底部操作 */}
{activeTab === "templates" && loadedTemplateId && (
<div className="ep-left-footer">
<button
className="ep-new-template-btn"
onClick={onNewTemplate}
>
</button>
</div>
)}
</div>
);
};
export default MediaPanel;
@@ -1,191 +1,268 @@
/**
* 中间预览 + 时间线面板 — V21 设计系统
* 视频/封面预览区 + 片段卡片时间线(支持拖拽排序)
* 中间时间线面板 — V21 设计系统
* 可视化时长条 + 片段卡片 + 拖拽排序 + 素材拖入
*/
import React from "react";
import { Button, Select, Tag } from "@/components/ui";
import type { TemplateSegment, TemplateMode } from "@/api/editingPlanner";
import React, { useState, useRef } from "react";
import { Button } from "@/components/ui";
import type { EditPlanClip, MediaAsset } from "@/api/editPlans";
import { MATERIAL_TYPE_ICONS, TRANSITION_OPTIONS } from "@/api/editPlans";
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;
clips: EditPlanClip[];
selectedClipId: string | null;
onSelectClip: (clipId: string | null) => void;
onRemoveClip: (clipId: string) => void;
onReorderClips: (fromIdx: number, toIdx: number) => void;
onAssetDrop: (asset: MediaAsset, insertIdx: number) => void;
onAddClip: () => void;
totalDuration: number;
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
segments,
currentMode,
estimatedDuration,
onAddSegment,
onRemoveSegment,
onUpdateSegment,
onDragStart,
onDragOver,
onDragEnd,
clips,
selectedClipId,
onSelectClip,
onRemoveClip,
onReorderClips,
onAssetDrop,
onAddClip,
totalDuration,
}) => {
const isOneShot = currentMode === "one_take";
const isMixedCut = currentMode === "voice_pip";
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const [isDragOverEmpty, setIsDragOverEmpty] = useState(false);
const dragIdxRef = useRef<number | null>(null);
/* ── 内部片段拖拽排序 ── */
const handleClipDragStart = (e: React.DragEvent, idx: number) => {
dragIdxRef.current = idx;
e.dataTransfer.setData("application/x-clip-index", String(idx));
e.dataTransfer.effectAllowed = "move";
};
const handleClipDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverIdx(idx);
};
const handleClipDragEnd = () => {
dragIdxRef.current = null;
setDragOverIdx(null);
};
/* ── 外部素材拖入 ── */
const handleAssetDragOver = (e: React.DragEvent) => {
if (e.dataTransfer.types.includes("application/x-media-asset")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
};
const handleDropOnClip = (e: React.DragEvent, insertIdx: number) => {
e.preventDefault();
setDragOverIdx(null);
// 内部片段排序
const clipIdx = e.dataTransfer.getData("application/x-clip-index");
if (clipIdx !== "") {
const fromIdx = Number(clipIdx);
if (fromIdx !== insertIdx && fromIdx !== insertIdx - 1) {
const adjustedTo = fromIdx < insertIdx ? insertIdx - 1 : insertIdx;
onReorderClips(fromIdx, adjustedTo);
}
return;
}
// 外部素材拖入
const assetJson = e.dataTransfer.getData("application/x-media-asset");
if (assetJson) {
try {
const asset: MediaAsset = JSON.parse(assetJson);
onAssetDrop(asset, insertIdx);
} catch {
// ignore
}
}
};
const handleDropOnEmpty = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOverEmpty(false);
const assetJson = e.dataTransfer.getData("application/x-media-asset");
if (assetJson) {
try {
const asset: MediaAsset = JSON.parse(assetJson);
onAssetDrop(asset, clips.length);
} catch {
// ignore
}
}
};
const handleEmptyDragOver = (e: React.DragEvent) => {
if (e.dataTransfer.types.includes("application/x-media-asset")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverEmpty(true);
}
};
/* ── 转场标签 ── */
const getTransitionLabel = (clip: EditPlanClip) => {
if (!clip.transition || clip.transition.type === "none") return null;
const opt = TRANSITION_OPTIONS.find((o) => o.value === clip.transition?.type);
return opt ? opt.label : clip.transition.type;
};
/* ── 时长条宽度百分比 ── */
const getClipWidth = (clip: EditPlanClip) => {
if (totalDuration === 0) return 100 / Math.max(clips.length, 1);
return (clip.duration / totalDuration) * 100;
};
/* ── 片段颜色 ── */
const clipColors = ["#4f46e5", "#7c3aed", "#2563eb", "#0891b2", "#059669", "#d97706"];
const getClipColor = (idx: number) => clipColors[idx % clipColors.length];
return (
<div className="ep-center">
{/* 预览区 */}
<div className="ep-preview">
{/* 视频预览 */}
<div className="ep-preview-frame">
<div className="ep-preview-frame-icon">🎥</div>
<p></p>
<span className="ep-preview-frame-info">9:16 </span>
{/* 可视化时长条 */}
<div className="ep-timeline-bar">
<div className="ep-timeline-bar-label">
线 <span className="ep-timeline-bar-duration">{totalDuration}s</span>
</div>
{/* 封面预览 */}
<div className="ep-preview-frame" style={{ marginLeft: 24 }}>
<div className="ep-preview-frame-icon">🖼</div>
<p></p>
<span className="ep-preview-frame-info">9:16 </span>
</div>
{/* 封面操作按钮 */}
<div className="ep-cover-btns">
<Button buttonType="ghost" buttonSize="sm">
🤖 AI
</Button>
<Button buttonType="ghost" buttonSize="sm">
</Button>
<Button buttonType="ghost" buttonSize="sm">
📤
</Button>
<Button buttonType="ghost" buttonSize="sm">
🔄 AI
</Button>
<div className="ep-timeline-bar-track">
{clips.map((clip, idx) => (
<div
key={clip.id}
className="ep-timeline-bar-segment"
style={{
width: `${getClipWidth(clip)}%`,
backgroundColor: getClipColor(idx),
}}
title={`片段 ${idx + 1}: ${clip.duration}s`}
/>
))}
{clips.length === 0 && (
<div className="ep-timeline-bar-empty"></div>
)}
</div>
</div>
{/* 时间线 */}
<div className="ep-timeline">
<div className="ep-timeline-header">
<h3>
线{" "}
<span className="ep-timeline-duration">
~{estimatedDuration}s
</span>
</h3>
<Button
buttonType="secondary"
buttonSize="sm"
onClick={onAddSegment}
disabled={isOneShot}
{/* 片段列表 */}
<div className="ep-timeline-header">
<h3> ({clips.length})</h3>
<Button buttonType="secondary" buttonSize="sm" onClick={onAddClip}>
</Button>
</div>
<div className="ep-timeline-list">
{clips.length === 0 ? (
<div
className={`ep-timeline-empty-drop${isDragOverEmpty ? " active" : ""}`}
onDragOver={handleEmptyDragOver}
onDragLeave={() => setIsDragOverEmpty(false)}
onDrop={handleDropOnEmpty}
>
</Button>
</div>
<div className="ep-timeline-empty-icon">🎬</div>
<p></p>
<span></span>
</div>
) : (
clips.map((clip, idx) => {
const isSelected = clip.id === selectedClipId;
const transitionLabel = getTransitionLabel(clip);
{/* 片段列表 */}
<div className="ep-timeline-list">
{segments.length === 0 ? (
<div className="ep-timeline-empty"></div>
) : (
segments.map((seg, idx) => (
<div
key={seg.id}
className="ep-segment-card"
draggable={!isOneShot}
onDragStart={() => onDragStart(idx)}
onDragOver={(e) => onDragOver(e, idx)}
onDragEnd={onDragEnd}
>
{/* 拖拽手柄 */}
<span className="ep-segment-drag">
{isOneShot ? "🔒" : "⠿"}
</span>
{/* 序号 */}
<span className="ep-segment-index">#{seg.segment_order}</span>
{/* 片段信息 */}
<div className="ep-segment-info">
<h4> {seg.segment_order}</h4>
<div className="ep-segment-controls">
{isOneShot ? (
<span className="ep-segment-duration">
</span>
) : (
<>
<label className="ep-segment-duration">
<input
type="range"
min={1}
max={seg.duration_max}
value={seg.duration_min}
onChange={(e) =>
onUpdateSegment(seg.id!, {
duration_min: Number(e.target.value),
})
}
style={{ width: 80, marginLeft: 4 }}
/>
<span>{seg.duration_min}s</span>
</label>
<label className="ep-segment-duration">
<input
type="range"
min={seg.duration_min}
max={60}
value={seg.duration_max}
onChange={(e) =>
onUpdateSegment(seg.id!, {
duration_max: Number(e.target.value),
})
}
style={{ width: 80, marginLeft: 4 }}
/>
<span>{seg.duration_max}s</span>
</label>
</>
)}
</div>
</div>
{/* 素材类型(voice_pip 模式) */}
{isMixedCut && (
<Select
value={seg.material_type || "人物"}
onChange={(v: string) =>
onUpdateSegment(seg.id!, { material_type: v })
}
options={[
{ value: "人物", label: "人物" },
{ value: "场景", label: "场景" },
]}
/>
return (
<React.Fragment key={clip.id}>
{/* 拖放插入指示器 */}
{dragOverIdx === idx && (
<div className="ep-timeline-drop-indicator" />
)}
{/* 操作 */}
<div className="ep-segment-actions">
<Tag variant="info">{seg.material_type || "通用"}</Tag>
{!isOneShot && (
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => onRemoveSegment(seg.id!)}
<div
className={`ep-clip-card${isSelected ? " selected" : ""}${dragOverIdx === idx ? " drag-over" : ""}`}
draggable
onDragStart={(e) => handleClipDragStart(e, idx)}
onDragOver={(e) => {
handleClipDragOver(e, idx);
handleAssetDragOver(e);
}}
onDragEnd={handleClipDragEnd}
onDrop={(e) => handleDropOnClip(e, idx)}
onClick={() => onSelectClip(clip.id)}
>
{/* 拖拽手柄 */}
<span className="ep-clip-drag"></span>
{/* 序号徽标 */}
<span
className="ep-clip-index"
style={{ backgroundColor: getClipColor(idx) }}
>
#{idx + 1}
</span>
{/* 片段信息 */}
<div className="ep-clip-info">
<div className="ep-clip-info-top">
<span className="ep-clip-type-icon">
{MATERIAL_TYPE_ICONS[clip.material_type] || "📄"}
</span>
<span className="ep-clip-script">
{clip.script_text || (
<em className="ep-clip-script-empty"></em>
)}
</span>
</div>
<div className="ep-clip-info-bottom">
<div className="ep-clip-duration-bar">
<div
className="ep-clip-duration-fill"
style={{
width: `${Math.min(100, (clip.duration / 60) * 100)}%`,
backgroundColor: getClipColor(idx),
}}
/>
</div>
<span className="ep-clip-duration-text">{clip.duration}s</span>
{clip.media_asset_id && (
<span className="ep-clip-asset-badge" title="已关联素材">
🔗
</span>
)}
{transitionLabel && (
<span className="ep-clip-transition-badge">
{transitionLabel}
</span>
)}
</div>
</div>
{/* 操作按钮 */}
<div className="ep-clip-actions">
<button
className="ep-clip-action-btn"
onClick={(e) => {
e.stopPropagation();
onRemoveClip(clip.id);
}}
title="删除片段"
>
</Button>
)}
</button>
</div>
</div>
</div>
))
)}
</div>
</React.Fragment>
);
})
)}
{/* 末尾插入指示器 */}
{clips.length > 0 && dragOverIdx === clips.length && (
<div className="ep-timeline-drop-indicator" />
)}
</div>
</div>
);
+496
View File
@@ -0,0 +1,496 @@
"""
edit_plans.py 剪辑计划 API 端点单元测试
覆盖(25+ 测试用例):
- 创建:正常创建、空名称 400、空 template_id 422
- 列表:默认分页、按状态筛选、按模板筛选、无效状态 400
- 详情:正常获取、不存在 404
- 更新:基础字段更新、状态机合法流转、状态机非法流转 400、不存在 404、无效状态值 400
- 删除:正常删除、不存在 404
"""
from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from fastapi import FastAPI
from fastapi.testclient import TestClient
from packages.domain.edit_plan import EditPlan, EditPlanStatus
# ---------------------------------------------------------------------------
# Stub Repository
# ---------------------------------------------------------------------------
class StubEditPlanRepository:
"""内存中的 EditPlan 仓储 stub"""
def __init__(self, plans: dict[str, EditPlan] | None = None):
self._plans = plans or {}
self._counter = 0
def _next_id(self) -> str:
self._counter += 1
return f"plan-{self._counter:03d}"
def list_all(
self,
*,
status: Optional[EditPlanStatus] = None,
skip: int = 0,
limit: int = 50,
) -> list[EditPlan]:
items = list(self._plans.values())
if status is not None:
items = [p for p in items if p.status == status]
items.sort(key=lambda p: p.created_at, reverse=True)
return items[skip : skip + limit]
def list_by_template(
self,
template_id: str,
*,
status: Optional[EditPlanStatus] = None,
skip: int = 0,
limit: int = 50,
) -> list[EditPlan]:
items = [p for p in self._plans.values() if p.template_id == template_id]
if status is not None:
items = [p for p in items if p.status == status]
items.sort(key=lambda p: p.created_at, reverse=True)
return items[skip : skip + limit]
def get(self, plan_id: str) -> Optional[EditPlan]:
return self._plans.get(plan_id)
def create(self, plan: EditPlan) -> EditPlan:
self._plans[plan.id] = plan
return plan
def update(self, plan: EditPlan) -> EditPlan:
if plan.id not in self._plans:
raise ValueError(f"EditPlan {plan.id} not found")
self._plans[plan.id] = plan
return plan
def delete(self, plan_id: str) -> bool:
if plan_id not in self._plans:
return False
del self._plans[plan_id]
return True
def count(
self,
*,
template_id: Optional[str] = None,
status: Optional[EditPlanStatus] = None,
) -> int:
items = list(self._plans.values())
if template_id:
items = [p for p in items if p.template_id == template_id]
if status is not None:
items = [p for p in items if p.status == status]
return len(items)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_auth_user():
"""构造 AuthenticatedUser mock"""
from app.auth import AuthenticatedUser
from packages.domain.entities import User
user = User(
id="user-001",
email="test@example.com",
display_name="测试用户",
)
return AuthenticatedUser(user=user)
def _create_test_app():
"""创建带 stub 注入的测试 FastAPI 应用"""
from app.api.routes import edit_plans as edit_plans_module
from app.api.routes.edit_plans import router
stub_repo = StubEditPlanRepository()
# 替换路由模块中的 Repository 类
original_repo_class = edit_plans_module.SQLAlchemyEditPlanRepository
edit_plans_module.SQLAlchemyEditPlanRepository = lambda db: stub_repo
app = FastAPI()
app.include_router(router, prefix="/api/v1/edit-plans")
# 覆盖认证依赖
app.dependency_overrides[edit_plans_module.get_current_user] = _make_auth_user
app.dependency_overrides[edit_plans_module.get_db_session] = lambda: MagicMock()
return app, stub_repo, lambda: setattr(
edit_plans_module, "SQLAlchemyEditPlanRepository", original_repo_class
)
@pytest.fixture
def client():
app, stub_repo, cleanup = _create_test_app()
yield TestClient(app), stub_repo
cleanup()
# ---------------------------------------------------------------------------
# 创建测试
# ---------------------------------------------------------------------------
class TestCreatePlan:
def test_create_success(self, client):
c, repo = client
resp = c.post(
"/api/v1/edit-plans",
json={
"template_id": "tpl-001",
"name": "我的剪辑计划",
"config": {"bgm": "happy"},
"total_duration": 60.0,
},
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "我的剪辑计划"
assert data["template_id"] == "tpl-001"
assert data["status"] == "draft"
assert data["total_duration"] == 60.0
assert data["config"] == {"bgm": "happy"}
assert "id" in data
assert "created_at" in data
def test_create_minimal(self, client):
c, repo = client
resp = c.post(
"/api/v1/edit-plans",
json={"template_id": "tpl-001", "name": "最小计划"},
)
assert resp.status_code == 201
data = resp.json()
assert data["config"] == {}
assert data["total_duration"] == 0.0
def test_create_empty_name_returns_422(self, client):
c, repo = client
resp = c.post(
"/api/v1/edit-plans",
json={"template_id": "tpl-001", "name": ""},
)
assert resp.status_code == 422
def test_create_missing_template_id_returns_422(self, client):
c, repo = client
resp = c.post(
"/api/v1/edit-plans",
json={"name": "没有模板的计划"},
)
assert resp.status_code == 422
def test_create_negative_duration_returns_422(self, client):
c, repo = client
resp = c.post(
"/api/v1/edit-plans",
json={"template_id": "tpl-001", "name": "test", "total_duration": -1.0},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# 列表测试
# ---------------------------------------------------------------------------
class TestListPlans:
def _seed_plans(self, repo, count=3, template_id="tpl-001"):
for i in range(count):
plan = EditPlan.create(
template_id=template_id,
name=f"计划{i+1}",
config={"index": i},
)
repo.create(plan)
return plan
def test_list_empty(self, client):
c, repo = client
resp = c.get("/api/v1/edit-plans")
assert resp.status_code == 200
data = resp.json()
assert data["items"] == []
assert data["total"] == 0
assert data["page"] == 1
assert data["page_size"] == 20
def test_list_with_items(self, client):
c, repo = client
self._seed_plans(repo, count=3)
resp = c.get("/api/v1/edit-plans")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 3
assert data["total"] == 3
def test_list_pagination(self, client):
c, repo = client
self._seed_plans(repo, count=5)
resp = c.get("/api/v1/edit-plans?page=1&page_size=2")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 2
assert data["total"] == 5
assert data["page"] == 1
resp2 = c.get("/api/v1/edit-plans?page=3&page_size=2")
data2 = resp2.json()
assert len(data2["items"]) == 1
def test_list_filter_by_status(self, client):
c, repo = client
p1 = EditPlan.create("tpl-001", "计划A")
repo.create(p1)
p2 = EditPlan.create("tpl-001", "计划B")
repo.create(p2)
p2.start_editing()
repo.update(p2)
resp = c.get("/api/v1/edit-plans?status=draft")
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["name"] == "计划A"
resp2 = c.get("/api/v1/edit-plans?status=editing")
data2 = resp2.json()
assert data2["total"] == 1
assert data2["items"][0]["name"] == "计划B"
def test_list_filter_by_template_id(self, client):
c, repo = client
p1 = EditPlan.create("tpl-001", "模板1计划")
repo.create(p1)
p2 = EditPlan.create("tpl-002", "模板2计划")
repo.create(p2)
resp = c.get("/api/v1/edit-plans?template_id=tpl-001")
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["name"] == "模板1计划"
def test_list_filter_by_template_and_status(self, client):
c, repo = client
p1 = EditPlan.create("tpl-001", "模板1草稿")
repo.create(p1)
p2 = EditPlan.create("tpl-001", "模板1编辑中")
repo.create(p2)
p2.start_editing()
repo.update(p2)
p3 = EditPlan.create("tpl-002", "模板2草稿")
repo.create(p3)
resp = c.get("/api/v1/edit-plans?template_id=tpl-001&status=draft")
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["name"] == "模板1草稿"
def test_list_invalid_status_returns_400(self, client):
c, repo = client
resp = c.get("/api/v1/edit-plans?status=invalid_status")
assert resp.status_code == 400
assert "无效的状态值" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# 详情测试
# ---------------------------------------------------------------------------
class TestGetPlan:
def test_get_success(self, client):
c, repo = client
plan = EditPlan.create("tpl-001", "测试计划", config={"key": "val"})
repo.create(plan)
resp = c.get(f"/api/v1/edit-plans/{plan.id}")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == plan.id
assert data["name"] == "测试计划"
assert data["config"] == {"key": "val"}
def test_get_not_found_returns_404(self, client):
c, repo = client
resp = c.get("/api/v1/edit-plans/nonexistent-id")
assert resp.status_code == 404
assert "剪辑计划不存在" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# 更新测试
# ---------------------------------------------------------------------------
class TestUpdatePlan:
def _seed_plan(self, repo, name="原计划", template_id="tpl-001"):
plan = EditPlan.create(template_id, name)
repo.create(plan)
return plan
def test_update_name(self, client):
c, repo = client
plan = self._seed_plan(repo)
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"name": "新名称"})
assert resp.status_code == 200
assert resp.json()["name"] == "新名称"
def test_update_config(self, client):
c, repo = client
plan = self._seed_plan(repo)
resp = c.put(
f"/api/v1/edit-plans/{plan.id}",
json={"config": {"bgm": "sad", "transition": "fade"}},
)
assert resp.status_code == 200
assert resp.json()["config"] == {"bgm": "sad", "transition": "fade"}
def test_update_total_duration(self, client):
c, repo = client
plan = self._seed_plan(repo)
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"total_duration": 120.5})
assert resp.status_code == 200
assert resp.json()["total_duration"] == 120.5
def test_update_status_draft_to_editing(self, client):
c, repo = client
plan = self._seed_plan(repo)
assert plan.status == EditPlanStatus.DRAFT
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "editing"})
assert resp.status_code == 200
assert resp.json()["status"] == "editing"
def test_update_status_full_happy_path(self, client):
c, repo = client
plan = self._seed_plan(repo)
# draft → editing
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "editing"})
assert resp.json()["status"] == "editing"
# editing → rendering
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "rendering"})
assert resp.json()["status"] == "rendering"
# rendering → completed
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "completed"})
assert resp.json()["status"] == "completed"
def test_update_status_failure_and_reset(self, client):
c, repo = client
plan = self._seed_plan(repo)
# draft → editing → rendering → failed
c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "editing"})
c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "rendering"})
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "failed"})
assert resp.json()["status"] == "failed"
# failed → draft (reset)
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "draft"})
assert resp.json()["status"] == "draft"
def test_update_invalid_transition_returns_400(self, client):
c, repo = client
plan = self._seed_plan(repo)
# draft → rendering 不合法
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "rendering"})
assert resp.status_code == 400
def test_update_draft_to_completed_returns_400(self, client):
c, repo = client
plan = self._seed_plan(repo)
# draft → completed 不合法
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "completed"})
assert resp.status_code == 400
def test_update_invalid_status_value_returns_400(self, client):
c, repo = client
plan = self._seed_plan(repo)
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "bogus"})
assert resp.status_code == 400
assert "无效的状态值" in resp.json()["detail"]
def test_update_same_status_is_noop(self, client):
c, repo = client
plan = self._seed_plan(repo)
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "draft"})
assert resp.status_code == 200
assert resp.json()["status"] == "draft"
def test_update_not_found_returns_404(self, client):
c, repo = client
resp = c.put("/api/v1/edit-plans/nonexistent", json={"name": "x"})
assert resp.status_code == 404
def test_update_combined_fields_and_status(self, client):
c, repo = client
plan = self._seed_plan(repo)
resp = c.put(
f"/api/v1/edit-plans/{plan.id}",
json={"name": "新名称", "status": "editing", "total_duration": 90.0},
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "新名称"
assert data["status"] == "editing"
assert data["total_duration"] == 90.0
# ---------------------------------------------------------------------------
# 删除测试
# ---------------------------------------------------------------------------
class TestDeletePlan:
def test_delete_success(self, client):
c, repo = client
plan = EditPlan.create("tpl-001", "待删除")
repo.create(plan)
resp = c.delete(f"/api/v1/edit-plans/{plan.id}")
assert resp.status_code == 204
assert repo.get(plan.id) is None
def test_delete_not_found_returns_404(self, client):
c, repo = client
resp = c.delete("/api/v1/edit-plans/nonexistent")
assert resp.status_code == 404
assert "剪辑计划不存在" in resp.json()["detail"]