diff --git a/alembic/versions/015_add_generation_task_extensions.py b/alembic/versions/015_add_generation_task_extensions.py new file mode 100644 index 000000000..f36c816fa --- /dev/null +++ b/alembic/versions/015_add_generation_task_extensions.py @@ -0,0 +1,34 @@ +"""add generation task extensions + +Revision ID: 015 +Revises: 014 +Create Date: 2026-06-29 +""" + +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +from alembic import op + +# revision identifiers +revision = "015" +down_revision = "014" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("generation_tasks", sa.Column("template_id", sa.String(36), nullable=False, server_default="")) + op.add_column("generation_tasks", sa.Column("asset_ids", mysql.JSON(), nullable=False, server_default="[]")) + op.add_column("generation_tasks", sa.Column("title_ids", mysql.JSON(), nullable=False, server_default="[]")) + op.add_column("generation_tasks", sa.Column("voice_ids", mysql.JSON(), nullable=False, server_default="[]")) + + op.create_index(op.f("ix_generation_tasks_template_id"), "generation_tasks", ["template_id"]) + + +def downgrade() -> None: + op.drop_index(op.f("ix_generation_tasks_template_id"), table_name="generation_tasks") + op.drop_column("generation_tasks", "voice_ids") + op.drop_column("generation_tasks", "title_ids") + op.drop_column("generation_tasks", "asset_ids") + op.drop_column("generation_tasks", "template_id") diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 712580c93..aa3505b5f 100644 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -1,3 +1,4 @@ +from app.api.routes.dashboard import router as dashboard_router from app.api.routes.asset_diagnosis import router as asset_diagnosis_router from app.api.routes.asset_libraries import router as asset_libraries_router from app.api.routes.assets import router as assets_router @@ -110,3 +111,8 @@ api_router.include_router( prefix="/templates", tags=["Template"], ) +api_router.include_router( + dashboard_router, + prefix="/dashboard", + tags=["Dashboard"], +) diff --git a/apps/api/app/api/routes/dashboard.py b/apps/api/app/api/routes/dashboard.py new file mode 100644 index 000000000..1cdc838cc --- /dev/null +++ b/apps/api/app/api/routes/dashboard.py @@ -0,0 +1,92 @@ +from typing import Any + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import ( + get_asset_repository, + get_generation_task_repository, + get_project_repository, + get_title_library_repository, + get_voice_library_repository, +) +from app.schemas.dashboard import DashboardOverviewResponse, RecentTaskItem, SubscriptionInfo +from fastapi import APIRouter, Depends + +router = APIRouter() + + +def _status_value(status) -> str: + return status.value if hasattr(status, "value") else str(status) + + +def _generation_step(status: str) -> str: + if status == "pending": + return "等待 Worker 执行" + if status == "running": + return "正在生成成片" + if status == "completed": + return "生成完成" + if status == "failed": + return "生成失败" + return status + + +@router.get("/overview", response_model=DashboardOverviewResponse) +def get_dashboard_overview( + authenticated_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), + asset_repository: Any = Depends(get_asset_repository), + generation_task_repository: Any = Depends(get_generation_task_repository), + title_library_repository: Any = Depends(get_title_library_repository), + voice_library_repository: Any = Depends(get_voice_library_repository), +) -> DashboardOverviewResponse: + """Dashboard 概览:用户级汇总数据。""" + user_id = authenticated_user.user.id + + # 获取用户可访问的所有 project + projects = project_repository.find_accessible_projects(user_id) + project_ids = [p.id for p in projects] + + # 素材统计 + total_assets = asset_repository.count_by_project_ids(project_ids) + used_storage_bytes = asset_repository.sum_storage_by_project_ids(project_ids) + + # 标题库 / 配音库统计 + total_titles = title_library_repository.count_by_user(user_id) + total_voices = voice_library_repository.count_by_user(user_id) + + # 生成任务统计 + total_tasks = generation_task_repository.count_by_user(user_id) + + # 最近任务(SQL 层 LIMIT 5) + recent = generation_task_repository.list_recent_by_user(user_id, limit=5) + recent_tasks = [] + for task in recent: + s = _status_value(task.status) + recent_tasks.append( + RecentTaskItem( + id=task.id, + task_type="generation", + status=s, + current_step=_generation_step(s), + error_message=task.error_message or "", + updated_at=task.completed_at or task.started_at or task.created_at, + ) + ) + + # 订阅信息 + user = authenticated_user.user + subscription = SubscriptionInfo( + plan=getattr(user, "subscription_plan", "free") or "free", + is_active=getattr(user, "subscription_status", "") == "active", + ) + + return DashboardOverviewResponse( + total_assets=total_assets, + used_storage_bytes=used_storage_bytes, + total_titles=total_titles, + total_voices=total_voices, + total_tasks=total_tasks, + total_products=len(projects), + subscription=subscription, + recent_tasks=recent_tasks, + ) diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index c363d5595..b01a869eb 100644 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -16,6 +16,7 @@ from app.schemas.generated_video import ( from app.schemas.generation_task import ( CreateGenerationTaskRequest, GenerationTaskResponse, + ListGenerationTasksResponse, ) from fastapi import APIRouter, Depends, HTTPException @@ -45,6 +46,10 @@ def _to_generation_task_response(task) -> GenerationTaskResponse: asset_library_id=task.asset_library_id, strategy_id=task.strategy_id, voice_library_id=task.voice_library_id, + template_id=task.template_id, + asset_ids=task.asset_ids, + title_ids=task.title_ids, + voice_ids=task.voice_ids, status=task.status, progress=task.progress, result_count=task.result_count, @@ -79,6 +84,43 @@ def _ensure_library_has_ready_video_assets(assets) -> None: ) +def _resolve_project_and_library( + request: CreateGenerationTaskRequest, + project_repository: Any, + asset_library_repository: Any, + asset_repository: Any, + authenticated_user: AuthenticatedUser, +) -> tuple[str, str]: + """解析 project_id 和 asset_library_id。 + + 支持两种模式: + - 显式传入(向后兼容) + - 从 asset_ids 反查 asset_library(模板模式) + 返回 (project_id, asset_library_id)。 + """ + project_id = request.project_id.strip() + asset_library_id = request.asset_library_id.strip() + + # 模板模式:project_id 未提供时,从 asset_ids 反查所属 project + if not project_id and request.asset_ids: + first_asset_id = request.asset_ids[0] + asset = asset_repository.find_by_id(first_asset_id) + if asset is not None: + project_id = asset.project_id + if not asset_library_id: + asset_library_id = asset.library_id + + # 向后兼容校验:project_id 已提供时验证权限 + if project_id: + project = project_repository.find_by_id(project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + if not project.can_access(authenticated_user.user.id): + raise HTTPException(status_code=403, detail="Access denied to project") + + return project_id, asset_library_id + + @router.post("/tasks", response_model=GenerationTaskResponse) def create_generation_task( request: CreateGenerationTaskRequest, @@ -88,26 +130,30 @@ def create_generation_task( asset_library_repository: Any = Depends(get_asset_library_repository), asset_repository: Any = Depends(get_asset_repository), ) -> GenerationTaskResponse: - project = project_repository.find_by_id(request.project_id) - if project is None: - raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found") - if not project.can_access(authenticated_user.user.id): - raise HTTPException(status_code=403, detail="Access denied to project") - - library = asset_library_repository.get(request.asset_library_id) - if library is None or library.project_id != request.project_id: - raise HTTPException(status_code=404, detail=f"AssetLibrary {request.asset_library_id} not found") - - assets = asset_repository.list_by_library(request.asset_library_id) - _ensure_library_has_ready_video_assets(assets) + project_id, asset_library_id = _resolve_project_and_library( + request, project_repository, asset_library_repository, asset_repository, authenticated_user + ) + + # asset_library 存在性校验(仅在提供了 asset_library_id 时) + if asset_library_id: + library = asset_library_repository.get(asset_library_id) + if library is None or (project_id and library.project_id != project_id): + raise HTTPException(status_code=404, detail=f"AssetLibrary {asset_library_id} not found") + + assets = asset_repository.find_by_library(asset_library_id) + _ensure_library_has_ready_video_assets(assets) use_case = CreateGenerationTaskUseCase(generation_task_repository) task = use_case.execute( CreateGenerationTaskCommand( - project_id=request.project_id, - asset_library_id=request.asset_library_id, + project_id=project_id, + asset_library_id=asset_library_id, strategy_id=request.strategy_id, voice_library_id=request.voice_library_id, + template_id=request.template_id, + asset_ids=request.asset_ids, + title_ids=request.title_ids, + voice_ids=request.voice_ids, created_by_user_id=authenticated_user.user.id, ) ) @@ -115,6 +161,17 @@ def create_generation_task( return _to_generation_task_response(task) +@router.get("/tasks", response_model=ListGenerationTasksResponse) +def list_generation_tasks( + authenticated_user: AuthenticatedUser = Depends(get_current_user), + generation_task_repository: Any = Depends(get_generation_task_repository), +) -> ListGenerationTasksResponse: + """用户级生成任务列表(跨 project)。""" + tasks = generation_task_repository.list_by_user(authenticated_user.user.id) + items = [_to_generation_task_response(task) for task in tasks] + return ListGenerationTasksResponse(items=items) + + @router.get("/tasks/{task_id}", response_model=GenerationTaskResponse) def get_generation_task( task_id: str, @@ -126,7 +183,8 @@ def get_generation_task( task = use_case.execute(task_id) if task is None: raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found") - _check_project_access(task.project_id, authenticated_user.user.id, project_repository) + if task.project_id: + _check_project_access(task.project_id, authenticated_user.user.id, project_repository) return _to_generation_task_response(task) @@ -141,7 +199,42 @@ def list_generation_results( task = generation_task_repository.get(task_id) if task is None: raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found") - _check_project_access(task.project_id, authenticated_user.user.id, project_repository) + if task.project_id: + _check_project_access(task.project_id, authenticated_user.user.id, project_repository) use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository) items = use_case.execute(task_id) return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items]) + + +@router.post("/tasks/{task_id}/retry", response_model=GenerationTaskResponse) +def retry_generation_task( + task_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + generation_task_repository: Any = Depends(get_generation_task_repository), +) -> GenerationTaskResponse: + """简化重试:通过 task_id 直接重试失败任务。""" + task = generation_task_repository.get(task_id) + if task is None: + raise HTTPException(status_code=404, detail="Generation task not found") + if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id: + raise HTTPException(status_code=403, detail="Access denied to this task") + status_val = task.status.value if hasattr(task.status, "value") else str(task.status) + if status_val != "failed": + raise HTTPException(status_code=409, detail="Only failed tasks can be retried") + + use_case = CreateGenerationTaskUseCase(generation_task_repository) + retried = use_case.execute( + CreateGenerationTaskCommand( + project_id=task.project_id, + asset_library_id=task.asset_library_id, + strategy_id=task.strategy_id, + voice_library_id=task.voice_library_id, + template_id=task.template_id, + asset_ids=task.asset_ids, + title_ids=task.title_ids, + voice_ids=task.voice_ids, + created_by_user_id=authenticated_user.user.id, + ) + ) + celery_app.send_task("worker.generate_video", args=[retried.id]) + return _to_generation_task_response(retried) diff --git a/apps/api/app/api/routes/projects.py b/apps/api/app/api/routes/projects.py index cf352a909..7e8e502b0 100644 --- a/apps/api/app/api/routes/projects.py +++ b/apps/api/app/api/routes/projects.py @@ -22,8 +22,10 @@ router = APIRouter() def _to_project_response(item) -> ProjectResponse: return ProjectResponse( id=item.id, + owner_user_id=item.owner_user_id, name=item.name, description=item.description, + shared_users=item.shared_users, ) diff --git a/apps/api/app/api/routes/task_center.py b/apps/api/app/api/routes/task_center.py index ab72eca14..62c97c960 100644 --- a/apps/api/app/api/routes/task_center.py +++ b/apps/api/app/api/routes/task_center.py @@ -7,7 +7,12 @@ from app.dependencies import ( get_ingest_job_repository, get_project_repository, ) -from app.schemas.task_center import ListProjectTasksResponse, ProjectTaskResponse +from app.schemas.task_center import ( + ListProjectTasksResponse, + ListTasksResponse, + ProjectTaskResponse, + UserTaskResponse, +) from fastapi import APIRouter, Depends, HTTPException from packages.application import ( @@ -34,28 +39,136 @@ def _humanize_task_error(error_message: str) -> str: return f"任务失败:{raw}" +def _status_value(status) -> str: + """安全获取状态值(兼容 StrEnum 和 plain string)。""" + return status.value if hasattr(status, "value") else str(status) + + def _generation_step(task) -> str: - if task.status.value == "pending": + s = _status_value(task.status) + if s == "pending": return "等待 Worker 执行" - if task.status.value == "running": + if s == "running": return "正在生成成片" - if task.status.value == "completed": + if s == "completed": return "生成完成" - if task.status.value == "failed": + if s == "failed": return "生成失败" - return task.status.value + return s def _ingest_step(job) -> str: - if job.status.value == "pending": + s = _status_value(job.status) + if s == "pending": return "等待导入" - if job.status.value == "processing": + if s == "processing": return "正在分析素材" - if job.status.value == "completed": + if s == "completed": return "导入完成" - if job.status.value == "failed": + if s == "failed": return "导入失败" - return job.status.value + return s + + +def _generation_task_to_project_response(task) -> ProjectTaskResponse: + return ProjectTaskResponse( + id=f"generation:{task.id}", + task_type="generation", + project_id=task.project_id, + status=_status_value(task.status), + progress=task.progress, + current_step=_generation_step(task), + error_message=task.error_message, + user_message=_humanize_task_error(task.error_message), + retryable=_status_value(task.status) == "failed", + source_id=task.id, + template_id=task.template_id, + created_at=task.created_at, + updated_at=task.completed_at or task.started_at or task.created_at, + ) + + +# ── 用户级端点(放在项目级端点之前,避免路由冲突) ── + + +@router.get("/tasks", response_model=ListTasksResponse) +def list_user_tasks( + authenticated_user: AuthenticatedUser = Depends(get_current_user), + ingest_job_repository: Any = Depends(get_ingest_job_repository), + generation_task_repository: Any = Depends(get_generation_task_repository), +) -> ListTasksResponse: + """用户级任务列表(跨 project),合并 ingest + generation 任务。""" + user_id = authenticated_user.user.id + items: list[UserTaskResponse] = [] + + for task in generation_task_repository.list_by_user(user_id): + items.append( + UserTaskResponse( + id=f"generation:{task.id}", + task_type="generation", + project_id=task.project_id, + template_id=task.template_id, + status=_status_value(task.status), + progress=task.progress, + current_step=_generation_step(task), + error_message=task.error_message, + user_message=_humanize_task_error(task.error_message), + retryable=_status_value(task.status) == "failed", + source_id=task.id, + created_at=task.created_at, + updated_at=task.completed_at or task.started_at or task.created_at, + ) + ) + + items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True) + return ListTasksResponse(items=items) + + +@router.post("/tasks/{task_id}/retry", response_model=UserTaskResponse) +def retry_task_by_id( + task_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + generation_task_repository: Any = Depends(get_generation_task_repository), +) -> UserTaskResponse: + """简化重试:通过 task_id 直接重试失败的生成任务。""" + task = generation_task_repository.get(task_id) + if task is None: + raise HTTPException(status_code=404, detail="Generation task not found") + if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id: + raise HTTPException(status_code=403, detail="Access denied to this task") + if _status_value(task.status) != "failed": + raise HTTPException(status_code=409, detail="Only failed tasks can be retried") + + use_case = CreateGenerationTaskUseCase(generation_task_repository) + retried = use_case.execute( + CreateGenerationTaskCommand( + project_id=task.project_id, + asset_library_id=task.asset_library_id, + strategy_id=task.strategy_id, + voice_library_id=task.voice_library_id, + template_id=task.template_id, + asset_ids=task.asset_ids, + title_ids=task.title_ids, + voice_ids=task.voice_ids, + created_by_user_id=authenticated_user.user.id, + ) + ) + celery_app.send_task("worker.generate_video", args=[retried.id]) + return UserTaskResponse( + id=f"generation:{retried.id}", + task_type="generation", + project_id=retried.project_id, + template_id=retried.template_id, + status=_status_value(retried.status), + progress=retried.progress, + current_step=_generation_step(retried), + source_id=retried.id, + created_at=retried.created_at, + updated_at=retried.created_at, + ) + + +# ── 项目级端点 ── @router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse) @@ -77,34 +190,19 @@ def list_project_tasks( id=f"ingest:{job.id}", task_type="ingest", project_id=job.project_id, - status=job.status.value, - progress=100.0 if job.status.value == "completed" else 0.0, + status=_status_value(job.status), + progress=100.0 if _status_value(job.status) == "completed" else 0.0, current_step=_ingest_step(job), error_message=job.error_message, user_message=_humanize_task_error(job.error_message), - retryable=job.status.value == "failed", + retryable=_status_value(job.status) == "failed", source_id=job.id, created_at=job.created_at, updated_at=job.updated_at, ) ) for task in generation_task_repository.list_by_project(project_id): - items.append( - ProjectTaskResponse( - id=f"generation:{task.id}", - task_type="generation", - project_id=task.project_id, - status=task.status.value, - progress=task.progress, - current_step=_generation_step(task), - error_message=task.error_message, - user_message=_humanize_task_error(task.error_message), - retryable=task.status.value == "failed", - source_id=task.id, - created_at=task.created_at, - updated_at=task.completed_at or task.started_at or task.created_at, - ) - ) + items.append(_generation_task_to_project_response(task)) items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True) return ListProjectTasksResponse(items=items) @@ -121,7 +219,7 @@ def retry_project_task( task = generation_task_repository.get(source_id) if task is None: raise HTTPException(status_code=404, detail="Generation task not found") - if task.status.value != "failed": + if _status_value(task.status) != "failed": raise HTTPException(status_code=409, detail="Only failed tasks can be retried") use_case = CreateGenerationTaskUseCase(generation_task_repository) retried = use_case.execute( @@ -130,27 +228,20 @@ def retry_project_task( asset_library_id=task.asset_library_id, strategy_id=task.strategy_id, voice_library_id=task.voice_library_id, - edit_plan_id=task.edit_plan_id, + template_id=task.template_id, + asset_ids=task.asset_ids, + title_ids=task.title_ids, + voice_ids=task.voice_ids, created_by_user_id=authenticated_user.user.id, ) ) celery_app.send_task("worker.generate_video", args=[retried.id]) - return ProjectTaskResponse( - id=f"generation:{retried.id}", - task_type="generation", - project_id=retried.project_id, - status=retried.status.value, - progress=retried.progress, - current_step=_generation_step(retried), - source_id=retried.id, - created_at=retried.created_at, - updated_at=retried.created_at, - ) + return _generation_task_to_project_response(retried) if task_type == "ingest": job = ingest_job_repository.get(source_id) if job is None: raise HTTPException(status_code=404, detail="Ingest job not found") - if job.status.value != "failed": + if _status_value(job.status) != "failed": raise HTTPException(status_code=409, detail="Only failed tasks can be retried") use_case = SubmitIngestJobUseCase(ingest_job_repository) retried = use_case.execute( @@ -165,7 +256,7 @@ def retry_project_task( id=f"ingest:{retried.id}", task_type="ingest", project_id=retried.project_id, - status=retried.status.value, + status=_status_value(retried.status), progress=0, current_step=_ingest_step(retried), source_id=retried.id, diff --git a/apps/api/app/schemas/dashboard.py b/apps/api/app/schemas/dashboard.py new file mode 100644 index 000000000..d1732b0a5 --- /dev/null +++ b/apps/api/app/schemas/dashboard.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class RecentTaskItem(BaseModel): + id: str + task_type: str = "generation" + status: str + current_step: str = "" + error_message: str = "" + updated_at: datetime | None = None + + +class SubscriptionInfo(BaseModel): + """用户订阅信息。""" + plan: str = "free" + is_active: bool = False + + +class DashboardOverviewResponse(BaseModel): + """Dashboard 概览数据。""" + total_assets: int = 0 + used_storage_bytes: int = 0 + total_titles: int = 0 + total_voices: int = 0 + total_tasks: int = 0 + total_products: int = 0 + subscription: SubscriptionInfo = Field(default_factory=SubscriptionInfo) + recent_tasks: list[RecentTaskItem] = Field(default_factory=list) diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index 0a6fec37b..a9e0beb91 100644 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -1,12 +1,35 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class CreateGenerationTaskRequest(BaseModel): - project_id: str = Field(..., min_length=1) - asset_library_id: str = Field(..., min_length=1) + """创建生成任务请求。 + + 支持两种模式(至少提供一种): + - 项目模式:project_id + asset_library_id(向后兼容) + - 模板模式:template_id + asset_ids / title_ids / voice_ids + """ + project_id: str = "" + asset_library_id: str = "" strategy_id: str = "" voice_library_id: str = "" created_by_user_id: str = "" + # ── 模板模式新增字段 ── + template_id: str = "" + asset_ids: list[str] = Field(default_factory=list) + title_ids: list[str] = Field(default_factory=list) + voice_ids: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest": + has_project = bool(self.project_id.strip()) + has_template = bool(self.template_id.strip()) + if not has_project and not has_template: + raise ValueError("project_id 或 template_id 至少需要提供一个") + has_library = bool(self.asset_library_id.strip()) + has_assets = bool(self.asset_ids or self.title_ids or self.voice_ids) + if not has_library and not has_assets: + raise ValueError("asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个") + return self class GenerationTaskResponse(BaseModel): @@ -15,7 +38,16 @@ class GenerationTaskResponse(BaseModel): asset_library_id: str strategy_id: str voice_library_id: str + template_id: str = "" + asset_ids: list[str] = Field(default_factory=list) + title_ids: list[str] = Field(default_factory=list) + voice_ids: list[str] = Field(default_factory=list) status: str progress: float result_count: int error_message: str + + +class ListGenerationTasksResponse(BaseModel): + """用户级生成任务列表响应(跨 project)。""" + items: list[GenerationTaskResponse] diff --git a/apps/api/app/schemas/task_center.py b/apps/api/app/schemas/task_center.py index a7c5f2f9a..0aaaf7170 100644 --- a/apps/api/app/schemas/task_center.py +++ b/apps/api/app/schemas/task_center.py @@ -14,9 +14,32 @@ class ProjectTaskResponse(BaseModel): user_message: str = "" retryable: bool = False source_id: str = "" + template_id: str = "" created_at: datetime | None = None updated_at: datetime | None = None class ListProjectTasksResponse(BaseModel): items: list[ProjectTaskResponse] = Field(default_factory=list) + + +class UserTaskResponse(BaseModel): + """用户级任务响应(跨 project,用于模板模式)。""" + id: str + task_type: str + project_id: str = "" + template_id: str = "" + status: str + progress: float + current_step: str + error_message: str = "" + user_message: str = "" + retryable: bool = False + source_id: str = "" + created_at: datetime | None = None + updated_at: datetime | None = None + + +class ListTasksResponse(BaseModel): + """用户级任务列表响应(GET /api/v1/tasks)。""" + items: list[UserTaskResponse] = Field(default_factory=list) diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts deleted file mode 100644 index 50f98674d..000000000 --- a/apps/web/src/api/editPlans.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * 编辑计划 API - * Phase 1 重构:去掉 projectId,编辑计划直接归属用户 - */ -import apiClient from './client'; - -/** 编辑模式 */ -export type EditingMode = 'one-take' | 'pip' | 'voiceover' | 'voice_pip'; - -/** 编辑模板 */ -export interface EditTemplateItem { - id: string; - name: string; - description: string; - target_duration: number; - clip_count: number; - is_active: boolean; - category?: string; - thumbnail_url?: string; -} - -/** 编辑计划片段 */ -export interface EditPlanClipItem { - id: string; - asset_id: string; - asset_name: string; - sequence: number; - start_time: number; - duration: number; - reason: string; - layer?: 'main' | 'pip' | 'broll'; - thumbnail_url?: string; -} - -/** 编辑计划 */ -export interface EditPlanItem { - id: string; - template_id: string; - asset_library_id: string; - title_id: string; - status: string; - editing_mode?: EditingMode; - summary: string; - clips: EditPlanClipItem[]; - created_at?: string; - updated_at?: string; -} - -// ─── 编辑计划 ────────────────────────────────────────────── - -/** 获取当前用户的编辑计划列表 */ -export const getEditPlans = async (): Promise => { - const response = await apiClient.get('/edit-plans'); - return response.data.items || []; -}; - -/** 获取单个编辑计划 */ -export const getEditPlan = async (planId: string): Promise => { - const response = await apiClient.get(`/edit-plans/${planId}`); - return response.data; -}; - -/** 创建编辑计划 */ -export const createEditPlan = async (data: { - asset_library_id: string; - template_id?: string; - title_id?: string; -}): Promise => { - const response = await apiClient.post('/edit-plans', data); - return response.data; -}; - -/** 智能编排 - 自动生成编辑计划 */ -export const autoGenerateEditPlan = async (params: { - template_id: string; - asset_ids?: string[]; - title_ids?: string[]; - voice_ids?: string[]; - editing_mode?: EditingMode; - target_duration?: number; -}): Promise => { - const response = await apiClient.post('/edit-plans/auto-generate', params); - return response.data; -}; - -/** 更新编辑计划 */ -export const updateEditPlan = async ( - planId: string, - data: Partial -): Promise => { - const response = await apiClient.patch(`/edit-plans/${planId}`, data); - return response.data; -}; - -/** 删除编辑计划 */ -export const deleteEditPlan = async (planId: string): Promise => { - await apiClient.delete(`/edit-plans/${planId}`); -}; diff --git a/apps/web/src/api/editingPlanner.ts b/apps/web/src/api/editingPlanner.ts index 063acbc9f..9c032d66a 100644 --- a/apps/web/src/api/editingPlanner.ts +++ b/apps/web/src/api/editingPlanner.ts @@ -1,8 +1,8 @@ /** * 剪辑计划编辑器 API - * 当前使用 mock 数据,后端 API 就绪后替换 + * 对接后端 /api/v1/templates 路由 */ -// import apiClient from './client'; // TODO: 后端 API 就绪后启用 +import apiClient from './client'; /* ──────────── 类型定义 ──────────── */ @@ -53,11 +53,11 @@ export interface BgmConfig { /** 模板片段 */ export interface TemplateSegment { - id: string; + id?: string; segment_order: number; duration_min: number; duration_max: number; - material_type: string | null; // 仅 口播+混剪 模式:人物/场景 + material_type: string | null; } /** 剪辑模板 */ @@ -72,6 +72,7 @@ export interface EditingTemplate { bgm_config: BgmConfig; estimated_duration: number; segments: TemplateSegment[]; + is_active?: boolean; created_at: string; updated_at: string; } @@ -80,6 +81,7 @@ export interface EditingTemplate { export interface TemplateCategory { id: string; name: string; + created_at?: string; } /** 创建/更新模板请求体 */ @@ -100,124 +102,46 @@ export interface GenerateFromTemplatePayload { voiceover_duration: number; } -/** 使用模板生成响应 */ -export interface GenerateFromTemplateResponse { - task_id: string; - warning?: string; +/** 验证/生成响应 */ +export interface ValidateWarning { + code: string; + message: string; + details?: Record; } -/* ──────────── Mock 数据 ──────────── */ +/** 使用模板生成响应 */ +export interface GenerateFromTemplateResponse { + template: EditingTemplate; + warnings: ValidateWarning[]; +} -let _nextId = 100; -const nextId = () => String(++_nextId); +/** 列表响应(带分页) */ +export interface ListTemplatesResponse { + items: EditingTemplate[]; + total: number; +} -const MOCK_CATEGORIES: TemplateCategory[] = [ - { id: 'cat-1', name: '生活' }, - { id: 'cat-2', name: '美食' }, - { id: 'cat-3', name: '旅行' }, - { id: 'cat-4', name: '知识' }, -]; +/** 分类列表响应 */ +export interface ListCategoriesResponse { + items: TemplateCategory[]; +} -const MOCK_TEMPLATES: EditingTemplate[] = [ - { - id: 'tpl-1', - name: '生活 Vlog 模板', - mode: 'pip', - category: '生活', - tags: ['vlog', '日常'], - title_config: { - ai_auto_select: true, - content: '', - font_preset: '思源黑体', - font_color: '#ffffff', - font_size: 32, - position: 'top', - }, - subtitle_config: { - enabled: true, - position: 'bottom', - font: '思源黑体', - color: '#ffffff', - size: 24, - animation: 'fade', - }, - bgm_config: { enabled: true, music_id: 'bgm-1' }, - estimated_duration: 30, - segments: [ - { id: 'seg-1', segment_order: 1, duration_min: 5, duration_max: 15, material_type: null }, - { id: 'seg-2', segment_order: 2, duration_min: 10, duration_max: 20, material_type: null }, - ], - created_at: '2026-06-20T10:00:00Z', - updated_at: '2026-06-20T10:00:00Z', - }, - { - id: 'tpl-2', - name: '知识分享口播', - mode: 'voice_over', - category: '知识', - tags: ['口播', '分享'], - title_config: { - ai_auto_select: false, - content: '每日知识分享', - font_preset: '站酷快乐体', - font_color: '#ffdd00', - font_size: 36, - position: 'top', - }, - subtitle_config: { - enabled: true, - position: 'bottom', - font: '思源黑体', - color: '#ffffff', - size: 28, - animation: 'typewriter', - }, - bgm_config: { enabled: false, music_id: '' }, - estimated_duration: 60, - segments: [ - { id: 'seg-3', segment_order: 1, duration_min: 10, duration_max: 30, material_type: null }, - { id: 'seg-4', segment_order: 2, duration_min: 20, duration_max: 40, material_type: null }, - { id: 'seg-5', segment_order: 3, duration_min: 10, duration_max: 20, material_type: null }, - ], - created_at: '2026-06-21T10:00:00Z', - updated_at: '2026-06-21T10:00:00Z', - }, - { - id: 'tpl-3', - name: '一镜到底展示', - mode: 'one_take', - category: '生活', - tags: ['一镜到底'], - title_config: { - ai_auto_select: true, - content: '', - font_preset: '思源黑体', - font_color: '#ffffff', - font_size: 32, - position: 'center', - }, - subtitle_config: { enabled: false, position: 'bottom', font: '思源黑体', color: '#ffffff', size: 24, animation: 'fade' }, - bgm_config: { enabled: true, music_id: 'bgm-2' }, - estimated_duration: 15, - segments: [ - { id: 'seg-6', segment_order: 1, duration_min: 10, duration_max: 20, material_type: null }, - ], - created_at: '2026-06-22T10:00:00Z', - updated_at: '2026-06-22T10:00:00Z', - }, -]; - -/* ──────────── Mock API 函数 ──────────── */ - -const delay = (ms = 200) => new Promise((r) => setTimeout(r, ms)); +// ============ API 函数 ============ /** 获取模板列表 */ export const getEditingTemplates = async (params?: { category?: string; tag?: string; + skip?: number; + limit?: number; }): Promise => { - await delay(); - let list = [...MOCK_TEMPLATES]; + const response = await apiClient.get('/templates', { + params: { + skip: params?.skip ?? 0, + limit: params?.limit ?? 50, + }, + }); + let list = response.data.items; if (params?.category) list = list.filter((t) => t.category === params.category); if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!)); return list; @@ -225,38 +149,16 @@ export const getEditingTemplates = async (params?: { /** 获取模板详情 */ export const getEditingTemplate = async (id: string): Promise => { - await delay(); - const tpl = MOCK_TEMPLATES.find((t) => t.id === id); - if (!tpl) throw new Error('模板不存在'); - return { ...tpl }; + const response = await apiClient.get(`/templates/${id}`); + return response.data; }; /** 创建模板 */ export const createEditingTemplate = async ( data: SaveTemplatePayload, ): Promise => { - await delay(300); - const now = new Date().toISOString(); - const tpl: EditingTemplate = { - id: nextId(), - name: data.name, - mode: data.mode, - category: data.category, - tags: data.tags, - title_config: data.title_config, - subtitle_config: data.subtitle_config, - bgm_config: data.bgm_config, - estimated_duration: data.estimated_duration, - segments: data.segments.map((s, i) => ({ - ...s, - id: nextId(), - segment_order: i + 1, - })), - created_at: now, - updated_at: now, - }; - MOCK_TEMPLATES.push(tpl); - return tpl; + const response = await apiClient.post('/templates', data); + return response.data; }; /** 更新模板 */ @@ -264,51 +166,29 @@ export const updateEditingTemplate = async ( id: string, data: SaveTemplatePayload, ): Promise => { - await delay(300); - const idx = MOCK_TEMPLATES.findIndex((t) => t.id === id); - if (idx === -1) throw new Error('模板不存在'); - const updated: EditingTemplate = { - ...MOCK_TEMPLATES[idx], - name: data.name, - mode: data.mode, - category: data.category, - tags: data.tags, - title_config: data.title_config, - subtitle_config: data.subtitle_config, - bgm_config: data.bgm_config, - estimated_duration: data.estimated_duration, - segments: data.segments.map((s, i) => ({ - ...s, - id: nextId(), - segment_order: i + 1, - })), - updated_at: new Date().toISOString(), - }; - MOCK_TEMPLATES[idx] = updated; - return updated; + const response = await apiClient.patch(`/templates/${id}`, data); + return response.data; }; /** 删除模板 */ export const deleteEditingTemplate = async (id: string): Promise => { - await delay(); - const idx = MOCK_TEMPLATES.findIndex((t) => t.id === id); - if (idx !== -1) MOCK_TEMPLATES.splice(idx, 1); + await apiClient.delete(`/templates/${id}`); }; /** 获取模板分类列表 */ export const getTemplateCategories = async (): Promise => { - await delay(); - return [...MOCK_CATEGORIES]; + const response = await apiClient.get('/templates/categories/list'); + return response.data.items; }; -/** 使用模板生成视频 */ +/** 使用模板生成视频(调用 validate 端点) */ export const generateFromTemplate = async ( - _templateId: string, - _data: GenerateFromTemplatePayload, + templateId: string, + data: GenerateFromTemplatePayload, ): Promise => { - await delay(500); - return { - task_id: nextId(), - warning: undefined, - }; + const response = await apiClient.post( + `/templates/${templateId}/validate`, + data, + ); + return response.data; }; diff --git a/apps/web/src/api/tasks.ts b/apps/web/src/api/tasks.ts index 536b859ba..de9ed2e89 100644 --- a/apps/web/src/api/tasks.ts +++ b/apps/web/src/api/tasks.ts @@ -1,13 +1,20 @@ /** * 任务相关 API - * Phase 1 重构:去掉 projectId,任务直接归属用户 + * 对接后端方案 A 扩展后的端点(PR #109) + * - POST /api/v1/generation/tasks — 创建生成任务(template_id + asset_ids 细粒度模式) + * - GET /api/v1/tasks — 用户级任务列表(跨 project) + * - POST /api/v1/tasks/{task_id}/retry — 简化重试 */ import apiClient from './client'; -/** 任务条目 */ +/* ──────────── 类型定义 ──────────── */ + +/** 任务条目(对应用户级 UserTaskResponse) */ export interface TaskItem { id: string; task_type: 'ingest' | 'generation' | string; + project_id: string; + template_id: string; status: string; progress: number; current_step: string; @@ -19,18 +26,6 @@ export interface TaskItem { updated_at?: string | null; } -/** 获取当前用户的所有任务(生成记录) */ -export const getUserTasks = async (): Promise => { - const response = await apiClient.get('/tasks'); - return response.data.items || []; -}; - -/** 重试失败的任务 */ -export const retryTask = async (taskId: string): Promise => { - const response = await apiClient.post(`/tasks/${taskId}/retry`); - return response.data; -}; - /** 创建生成任务请求参数 */ export interface CreateGenerationTaskRequest { template_id: string; @@ -39,31 +34,44 @@ export interface CreateGenerationTaskRequest { voice_ids: string[]; } -/** 创建生成任务响应 */ +/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */ export interface CreateGenerationTaskResponse { - task_id: string; + id: string; + project_id: string; + asset_library_id: string; + strategy_id: string; + voice_library_id: string; + template_id: string; + asset_ids: string[]; + title_ids: string[]; + voice_ids: string[]; status: string; - message: string; + progress: number; + result_count: number; + error_message: string; } -// TODO: 后端生成接口适配扁平化架构后切换为 false -const USE_MOCK = true; +/* ──────────── API 函数 ──────────── */ /** 创建生成任务(一键生成) */ export const createGenerationTask = async ( params: CreateGenerationTaskRequest, ): Promise => { - if (USE_MOCK) { - await new Promise((r) => setTimeout(r, 800)); - return { - task_id: `task_${Date.now()}`, - status: 'pending', - message: '生成任务已创建', - }; - } - const response = await apiClient.post( + const { data } = await apiClient.post( '/generation/tasks', params, ); - return response.data; + return data; +}; + +/** 获取当前用户的所有任务(跨 project) */ +export const getUserTasks = async (): Promise => { + const { data } = await apiClient.get('/tasks'); + return data.items || []; +}; + +/** 重试失败的任务 */ +export const retryTask = async (taskId: string): Promise => { + const { data } = await apiClient.post(`/tasks/${taskId}/retry`); + return data; }; diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index 4dd1e7b8f..e38d2df1d 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -168,7 +168,7 @@ const EditingPlanner: React.FC = () => { mutationFn: ({ templateId, duration }: { templateId: string; duration: number }) => generateFromTemplate(templateId, { voiceover_duration: duration }), onSuccess: (data) => { - const msg = data.warning ? `生成任务已提交(${data.warning})` : '生成任务已提交'; + const msg = data.warnings && data.warnings.length > 0 ? `生成任务已提交(${data.warnings.map(w => w.message).join('; ')})` : '生成任务已提交'; message.success(msg); setGenerateModalOpen(false); }, diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index 6f1d0cbb1..709be93c7 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -136,7 +136,7 @@ const TimelinePanel: React.FC = ({ size="small" danger icon={} - onClick={() => onRemoveSegment(seg.id)} + onClick={() => onRemoveSegment(seg.id!)} disabled={isOneShot} style={{ marginLeft: 'auto' }} /> @@ -154,7 +154,7 @@ const TimelinePanel: React.FC = ({ min={1} max={seg.duration_max} value={seg.duration_min} - onChange={(v) => onUpdateSegment(seg.id, { duration_min: v })} + onChange={(v) => onUpdateSegment(seg.id!, { duration_min: v })} />
@@ -163,7 +163,7 @@ const TimelinePanel: React.FC = ({ min={seg.duration_min} max={60} value={seg.duration_max} - onChange={(v) => onUpdateSegment(seg.id, { duration_max: v })} + onChange={(v) => onUpdateSegment(seg.id!, { duration_max: v })} />
@@ -175,7 +175,7 @@ const TimelinePanel: React.FC = ({