feat(task-2.14): 剪辑计划编辑器完整版 — 三栏布局 + 拖拽 + 素材面板 #145
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user