Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia 1d1329facd ci: trigger verification 2026-07-13 18:49:37 +08:00
243 changed files with 4171 additions and 41555 deletions
-1
View File
@@ -1 +0,0 @@
re-trigger
+1 -1
View File
@@ -1 +1 @@
trigger: 1784009947
# CI trigger Fri Jun 26 09:53:28 PM CST 2026
Regular → Executable
+943 -962
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -263,3 +263,5 @@ pytest --cov=packages --cov-report=html
---
**License**: MIT
<!-- ci-trigger-1783939777 -->
@@ -1,47 +0,0 @@
"""add error_info and retry fields to generation_tasks
Revision ID: 038_error_retry
Revises: 037_generation_logs
Create Date: 2026-07-13 22:15:00.000000
"""
import sqlalchemy as sa
from sqlalchemy.dialects.mysql import JSON as MySQLJSON
from alembic import op
# revision identifiers, used by Alembic.
revision = "038_error_retry"
down_revision = "037_generation_logs"
branch_labels = None
depends_on = None
def upgrade():
# error_info: 结构化错误信息(error_type, message, stack_trace, failed_at, stage等)
op.add_column(
"generation_tasks",
sa.Column("error_info", sa.JSON(), nullable=True),
)
# retry_count: 重试次数
op.add_column(
"generation_tasks",
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
)
# auto_retry_enabled: 是否开启自动重试
op.add_column(
"generation_tasks",
sa.Column("auto_retry_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
# auto_retry_max: 最大自动重试次数
op.add_column(
"generation_tasks",
sa.Column("auto_retry_max", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade():
op.drop_column("generation_tasks", "auto_retry_max")
op.drop_column("generation_tasks", "auto_retry_enabled")
op.drop_column("generation_tasks", "retry_count")
op.drop_column("generation_tasks", "error_info")
@@ -1,34 +0,0 @@
"""add transition_duration to edit_plan_clips
Revision ID: 039_transition_duration
Revises: 038_error_retry
Create Date: 2026-07-14 09:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "039_transition_duration"
down_revision = "038_error_retry"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plan_clips",
sa.Column(
"transition_duration",
sa.Float(),
nullable=False,
server_default="0.0",
),
)
def downgrade() -> None:
op.drop_column("edit_plan_clips", "transition_duration")
@@ -1,29 +0,0 @@
"""add playback_speed to edit_plan_clips
Revision ID: 040_playback_speed
Revises: 039_transition_duration
Create Date: 2026-07-14 10:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "040_playback_speed"
down_revision = "039_transition_duration"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plan_clips",
sa.Column("playback_speed", sa.Float(), nullable=False, server_default="1.0"),
)
def downgrade() -> None:
op.drop_column("edit_plan_clips", "playback_speed")
-5
View File
@@ -19,7 +19,6 @@ from app.api.routes.templates import router as templates_router
from app.api.routes.titles import router as titles_router
from app.api.routes.tts import router as tts_router
from app.api.routes.upload import router as upload_router
from app.api.routes.videos import router as videos_router
from app.api.routes.voice_clones import router as voice_clones_router
from app.api.routes.voices import router as voices_router
from fastapi import APIRouter
@@ -100,10 +99,6 @@ api_router.include_router(
prefix="/voice-clones",
tags=["VoiceClone"],
)
api_router.include_router(
videos_router,
tags=["VideoCenter"],
)
api_router.include_router(
duplication_router,
prefix="/duplication",
+6 -5
View File
@@ -12,7 +12,7 @@ from app.schemas.asset_library import (
EnsureDefaultLibraryRequest,
ListAssetLibrariesResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from packages.application import (
CreateAssetLibraryCommand,
@@ -146,7 +146,7 @@ def ensure_default_library(
return _to_asset_library_response(created)
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_asset_library(
library_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -163,10 +163,11 @@ def delete_asset_library(
# 权限校验:检查用户是否有项目访问权限
check_project_access(library.project_id, authenticated_user.user.id, project_repository)
# 删除库内所有素材(硬删除,素材库已删除,无需保留软删除状态
# 删除库内所有素材(无 FK 级联,需手动清理
assets_in_library = asset_repository.find_by_library(library_id)
for asset in assets_in_library:
asset_repository.delete(asset.id)
if assets_in_library:
asset_ids_to_delete = [a.id for a in assets_in_library]
asset_repository.batch_delete(asset_ids_to_delete)
# 删除素材库本身
asset_library_repository.delete(library_id)
+22 -185
View File
@@ -1,7 +1,6 @@
import logging
from typing import Any, Optional
from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
@@ -12,18 +11,15 @@ from app.dependencies import (
)
from app.schemas.asset import (
AssetResponse,
BatchClassifyRequest,
BatchDeleteRequest,
BatchMarkRequest,
BatchOperationResponse,
BatchTagRequest,
BatchDeleteResponse,
CreateAssetRequest,
ListAssetsResponse,
UpdateAssetRequest,
UpdateAssetReviewRequest,
)
from app.schemas.tag import TagAssetsRequest
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import (
CreateAssetCommand,
@@ -31,6 +27,8 @@ from packages.application import (
)
from packages.domain import AssetStatus, ClassificationStatus
from app.api.routes._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -76,6 +74,7 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
)
@router.get("", response_model=ListAssetsResponse)
def list_assets(
library_id: Optional[str] = Query(None),
@@ -85,15 +84,6 @@ def list_assets(
gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"),
style: Optional[str] = Query(None, description="按 metadata.style 筛选"),
tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"),
smart_view: Optional[str] = Query(
None,
description="智能视图筛选:recommended=推荐(质量分≥80)、cautious=慎用(60-79)、risky=高风险(<60或已驳回)、unused=未使用、used=已使用、pending_review=待复核",
pattern="^(recommended|cautious|risky|unused|used|pending_review)$",
),
classification: Optional[str] = Query(
None,
description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他",
),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -113,11 +103,11 @@ def list_assets(
if not filter_tag_ids:
filter_tag_ids = None
# 需要内存过滤的标志(keyword/gender/style/tag_ids/smart_view/classification 无法在 DB 层过滤)
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids or smart_view or classification)
# 需要内存过滤的标志(keyword/gender/style/tag_ids 无法在 DB 层过滤)
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids)
def _apply_memory_filters(items):
"""应用 keyword / gender / style / tag_ids / smart_view / classification 内存过滤。"""
"""应用 keyword / gender / style / tag_ids 内存过滤。"""
result = items
if keyword:
kw = keyword.lower()
@@ -126,38 +116,9 @@ def list_assets(
result = [i for i in result if (i.metadata or {}).get("gender") == gender]
if style:
result = [i for i in result if (i.metadata or {}).get("style") == style]
if classification:
result = [i for i in result if (i.metadata or {}).get("classification") == classification]
if filter_tag_ids:
tag_set = set(filter_tag_ids)
result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))]
if smart_view:
def __meta(a):
return a.metadata or {}
def __use_count(a):
return int(__meta(a).get("generation_use_count") or 0)
def __review_status(a):
return __meta(a).get("review_status", "")
if smart_view == "recommended":
result = [i for i in result if i.quality_score is not None and i.quality_score >= 80]
elif smart_view == "cautious":
result = [i for i in result if i.quality_score is not None and 60 <= i.quality_score < 80]
elif smart_view == "risky":
result = [
i
for i in result
if (i.quality_score is not None and i.quality_score < 60) or __review_status(i) == "rejected"
]
elif smart_view == "unused":
result = [i for i in result if __use_count(i) == 0]
elif smart_view == "used":
result = [i for i in result if __use_count(i) > 0]
elif smart_view == "pending_review":
result = [i for i in result if __review_status(i) == "pending_review"]
return result
# ── 优化路径:无内存过滤时,使用 DB 级分页 ──
@@ -301,157 +262,33 @@ def update_asset_review_status(
return _to_asset_response(updated)
@router.post("/batch-delete", response_model=BatchOperationResponse)
@router.post("/batch-delete", response_model=BatchDeleteResponse)
def batch_delete_assets(
request: BatchDeleteRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> BatchOperationResponse:
"""批量删除素材(软删除,标记 status=deleted),需逐项校验项目权限。"""
) -> BatchDeleteResponse:
"""批量删除素材(配音素材等),需逐项校验项目权限。"""
user_id = authenticated_user.user.id
success_ids: list[str] = []
failed_details: dict[str, str] = {}
deleted_ids: list[str] = []
failed_ids: list[str] = []
for asset_id in request.asset_ids:
for asset_id in request.ids:
item = asset_repository.find_by_id(asset_id)
if item is None:
failed_details[asset_id] = "not_found"
failed_ids.append(asset_id)
continue
try:
check_project_access(item.project_id, user_id, project_repository)
success_ids.append(asset_id)
deleted_ids.append(asset_id)
except HTTPException:
failed_details[asset_id] = "access_denied"
failed_ids.append(asset_id)
if success_ids:
asset_repository.batch_delete(success_ids)
if deleted_ids:
asset_repository.batch_delete(deleted_ids)
return BatchOperationResponse(
success_count=len(success_ids),
failed_ids=list(failed_details.keys()),
failed_details=failed_details,
)
@router.post("/batch-tag", response_model=BatchOperationResponse)
def batch_tag_assets(
request: BatchTagRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
tag_repository: Any = Depends(get_tag_repository),
) -> BatchOperationResponse:
"""批量打标签(添加或替换模式),需逐项校验项目权限和标签权限。"""
user_id = authenticated_user.user.id
success_ids: list[str] = []
failed_details: dict[str, str] = {}
# 校验标签存在且属于当前用户
for tag_id in request.tag_ids:
tag = tag_repository.get(tag_id)
if tag is None:
return BatchOperationResponse(
success_count=0,
failed_ids=list(request.asset_ids),
failed_details={aid: f"tag_not_found:{tag_id}" for aid in request.asset_ids},
)
if tag.user_id != user_id:
return BatchOperationResponse(
success_count=0,
failed_ids=list(request.asset_ids),
failed_details={aid: f"tag_access_denied:{tag_id}" for aid in request.asset_ids},
)
# 校验素材权限
for asset_id in request.asset_ids:
item = asset_repository.find_by_id(asset_id)
if item is None:
failed_details[asset_id] = "not_found"
continue
try:
check_project_access(item.project_id, user_id, project_repository)
success_ids.append(asset_id)
except HTTPException:
failed_details[asset_id] = "access_denied"
if success_ids:
if request.mode == "replace":
asset_repository.batch_replace_tags(success_ids, request.tag_ids)
else:
asset_repository.batch_add_tags(success_ids, request.tag_ids)
return BatchOperationResponse(
success_count=len(success_ids),
failed_ids=list(failed_details.keys()),
failed_details=failed_details,
)
@router.post("/batch-classify", response_model=BatchOperationResponse)
def batch_classify_assets(
request: BatchClassifyRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> BatchOperationResponse:
"""批量修改素材内容分类(person/scenic/product等),存在metadata.category中。"""
user_id = authenticated_user.user.id
success_ids: list[str] = []
failed_details: dict[str, str] = {}
for asset_id in request.asset_ids:
item = asset_repository.find_by_id(asset_id)
if item is None:
failed_details[asset_id] = "not_found"
continue
try:
check_project_access(item.project_id, user_id, project_repository)
success_ids.append(asset_id)
except HTTPException:
failed_details[asset_id] = "access_denied"
if success_ids:
asset_repository.batch_update_metadata(success_ids, {"category": request.category})
return BatchOperationResponse(
success_count=len(success_ids),
failed_ids=list(failed_details.keys()),
failed_details=failed_details,
)
@router.post("/batch-mark", response_model=BatchOperationResponse)
def batch_mark_assets(
request: BatchMarkRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> BatchOperationResponse:
"""批量设置智能视图标记(recommended/caution/high_risk),存在metadata.smart_view中。"""
user_id = authenticated_user.user.id
success_ids: list[str] = []
failed_details: dict[str, str] = {}
for asset_id in request.asset_ids:
item = asset_repository.find_by_id(asset_id)
if item is None:
failed_details[asset_id] = "not_found"
continue
try:
check_project_access(item.project_id, user_id, project_repository)
success_ids.append(asset_id)
except HTTPException:
failed_details[asset_id] = "access_denied"
if success_ids:
asset_repository.batch_update_metadata(success_ids, {"smart_view": request.smart_view})
return BatchOperationResponse(
success_count=len(success_ids),
failed_ids=list(failed_details.keys()),
failed_details=failed_details,
)
return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_ids)
@router.get("/{asset_id}", response_model=AssetResponse)
@@ -493,7 +330,7 @@ def update_asset(
return _to_asset_response(updated)
@router.delete("/{asset_id}", status_code=204, response_class=Response)
@router.delete("/{asset_id}", status_code=204)
def delete_asset(
asset_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -532,7 +369,7 @@ def tag_asset(
return _to_asset_response(updated)
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204, response_class=Response)
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204)
def untag_asset(
asset_id: str,
tag_id: str,
+11 -11
View File
@@ -105,7 +105,7 @@ async def register(
request: RegisterRequest,
user_repository: UserRepository = Depends(get_user_repository),
email_service=Depends(get_auth_email_service),
) -> RegisterResponse:
):
use_case = RegisterUserUseCase(
user_repository=user_repository,
base_url=settings.APP_BASE_URL,
@@ -136,7 +136,7 @@ async def login(
request: LoginRequest,
user_repository: UserRepository = Depends(get_user_repository),
session_store=Depends(get_auth_session_store),
) -> LoginResponse:
):
use_case = LoginUseCase(
user_repository=user_repository,
session_store=session_store,
@@ -162,7 +162,7 @@ async def refresh(
request: RefreshRequest,
user_repository: UserRepository = Depends(get_user_repository),
session_store=Depends(get_auth_session_store),
) -> LoginResponse:
):
use_case = RefreshTokenUseCase(
user_repository=user_repository,
session_store=session_store,
@@ -194,7 +194,7 @@ def _verify_email_token(token: str, user_repository: UserRepository) -> MessageR
async def verify_email(
token: str,
user_repository: UserRepository = Depends(get_user_repository),
) -> MessageResponse:
):
return _verify_email_token(token, user_repository)
@@ -202,7 +202,7 @@ async def verify_email(
async def verify_email_post(
request: VerifyEmailRequestModel,
user_repository: UserRepository = Depends(get_user_repository),
) -> MessageResponse:
):
return _verify_email_token(request.token, user_repository)
@@ -211,7 +211,7 @@ async def forgot_password(
request: PasswordResetRequestModel,
user_repository: UserRepository = Depends(get_user_repository),
email_service=Depends(get_auth_email_service),
) -> MessageResponse:
):
success, error = RequestPasswordResetUseCase(
user_repository=user_repository,
base_url=settings.APP_BASE_URL,
@@ -227,7 +227,7 @@ async def forgot_password(
async def reset_password(
request: ResetPasswordModel,
user_repository: UserRepository = Depends(get_user_repository),
) -> MessageResponse:
):
success, error = ResetPasswordUseCase(user_repository=user_repository).execute(
ResetPasswordRequest(token=request.token, new_password=request.new_password)
)
@@ -241,7 +241,7 @@ async def reset_password(
async def logout(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> MessageResponse:
):
"""登出 - 将当前 token 加入黑名单"""
if credentials:
@@ -257,7 +257,7 @@ async def logout(
@router.get("/me", response_model=CurrentUserResponse)
async def get_current_user_info(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
) -> CurrentUserResponse:
):
user = authenticated_user.user
return CurrentUserResponse(
user_id=user.id,
@@ -323,7 +323,7 @@ def _get_internal_api_keys() -> list[str]:
if content:
return [k.strip() for k in content.split(",") if k.strip()]
except Exception:
logger.warning("无法读取内部 API 密钥文件,仅依赖环境变量配置", exc_info=True)
logger.debug("Failed to read internal API keys from file", exc_info=True)
return []
@@ -354,7 +354,7 @@ async def wechat_sync(
request: WechatSyncRequest,
user_repository: UserRepository = Depends(get_user_repository),
_: bool = Depends(_verify_internal_api_key),
) -> WechatSyncResponse:
):
"""
微信同步登录/注册(系统级内部接口)
+2 -1
View File
@@ -13,7 +13,6 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from app.api.routes._helpers import require_project_and_library
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
@@ -35,6 +34,8 @@ from fastapi.params import File
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
from app.api.routes._helpers import require_project_and_library
router = APIRouter()
logger = logging.getLogger(__name__)
+2 -2
View File
@@ -239,7 +239,7 @@ def get_duplication_detail(
return _to_detail_response(record)
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_duplication_record(
record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -257,7 +257,7 @@ def delete_duplication_record(
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
use_case.execute(record_id)
return
return Response(status_code=204)
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
+776 -22
View File
@@ -6,11 +6,12 @@ RESTful CRUD for EditPlan:
- POST /api/v1/edit-plans 创建
- PUT /api/v1/edit-plans/{id} 更新(含状态机流转)
- DELETE /api/v1/edit-plans/{id} 删除
拆分模块(各自独立 router,由本文件 include_router 聚合):
- edit_plans_generation.py 生成相关(generate / generation-status / generations
- edit_plans_ai.py AI 推荐 & 封面(ai-recommend / generate-cover
- edit_plans_timeline.py 时间线 & 模板生成(timeline / generate-from-template
- POST /api/v1/edit-plans/{id}/generate 触发剪辑渲染生成(任务 2.05)
- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05)
- POST /api/v1/edit-plans/{id}/ai-recommend AI 推荐片段方案(任务 3.09
- POST /api/v1/edit-plans/{id}/generate-cover AI 生成封面(任务 3.09
- GET /api/v1/edit-plans/{id}/timeline 时间线场景数据
- POST /api/v1/edit-plans/generate-from-template 基于模板+素材自动生成剪辑计划
业务逻辑委托给 EditPlanService 服务层。
"""
@@ -22,17 +23,32 @@ 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, get_project_repository
from app.core.celery_app import celery_app
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.schemas.generation_task import GenerationTaskResponse
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from app.services import EditPlanService, PlanGeneratorService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from ._helpers import check_project_access
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
logger = logging.getLogger(__name__)
@@ -146,7 +162,6 @@ class AIRecommendClipItem(BaseModel):
text_content: str = Field(default="", description="文字内容")
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
transition_effect: str = Field(default="cut", description="转场效果")
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
asset_id: str = Field(default="", description="关联素材 ID")
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
@@ -210,8 +225,6 @@ class _PlanClipItem(BaseModel):
start_time: float
duration: float
transition_effect: str
transition_duration: float
playback_speed: float = 1.0
status: str
config: Optional[dict[str, Any]] = None
created_at: datetime
@@ -245,7 +258,7 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
)
# ── CRUD Routes ───────────────────────────────────────────────────────────────
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanListResponse)
@@ -425,7 +438,7 @@ def update_plan(
return _to_response(result)
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_plan(
plan_id: str,
db: Session = Depends(get_db_session),
@@ -451,12 +464,753 @@ def delete_plan(
)
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
# ── 生成相关端点(任务 2.05) ─────────────────────────────────────────────────
from .edit_plans_ai import router as ai_router
from .edit_plans_generation import router as generation_router
from .edit_plans_timeline import router as timeline_router
router.include_router(generation_router)
router.include_router(ai_router)
router.include_router(timeline_router)
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
def generate_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repo: Any = Depends(get_asset_library_repository),
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发剪辑计划渲染生成
前置条件:计划状态必须为 editing,且至少有一个片段。
流程:
1. 验证计划状态为 editing
2. 将 pending 片段标记为 ready
3. 创建 GenerationTask
4. 调度 Celery 任务 worker.render_edit_plan
5. 将计划状态流转为 rendering
"""
svc = EditPlanService(db)
# 项目鉴权
plan_check = svc.get_plan(plan_id)
if plan_check is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan_check.project_id:
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
# ── 自动兜底 1: draft → editing ──────────────────────────────────────
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
# ── 自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置 ──────────
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
# 优先从新模型 template_clip_configs 读取,若无则回退到旧模型 template_segments
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=(
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
)
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
else:
# 回退到旧模型 template_segments
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main", # 旧模型无结构角色,统一为主体片段
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
# ── 自动兜底 3: 为没有素材的片段分配素材 ──────────────────────────
# 如果 plan.config.asset_ids 有素材,但 clips 没有 asset_id,自动按顺序分配
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
material_mode = (plan_check.config or {}).get("material_mode", "manual")
if clips_without_asset and config_asset_ids:
logger.info(
"自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
clips_without_asset = [] # 已分配完
# ── 自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取 ────────────
if clips_without_asset and material_mode == "auto" and plan_check.project_id:
import random
logger.info(
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
plan_id,
len(clips_without_asset),
)
# 找到项目的视频素材库
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
# 筛选 ready 状态的视频素材
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
if ready_videos:
# 随机选取,按片段数轮询分配
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
plan_id,
video_lib.name,
len(ready_videos),
len(clips_without_asset),
)
else:
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
else:
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
# 检查是否可生成
try:
can_gen, reason = svc.can_generate(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
if not can_gen:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=reason,
)
# 核心生成流程:捕获异常返回明确错误信息,避免裸 500
try:
# 将 pending 片段标记为 ready
clip_count = svc.mark_clips_ready(plan_id)
# 创建 GenerationTask
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
# 队列限流预检查(repository 不支持计数时跳过)
user_id = current_user.user.id
try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
gen_task_repo, "count_pending_total"
)
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
)
)
# 将 generation_task_id 存入 plan config
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
# 流转状态为 rendering
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
# 调度 Celery 任务
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
# 获取最新状态
updated_plan = svc.get_plan_or_raise(plan_id)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
# 已处理的 HTTP 异常直接透传
raise
except Exception:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
)
@router.get(
"/{plan_id}/generation-status",
response_model=EditPlanGenerationStatusResponse,
)
def get_generation_status(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationStatusResponse:
"""查询剪辑计划生成进度
返回计划状态、关联的 GenerationTask ID、以及每个片段的状态。
"""
svc = EditPlanService(db)
try:
gen_status = svc.get_generation_status(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
plan = gen_status["plan"]
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = gen_status["clips"]
clip_items = [
ClipStatusItem(
clip_id=c.id,
clip_type=c.clip_type,
order=c.order,
status=c.status.value if hasattr(c.status, "value") else c.status,
asset_id=c.asset_id or "",
text_content=c.text_content or "",
duration=c.duration,
)
for c in clips
]
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
generation_task_id=gen_status["generation_task_id"],
clips=clip_items,
)
@router.get(
"/{plan_id}/generations",
response_model=EditPlanGenerationsResponse,
)
def list_plan_generations(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationsResponse:
"""查询剪辑计划关联的所有生成记录
返回该剪辑计划触发的所有 GenerationTask,按创建时间倒序。
"""
svc = EditPlanService(db)
# 验证计划存在 + 项目鉴权
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
items = [
GenerationTaskResponse(
id=t.id,
project_id=t.project_id,
asset_library_id=t.asset_library_id,
strategy_id=t.strategy_id,
voice_library_id=t.voice_library_id,
template_id=t.template_id,
asset_ids=t.asset_ids,
title_ids=t.title_ids,
voice_ids=t.voice_ids,
source_edit_plan_id=t.source_edit_plan_id or "",
status=t.status.value if hasattr(t.status, "value") else t.status,
progress=t.progress,
result_count=t.result_count,
error_message=t.error_message,
)
for t in tasks
]
return EditPlanGenerationsResponse(items=items, total=len(items))
# ── AI 推荐 & 封面生成端点(任务 3.09) ────────────────────────────────────────
@router.post(
"/{plan_id}/ai-recommend",
response_model=AIRecommendResponse,
)
def ai_recommend_clips(
plan_id: str,
body: AIRecommendRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> AIRecommendResponse:
"""AI 推荐片段方案
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
流程:
1. 验证计划存在且状态为 draft/editing
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
3. 清除计划现有片段,按推荐方案重新创建
4. 更新计划 configcover/title/subtitle/bgm)和 total_duration
5. 返回推荐方案详情
前端对接:
- 请求体只需传 asset_ids(必填),editing_mode 和 target_duration 可选
- 返回的 clips 可直接渲染到时间线
- 返回的 config 包含推荐的封面/标题/字幕/BGM 配置
"""
svc = EditPlanService(db)
# 验证计划存在
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证状态:只允许 draft 或 editing
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status not in ("draft", "editing"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
)
# 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步)
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
result = run_ai_recommend(
plan_id=plan_id,
template_id=plan.template_id,
asset_ids=body.asset_ids,
editing_mode=body.editing_mode,
target_duration=body.target_duration,
)
# ── 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中 ──
# TODO: 当前各 repo 方法内部 commit(),无法真正回滚。
# 后续重构 repo 为 flush() 模式后,此处改为统一 commit。
try:
# 清除现有片段
svc.delete_all_clips(plan_id)
# 按推荐方案创建新片段
for clip_data in result["clips"]:
svc.create_clip(
plan_id=plan_id,
clip_type=clip_data["clip_type"],
order=clip_data["order"],
text_content=clip_data.get("text_content", ""),
duration=clip_data["duration"],
transition_effect=clip_data.get("transition_effect", "cut"),
asset_id=clip_data.get("asset_id", ""),
start_time=clip_data.get("start_time", 0.0),
config=clip_data.get("config", {}),
)
# 更新计划 config 和 total_duration
normalized_config = normalize_plan_config(result.get("config", {}))
svc.update_plan(
plan_id,
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
# 尝试回滚未提交的变更
try:
db.rollback()
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
)
logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
plan_id,
len(result["clips"]),
result["total_duration"],
current_user.user.id,
)
return AIRecommendResponse(
plan_id=plan_id,
clips=[
AIRecommendClipItem(
clip_type=c["clip_type"],
order=c["order"],
text_content=c.get("text_content", ""),
duration=c["duration"],
transition_effect=c.get("transition_effect", "cut"),
asset_id=c.get("asset_id", ""),
start_time=c.get("start_time", 0.0),
config=c.get("config", {}),
)
for c in result["clips"]
],
config=normalized_config,
total_duration=result["total_duration"],
confidence=result["confidence"],
)
@router.post(
"/{plan_id}/generate-cover",
response_model=GenerateCoverResponse,
)
def generate_cover(
plan_id: str,
body: GenerateCoverRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateCoverResponse:
"""AI 生成封面
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
流程:
1. 验证计划存在
2. 调用 AI 封面生成服务(当前为 stub,后续接入真实 AI)
3. 更新 plan.config["cover"] 为生成的封面数据
4. 返回封面数据
前端对接:
- cover_type=ai_frame: AI 智能选帧(默认)
- cover_type=manual: 手动选帧,需传 frame_time
- cover_type=upload: 用户上传,接口返回空 image_url,前端自行上传后更新
- cover_type=ai_regenerate: AI 重新生成
"""
svc = EditPlanService(db)
# 验证计划存在
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 调用 AI 封面生成服务
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
cover_type=body.cover_type,
frame_time=body.frame_time,
)
# 更新 plan.config["cover"]
current_config = dict(plan.config)
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"AI 封面生成: plan_id=%s type=%s by user=%s",
plan_id,
body.cover_type,
current_user.user.id,
)
return GenerateCoverResponse(
plan_id=plan_id,
cover=cover_data,
)
# ── Timeline / Scene 端点(P2-6) ─────────────────────────────────────────────
class TimelineSceneResponse(BaseModel):
"""时间线场景"""
scene: str = Field(..., description="场景描述")
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
duration: float = Field(..., ge=0, description="时长(秒)")
color: str = Field(..., description="展示颜色")
clip_id: str = Field(default="", description="关联的片段 ID")
clip_type: str = Field(default="", description="片段类型")
class TimelineResponse(BaseModel):
"""时间线响应"""
plan_id: str
total_duration: float
scenes: List[TimelineSceneResponse]
# clip_type → 颜色映射
_CLIP_TYPE_COLORS = {
"intro": "#6366f1",
"title": "#6366f1",
"product": "#818cf8",
"showcase": "#10b981",
"scene": "#10b981",
"subtitle": "#f59e0b",
"text": "#f59e0b",
"cta": "#ef4444",
"outro": "#ef4444",
"voiceover": "#8b5cf6",
"transition": "#64748b",
}
_DEFAULT_COLOR = "#6366f1"
def _format_time(seconds: float) -> str:
"""将秒数格式化为 M:SS"""
m = int(seconds) // 60
s = int(seconds) % 60
return f"{m}:{s:02d}"
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
"""根据 clip_type 和 text_content 生成场景描述"""
type_labels = {
"intro": "开场",
"title": "标题",
"product": "产品展示",
"showcase": "场景展示",
"scene": "场景",
"subtitle": "字幕",
"text": "文字",
"cta": "结尾 CTA",
"outro": "结尾",
"voiceover": "配音",
"transition": "转场",
}
label = type_labels.get(clip_type, clip_type or "片段")
if text_content:
# 截取前 20 个字符作为副标题
short = text_content[:20].strip()
if short:
return f"{label} - {short}"
return label
@router.get(
"/{plan_id}/timeline",
response_model=TimelineResponse,
)
def get_plan_timeline(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> TimelineResponse:
"""获取剪辑计划的时间线场景数据
返回按计划片段排序的时间线场景列表,供前端 GeneratePage 渲染使用。
"""
svc = EditPlanService(db)
plan = svc.get_plan_or_raise(plan_id)
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
# 按 order 排序
clips.sort(key=lambda c: c.order)
scenes: List[TimelineSceneResponse] = []
current_time = 0.0
for clip in clips:
start = current_time
end = start + clip.duration
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
scenes.append(
TimelineSceneResponse(
scene=scene_label,
time=f"{_format_time(start)} - {_format_time(end)}",
duration=clip.duration,
color=color,
clip_id=clip.id,
clip_type=clip.clip_type,
)
)
current_time = end
total_duration = sum(s.duration for s in scenes) or plan.total_duration
return TimelineResponse(
plan_id=plan_id,
total_duration=total_duration,
scenes=scenes,
)
# ── 基于模板生成剪辑计划 ─────────────────────────────────────────────────────
@router.post(
"/generate-from-template",
response_model=GenerateFromTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
def generate_from_template(
body: GenerateFromTemplateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateFromTemplateResponse:
"""基于模板 + 素材自动生成剪辑计划
流程:
1. 获取模板及其片段配置
2. 调用 PlanGeneratorService 生成 EditPlan + EditPlanClips
3. 返回完整的计划和片段列表
"""
from app.services import EditTemplateService
# 项目鉴权
if body.project_id:
check_project_access(body.project_id, current_user.user.id, project_repository)
template_svc = EditTemplateService(db)
# 获取模板
try:
template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
# 获取模板片段配置
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
# 调用 PlanGeneratorService 生成计划
generator = PlanGeneratorService(db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=body.asset_ids,
project_id=body.project_id,
created_by_user_id=current_user.user.id,
name=body.name,
)
plan = result["plan"]
clips = result["clips"]
logger.info(
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
plan.id,
body.template_id,
len(clips),
current_user.user.id,
)
return GenerateFromTemplateResponse(
plan=_to_response(plan),
clips=[
_PlanClipItem(
id=c.id,
clip_type=c.clip_type,
order=c.order,
asset_id=c.asset_id,
text_content=c.text_content,
start_time=c.start_time,
duration=c.duration,
transition_effect=c.transition_effect,
status=c.status.value if hasattr(c.status, "value") else c.status,
config=c.config,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in clips
],
)
-199
View File
@@ -1,199 +0,0 @@
"""剪辑计划 AI 推荐 & 封面生成 API 端点。
从 edit_plans.py 拆分,包含:
- POST /{plan_id}/ai-recommend AI 推荐片段方案
- POST /{plan_id}/generate-cover AI 生成封面
"""
from __future__ import annotations
import logging
from typing import Any
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
AIRecommendClipItem,
AIRecommendRequest,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
)
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/{plan_id}/ai-recommend",
response_model=AIRecommendResponse,
)
def ai_recommend_clips(
plan_id: str,
body: AIRecommendRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> AIRecommendResponse:
"""AI 推荐片段方案
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
流程:
1. 验证计划存在且状态为 draft/editing
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
3. 清除计划现有片段,按推荐方案重新创建
4. 更新计划 configcover/title/subtitle/bgm)和 total_duration
5. 返回推荐方案详情
"""
svc = EditPlanService(db)
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status not in ("draft", "editing"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
)
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
result = run_ai_recommend(
plan_id=plan_id,
template_id=plan.template_id,
asset_ids=body.asset_ids,
editing_mode=body.editing_mode,
target_duration=body.target_duration,
)
# 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中
try:
svc.delete_all_clips(plan_id)
for clip_data in result["clips"]:
svc.create_clip(
plan_id=plan_id,
clip_type=clip_data["clip_type"],
order=clip_data["order"],
text_content=clip_data.get("text_content", ""),
duration=clip_data["duration"],
transition_effect=clip_data.get("transition_effect", "cut"),
asset_id=clip_data.get("asset_id", ""),
start_time=clip_data.get("start_time", 0.0),
config=clip_data.get("config", {}),
)
normalized_config = normalize_plan_config(result.get("config", {}))
svc.update_plan(
plan_id,
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
try:
db.rollback()
except Exception as rollback_err:
logger.error(
"AI 推荐回滚失败,数据库会话可能处于不一致状态: plan_id=%s error=%s",
plan_id,
rollback_err,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
)
logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
plan_id,
len(result["clips"]),
result["total_duration"],
current_user.user.id,
)
return AIRecommendResponse(
plan_id=plan_id,
clips=[
AIRecommendClipItem(
clip_type=c["clip_type"],
order=c["order"],
text_content=c.get("text_content", ""),
duration=c["duration"],
transition_effect=c.get("transition_effect", "cut"),
asset_id=c.get("asset_id", ""),
start_time=c.get("start_time", 0.0),
config=c.get("config", {}),
)
for c in result["clips"]
],
config=normalized_config,
total_duration=result["total_duration"],
confidence=result["confidence"],
)
@router.post(
"/{plan_id}/generate-cover",
response_model=GenerateCoverResponse,
)
def generate_cover(
plan_id: str,
body: GenerateCoverRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateCoverResponse:
"""AI 生成封面
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
"""
svc = EditPlanService(db)
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
cover_type=body.cover_type,
frame_time=body.frame_time,
)
current_config = dict(plan.config)
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"AI 封面生成: plan_id=%s type=%s by user=%s",
plan_id,
body.cover_type,
current_user.user.id,
)
return GenerateCoverResponse(
plan_id=plan_id,
cover=cover_data,
)
@@ -1,383 +0,0 @@
"""剪辑计划生成相关 API 端点。
从 edit_plans.py 拆分,包含:
- POST /{plan_id}/generate 触发剪辑渲染生成
- GET /{plan_id}/generation-status 查询生成进度
- GET /{plan_id}/generations 查询关联的生成记录
"""
from __future__ import annotations
import logging
from typing import Any
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
ClipStatusItem,
EditPlanGenerateResponse,
EditPlanGenerationsResponse,
EditPlanGenerationStatusResponse,
)
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from packages.domain.edit_plan import EditPlanStatus
logger = logging.getLogger(__name__)
router = APIRouter()
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
"""自动兜底 1: draft → editing"""
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=(
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
)
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
else:
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main",
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
def _auto_fallback_assign_assets(
svc: EditPlanService,
plan_id: str,
plan_check,
) -> list:
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
if clips_without_asset and config_asset_ids:
logger.info(
"自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
clips_without_asset = []
return clips_without_asset
def _auto_fallback_auto_material_mode(
svc: EditPlanService,
plan_id: str,
plan_check,
clips_without_asset: list,
asset_library_repo: Any,
asset_repo: Any,
) -> None:
"""自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取"""
if not clips_without_asset:
return
material_mode = (plan_check.config or {}).get("material_mode", "manual")
if material_mode != "auto" or not plan_check.project_id:
return
import random
logger.info(
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
plan_id,
len(clips_without_asset),
)
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
if ready_videos:
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
plan_id,
video_lib.name,
len(ready_videos),
len(clips_without_asset),
)
else:
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
else:
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查"""
try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
gen_task_repo, "count_pending_total"
)
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
def generate_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repo: Any = Depends(get_asset_library_repository),
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发剪辑计划渲染生成
前置条件:计划状态必须为 editing,且至少有一个片段。
流程:
1. 验证计划状态为 editing
2. 将 pending 片段标记为 ready
3. 创建 GenerationTask
4. 调度 Celery 任务 worker.render_edit_plan
5. 将计划状态流转为 rendering
"""
svc = EditPlanService(db)
plan_check = svc.get_plan(plan_id)
if plan_check is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan_check.project_id:
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
# 自动兜底流程
_auto_fallback_draft_to_editing(svc, plan_id, plan_check)
_auto_fallback_copy_template_clips(svc, plan_id, plan_check, db)
clips_without_asset = _auto_fallback_assign_assets(svc, plan_id, plan_check)
_auto_fallback_auto_material_mode(svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo)
# 检查是否可生成
try:
can_gen, reason = svc.can_generate(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if not can_gen:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
# 核心生成流程
try:
clip_count = svc.mark_clips_ready(plan_id)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
user_id = current_user.user.id
_check_queue_limits(gen_task_repo, user_id)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
)
)
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
updated_plan = svc.get_plan_or_raise(plan_id)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
raise
except Exception:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
)
@router.get(
"/{plan_id}/generation-status",
response_model=EditPlanGenerationStatusResponse,
)
def get_generation_status(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationStatusResponse:
"""查询剪辑计划生成进度"""
svc = EditPlanService(db)
try:
gen_status = svc.get_generation_status(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
plan = gen_status["plan"]
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = gen_status["clips"]
clip_items = [
ClipStatusItem(
clip_id=c.id,
clip_type=c.clip_type,
order=c.order,
status=c.status.value if hasattr(c.status, "value") else c.status,
asset_id=c.asset_id or "",
text_content=c.text_content or "",
duration=c.duration,
)
for c in clips
]
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
generation_task_id=gen_status["generation_task_id"],
clips=clip_items,
)
@router.get(
"/{plan_id}/generations",
response_model=EditPlanGenerationsResponse,
)
def list_plan_generations(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationsResponse:
"""查询剪辑计划关联的所有生成记录"""
svc = EditPlanService(db)
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
from app.schemas.generation_task import GenerationTaskResponse
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
items = [
GenerationTaskResponse(
id=t.id,
project_id=t.project_id,
asset_library_id=t.asset_library_id,
strategy_id=t.strategy_id,
voice_library_id=t.voice_library_id,
template_id=t.template_id,
asset_ids=t.asset_ids,
title_ids=t.title_ids,
voice_ids=t.voice_ids,
source_edit_plan_id=t.source_edit_plan_id or "",
status=t.status.value if hasattr(t.status, "value") else t.status,
progress=t.progress,
result_count=t.result_count,
error_message=t.error_message,
)
for t in tasks
]
return EditPlanGenerationsResponse(items=items, total=len(items))
@@ -1,221 +0,0 @@
"""剪辑计划时间线 & 模板生成 API 端点。
从 edit_plans.py 拆分,包含:
- GET /{plan_id}/timeline 时间线场景数据
- POST /generate-from-template 基于模板+素材自动生成剪辑计划
"""
from __future__ import annotations
import logging
from typing import Any, List
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
_PlanClipItem,
_to_response,
)
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService, PlanGeneratorService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Timeline Schemas ──────────────────────────────────────────────────────────
class TimelineSceneResponse(BaseModel):
"""时间线场景"""
scene: str = Field(..., description="场景描述")
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
duration: float = Field(..., ge=0, description="时长(秒)")
color: str = Field(..., description="展示颜色")
clip_id: str = Field(default="", description="关联的片段 ID")
clip_type: str = Field(default="", description="片段类型")
class TimelineResponse(BaseModel):
"""时间线响应"""
plan_id: str
total_duration: float
scenes: List[TimelineSceneResponse]
# clip_type → 颜色映射
_CLIP_TYPE_COLORS = {
"intro": "#6366f1",
"title": "#6366f1",
"product": "#818cf8",
"showcase": "#10b981",
"scene": "#10b981",
"subtitle": "#f59e0b",
"text": "#f59e0b",
"cta": "#ef4444",
"outro": "#ef4444",
"voiceover": "#8b5cf6",
"transition": "#64748b",
}
_DEFAULT_COLOR = "#6366f1"
def _format_time(seconds: float) -> str:
"""将秒数格式化为 M:SS"""
m = int(seconds) // 60
s = int(seconds) % 60
return f"{m}:{s:02d}"
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
"""根据 clip_type 和 text_content 生成场景描述"""
type_labels = {
"intro": "开场",
"title": "标题",
"product": "产品展示",
"showcase": "场景展示",
"scene": "场景",
"subtitle": "字幕",
"text": "文字",
"cta": "结尾 CTA",
"outro": "结尾",
"voiceover": "配音",
"transition": "转场",
}
label = type_labels.get(clip_type, clip_type or "片段")
if text_content:
short = text_content[:20].strip()
if short:
return f"{label} - {short}"
return label
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get(
"/{plan_id}/timeline",
response_model=TimelineResponse,
)
def get_plan_timeline(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> TimelineResponse:
"""获取剪辑计划的时间线场景数据"""
svc = EditPlanService(db)
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
clips.sort(key=lambda c: c.order)
scenes: List[TimelineSceneResponse] = []
current_time = 0.0
for clip in clips:
start = current_time
end = start + clip.duration
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
scenes.append(
TimelineSceneResponse(
scene=scene_label,
time=f"{_format_time(start)} - {_format_time(end)}",
duration=clip.duration,
color=color,
clip_id=clip.id,
clip_type=clip.clip_type,
)
)
current_time = end
total_duration = sum(s.duration for s in scenes) or plan.total_duration
return TimelineResponse(
plan_id=plan_id,
total_duration=total_duration,
scenes=scenes,
)
@router.post(
"/generate-from-template",
response_model=GenerateFromTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
def generate_from_template(
body: GenerateFromTemplateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateFromTemplateResponse:
"""基于模板 + 素材自动生成剪辑计划"""
from app.services import EditTemplateService
if body.project_id:
check_project_access(body.project_id, current_user.user.id, project_repository)
template_svc = EditTemplateService(db)
try:
template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
generator = PlanGeneratorService(db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=body.asset_ids,
project_id=body.project_id,
created_by_user_id=current_user.user.id,
name=body.name,
)
plan = result["plan"]
clips = result["clips"]
logger.info(
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
plan.id,
body.template_id,
len(clips),
current_user.user.id,
)
return GenerateFromTemplateResponse(
plan=_to_response(plan),
clips=[
_PlanClipItem(
id=c.id,
clip_type=c.clip_type,
order=c.order,
asset_id=c.asset_id,
text_content=c.text_content,
start_time=c.start_time,
duration=c.duration,
transition_effect=c.transition_effect,
transition_duration=c.transition_duration,
status=c.status.value if hasattr(c.status, "value") else c.status,
config=c.config,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in clips
],
)
+8 -8
View File
@@ -19,7 +19,7 @@ from typing import Optional
from app.api.routes.auth import _verify_internal_api_key
from app.config import settings
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from packages.adapters.redis.feature_flag_store import (
@@ -89,7 +89,7 @@ def _validate_flag_name(name: str) -> None:
async def list_feature_flags(
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) -> list[FeatureFlagResponse]:
):
"""列出所有 Feature Flag。"""
try:
flags = store.list_all()
@@ -113,7 +113,7 @@ async def get_feature_flag(
name: str,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) -> FeatureFlagResponse:
):
"""获取单个 Feature Flag 配置。"""
try:
config = store.get(name)
@@ -129,7 +129,7 @@ async def check_feature_flag(
identifier: Optional[str] = Query(None, description="标识符,如 user_id"),
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) -> FeatureFlagCheckResponse:
):
"""检查某个标识符是否命中 Feature Flag。"""
try:
active = store.is_active(name, identifier=identifier)
@@ -145,7 +145,7 @@ async def update_feature_flag(
request: FeatureFlagUpdateRequest,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) -> FeatureFlagResponse:
):
"""更新 Feature Flag 配置。
只允许修改 ALLOWED_FLAGS 列表中的 flag。
@@ -173,12 +173,12 @@ async def update_feature_flag(
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_feature_flag(
name: str,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) :
):
"""删除 Feature Flag。
只允许删除 ALLOWED_FLAGS 列表中的 flag。
@@ -188,7 +188,7 @@ async def delete_feature_flag(
try:
deleted = store.delete(name)
logger.info("Feature flag deleted: name=%s deleted=%s", name, deleted)
pass
return None
except Exception as exc:
logger.error("Failed to delete feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
+2 -3
View File
@@ -3,7 +3,6 @@ import random
import uuid
from typing import Any
from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.core.task_enqueue import (
@@ -32,6 +31,8 @@ from app.schemas.generation_task import (
)
from fastapi import APIRouter, Depends, HTTPException
from app.api.routes._helpers import check_project_access
from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
@@ -267,8 +268,6 @@ def create_generation_task(
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
auto_retry_enabled=request.auto_retry_enabled,
auto_retry_max=request.auto_retry_max,
)
)
try:
+4 -4
View File
@@ -7,7 +7,7 @@ from app.schemas.project import (
ListProjectsResponse,
ProjectResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, HTTPException, status
from packages.application import (
CreateProjectCommand,
@@ -72,12 +72,12 @@ def create_project(
return _to_project_response(project)
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/{project_id}")
def delete_project(
project_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> dict:
):
use_case = DeleteProjectUseCase(project_repository)
try:
deleted = use_case.execute(project_id, authenticated_user.user.id)
@@ -88,4 +88,4 @@ def delete_project(
)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return
return {"message": "Project deleted successfully"}
+6 -6
View File
@@ -92,7 +92,7 @@ def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
@router.get("/current", response_model=SubscriptionInfo)
async def get_current_subscription(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> SubscriptionInfo:
):
"""获取当前订阅信息"""
return _build_subscription_info(current_user)
@@ -100,7 +100,7 @@ async def get_current_subscription(
@router.get("/billing-records", response_model=List[BillingRecord])
async def get_billing_records(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> List[BillingRecord]:
):
"""获取账单记录列表"""
from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository
from packages.adapters.sqlalchemy_impl.session import SessionLocal
@@ -134,7 +134,7 @@ async def change_plan(
request: ChangePlanRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> ChangePlanResponse:
):
"""变更订阅套餐(升级/降级)"""
# TODO: 接入支付验证(支付宝/微信支付)
valid_plans = {"free", "standard", "pro", "enterprise"}
@@ -186,7 +186,7 @@ async def change_plan(
async def cancel_subscription(
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> SimpleResponse:
):
"""取消订阅"""
user = current_user.user
if user.subscription_plan == "free":
@@ -212,7 +212,7 @@ async def payment_callback(
amount: float,
payment_method: str = "alipay",
payment_id: str = "",
) -> dict:
):
"""支付回调 - 在事务中更新账单和订阅状态
注意:生产环境需要验证支付签名
@@ -263,7 +263,7 @@ async def payment_callback(
async def toggle_auto_renew(
request: ToggleAutoRenewRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
) -> SimpleResponse:
):
"""切换自动续费"""
# TODO: 实际需要在数据库中存储 auto_renew 字段
status_text = "已开启自动续费" if request.enabled else "已关闭自动续费"
+2 -2
View File
@@ -10,7 +10,7 @@ from app.schemas.tag import (
ListTagsResponse,
TagResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi import APIRouter, Depends, HTTPException
from packages.domain import Tag
@@ -52,7 +52,7 @@ def create_tag(
return TagResponse(id=created.id, name=created.name, created_at=created.created_at)
@router.delete("/{tag_id}", status_code=204, response_class=Response)
@router.delete("/{tag_id}", status_code=204)
def delete_tag(
tag_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
+79 -136
View File
@@ -21,12 +21,11 @@ from app.schemas.task_center import (
ProjectTaskResponse,
UserTaskResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException
from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
RetryGenerationTaskUseCase,
SubmitIngestJobCommand,
SubmitIngestJobUseCase,
)
@@ -35,10 +34,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
DEFAULT_PAGE_SIZE = 50
MAX_PAGE_SIZE = 200
def _humanize_task_error(error_message: str) -> str:
raw = (error_message or "").strip()
if not raw:
@@ -68,8 +63,6 @@ def _generation_step(task) -> str:
return "生成完成"
if s == "failed":
return "生成失败"
if s == "cancelled":
return "已取消"
return s
@@ -86,26 +79,6 @@ def _ingest_step(job) -> str:
return s
def _generation_task_to_user_response(task) -> UserTaskResponse:
return 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,
error_info=task.error_info or {},
user_message=_humanize_task_error(task.error_message),
retryable=_status_value(task.status) == "failed",
retry_count=task.retry_count or 0,
source_id=task.id,
created_at=task.created_at,
updated_at=task.completed_at or task.started_at or task.created_at,
)
def _generation_task_to_project_response(task) -> ProjectTaskResponse:
return ProjectTaskResponse(
id=f"generation:{task.id}",
@@ -115,10 +88,8 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse:
progress=task.progress,
current_step=_generation_step(task),
error_message=task.error_message,
error_info=task.error_info or {},
user_message=_humanize_task_error(task.error_message),
retryable=_status_value(task.status) == "failed",
retry_count=task.retry_count or 0,
source_id=task.id,
template_id=task.template_id,
created_at=task.created_at,
@@ -126,66 +97,40 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse:
)
def _validate_status(status: str | None) -> str | None:
"""校验状态值合法性。"""
if status is None:
return None
valid = {"pending", "running", "completed", "failed", "cancelled"}
if status not in valid:
raise HTTPException(
status_code=400,
detail=f"无效的状态筛选值: {status},允许值: {', '.join(sorted(valid))}",
)
return status
def _clamp_page_size(page_size: int) -> int:
if page_size <= 0:
return DEFAULT_PAGE_SIZE
if page_size > MAX_PAGE_SIZE:
return MAX_PAGE_SIZE
return page_size
# ── 用户级端点(放在项目级端点之前,避免路由冲突) ──
@router.get("/tasks", response_model=ListTasksResponse)
def list_user_tasks(
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
page: int = Query(1, ge=1, description="页码,从1开始"),
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
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),支持状态/类型筛选和分页"""
status = _validate_status(status)
page_size = _clamp_page_size(page_size)
"""用户级任务列表(跨 project),合并 ingest + generation 任务"""
user_id = authenticated_user.user.id
offset = (page - 1) * page_size
items: list[UserTaskResponse] = []
# 生成任务
if task_type is None or task_type == "generation":
gen_result = generation_task_repository.list_by_user_filtered(
user_id,
status=status,
limit=page_size + 1, # 多取一条判断是否还有下一页(简单起见这里用offset)
offset=offset,
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,
)
)
for task in gen_result:
items.append(_generation_task_to_user_response(task))
# 按时间倒序
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
# 总数(仅generation,ingest暂不计入总数以保持简单)
total = generation_task_repository.count_by_user_filtered(user_id, status=status)
return ListTasksResponse(items=items[:page_size], total=total)
return ListTasksResponse(items=items)
@router.post("/tasks/{task_id}/retry", response_model=UserTaskResponse)
@@ -194,7 +139,7 @@ def retry_task_by_id(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> UserTaskResponse:
"""原地重试失败的生成任务(复用同一个task_idretry_count+1"""
"""简化重试:通过 task_id 直接重试失败的生成任务"""
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
@@ -204,7 +149,6 @@ def retry_task_by_id(
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
@@ -219,11 +163,20 @@ def retry_task_by_id(
detail="系统繁忙,请稍后再试",
)
# 原地重试
use_case = RetryGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(task_id)
# 重新入队
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=user_id,
)
)
try:
if not safe_enqueue_generation_task(
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
@@ -239,8 +192,18 @@ def retry_task_by_id(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
return _generation_task_to_user_response(retried)
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,
)
# ── 项目级端点 ──
@@ -249,64 +212,37 @@ def retry_task_by_id(
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
def list_project_tasks(
project_id: str,
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
page: int = Query(1, ge=1, description="页码,从1开始"),
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> ListProjectTasksResponse:
"""项目级任务列表,支持状态/类型筛选和分页。"""
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
status = _validate_status(status)
page_size = _clamp_page_size(page_size)
offset = (page - 1) * page_size
items: list[ProjectTaskResponse] = []
# 导入任务
if task_type is None or task_type == "ingest":
for job in ingest_job_repository.list_by_project(project_id):
if status and _status_value(job.status) != status:
continue
items.append(
ProjectTaskResponse(
id=f"ingest:{job.id}",
task_type="ingest",
project_id=job.project_id,
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=_status_value(job.status) == "failed",
source_id=job.id,
created_at=job.created_at,
updated_at=job.updated_at,
)
for job in ingest_job_repository.list_by_project(project_id):
items.append(
ProjectTaskResponse(
id=f"ingest:{job.id}",
task_type="ingest",
project_id=job.project_id,
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=_status_value(job.status) == "failed",
source_id=job.id,
created_at=job.created_at,
updated_at=job.updated_at,
)
# 生成任务
if task_type is None or task_type == "generation":
gen_items = generation_task_repository.list_by_project_filtered(
project_id,
status=status,
limit=page_size + 1,
offset=offset,
)
for task in gen_items:
items.append(_generation_task_to_project_response(task))
for task in generation_task_repository.list_by_project(project_id):
items.append(_generation_task_to_project_response(task))
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
total = generation_task_repository.count_by_project_filtered(project_id, status=status)
return ListProjectTasksResponse(items=items[:page_size], total=total)
return ListProjectTasksResponse(items=items)
@router.post("/tasks/{task_type}/{source_id}/retry", response_model=ProjectTaskResponse)
@@ -317,7 +253,6 @@ def retry_project_task(
ingest_job_repository: Any = Depends(get_ingest_job_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> ProjectTaskResponse:
"""项目级任务重试。"""
if task_type == "generation":
task = generation_task_repository.get(source_id)
if task is None:
@@ -326,7 +261,6 @@ def retry_project_task(
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
@@ -341,10 +275,20 @@ def retry_project_task(
detail="系统繁忙,请稍后再试",
)
# 原地重试
use_case = RetryGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(source_id)
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=user_id,
)
)
try:
if not safe_enqueue_generation_task(
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
@@ -361,7 +305,6 @@ def retry_project_task(
detail="系统繁忙,请稍后再试",
) from None
return _generation_task_to_project_response(retried)
if task_type == "ingest":
job = ingest_job_repository.get(source_id)
if job is None:
+8 -95
View File
@@ -8,16 +8,13 @@ from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.template import (
CategoryResponse,
CopyTemplateRequest,
CreateCategoryRequest,
CreateTemplateRequest,
GenerateWarningResponse,
ListCategoriesResponse,
ListTagsResponse,
ListTemplatesResponse,
SegmentResponse,
TemplateResponse,
TemplateUsageResponse,
ToggleFavoriteResponse,
UpdateTemplateRequest,
ValidateTemplateRequest,
@@ -30,24 +27,19 @@ logger = logging.getLogger(__name__)
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
from packages.application.template.commands import (
CopyTemplateCommand,
CreateCategoryCommand,
CreateTemplateCommand,
ListTemplatesFilter,
SegmentCommand,
UpdateTemplateCommand,
ValidateTemplateCommand,
)
from packages.application.template.use_cases import (
CopyTemplateUseCase,
CountTemplatesUseCase,
CreateCategoryUseCase,
CreateTemplateUseCase,
DeleteCategoryUseCase,
DeleteTemplateUseCase,
GetTemplateUseCase,
ListCategoriesUseCase,
ListTagsUseCase,
ListTemplatesUseCase,
NotFoundError,
UpdateTemplateUseCase,
@@ -75,7 +67,7 @@ def _segment_to_response(seg) -> SegmentResponse:
)
def _to_response(template, usage_count: int = 0) -> TemplateResponse:
def _to_response(template) -> TemplateResponse:
return TemplateResponse(
id=template.id,
user_id=template.user_id,
@@ -89,7 +81,6 @@ def _to_response(template, usage_count: int = 0) -> TemplateResponse:
estimated_duration=template.estimated_duration,
segments=[_segment_to_response(s) for s in getattr(template, "segments", [])],
is_active=template.is_active,
usage_count=usage_count,
created_at=template.created_at,
updated_at=template.updated_at,
)
@@ -102,36 +93,19 @@ def _to_response(template, usage_count: int = 0) -> TemplateResponse:
def list_templates(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
category: str | None = Query(None, description="按分类筛选"),
tag: str | None = Query(None, description="按标签筛选"),
keyword: str | None = Query(None, description="按名称关键词搜索"),
mode: str | None = Query(None, description="按剪辑模式筛选"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListTemplatesResponse:
user_id = authenticated_user.user.id
try:
tpl_filter = ListTemplatesFilter(
category=category,
tag=tag,
keyword=keyword,
mode=mode,
)
use_case = ListTemplatesUseCase(template_repository)
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
count_use_case = CountTemplatesUseCase(template_repository)
total = count_use_case.execute(user_id, filter=tpl_filter)
# 批量查询使用次数
items = []
for t in templates:
usage = template_repository.get_usage_count(t.id)
items.append(_to_response(t, usage_count=usage))
templates = use_case.execute(user_id, skip=skip, limit=limit)
total = template_repository.count_by_user(user_id)
except Exception:
logger.exception("list_templates 查询失败: user_id=%s", user_id)
return ListTemplatesResponse(items=[], total=0)
return ListTemplatesResponse(
items=items,
items=[_to_response(t) for t in templates],
total=total,
)
@@ -146,13 +120,12 @@ def get_template(
try:
use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id)
usage = template_repository.get_usage_count(template_id)
except Exception:
logger.exception("get_template 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template, usage_count=usage)
return _to_response(template)
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
@@ -233,7 +206,7 @@ def update_template(
return _to_response(template)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_template(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -244,48 +217,7 @@ def delete_template(
deleted = use_case.execute(template_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return
@router.post("/{template_id}/copy", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
def copy_template(
template_id: str,
request: CopyTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateResponse:
"""复制模板(含所有片段配置)"""
user_id = authenticated_user.user.id
command = CopyTemplateCommand(
template_id=template_id,
user_id=user_id,
new_name=request.new_name,
)
use_case = CopyTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template)
@router.get("/{template_id}/usage", response_model=TemplateUsageResponse)
def get_template_usage(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateUsageResponse:
"""获取模板使用次数(关联的剪辑计划数量)"""
user_id = authenticated_user.user.id
# 鉴权:确保模板存在且属于当前用户
use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id)
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
usage = template_repository.get_usage_count(template_id)
return TemplateUsageResponse(template_id=template_id, usage_count=usage)
return Response(status_code=204)
@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse)
@@ -375,7 +307,7 @@ def create_category(
)
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_category(
category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -387,22 +319,3 @@ def delete_category(
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
return Response(status_code=204)
# ── Tags ──
@router.get("/tags/list", response_model=ListTagsResponse)
def list_tags(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListTagsResponse:
"""获取用户所有模板标签(去重排序)"""
user_id = authenticated_user.user.id
try:
use_case = ListTagsUseCase(template_repository)
tags = use_case.execute(user_id)
except Exception:
logger.exception("list_tags 查询失败: user_id=%s", user_id)
return ListTagsResponse(items=[])
return ListTagsResponse(items=tags)
+5 -44
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from typing import Optional
from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_user_repository
from app.schemas.title_library import (
@@ -17,23 +16,20 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.title_library_repository import SQLAlchemyTitleLibraryRepository
from packages.application.title_library.commands import (
CreateTitleLibraryCommand,
PickTitleCommand,
UpdateTitleLibraryCommand,
)
from packages.application.title_library.commands import CreateTitleLibraryCommand, UpdateTitleLibraryCommand
from packages.application.title_library.use_cases import (
CreateTitleLibraryUseCase,
DeleteTitleLibraryUseCase,
GetTitleLibraryUseCase,
ListTitleLibraryUseCase,
NotFoundError,
PickTitleUseCase,
QuotaExceededError,
UpdateTitleLibraryUseCase,
)
from packages.ports.user_repository import UserRepository
from app.api.routes._helpers import get_user_plan
router = APIRouter()
@@ -75,41 +71,6 @@ def list_titles(
)
@router.post("/pick", response_model=TitleLibraryItemResponse)
def pick_title(
category: Optional[str] = Query(None, description="按分类筛选,不填则从全部标题中选"),
exclude_ids: Optional[str] = Query(
None,
description="排除的标题ID(逗号分隔),用于批量生成时避免重复",
),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
) -> TitleLibraryItemResponse:
"""智能选择一个标题。
策略:优先使用次数少的,从最少的前5个中随机选一个,兼顾公平和多样性。
"""
user_id = authenticated_user.user.id
exclude_list: list[str] = []
if exclude_ids:
exclude_list = [t.strip() for t in exclude_ids.split(",") if t.strip()]
use_case = PickTitleUseCase(title_repository)
item = use_case.execute(
PickTitleCommand(
user_id=user_id,
category=category,
exclude_ids=exclude_list,
)
)
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="标题库为空,请先添加标题",
)
return _to_response(item)
@router.get("/{title_id}", response_model=TitleLibraryItemResponse)
def get_title(
title_id: str,
@@ -177,7 +138,7 @@ def update_title(
return _to_response(item)
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_title(
title_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -188,4 +149,4 @@ def delete_title(
deleted = use_case.execute(title_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
return
return Response(status_code=204)
+5 -34
View File
@@ -46,7 +46,6 @@ from packages.application.voice_library.use_cases import (
CreateVoiceLibraryUseCase,
QuotaExceededError,
)
from packages.domain.voice_presets import list_voices
from packages.ports.user_repository import UserRepository
logger = logging.getLogger(__name__)
@@ -54,34 +53,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/presets", summary="获取预设音色列表")
def list_preset_voices(
gender: Optional[str] = Query(None, description="按性别筛选: male/female/child"),
style: Optional[str] = Query(None, description="按风格筛选: stable/lively/customer_service/narration/news/story"),
keyword: Optional[str] = Query(None, description="按关键词搜索"),
_user: AuthenticatedUser = Depends(get_current_user),
) -> list[dict]:
"""获取可用的预设音色列表。
用于配音功能的音色选择。
"""
voices = list_voices(gender=gender, style=style, keyword=keyword)
return [
{
"voice_id": v.voice_id,
"name": v.name,
"gender": v.gender.value,
"style": v.style.value,
"description": v.description,
"default_speed": v.default_speed,
"default_pitch": v.default_pitch,
"sample_rate": v.sample_rate,
"language": v.language,
}
for v in voices
]
def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTSJobRepository:
return SQLAlchemyTTSJobRepository(session)
@@ -270,7 +241,7 @@ def get_tts_job_status(
)
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_tts_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -282,7 +253,7 @@ def delete_tts_job(
deleted = use_case.execute(job_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return
return Response(status_code=204)
@router.post(
@@ -401,10 +372,10 @@ async def tts_websocket_stream(
streaming_service = TTSStreamingService(cosyvoice_service)
await streaming_service.synthesize_and_stream(websocket, params)
except WebSocketDisconnect:
logger.info("WebSocket 客户端主动断开连接")
logger.info("WebSocket 客户端断开连接")
except Exception as e:
logger.error(f"WebSocket 流式合成异常: {e}", exc_info=True)
try:
await websocket.send_json({"type": "error", "message": f"服务异常: {e}"})
except Exception as send_err:
logger.warning("WebSocket 错误消息发送失败(连接可能已断开): %s", send_err)
except Exception:
pass
+2 -1
View File
@@ -2,7 +2,6 @@ import logging
from typing import Any
from uuid import uuid4
from app.api.routes._helpers import require_project_and_library
from app.auth import AuthenticatedUser, get_current_user
from app.config import get_settings
from app.core.celery_app import celery_app
@@ -24,6 +23,8 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, s
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
from app.api.routes._helpers import require_project_and_library
logger = logging.getLogger(__name__)
router = APIRouter()
-179
View File
@@ -1,179 +0,0 @@
import logging
import uuid
from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_generated_video_repository
from app.schemas.video_center import (
BatchDownloadRequest,
BatchDownloadResponse,
ListVideosResponse,
UpdateVideoReviewRequest,
VideoItemResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import (
GetGeneratedVideoUseCase,
GetVideosByIdsUseCase,
ListGeneratedVideosPaginatedUseCase,
UpdateVideoReviewStatusUseCase,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoItemResponse:
download_url = None
if storage and item.file_url:
try:
download_url = storage.get_download_url(item.file_url)
except Exception:
download_url = item.file_url
return VideoItemResponse(
id=item.id,
project_id=item.project_id,
generation_task_id=item.generation_task_id,
name=item.name,
file_url=item.file_url,
file_size=item.file_size,
duration=item.duration,
thumbnail_url=item.thumbnail_url,
width=item.width,
height=item.height,
fps=item.fps,
status=item.status,
review_status=item.review_status,
generation_params=item.generation_params,
download_url=download_url,
generated_at=item.generated_at.isoformat() if hasattr(item, "generated_at") and item.generated_at else "",
)
@router.get("/videos", response_model=ListVideosResponse)
def list_videos(
project_id: str | None = Query(None, description="项目ID,不传则返回所有项目"),
status: str | None = Query(None, description="按状态筛选"),
review_status: str | None = Query(None, description="按复核状态筛选"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
repo=Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""成片列表,支持分页、按项目/状态/复核状态筛选。"""
use_case = ListGeneratedVideosPaginatedUseCase(repo)
items, total = use_case.execute(
project_id=project_id,
status=status,
review_status=review_status,
page=page,
page_size=page_size,
)
return ListVideosResponse(
items=[_to_video_response(item, storage) for item in items],
total=total,
page=page,
page_size=page_size,
)
@router.get("/videos/{video_id}", response_model=VideoItemResponse)
def get_video(
video_id: str,
repo=Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""获取单个成片详情。"""
use_case = GetGeneratedVideoUseCase(repo)
item = use_case.execute(video_id)
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
return _to_video_response(item, storage)
@router.patch("/videos/{video_id}/review", response_model=VideoItemResponse)
def update_video_review_status(
video_id: str,
request: UpdateVideoReviewRequest,
repo=Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""更新成片复核状态:pending_review / approved / rejected。"""
use_case = UpdateVideoReviewStatusUseCase(repo)
item = use_case.execute(video_id, request.review_status)
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
logger.info("Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user_id)
return _to_video_response(item, storage)
@router.post("/videos/batch-download", response_model=BatchDownloadResponse)
def batch_download_videos(
request: BatchDownloadRequest,
repo=Depends(get_generated_video_repository),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""批量下载成片,异步打包 zip。
传入 video_ids 列表,创建一个批量下载任务,任务完成后返回 zip 下载链接。
"""
if not request.video_ids:
raise HTTPException(status_code=400, detail="video_ids cannot be empty")
if len(request.video_ids) > 50:
raise HTTPException(status_code=400, detail="Maximum 50 videos per batch download")
# 校验视频都存在
use_case = GetVideosByIdsUseCase(repo)
videos = use_case.execute(request.video_ids)
if len(videos) != len(request.video_ids):
raise HTTPException(status_code=404, detail="Some videos not found")
# 发送 celery 任务
task = celery_app.send_task(
"worker.batch_download_videos",
args=[request.video_ids, current_user.user_id],
)
logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids))
return BatchDownloadResponse(job_id=task.id, status="pending")
@router.get("/videos/batch-download/{job_id}", response_model=BatchDownloadResponse)
def get_batch_download_status(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""查询批量下载任务状态。"""
from celery.result import AsyncResult
task = AsyncResult(job_id, app=celery_app)
status_map = {
"PENDING": "pending",
"STARTED": "running",
"SUCCESS": "success",
"FAILURE": "failed",
"RETRY": "pending",
"REVOKED": "cancelled",
}
api_status = status_map.get(task.state, "pending")
download_url = None
if task.state == "SUCCESS" and task.result:
if isinstance(task.result, dict):
download_url = task.result.get("download_url")
elif isinstance(task.result, str):
download_url = task.result
return BatchDownloadResponse(
job_id=job_id,
status=api_status,
download_url=download_url,
)
+1 -2
View File
@@ -172,7 +172,6 @@ def get_voice_clone_status(
"/{clone_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
response_class=Response,
)
def delete_voice_clone(
clone_id: str,
@@ -185,7 +184,7 @@ def delete_voice_clone(
deleted = use_case.execute(clone_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return
return Response(status_code=204)
@router.post("/{clone_id}/retry", response_model=VoiceCloneProfileResponse)
+4 -3
View File
@@ -7,7 +7,6 @@ from __future__ import annotations
from typing import Literal, Optional
from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
from app.schemas.voice import (
@@ -40,6 +39,8 @@ from packages.application.voice_library.use_cases import (
from packages.domain.preset_voices import PRESET_VOICES
from packages.ports.user_repository import UserRepository
from app.api.routes._helpers import get_user_plan
router = APIRouter()
@@ -322,7 +323,7 @@ def update_voice(
return _to_response(item, sign_url)
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_voice(
voice_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -333,4 +334,4 @@ def delete_voice(
deleted = use_case.execute(voice_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return
return Response(status_code=204)
+1 -1
View File
@@ -5,8 +5,8 @@ This module keeps old import paths working so existing code
does not need to change.
"""
from packages.shared.storage import SharedStorageService as OSSStorageService
from packages.shared.storage import (
SharedStorageService as OSSStorageService,
get_shared_storage_service,
get_storage_service,
)
+1 -9
View File
@@ -7,16 +7,12 @@ common auth dependencies for backward compatibility.
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser
from app.auth import get_current_user as get_authenticated_user
from app.dependencies import get_user_repository
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
logger = logging.getLogger(__name__)
from packages.domain.entities import User
from packages.ports.user_repository import UserRepository
@@ -37,10 +33,6 @@ async def get_current_user_optional(
return None
try:
authenticated_user = await get_authenticated_user(credentials, user_repository)
except HTTPException as exc:
if exc.status_code >= 500:
# 服务端错误不应被静默吞掉,记录日志
logger.error("可选认证遇到服务端错误,status=%s", exc.status_code, exc_info=True)
# 4xx 认证失败(如 token 无效、用户不存在)属于正常流程,返回 None
except HTTPException:
return None
return authenticated_user.user
+1 -1
View File
@@ -61,7 +61,7 @@ class APIVersionMiddleware(BaseHTTPMiddleware):
class VersionNotFoundMiddleware(BaseHTTPMiddleware):
"""处理已下线的 API 版本"""
SUNSET_VERSIONS: list[str] = [] # 已下线的版本列表
SUNSET_VERSIONS = [] # 已下线的版本列表
async def dispatch(self, request: Request, call_next):
version = self._extract_version(request.url.path)
Executable → Regular
+6 -34
View File
@@ -54,45 +54,17 @@ class AssetResponse(BaseModel):
tag_ids: list[str] = Field(default_factory=list)
MAX_BATCH_SIZE = 200
class BatchDeleteRequest(BaseModel):
"""批量删除请求(软删除)"""
"""批量删除请求。"""
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="要删除的素材 ID 列表")
ids: list[str] = Field(..., min_length=1, max_length=100, description="要删除的素材 ID 列表")
class BatchOperationResponse(BaseModel):
"""批量操作通用响应。"""
class BatchDeleteResponse(BaseModel):
"""批量删除响应。"""
success_count: int = Field(..., ge=0, description="成功数量")
failed_ids: list[str] = Field(default_factory=list, description="失败的 ID 列表")
failed_details: dict[str, str] = Field(default_factory=dict, description="失败详情 {asset_id: reason}")
class BatchTagRequest(BaseModel):
"""批量打标签请求。"""
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
tag_ids: list[str] = Field(..., min_length=1, max_length=50, description="标签 ID 列表")
mode: str = Field(default="add", pattern="^(add|replace)$", description="add=添加合并,replace=全量替换")
class BatchClassifyRequest(BaseModel):
"""批量修改分类请求。"""
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
category: str = Field(..., min_length=1, max_length=50, description="内容分类,如 person/scenic/product")
class BatchMarkRequest(BaseModel):
"""批量设置智能视图标记请求。"""
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
smart_view: str = Field(
..., pattern="^(recommended|caution|high_risk)$", description="智能视图标记:recommended/caution/high_risk"
)
deleted_count: int = Field(..., ge=0, description="实际删除数量")
failed_ids: list[str] = Field(default_factory=list, description="删除失败的 ID 列表")
class ListAssetsResponse(BaseModel):
-15
View File
@@ -33,17 +33,6 @@ class CreateGenerationTaskRequest(BaseModel):
asset_select_count: int = Field(
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
)
# ── 自动重试 ──
auto_retry_enabled: bool = Field(
default=False,
description="是否开启失败自动重试,默认关闭",
)
auto_retry_max: int = Field(
default=0,
ge=0,
le=5,
description="最大自动重试次数,0表示不自动重试,最大5次",
)
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -75,10 +64,6 @@ class GenerationTaskResponse(BaseModel):
progress: float
result_count: int
error_message: str
error_info: dict = Field(default_factory=dict)
retry_count: int = 0
auto_retry_enabled: bool = False
auto_retry_max: int = 0
logs: list[dict] = Field(default_factory=list)
@field_validator("logs", mode="before")
-6
View File
@@ -11,10 +11,8 @@ class ProjectTaskResponse(BaseModel):
progress: float
current_step: str
error_message: str = ""
error_info: dict = Field(default_factory=dict)
user_message: str = ""
retryable: bool = False
retry_count: int = 0
source_id: str = ""
template_id: str = ""
created_at: datetime | None = None
@@ -23,7 +21,6 @@ class ProjectTaskResponse(BaseModel):
class ListProjectTasksResponse(BaseModel):
items: list[ProjectTaskResponse] = Field(default_factory=list)
total: int = 0
class UserTaskResponse(BaseModel):
@@ -37,10 +34,8 @@ class UserTaskResponse(BaseModel):
progress: float
current_step: str
error_message: str = ""
error_info: dict = Field(default_factory=dict)
user_message: str = ""
retryable: bool = False
retry_count: int = 0
source_id: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -50,4 +45,3 @@ class ListTasksResponse(BaseModel):
"""用户级任务列表响应(GET /api/v1/tasks)。"""
items: list[UserTaskResponse] = Field(default_factory=list)
total: int = 0
Executable → Regular
-23
View File
@@ -45,7 +45,6 @@ class TemplateResponse(BaseModel):
segments: List[SegmentResponse] = Field(default_factory=list)
is_active: bool = True
is_favorite: bool = False
usage_count: int = 0
created_at: datetime
updated_at: datetime
@@ -121,25 +120,3 @@ class CreateCategoryRequest(BaseModel):
class ListCategoriesResponse(BaseModel):
items: List[CategoryResponse]
# ── Copy Template ──
class CopyTemplateRequest(BaseModel):
new_name: str
# ── Tags ──
class ListTagsResponse(BaseModel):
items: List[str]
# ── Usage Stats ──
class TemplateUsageResponse(BaseModel):
template_id: str
usage_count: int
-45
View File
@@ -1,45 +0,0 @@
from typing import Literal
from pydantic import BaseModel, Field
VideoReviewStatus = Literal["pending_review", "approved", "rejected"]
class VideoItemResponse(BaseModel):
id: str
project_id: str
generation_task_id: str
name: str
file_url: str
file_size: int
duration: float
thumbnail_url: str | None = None
width: int
height: int
fps: float
status: str = "completed"
review_status: str = "pending_review"
generation_params: dict = Field(default_factory=dict)
download_url: str | None = None
generated_at: str = ""
class ListVideosResponse(BaseModel):
items: list[VideoItemResponse]
total: int
page: int
page_size: int
class UpdateVideoReviewRequest(BaseModel):
review_status: VideoReviewStatus
class BatchDownloadRequest(BaseModel):
video_ids: list[str]
class BatchDownloadResponse(BaseModel):
job_id: str
status: str = "pending"
download_url: str | None = None
-19
View File
@@ -281,8 +281,6 @@ class EditPlanService:
start_time: float = 0.0,
duration: float = 0.0,
transition_effect: str = "cut",
transition_duration: float = 0.0,
playback_speed: float = 1.0,
config: Optional[dict[str, Any]] = None,
) -> EditPlanClip:
"""创建片段
@@ -303,8 +301,6 @@ class EditPlanService:
start_time=start_time,
duration=duration,
transition_effect=transition_effect,
transition_duration=transition_duration,
playback_speed=playback_speed,
config=config,
)
created = self._clip_repo.create(clip)
@@ -328,8 +324,6 @@ class EditPlanService:
start_time: Optional[float] = None,
duration: Optional[float] = None,
transition_effect: Optional[str] = None,
transition_duration: Optional[float] = None,
playback_speed: Optional[float] = None,
config: Optional[dict[str, Any]] = None,
) -> EditPlanClip:
"""更新片段
@@ -339,15 +333,6 @@ class EditPlanService:
"""
existing = self.get_clip_or_raise(clip_id)
# 速度边界钳制
if playback_speed is not None:
if playback_speed <= 0:
playback_speed = 1.0
elif playback_speed < 0.25:
playback_speed = 0.25
elif playback_speed > 4.0:
playback_speed = 4.0
updated = EditPlanClip(
id=existing.id,
plan_id=existing.plan_id,
@@ -361,10 +346,6 @@ class EditPlanService:
transition_effect=(
transition_effect.strip() if transition_effect is not None else existing.transition_effect
),
transition_duration=(
transition_duration if transition_duration is not None else existing.transition_duration
),
playback_speed=playback_speed if playback_speed is not None else existing.playback_speed,
status=existing.status,
config=config if config is not None else existing.config,
created_at=existing.created_at,
+1
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import logging
from typing import Any
from packages.application.jobs import (
CancelJobUseCase,
CompleteJobCommand,
+68 -222
View File
@@ -86,10 +86,7 @@ async function createProject(
): Promise<string> {
const resp = await request.post(`${apiBase}/projects`, {
headers,
data: {
name: `Assets Test Proj ${suffix}`,
description: "E2E assets test",
},
data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" },
});
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
@@ -181,30 +178,20 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("素材库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-load");
const projectId = await createProject(
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
headers,
Date.now().toString(),
"assets-load",
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "默认视频库", "video");
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
// 页面布局容器
await expect(page.locator(".xx-assets-page")).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
// 左侧素材库列表
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
@@ -224,33 +211,23 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-create");
const projectId = await createProject(
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
headers,
Date.now().toString(),
"assets-create",
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "初始库", "video");
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
// 点击新建素材库
await page.locator(".xx-asset-library-add").click();
// 弹窗出现
const modal = page
.locator(".ant-modal-content")
.filter({ hasText: "新建素材库" });
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" });
await expect(modal).toBeVisible();
// 填写表单
@@ -282,13 +259,11 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("切换不同素材库", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-switch");
const projectId = await createProject(
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
headers,
Date.now().toString(),
"assets-switch",
);
const projectId = await createProject(request, headers, Date.now().toString());
const videoLibName = "视频素材库 A";
const imageLibName = "图片素材库 B";
@@ -317,16 +292,10 @@ test.describe("素材库页面 - 完整交互测试", () => {
"demo_video.mp4",
);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
// 点击视频库,应显示素材
const videoLibItem = page
@@ -336,9 +305,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
await expect(videoLibItem).toHaveClass(/active/);
// 验证视频素材出现
await expect(page.getByText("demo_video.mp4")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 });
// 点击图片库,应切换且不显示视频
const imageLibItem = page
@@ -348,22 +315,18 @@ test.describe("素材库页面 - 完整交互测试", () => {
await expect(imageLibItem).toHaveClass(/active/);
// 空状态或图片库内容
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
timeout: 5_000,
});
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 });
});
// ─── 素材搜索 ──────────────────────────────────────
test("素材搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-search");
const projectId = await createProject(
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
headers,
Date.now().toString(),
"assets-search",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -373,33 +336,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
);
// 创建两个不同名称的素材
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"apple_clip.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"banana_clip.mp4",
);
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4");
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
// 确保在测试库中
const libItem = page
@@ -408,9 +351,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
await libItem.click({ force: true });
// 两个素材都应可见
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
// 搜索 apple,只显示 apple
@@ -420,9 +361,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
// 清空搜索,两个都显示
await page.getByPlaceholder("搜索素材名称...").fill("");
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
});
@@ -430,13 +369,11 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("素材类型筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-filter");
const projectId = await createProject(
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
headers,
Date.now().toString(),
"assets-filter",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -446,25 +383,12 @@ test.describe("素材库页面 - 完整交互测试", () => {
);
// 创建视频素材
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"video_clip.mp4",
);
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4");
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
@@ -472,9 +396,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
await libItem.click({ force: true });
// 素材应可见
await expect(page.getByText("video_clip.mp4")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 });
// 筛选类型下拉存在
const filterSelect = page.locator(".xx-assets-filters-left select").first();
@@ -485,13 +407,11 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-detail");
const projectId = await createProject(
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
headers,
Date.now().toString(),
"assets-detail",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -499,25 +419,12 @@ test.describe("素材库页面 - 完整交互测试", () => {
"详情测试库",
"video",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"play_test.mp4",
);
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4");
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
@@ -534,9 +441,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
await assetCard.locator(".xx-asset-play").click({ force: true });
// 播放弹窗出现
const modal = page
.locator(".ant-modal-content")
.filter({ hasText: "播放" });
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" });
await expect(modal).toBeVisible();
// 关闭弹窗
@@ -548,13 +453,11 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("删除素材 - 带确认对话框", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-delete");
const projectId = await createProject(
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
headers,
Date.now().toString(),
"assets-delete",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -562,25 +465,12 @@ test.describe("素材库页面 - 完整交互测试", () => {
"删除测试库",
"video",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"to_delete.mp4",
);
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4");
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
@@ -601,15 +491,14 @@ test.describe("素材库页面 - 完整交互测试", () => {
await deleteBtn.click({ force: true });
// 确认对话框出现
const confirmModal = page
.locator(".ant-popover")
.filter({ hasText: "确认删除" });
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" });
await expect(confirmModal).toBeVisible();
// 监听删除请求
const deletePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/assets/") && resp.request().method() === "DELETE",
resp.url().includes("/assets/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
);
@@ -629,13 +518,11 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("批量删除素材", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-batch");
const projectId = await createProject(
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
headers,
Date.now().toString(),
"assets-batch",
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -645,41 +532,14 @@ test.describe("素材库页面 - 完整交互测试", () => {
);
// 创建多个素材
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"batch_1.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"batch_2.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"batch_3.mp4",
);
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4");
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
@@ -687,9 +547,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
await libItem.click({ force: true });
// 所有素材应可见
await expect(page.getByText("batch_1.mp4")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("batch_2.mp4")).toBeVisible();
await expect(page.getByText("batch_3.mp4")).toBeVisible();
@@ -709,9 +567,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
await batchDeleteBtn.click();
// 确认对话框
const confirmPop = page
.locator(".ant-popover")
.filter({ hasText: "确定删除" });
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" });
await expect(confirmPop).toBeVisible();
// 确认删除
@@ -739,25 +595,17 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("空素材库展示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-empty");
const projectId = await createProject(
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
headers,
Date.now().toString(),
"assets-empty",
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "空素材库", "video");
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
const libItem = page
.locator(".xx-asset-library-item")
@@ -765,9 +613,7 @@ test.describe("素材库页面 - 完整交互测试", () => {
await libItem.click({ force: true });
// 空状态应显示
await expect(page.locator(".xx-assets-empty")).toBeVisible({
timeout: 10_000,
});
await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
});
-3
View File
@@ -251,9 +251,6 @@ test.describe("Core generation flow", () => {
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 15_000,
});
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
await page.unrouteAll({ behavior: "ignoreErrors" });
});
test("generation task API creates and lists tasks", async ({ request }) => {
+3 -5
View File
@@ -180,11 +180,9 @@ test.describe("Core media upload flow", () => {
await expect(page.locator(".xx-assets-content")).toBeVisible({
timeout: 20_000,
});
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible(
{
timeout: 20_000,
},
);
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
timeout: 20_000,
});
// Verify asset card shows status
const assetCard = page
+38 -76
View File
@@ -121,11 +121,7 @@ test.describe("去重流程", () => {
"dup-load",
);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication");
@@ -150,11 +146,7 @@ test.describe("去重流程", () => {
"dup-upload-zone",
);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -164,12 +156,12 @@ test.describe("去重流程", () => {
await expect(uploadZone).toBeVisible();
// 上传图标和文字
await expect(
uploadZone.getByText("点击或拖拽视频文件到此区域"),
).toBeVisible();
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible();
// 格式提示
await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible();
await expect(
uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/),
).toBeVisible();
// 格式标签
await expect(page.locator(".dup-upload-formats")).toBeVisible();
@@ -192,11 +184,7 @@ test.describe("去重流程", () => {
"dup-info",
);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -227,11 +215,7 @@ test.describe("去重流程", () => {
"dup-list",
);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
@@ -255,11 +239,7 @@ test.describe("去重流程", () => {
"dup-list-empty",
);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -277,11 +257,7 @@ test.describe("去重流程", () => {
"dup-filter",
);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -311,11 +287,7 @@ test.describe("去重流程", () => {
"dup-nav",
);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -334,8 +306,10 @@ test.describe("去重流程", () => {
request,
}) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "dup-detail");
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-detail",
);
// 先上传一个文件进行查重,获取 record id
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -361,11 +335,7 @@ test.describe("去重流程", () => {
const recordId = uploadData.id;
expect(recordId, "应返回查重记录 ID").toBeTruthy();
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
// 访问详情页
await page.goto(`/app/duplication/${recordId}`);
@@ -383,8 +353,10 @@ test.describe("去重流程", () => {
test("去重记录删除 - API 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "dup-delete");
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-delete",
);
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -447,8 +419,10 @@ test.describe("去重流程", () => {
test("去重记录删除 - UI 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "dup-delete-ui");
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-delete-ui",
);
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -469,20 +443,14 @@ test.describe("去重流程", () => {
return;
}
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 记录卡片应存在
const resultCard = page.locator(".dup-result-card").first();
const cardVisible = await resultCard
.isVisible({ timeout: 10_000 })
.catch(() => false);
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
if (cardVisible) {
// 删除按钮存在
@@ -499,14 +467,12 @@ test.describe("去重流程", () => {
});
// 监听删除请求
const deletePromise = page
.waitForResponse(
(resp) =>
resp.url().includes("/duplication/records/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
)
.catch(() => null);
const deletePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/duplication/records/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
).catch(() => null);
await deleteBtn.click();
@@ -521,8 +487,10 @@ test.describe("去重流程", () => {
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "dup-retry");
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-retry",
);
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -543,20 +511,14 @@ test.describe("去重流程", () => {
return;
}
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 记录列表中至少有一条记录
const resultCard = page.locator(".dup-result-card").first();
const cardVisible = await resultCard
.isVisible({ timeout: 10_000 })
.catch(() => false);
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
if (cardVisible) {
// 验证记录卡片基本结构
+3 -18
View File
@@ -6,12 +6,7 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -263,12 +258,7 @@ test.describe("剪辑计划 - API 操作", () => {
mode: "pip",
estimated_duration: 30,
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 10,
material_type: "video",
},
{ segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" },
],
},
});
@@ -279,12 +269,7 @@ test.describe("剪辑计划 - API 操作", () => {
mode: "voice_over",
estimated_duration: 60,
segments: [
{
segment_order: 1,
duration_min: 10,
duration_max: 30,
material_type: "video",
},
{ segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" },
],
},
});
+29 -112
View File
@@ -93,8 +93,7 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) {
resolution: "1080x1920",
file_size: (5 + i) * 1024 * 1024,
duplicate_rate: i * 5,
video_url:
status === "completed" ? "https://example.com/video.mp4" : undefined,
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
thumbnail_url: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
@@ -219,11 +218,7 @@ test.describe("作品库页面", () => {
const products = mockProducts(3, ["completed", "processing", "failed"]);
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
@@ -260,24 +255,12 @@ test.describe("作品库页面", () => {
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
{
...mockProducts(1, ["processing"])[0],
title: "处理中作品",
id: `mock-prod-${Date.now()}-p`,
},
{
...mockProducts(1, ["failed"])[0],
title: "失败作品",
id: `mock-prod-${Date.now()}-f`,
},
{ ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` },
{ ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` },
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-page")).toBeVisible({
@@ -293,9 +276,9 @@ test.describe("作品库页面", () => {
const completedCard = page
.locator(".xx-product-card")
.filter({ hasText: "已完成作品" });
await expect(
completedCard.locator(".xx-product-status.completed"),
).toHaveText("已完成");
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText(
"已完成",
);
const processingCard = page
.locator(".xx-product-card")
@@ -326,11 +309,7 @@ test.describe("作品库页面", () => {
const productId = products[0].id;
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
// 直接访问详情页
await page.goto(`/app/products/${productId}`);
@@ -358,11 +337,7 @@ test.describe("作品库页面", () => {
products[0].video_url = "https://example.com/test-video.mp4";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -381,10 +356,7 @@ test.describe("作品库页面", () => {
// 播放弹窗出现 - 验证有视频元素或播放器容器
// (通过 Mock 的 video_urlvideo 元素应能渲染)
const videoEl = page.locator("video");
const videoVisible = await videoEl
.first()
.isVisible({ timeout: 5000 })
.catch(() => false);
const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false);
// 或弹窗容器可见
const modalVisible = await page
.locator(".ant-modal-content")
@@ -408,11 +380,7 @@ test.describe("作品库页面", () => {
products[0].title = "下载测试作品";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -441,11 +409,7 @@ test.describe("作品库页面", () => {
products[0].title = "处理中下载测试";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -520,11 +484,7 @@ test.describe("作品库页面", () => {
route.continue();
});
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -547,12 +507,9 @@ test.describe("作品库页面", () => {
const { headers } = await createAuthedUser(request, "products-del-api");
// 测试删除不存在的产品,验证 API 端点存在
const resp = await request.delete(
`${apiBase}/products/nonexistent-test-id`,
{
headers,
},
);
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
headers,
});
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
// 404 表示资源不存在但端点存在
@@ -572,11 +529,7 @@ test.describe("作品库页面", () => {
// Mock 空列表
await mockProductsApi(page, []);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-page")).toBeVisible({
@@ -600,24 +553,12 @@ test.describe("作品库页面", () => {
);
const products = [
{
...mockProducts(1, ["completed"])[0],
title: "苹果宣传视频",
id: `mock-prod-${Date.now()}-apple`,
},
{
...mockProducts(1, ["completed"])[0],
title: "香蕉推广视频",
id: `mock-prod-${Date.now()}-banana`,
},
{ ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` },
{ ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` },
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -625,9 +566,7 @@ test.describe("作品库页面", () => {
});
// 两个作品都可见
await expect(page.getByText("苹果宣传视频")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("香蕉推广视频")).toBeVisible();
// 搜索"苹果"
@@ -637,9 +576,7 @@ test.describe("作品库页面", () => {
// 清空搜索
await page.getByPlaceholder("搜索成片名称...").fill("");
await expect(page.getByText("香蕉推广视频")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 });
});
test("作品状态筛选", async ({ page, request }) => {
@@ -650,24 +587,12 @@ test.describe("作品库页面", () => {
);
const products = [
{
...mockProducts(1, ["completed"])[0],
title: "已完成筛选",
id: `mock-prod-${Date.now()}-done`,
},
{
...mockProducts(1, ["processing"])[0],
title: "处理中筛选",
id: `mock-prod-${Date.now()}-proc`,
},
{ ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` },
{ ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` },
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -704,11 +629,7 @@ test.describe("作品库页面", () => {
products[2].title = "批量测试 3";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -732,12 +653,8 @@ test.describe("作品库页面", () => {
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
// 批量按钮存在
await expect(
batchBar.getByRole("button", { name: "批量下载" }),
).toBeVisible();
await expect(
batchBar.getByRole("button", { name: "批量删除" }),
).toBeVisible();
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible();
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible();
// 取消选择
await batchBar.getByRole("button", { name: "取消选择" }).click();
+2 -10
View File
@@ -6,12 +6,7 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -406,10 +401,7 @@ test.describe("个人设置 - 退出登录", () => {
test.describe.configure({ timeout: 120_000 });
test("登出 API - 正向", async ({ request }) => {
const { headers, email } = await createAuthedUser(
request,
"profile-logout",
);
const { headers, email } = await createAuthedUser(request, "profile-logout");
const response = await request.post(`${apiBase}/auth/logout`, {
headers,
+12 -47
View File
@@ -49,9 +49,7 @@ test.describe("注册页面", () => {
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
// 标题/描述
await expect(
page.getByText("创建账户,开启智能视频创作之旅"),
).toBeVisible();
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible();
// 表单字段
await expect(page.getByLabel("邮箱")).toBeVisible();
@@ -71,10 +69,7 @@ test.describe("注册页面", () => {
await page.goto("/register");
// 直接点击注册按钮
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示必填错误
await expect(page.getByText("请输入邮箱")).toBeVisible();
@@ -91,10 +86,7 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示邮箱格式错误
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
@@ -108,10 +100,7 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill("123");
await page.getByLabel("确认密码").fill("123");
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示密码长度错误
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
@@ -125,10 +114,7 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill("Different123!");
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示密码不一致错误
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
@@ -142,10 +128,7 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await expect(page.getByText("请输入用户名")).toBeVisible();
});
@@ -171,16 +154,10 @@ test.describe("注册页面", () => {
{ timeout: 15_000 },
);
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
const resp = await registerResponse;
expect(
resp.ok(),
`注册请求应返回 2xx,实际: ${resp.status()}`,
).toBeTruthy();
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy();
// 注册成功后应跳转到登录页或显示成功消息
// 页面应停留在可识别的状态(成功提示或跳转)
@@ -222,19 +199,14 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
// 应显示错误提示(通过 antd message 或表单错误)
await expect
.poll(
async () => {
// 检查是否有错误消息
const hasError = await page
.getByText(/注册失败|已注册|已存在|exists/)
.isVisible();
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible();
return hasError ? "error_shown" : "waiting";
},
{ timeout: 10_000 },
@@ -282,12 +254,7 @@ test.describe("注册页面", () => {
// 注册
await request.post(`${apiBase}/auth/register`, {
data: {
email,
password: PASSWORD,
username,
display_name: "Reg Auth Test",
},
data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" },
});
// 登录
@@ -326,8 +293,6 @@ test.describe("注册页面", () => {
// 注册页对已登录用户也可访问(注册页是公开页面)
// 验证页面正常渲染
await expect(page.getByLabel("邮箱")).toBeVisible();
await expect(
page.locator("button[type='submit']").filter({ hasText: "注册" }),
).toBeVisible();
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible();
});
});
+14 -30
View File
@@ -9,12 +9,7 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -248,13 +243,8 @@ test.describe("订阅套餐页 - 升级交互", () => {
const url = page.url();
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
expect(
url.includes("/subscription/upgrade") ||
url.includes("/subscription") ||
(await page
.locator(".ant-modal, [role='dialog']")
.first()
.isVisible()
.catch(() => false)),
url.includes("/subscription/upgrade") || url.includes("/subscription") ||
(await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)),
).toBeTruthy();
}
});
@@ -548,16 +538,13 @@ test.describe("订阅 - 支付流程", () => {
test("创建支付订单 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-pay-api");
const response = await request.post(
`${apiBase}/subscription/create-order`,
{
headers,
data: {
plan_id: "pro",
billing_cycle: "monthly",
},
const response = await request.post(`${apiBase}/subscription/create-order`, {
headers,
data: {
plan_id: "pro",
billing_cycle: "monthly",
},
);
});
// 创建支付订单可能成功或接口不存在
expect(
@@ -573,15 +560,12 @@ test.describe("订阅 - 支付流程", () => {
});
test("未登录创建订单 - 反向", async ({ request }) => {
const response = await request.post(
`${apiBase}/subscription/create-order`,
{
data: {
plan_id: "pro",
billing_cycle: "monthly",
},
const response = await request.post(`${apiBase}/subscription/create-order`, {
data: {
plan_id: "pro",
billing_cycle: "monthly",
},
);
});
expect([401, 403, 404]).toContain(response.status());
});
});
+1 -4
View File
@@ -178,10 +178,7 @@ test.describe("订阅过期处理", () => {
// 免费用户可能不需要取消,返回 400 或类似错误
if (!response.ok()) {
const data = await response.json();
expect(
data.error?.message || data.detail || data.message,
"应返回错误信息",
).toBeTruthy();
expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy();
}
});
+7 -16
View File
@@ -6,12 +6,7 @@
*
* auth token
*/
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -331,9 +326,7 @@ test.describe("模板库 - 模板展示", () => {
if (await modal.isVisible({ timeout: 5_000 })) {
await expect(modal).toBeVisible();
// 验证预览内容存在
await expect(
modal.locator(".xx-template-modal-title-row"),
).toBeVisible();
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible();
}
}
});
@@ -494,10 +487,7 @@ test.describe("模板库 - API 操作", () => {
`${apiBase}/templates/${templateId}/favorite`,
{ headers },
);
expect(
unfavResp.status() < 500,
"取消收藏请求应返回 2xx 或 4xx",
).toBeTruthy();
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy();
});
test("获取模板详情 - 正向", async ({ request }) => {
@@ -525,9 +515,10 @@ test.describe("模板库 - API 操作", () => {
expect(createResp.ok()).toBeTruthy();
const created = await createResp.json();
const detailResp = await request.get(`${apiBase}/templates/${created.id}`, {
headers,
});
const detailResp = await request.get(
`${apiBase}/templates/${created.id}`,
{ headers },
);
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
const detail = await detailResp.json();
expect(detail.id).toBe(created.id);
+5 -6
View File
@@ -175,9 +175,10 @@ test.describe("认证流程", () => {
},
});
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(
response.status(),
);
expect(
[400, 422],
"缺少用户名字段应返回 4xx 校验错误",
).toContain(response.status());
});
// ─── 登录 ────────────────────────────────────────────
@@ -229,9 +230,7 @@ test.describe("认证流程", () => {
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
});
if (response.status() !== 429) break;
console.log(
`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`,
);
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`);
await new Promise((r) => setTimeout(r, 65_000));
}
+7 -34
View File
@@ -9,12 +9,7 @@
*
* auth token
*/
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -229,11 +224,7 @@ test.describe("标题库 - API 完整操作", () => {
test("编辑标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-update");
const titleId = await createTitle(
request,
headers,
Date.now().toString(36),
);
const titleId = await createTitle(request, headers, Date.now().toString(36));
const newName = `更新后的标题 ${Date.now()}`;
const newText = "这是更新后的标题内容";
@@ -265,11 +256,7 @@ test.describe("标题库 - API 完整操作", () => {
test("删除标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-delete");
const titleId = await createTitle(
request,
headers,
Date.now().toString(36),
);
const titleId = await createTitle(request, headers, Date.now().toString(36));
// 删除
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
@@ -292,21 +279,9 @@ test.describe("标题库 - API 完整操作", () => {
const suffix = Date.now().toString(36);
const titles = [
{
name: `批量标题 1 ${suffix}`,
text: `内容 1 ${suffix}`,
category: "default",
},
{
name: `批量标题 2 ${suffix}`,
text: `内容 2 ${suffix}`,
category: "种草",
},
{
name: `批量标题 3 ${suffix}`,
text: `内容 3 ${suffix}`,
category: "知识",
},
{ name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" },
{ name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" },
{ name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" },
];
const response = await request.post(`${apiBase}/titles/batch-import`, {
@@ -322,9 +297,7 @@ test.describe("标题库 - API 完整操作", () => {
if (response.ok()) {
const data = await response.json();
expect(
Array.isArray(data) || data.success_count !== undefined,
).toBeTruthy();
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy();
}
});
+7 -15
View File
@@ -6,12 +6,7 @@
*
* auth token
*/
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -331,9 +326,10 @@ test.describe("声音克隆 - API 操作", () => {
).toBeTruthy();
// 验证已删除
const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, {
headers,
});
const getResp = await request.get(
`${apiBase}/voice-clones/${cloneId}`,
{ headers },
);
expect([404, 410]).toContain(getResp.status());
}
// 如果创建失败(比如音频格式问题),测试也通过
@@ -495,15 +491,11 @@ test.describe("声音克隆 - 上传区域", () => {
});
// 尝试点击克隆新音色按钮
const cloneBtn = page.getByRole("button", {
name: /克隆新音色|立即克隆|新建/,
});
const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ });
if (await cloneBtn.isVisible()) {
await cloneBtn.click();
// 弹窗应该出现
const modal = page.locator(
".ant-modal, .vc-edit-dialog, [role='dialog']",
);
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']");
if (await modal.first().isVisible({ timeout: 5_000 })) {
await expect(modal.first()).toBeVisible();
}
+2 -9
View File
@@ -6,12 +6,7 @@
*
* auth token
*/
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -162,9 +157,7 @@ test.describe("音色库页面 - 页面加载", () => {
});
// 验证搜索框存在
const searchInput = page.locator(
"input[type='search'], .xx-voices-search input, input[placeholder*='搜索']",
);
const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']");
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
});
});
-6
View File
@@ -26,9 +26,6 @@ export default defineConfig({
use: {
...devices["Desktop Chrome"],
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
launchOptions: {
args: ["--disable-gpu", "--disable-software-rasterizer"],
},
},
},
{
@@ -54,9 +51,6 @@ export default defineConfig({
use: {
...devices["Desktop Chrome"],
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
launchOptions: {
args: ["--disable-gpu", "--disable-software-rasterizer"],
},
},
},
],
+4 -81
View File
@@ -5,32 +5,6 @@
import apiClient from "./client";
import { getOrCreateDefaultProject } from "./projects";
/** 素材元数据 */
export interface AssetMetadata {
/** 时长(秒) */
duration?: number;
/** 宽度(像素) */
width?: number;
/** 高度(像素) */
height?: number;
/** 比特率(bps */
bitrate?: number;
/** 编码格式 */
codec?: string;
/** 帧率 */
fps?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 声道数 */
channels?: number;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** 素材分类状态 */
export type AssetClassificationStatus =
"pending" | "processing" | "completed" | "failed";
/** 素材条目 */
export interface AssetItem {
id: string;
@@ -38,14 +12,12 @@ export interface AssetItem {
name: string;
storage_key: string;
mime_type: string;
metadata: AssetMetadata;
metadata: Record<string, unknown>;
file_size?: number;
file_url?: string;
thumbnail_url?: string;
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
duration?: number;
status?: string;
classification_status?: AssetClassificationStatus | null;
classification_status?: string | null;
quality_score?: number | null;
tag_ids?: string[];
created_at?: string;
@@ -195,7 +167,7 @@ export const createAsset = async (data: {
name: string;
storage_key: string;
mime_type: string;
metadata?: AssetMetadata;
metadata?: Record<string, unknown>;
}): Promise<AssetItem> => {
const response = await apiClient.post("/assets", data);
return response.data;
@@ -204,7 +176,7 @@ export const createAsset = async (data: {
/** 更新素材(名称、metadata 等) */
export const updateAsset = async (
assetId: string,
data: { name?: string; metadata?: AssetMetadata },
data: { name?: string; metadata?: Record<string, unknown> },
): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data);
return response.data;
@@ -378,52 +350,3 @@ export const getClassificationJob = async (
const response = await apiClient.get(`/classification-jobs/${jobId}`);
return response.data;
};
// ─── 批量操作 ───────────────────────────────────────────────
/** 批量操作结果 */
export interface BatchOperationResult {
succeeded: string[];
failed: string[];
total: number;
success_count: number;
failure_count: number;
}
/** 批量删除素材 */
export const batchDeleteAssets = async (
assetIds: string[],
): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-delete", {
asset_ids: assetIds,
});
return response.data;
};
/** 批量打标签 */
export const batchTagAssets = async (data: {
asset_ids: string[];
tags: string[];
mode: "add" | "replace";
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-tag", data);
return response.data;
};
/** 批量改分类 */
export const batchClassifyAssets = async (data: {
asset_ids: string[];
category: string;
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-classify", data);
return response.data;
};
/** 批量智能标记 */
export const batchMarkAssets = async (data: {
asset_ids: string[];
smart_view: "recommended" | "caution" | "high_risk";
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-mark", data);
return response.data;
};
-70
View File
@@ -1,70 +0,0 @@
/**
* BGM API
* BGM +
*/
import apiClient from "./client";
/* ──────────── 类型 ──────────── */
/** BGM 风格分类 */
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商";
/** BGM 预设项 */
export interface BgmPreset {
id: string;
name: string;
category: BgmCategory;
/** 音频文件 URL */
url: string;
/** 时长(秒) */
duration: number;
/** 关键词标签 */
tags: string[];
/** 封面图 URL */
cover_url?: string;
}
/** BGM 预设列表查询参数 */
export interface BgmPresetsQuery {
category?: BgmCategory | string;
keyword?: string;
}
/** BGM 混音配置(嵌入剪辑计划) */
export interface BgmMixConfig {
/** 是否启用 BGM */
enabled: boolean;
/** 选中的 BGM ID */
music_id: string;
/** BGM 音量 0-100 */
volume: number;
/** 淡入时长(秒) 0-3 */
fade_in: number;
/** 淡出时长(秒) 0-3 */
fade_out: number;
/** 人声闪避(sidechain */
voice_dodge: boolean;
}
/** 默认 BGM 混音配置 */
export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
enabled: false,
music_id: "",
volume: 50,
fade_in: 0.5,
fade_out: 0.5,
voice_dodge: true,
};
/* ──────────── API ──────────── */
/** 获取 BGM 预设列表 */
export const getBgmPresets = async (
params?: BgmPresetsQuery,
): Promise<BgmPreset[]> => {
const searchParams: Record<string, string> = {};
if (params?.category) searchParams.category = params.category;
if (params?.keyword) searchParams.keyword = params.keyword;
const res = await apiClient.get("/bgm/presets", { params: searchParams });
return res.data?.data ?? res.data ?? [];
};
+1 -2
View File
@@ -129,8 +129,7 @@ apiClient.interceptors.response.use(
const safeExtractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
+32 -216
View File
@@ -4,15 +4,6 @@
*/
import apiClient from "./client";
import type { AssetItem } from "./assets";
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types";
/* ============================================================
* API Schema
@@ -22,107 +13,6 @@ import type {
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed";
/** 标题配置(对齐后端 title_config */
export interface TitleConfig {
ai_auto_select: boolean;
content: string;
font_preset: string;
font_color: string;
font_size: number;
position: string;
}
/** 字幕配置 */
export interface SubtitleConfig {
enabled: boolean;
position: string;
font: string;
color: string;
size: number;
animation: string;
}
/** BGM 配置 */
export interface BgmConfig {
enabled: boolean;
music_id: string;
}
/** 片段 TTS 配置 */
export interface SegmentTtsConfig {
mode: string;
text: string;
voice_id: string;
speed: number;
pitch: number;
volume: number;
subtitle_sync: boolean;
}
/** 片段裁剪配置 */
export interface SegmentTrimConfig {
start_time: number;
end_time: number;
}
/** 片段转场配置 */
export interface SegmentTransitionConfig {
type: string;
duration: number;
}
/** 剪辑计划中的单个片段(config 内部 segments 项) */
export interface EditPlanSegment {
segment_order: number;
duration_min: number;
duration_max: number;
material_type: string;
transition?: SegmentTransitionConfig;
playback_speed?: number;
tts_config?: SegmentTtsConfig;
trim_config?: SegmentTrimConfig;
}
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
export interface EditPlanConfig {
title_config?: TitleConfig;
subtitle_config?: SubtitleConfig;
bgm_config?: BgmConfig;
estimated_duration?: number;
segments?: EditPlanSegment[];
watermark_config?: WatermarkConfig;
intro_outro_config?: IntroOutroConfig;
pip_config?: PipConfig;
filter_config?: FilterConfig;
green_screen_config?: ChromaKeyConfig;
sticker_config?: StickerConfig;
cover_config?: CoverConfig;
/** 前端扩展:关联的素材 ID 列表 */
asset_ids?: string[];
/** 配音 ID */
voice_id?: string;
/** 克隆音色档案 ID */
voice_clone_profile_id?: string;
/** 自定义配音音频 URL */
custom_audio_url?: string;
/** 自定义配音文本 */
custom_text?: string;
/** 视频比例 */
ratio?: string;
/** 视频风格 */
style?: string;
/** 目标时长(秒) */
duration?: number;
/** 是否自动生成字幕 */
auto_subtitles?: boolean;
/** 是否启用 BGM */
bgm?: boolean;
/** 生成数量 */
generate_count?: number;
/** 素材模式 */
material_mode?: string;
}
/** 剪辑计划(后端响应) */
export interface EditPlan {
id: string;
@@ -130,7 +20,7 @@ export interface EditPlan {
name: string;
status: EditPlanStatus;
total_duration: number;
config: EditPlanConfig;
config: Record<string, unknown>;
created_at: string;
updated_at: string;
}
@@ -139,7 +29,7 @@ export interface EditPlan {
export interface CreateEditPlanRequest {
template_id: string;
name: string;
config?: EditPlanConfig;
config?: Record<string, unknown>;
total_duration?: number;
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
source_edit_plan_id?: string;
@@ -148,7 +38,7 @@ export interface CreateEditPlanRequest {
/** 更新剪辑计划请求 */
export interface UpdateEditPlanRequest {
name?: string;
config?: EditPlanConfig;
config?: Record<string, unknown>;
total_duration?: number;
status?: EditPlanStatus;
}
@@ -190,26 +80,6 @@ export interface GenerationStatusResponse {
clips: ClipStatusItem[];
}
/** 生成视频详情(对应后端 GeneratedVideoResponse */
export interface GeneratedVideo {
id: string;
project_id?: string;
generation_task_id?: string;
name: string;
file_url: string;
file_size?: number;
duration?: number;
thumbnail_url?: string;
width?: number;
height?: number;
fps?: number;
status: string;
review_status?: string;
download_url?: string;
created_at?: string;
updated_at?: string;
}
/* ============================================================
* AI & 3.09
* ============================================================ */
@@ -230,14 +100,14 @@ export interface AIRecommendClipItem {
transition_effect: string;
asset_id: string;
start_time: number;
config: EditPlanConfig;
config: Record<string, unknown>;
}
/** AI 推荐响应 */
export interface AIRecommendResponse {
plan_id: string;
clips: AIRecommendClipItem[];
config: EditPlanConfig;
config: Record<string, unknown>;
total_duration: number;
confidence: number;
}
@@ -252,15 +122,7 @@ export interface GenerateCoverRequest {
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string;
cover: CoverResult;
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string;
asset_id?: string;
frame_time?: number;
thumbnail_url?: string;
cover: Record<string, unknown>;
}
/* ============================================================
@@ -285,27 +147,10 @@ export interface EditPlanClip {
order: number;
}
/** 转场效果14 种预设) */
/** 转场效果 */
export interface TransitionEffect {
type:
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop";
type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide";
duration: number; // 转场时长(秒)
/** 播放速度倍率 */
playback_speed?: number;
}
/** 素材库资产(UI 层类型,映射自后端 AssetResponse */
@@ -332,30 +177,15 @@ export interface MediaAsset {
* API
* ============================================================ */
/** 剪辑计划列表查询参数 */
export interface EditPlanListParams {
/** 获取剪辑计划列表 */
export async function getEditPlans(params?: {
page?: number;
page_size?: number;
template_id?: string;
status?: string;
}
/** 剪辑计划列表分页响应 */
export interface EditPlanListResponse {
items: EditPlan[];
total: number;
page: number;
page_size: number;
}
/** 获取剪辑计划列表(支持分页和筛选) */
export async function getEditPlans(
params?: EditPlanListParams,
): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
params,
});
return response.data;
}): Promise<EditPlan[]> {
const response = await apiClient.get("/edit-plans", { params });
return response.data.items || [];
}
/** 获取单个剪辑计划 */
@@ -436,14 +266,6 @@ export async function getEditPlanGenerations(
return response.data.items || [];
}
/** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults(
taskId: string,
): Promise<GeneratedVideo[]> {
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
return response.data.items || response.data || [];
}
/**
* GET /api/v1/assets?library_id=xxx
* AssetResponse MediaAsset
@@ -475,22 +297,26 @@ function inferMediaType(mimeType: string): "video" | "image" | "audio" {
}
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
// 优先取顶层 duration,其次从 metadata 回退
const metaDuration =
typeof asset.metadata?.duration === "number"
? asset.metadata.duration
: undefined;
const meta = (asset.metadata || {}) as Record<string, unknown>;
const ext = asset as AssetItem & Record<string, unknown>;
return {
id: asset.id,
name: asset.name,
type: inferMediaType(asset.mime_type || ""),
thumbnail_url: asset.thumbnail_url,
duration: asset.duration ?? metaDuration,
thumbnail_url:
typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined,
duration:
typeof ext.duration === "number"
? ext.duration
: typeof meta.duration === "number"
? (meta.duration as number)
: undefined,
size: asset.file_size ?? undefined,
tags: [],
created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined,
classification_status: asset.classification_status ?? undefined,
classification_status: (asset.classification_status ??
undefined) as MediaAsset["classification_status"],
};
}
@@ -498,27 +324,17 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
*
* ============================================================ */
/** 转场效果选项14 种预设) */
/** 转场效果选项 */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"];
label: string;
icon: string;
}[] = [
{ value: "none", label: "无转场", icon: "⊘" },
{ value: "cut", label: "硬切", icon: "✂" },
{ value: "fade", label: "淡入淡出", icon: "◐" },
{ value: "dissolve", label: "溶解", icon: "◈" },
{ value: "zoom", label: "缩放", icon: "⊕" },
{ value: "slide_left", label: "左滑", icon: "←" },
{ value: "slide_right", label: "右滑", icon: "→" },
{ value: "slide_up", label: "上滑", icon: "↑" },
{ value: "slide_down", label: "下滑", icon: "↓" },
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
{ value: "none", label: "无转场" },
{ value: "fade", label: "淡入淡出" },
{ value: "dissolve", label: "溶解" },
{ value: "wipe", label: "擦除" },
{ value: "zoom", label: "缩放" },
{ value: "slide", label: "滑动" },
];
/** 素材类型标签 */
+1 -50
View File
@@ -3,15 +3,6 @@
* /api/v1/templates
*/
import apiClient from "./client";
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types";
/* ──────────── 类型定义 ──────────── */
@@ -81,20 +72,6 @@ export interface EditingTemplate {
bgm_config: BgmConfig;
estimated_duration: number;
segments: TemplateSegment[];
/** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig;
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig;
/** 画中画配置 */
pip_config?: PipConfig;
/** 滤镜调色配置 */
filter_config?: FilterConfig;
/** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig;
/** 贴纸配置 */
sticker_config?: StickerConfig;
/** 封面配置 */
cover_config?: CoverConfig;
is_active?: boolean;
created_at: string;
updated_at: string;
@@ -118,20 +95,6 @@ export interface SaveTemplatePayload {
bgm_config: BgmConfig;
estimated_duration: number;
segments: Omit<TemplateSegment, "id">[];
/** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig;
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig;
/** 画中画配置 */
pip_config?: PipConfig;
/** 滤镜调色配置 */
filter_config?: FilterConfig;
/** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig;
/** 贴纸配置 */
sticker_config?: StickerConfig;
/** 封面配置 */
cover_config?: CoverConfig;
}
/** 使用模板生成请求体 */
@@ -139,23 +102,11 @@ export interface GenerateFromTemplatePayload {
voiceover_duration: number;
}
/** 验证警告详情 */
export interface ValidationWarningDetails {
/** 相关字段名 */
field?: string;
/** 期望值 */
expected?: string | number;
/** 实际值 */
actual?: string | number;
/** 建议值 */
suggested?: string | number;
}
/** 验证/生成响应 */
export interface ValidateWarning {
code: string;
message: string;
details?: ValidationWarningDetails;
details?: Record<string, unknown>;
}
/** 使用模板生成响应 */
+14 -113
View File
@@ -1,13 +1,8 @@
/**
* / API
* /products /generation/tasks
* API
* Phase 1 projectId
*/
import apiClient from "./client";
import { getGenerationTaskResults } from "./editPlans";
import type { GeneratedVideo } from "./editPlans";
/** 复核状态 */
export type ReviewStatus = "pending_review" | "approved" | "rejected";
/** 成品条目 */
export interface ProductItem {
@@ -19,127 +14,33 @@ export interface ProductItem {
file_size?: number;
resolution?: string;
status: "processing" | "completed" | "failed";
/** 复核状态 */
review_status?: ReviewStatus;
/** 所属项目 ID */
project_id?: string;
/** 所属项目名称 */
project_name?: string;
/** 查重率(百分比) */
duplicate_rate?: number;
created_at?: string;
updated_at?: string;
}
/** 列表查询参数 */
export interface ProductListParams {
page?: number;
page_size?: number;
project_id?: string;
review_status?: ReviewStatus | "all";
}
/** 分页响应 */
export interface ProductListResponse {
items: ProductItem[];
total: number;
page: number;
page_size: number;
}
/** 批量下载任务状态 */
export interface BatchDownloadStatus {
job_id: string;
status: "processing" | "completed" | "failed";
/** 完成后返回的下载 URL */
download_url?: string;
/** 进度百分比 */
progress?: number;
}
/**
* generation task ProductItem
*/
function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
return {
id: task.id,
title: task.name || "未命名视频",
video_url: task.file_url,
thumbnail_url: task.thumbnail_url,
duration_seconds: task.duration,
file_size: task.file_size,
resolution:
task.width && task.height ? `${task.width}x${task.height}` : undefined,
status:
task.status === "completed"
? "completed"
: task.status === "failed"
? "failed"
: "processing",
review_status: task.review_status as ReviewStatus | undefined,
project_id: task.project_id,
created_at: task.created_at,
updated_at: task.updated_at,
};
}
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */
export const getProducts = async (
params?: ProductListParams,
): Promise<ProductItem[]> => {
const response = await apiClient.get("/generation/tasks", { params });
const tasks = response.data.items || response.data || [];
return tasks.map(mapTaskToProductItem);
/** 获取当前用户的所有成品 */
export const getProducts = async (): Promise<ProductItem[]> => {
const response = await apiClient.get("/products");
return response.data.items || response.data || [];
};
/** 获取单个成品详情 — 通过 task ID 获取结果 */
/** 获取单个成品详情 */
export const getProduct = async (productId: string): Promise<ProductItem> => {
const response = await apiClient.get(`/generation/tasks/${productId}`);
return mapTaskToProductItem(response.data);
const response = await apiClient.get(`/products/${productId}`);
return response.data;
};
/** 删除成品 — 删除 generation task */
/** 删除成品 */
export const deleteProduct = async (productId: string): Promise<void> => {
await apiClient.delete(`/generation/tasks/${productId}`);
await apiClient.delete(`/products/${productId}`);
};
/** 获取成品下载链接 — 从 generation task results 获取 */
/** 获取成品下载链接 */
export const getProductDownloadUrl = async (
productId: string,
): Promise<{ url: string; expires_at: string }> => {
const videos = await getGenerationTaskResults(productId);
const video = videos[0];
if (!video?.download_url) throw new Error("下载链接不可用");
return { url: video.download_url, expires_at: "" };
};
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
export const updateReviewStatus = async (
productId: string,
status: ReviewStatus,
): Promise<ProductItem> => {
// 后端暂无 /generation/tasks/{id}/review 端点
// 暂时返回当前状态,后续可扩展
const product = await getProduct(productId);
return { ...product, review_status: status };
};
/** 发起批量下载 — TODO: 后端暂无对应端点 */
export const batchDownload = async (
videoIds: string[],
): Promise<{ job_id: string }> => {
// 后端暂无 /generation/tasks/batch-download 端点
// 暂时返回模拟 job_id,后续可扩展
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds);
return { job_id: `mock-${Date.now()}` };
};
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
export const getBatchDownloadStatus = async (
jobId: string,
): Promise<BatchDownloadStatus> => {
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
// 暂时返回模拟状态,后续可扩展
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId);
return { job_id: jobId, status: "processing", progress: 0 };
const response = await apiClient.get(`/products/${productId}/download-url`);
return response.data;
};
+10 -56
View File
@@ -1,67 +1,31 @@
/**
* API
* API
* - POST /api/v1/generation/tasks
* - GET /api/v1/tasks /
* - GET /api/v1/tasks/{task_id} error_info
* - POST /api/v1/tasks/{task_id}/retry
* 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";
/* ──────────── 类型定义 ──────────── */
/** 任务状态 */
export type TaskStatus =
"pending" | "waiting" | "running" | "completed" | "failed" | "cancelled";
/** 任务类型 */
export type TaskType = "ingest" | "generation" | string;
/** 错误详情 */
export interface TaskErrorInfo {
error_type: string;
error_message: string;
failed_step: string;
stack_trace?: string;
}
/** 任务条目(对应用户级 UserTaskResponse */
export interface TaskItem {
id: string;
task_type: TaskType;
task_type: "ingest" | "generation" | string;
project_id: string;
template_id?: string;
status: TaskStatus;
template_id: string;
status: string;
progress: number;
current_step: string;
error_message: string;
user_message: string;
retryable: boolean;
source_id: string;
/** 错误详情(失败任务) */
error_info?: TaskErrorInfo;
/** 耗时(秒) */
duration_seconds?: number;
created_at?: string | null;
updated_at?: string | null;
}
/** 任务列表查询参数 */
export interface TaskListParams {
page?: number;
page_size?: number;
status?: TaskStatus | "all";
task_type?: TaskType | "all";
}
/** 任务列表分页响应 */
export interface TaskListResponse {
items: TaskItem[];
total: number;
page: number;
page_size: number;
}
/** 创建生成任务请求参数 */
export interface CreateGenerationTaskRequest {
template_id: string;
@@ -100,23 +64,13 @@ export const createGenerationTask = async (
return data;
};
/** 获取任务列表(支持分页和筛选 */
export const getTasks = async (
params?: TaskListParams,
): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
params,
});
return data;
};
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
/** 获取当前用户的所有任务(跨 project */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get("/tasks");
return data.items || data || [];
return data.items || [];
};
/** 获取单个任务详情(含 error_info */
/** 获取单个任务详情(用于轮询进度 */
export const getTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.get(`/tasks/${taskId}`);
return data;
+4 -133
View File
@@ -1,112 +1,26 @@
/**
* API
*
* - GET /api/v1/templates /
* - GET /api/v1/templates/{id}
* - POST /api/v1/templates/{id}/copy
* - POST /api/v1/templates/{id}/generate
* - POST /api/v1/templates/{id}/toggle-favorite /
* Phase 1
*/
import apiClient from "./client";
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner";
import type { EditPlanConfig } from "./editPlans";
/* ──────────── 类型定义 ──────────── */
/** 模板条目(后端 TemplateResponse */
/** 模板条目 */
export interface TemplateItem {
id: string;
name: string;
description: string;
category: string;
tags?: string[];
target_duration: number;
clip_count: number;
/** 使用次数 */
usage_count?: number;
thumbnail_url?: string;
preview_url?: string;
is_active: boolean;
is_favorite?: boolean;
/** 素材规则(片段配置) */
segments?: TemplateSegment[];
/** 字幕样式 */
subtitle_config?: SubtitleConfig;
/** BGM 配置 */
bgm_config?: BgmConfig;
/** 标题配置 */
title_config?: TitleConfig;
/** 视频比例 */
aspect_ratio?: string;
created_at?: string;
updated_at?: string;
}
/** 模板片段(素材规则) */
export interface TemplateSegment {
id?: string;
segment_order: number;
duration_min: number;
duration_max: number;
material_type: string | null;
description?: string;
}
/** 模板列表查询参数 */
export interface TemplateListParams {
page?: number;
page_size?: number;
category?: string;
tags?: string;
keyword?: string;
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
duration_range?: "short" | "medium" | "long";
}
/** 模板列表分页响应 */
export interface TemplateListResponse {
items: TemplateItem[];
total: number;
page: number;
page_size: number;
}
/** 从模板生成剪辑计划请求 */
export interface GenerateFromTemplateRequest {
asset_ids?: string[];
name?: string;
config?: EditPlanConfig;
}
/** 从模板生成剪辑计划响应 */
export interface GenerateFromTemplateResponse {
plan_id: string;
template_id: string;
status: string;
name: string;
}
/** 复制模板响应 */
export interface CopyTemplateResponse {
id: string;
name: string;
source_template_id: string;
}
/* ──────────── API 函数 ──────────── */
/** 获取模板列表(支持分页和筛选) */
export const getTemplates = async (
params?: TemplateListParams,
): Promise<TemplateListResponse> => {
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
params,
});
return data;
};
/** 获取模板列表(兼容旧接口,返回数组) */
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
/** 获取全局模板列表 */
export const getTemplates = async (): Promise<TemplateItem[]> => {
const response = await apiClient.get("/templates");
return response.data.items || response.data || [];
};
@@ -128,46 +42,3 @@ export const toggleFavoriteTemplate = async (
);
return response.data;
};
/** 复制模板(创建副本到我的模板) */
export const copyTemplate = async (
templateId: string,
): Promise<CopyTemplateResponse> => {
const response = await apiClient.post<CopyTemplateResponse>(
`/templates/${templateId}/copy`,
);
return response.data;
};
/** 从模板生成剪辑计划 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
);
return response.data;
};
/* ──────────── 常量 ──────────── */
/** 模板分类选项 */
export const TEMPLATE_CATEGORY_OPTIONS = [
{ value: "", label: "全部分类" },
{ value: "口播", label: "口播" },
{ value: "种草", label: "种草" },
{ value: "产品", label: "产品" },
{ value: "品牌", label: "品牌" },
{ value: "混剪", label: "混剪" },
{ value: "Vlog", label: "Vlog" },
];
/** 时长筛选选项 */
export const TEMPLATE_DURATION_OPTIONS = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
];
+2 -63
View File
@@ -8,18 +8,6 @@ import apiClient from "./client";
/* ── 类型定义 ──────────────────────────────────── */
/** TTS 元数据(合成时附带的扩展信息) */
export interface TTSMetadata {
/** 语音时长(秒) */
duration?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 语言 */
language?: string;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string;
@@ -30,7 +18,7 @@ export interface TTSSynthesizeRequest {
voice_model?: string;
voice_clone_profile_id?: string;
format?: string;
metadata?: TTSMetadata;
metadata?: Record<string, unknown>;
}
/** TTS 合成创建响应 */
@@ -61,7 +49,7 @@ export interface TTSJob {
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: TTSMetadata | null;
metadata_: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
@@ -152,52 +140,3 @@ export const saveTtsToLibrary = async (
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`);
};
/* ── 音色列表 ──────────────────────────────────── */
/** TTS 音色 */
export interface TTSVoice {
id: string;
name: string;
/** 音色分类标签:male/female/young/service/news/emotion */
category?: string;
/** 语言 */
language?: string;
/** 试听 URL */
preview_url?: string;
/** 描述 */
description?: string;
}
/** 获取 TTS 音色列表 */
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
const response = await apiClient.get<TTSVoice[]>("/tts/voices");
return response.data;
};
/* ── TTS 试听 ──────────────────────────────────── */
/** TTS 试听请求参数 */
export interface TTSPreviewRequest {
text: string;
voice_id: string;
speed?: number;
pitch?: number;
}
/** TTS 试听响应 */
export interface TTSPreviewResponse {
audio_url: string;
duration?: number;
}
/** TTS 试听 */
export const previewTts = async (
data: TTSPreviewRequest,
): Promise<TTSPreviewResponse> => {
const response = await apiClient.post<TTSPreviewResponse>(
"/tts/preview",
data,
);
return response.data;
};
+2 -14
View File
@@ -36,18 +36,6 @@ export interface CreateVoiceCloneRequest {
/* ── 后端 API 类型 ────────────────────────────────────── */
/** 音色克隆元数据(克隆时附带的扩展信息) */
export interface VoiceCloneMetadata {
/** 语音时长(秒) */
duration?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 音色 ID(克隆完成后分配) */
voice_id?: string;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** 后端克隆档案响应 */
export interface VoiceCloneProfile {
id: string;
@@ -63,7 +51,7 @@ export interface VoiceCloneProfile {
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: VoiceCloneMetadata | null;
metadata_: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
@@ -92,7 +80,7 @@ export interface CreateVoiceCloneRequestFull {
language?: string;
gender?: string;
max_retries?: number;
metadata_?: VoiceCloneMetadata;
metadata_?: Record<string, unknown>;
}
/* ── 辅助函数 ─────────────────────────────────────────── */
+113
View File
@@ -258,6 +258,51 @@
font-size: 24px !important;
}
/* ============================================================
Table 表格
============================================================ */
.xx-table .ant-table {
background: var(--bg-primary) !important;
color: var(--text-primary) !important;
border-radius: var(--radius-md) !important;
overflow: hidden;
}
.xx-table .ant-table-thead > tr > th {
background: var(--bg-secondary) !important;
color: var(--text-secondary) !important;
font-weight: var(--font-weight-semibold) !important;
border-bottom: 1px solid var(--border-color) !important;
font-size: var(--font-size-sm) !important;
text-transform: uppercase;
letter-spacing: var(--letter-spacing-wide);
}
.xx-table .ant-table-tbody > tr > td {
border-bottom: 1px solid var(--border-light) !important;
color: var(--text-primary) !important;
transition: var(--transition-fast) !important;
}
.xx-table .ant-table-tbody > tr:hover > td {
background: var(--primary-soft) !important;
}
.xx-table .ant-table-tbody > tr:last-child > td {
border-bottom: none !important;
}
/* 排序图标 */
.xx-table .ant-table-column-sorter-up.active,
.xx-table .ant-table-column-sorter-down.active {
color: var(--primary-color) !important;
}
/* 分页 */
.xx-table .ant-pagination {
padding: var(--space-md) 0 !important;
}
/* ============================================================
Card 卡片
============================================================ */
@@ -384,6 +429,69 @@
border-bottom: 1px solid var(--border-light) !important;
}
/* ============================================================
Form 表单
============================================================ */
.xx-form .ant-form-item-label > label {
color: var(--text-primary) !important;
font-weight: var(--font-weight-medium) !important;
font-size: var(--font-size-base) !important;
}
.xx-form .ant-form-item-explain-error {
color: var(--error-color) !important;
font-size: var(--font-size-sm) !important;
}
.xx-form .ant-form-item {
margin-bottom: var(--space-lg) !important;
}
/* 表单项间距紧凑 */
.xx-form-compact .ant-form-item {
margin-bottom: var(--space-md) !important;
}
/* ============================================================
Pagination 分页
============================================================ */
.xx-pagination .ant-pagination-item {
border-radius: var(--radius-xs) !important;
border-color: var(--border-color) !important;
transition: var(--transition-fast) !important;
}
.xx-pagination .ant-pagination-item a {
color: var(--text-primary) !important;
}
.xx-pagination .ant-pagination-item:hover {
border-color: var(--primary-color) !important;
}
.xx-pagination .ant-pagination-item:hover a {
color: var(--primary-color) !important;
}
.xx-pagination .ant-pagination-item-active {
background: var(--gradient-primary) !important;
border-color: transparent !important;
}
.xx-pagination .ant-pagination-item-active a {
color: var(--text-inverse) !important;
}
.xx-pagination .ant-pagination-prev .ant-pagination-item-link,
.xx-pagination .ant-pagination-next .ant-pagination-item-link {
border-radius: var(--radius-xs) !important;
color: var(--text-secondary) !important;
}
.xx-pagination .ant-pagination-disabled .ant-pagination-item-link {
color: var(--text-disabled) !important;
}
/* ============================================================
响应式
============================================================ */
@@ -408,6 +516,10 @@
.xx-modal .ant-modal-header {
padding: var(--space-md) !important;
}
.xx-form .ant-form-item {
margin-bottom: var(--space-md) !important;
}
}
@media (max-width: 480px) {
@@ -421,6 +533,7 @@
}
}
/* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */
/* AdminComingSoon 等页面使用 <Card className="xx-card"> 时需要 */
/* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */
-25
View File
@@ -17,7 +17,6 @@ import {
ScanOutlined,
ControlOutlined,
CrownOutlined,
UnorderedListOutlined,
} from "@ant-design/icons";
/** 导航项类型 */
@@ -81,12 +80,6 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/my-templates",
icon: React.createElement(FolderOutlined),
},
{
key: "edit-plans",
label: "剪辑计划",
path: "/app/edit-plans",
icon: React.createElement(UnorderedListOutlined),
},
{
key: "generate",
label: "一键生成",
@@ -111,12 +104,6 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/duplication",
icon: React.createElement(ScanOutlined),
},
{
key: "tasks",
label: "任务中心",
path: "/app/tasks",
icon: React.createElement(UnorderedListOutlined),
},
];
/** 侧边栏导航分组(Sidebar 分组列表使用) */
@@ -142,12 +129,6 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/editing-planner",
icon: React.createElement(EditOutlined),
},
{
key: "edit-plans",
label: "剪辑计划",
path: "/app/edit-plans",
icon: React.createElement(UnorderedListOutlined),
},
],
},
{
@@ -200,12 +181,6 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/history",
icon: React.createElement(HistoryOutlined),
},
{
key: "tasks",
label: "任务中心",
path: "/app/tasks",
icon: React.createElement(UnorderedListOutlined),
},
{
key: "duplication",
label: "查重",
+5 -1
View File
@@ -13,7 +13,11 @@ import "./accounts.css";
/* ── 类型定义 ───────────────────────────────────────────── */
export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat";
export type PlatformId =
| "douyin"
| "kuaishou"
| "xiaohongshu"
| "wechat";
export interface Platform {
id: PlatformId;
+12 -417
View File
@@ -4,17 +4,7 @@
* 使 useQuery APIapi/assets.ts
*/
import React, { useMemo, useState } from "react";
import {
Upload,
Modal as AntModal,
message,
Popconfirm,
Drawer,
Tag,
Input as AntInput,
Radio,
Select as AntSelect,
} from "antd";
import { Upload, Modal as AntModal, message, Popconfirm } from "antd";
import {
PlusOutlined,
SearchOutlined,
@@ -27,11 +17,6 @@ import {
ExperimentOutlined,
LoadingOutlined,
ExclamationCircleOutlined,
TagsOutlined,
FolderOutlined,
ThunderboltOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
@@ -42,13 +27,8 @@ import {
deleteAsset,
uploadAssetDirect,
getAssetDiagnosis,
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type AssetLibraryItem,
type AssetItem as ApiAssetItem,
type BatchOperationResult,
} from "@/api/assets";
import { Button, Input, Select } from "@/components/ui";
import "./assets.css";
@@ -427,33 +407,6 @@ const AssetLibrary: React.FC = () => {
/* 诊断中状态 — 记录正在诊断的素材 ID */
const [diagnosingId, setDiagnosingId] = useState<string | null>(null);
/* ── 批量操作弹窗状态 ── */
const [tagModalOpen, setTagModalOpen] = useState(false);
const [classifyModalOpen, setClassifyModalOpen] = useState(false);
const [markModalOpen, setMarkModalOpen] = useState(false);
const [resultDrawerOpen, setResultDrawerOpen] = useState(false);
/* 批量打标签 */
const [batchTagInput, setBatchTagInput] = useState("");
const [batchTags, setBatchTags] = useState<string[]>([]);
const [tagMode, setTagMode] = useState<"add" | "replace">("add");
/* 批量改分类 */
const [batchCategory, setBatchCategory] = useState("");
/* 批量智能标记 */
const [batchSmartView, setBatchSmartView] = useState<
"recommended" | "caution" | "high_risk"
>("recommended");
/* 操作结果 */
const [operationResult, setOperationResult] =
useState<BatchOperationResult | null>(null);
const [operationTitle, setOperationTitle] = useState("");
/* 批量操作 loading */
const [batchLoading, setBatchLoading] = useState(false);
/* 派生数据 */
const filteredAssets = useMemo(() => {
let list = assets;
@@ -610,152 +563,19 @@ const AssetLibrary: React.FC = () => {
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds);
setBatchLoading(true);
try {
const result = await batchDeleteAssets(ids);
setOperationResult(result);
setOperationTitle("批量删除");
setResultDrawerOpen(true);
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
setSelectedIds(new Set());
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`);
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
);
let successCount = 0;
for (const id of ids) {
try {
await deleteAsset(id);
successCount++;
} catch {
// 忽略单个失败
}
} catch {
message.error("批量删除失败,请重试");
} finally {
setBatchLoading(false);
}
};
/* 批量打标签 */
const handleBatchTag = async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签");
return;
}
const ids = Array.from(selectedIds);
setBatchLoading(true);
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
});
setOperationResult(result);
setOperationTitle("批量打标签");
setResultDrawerOpen(true);
setTagModalOpen(false);
setBatchTags([]);
setBatchTagInput("");
setTagMode("add");
queryClient.invalidateQueries({ queryKey: ["assets"] });
setSelectedIds(new Set());
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`);
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
);
}
} catch {
message.error("批量打标签失败,请重试");
} finally {
setBatchLoading(false);
}
};
/* 批量改分类 */
const handleBatchClassify = async () => {
if (!batchCategory) {
message.warning("请选择分类");
return;
}
const ids = Array.from(selectedIds);
setBatchLoading(true);
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
});
setOperationResult(result);
setOperationTitle("批量改分类");
setResultDrawerOpen(true);
setClassifyModalOpen(false);
setBatchCategory("");
queryClient.invalidateQueries({ queryKey: ["assets"] });
setSelectedIds(new Set());
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材改为「${batchCategory}`,
);
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
);
}
} catch {
message.error("批量改分类失败,请重试");
} finally {
setBatchLoading(false);
}
};
/* 批量智能标记 */
const handleBatchMark = async () => {
const ids = Array.from(selectedIds);
setBatchLoading(true);
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
});
setOperationResult(result);
setOperationTitle("批量智能标记");
setResultDrawerOpen(true);
setMarkModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["assets"] });
setSelectedIds(new Set());
const labelMap = {
recommended: "推荐",
caution: "慎用",
high_risk: "高风险",
};
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}`,
);
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
);
}
} catch {
message.error("批量智能标记失败,请重试");
} finally {
setBatchLoading(false);
}
};
/* 标签输入处理 */
const handleTagInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault();
const tag = batchTagInput.trim();
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag]);
}
setBatchTagInput("");
}
};
const removeBatchTag = (tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag));
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
setSelectedIds(new Set());
message.success(`已删除 ${successCount}/${ids.length} 个素材`);
};
// ── Loading 状态 ──
@@ -949,30 +769,6 @@ const AssetLibrary: React.FC = () => {
<Button buttonType="ghost" buttonSize="sm" onClick={deselectAll}>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<TagsOutlined />}
onClick={() => setTagModalOpen(true)}
>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<FolderOutlined />}
onClick={() => setClassifyModalOpen(true)}
>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<ThunderboltOutlined />}
onClick={() => setMarkModalOpen(true)}
>
</Button>
<Popconfirm
title={`确定删除 ${selectedIds.size} 个素材?`}
onConfirm={handleBatchDelete}
@@ -1102,207 +898,6 @@ const AssetLibrary: React.FC = () => {
</div>
)}
</AntModal>
{/* ─── 批量打标签弹窗 ─── */}
<AntModal
title={`批量打标签(${selectedIds.size} 个素材)`}
open={tagModalOpen}
onCancel={() => {
setTagModalOpen(false);
setBatchTags([]);
setBatchTagInput("");
}}
onOk={handleBatchTag}
confirmLoading={batchLoading}
okText="确认打标签"
cancelText="取消"
>
<div className="xx-batch-tag-modal">
<div className="xx-batch-tag-mode">
<span className="xx-batch-tag-mode-label"></span>
<Radio.Group
value={tagMode}
onChange={(e) => setTagMode(e.target.value)}
>
<Radio value="add"></Radio>
<Radio value="replace"></Radio>
</Radio.Group>
</div>
<div className="xx-batch-tag-input-row">
<AntInput
placeholder="输入标签后按 Enter 添加"
value={batchTagInput}
onChange={(e) => setBatchTagInput(e.target.value)}
onKeyDown={handleTagInputKeyDown}
style={{ flex: 1 }}
/>
</div>
{batchTags.length > 0 && (
<div className="xx-batch-tag-list">
{batchTags.map((tag) => (
<Tag
key={tag}
closable
onClose={() => removeBatchTag(tag)}
color="blue"
>
{tag}
</Tag>
))}
</div>
)}
{tagMode === "replace" && batchTags.length > 0 && (
<div className="xx-batch-tag-warning">
<ExclamationCircleOutlined />
</div>
)}
</div>
</AntModal>
{/* ─── 批量改分类弹窗 ─── */}
<AntModal
title={`批量改分类(${selectedIds.size} 个素材)`}
open={classifyModalOpen}
onCancel={() => {
setClassifyModalOpen(false);
setBatchCategory("");
}}
onOk={handleBatchClassify}
confirmLoading={batchLoading}
okText="确认修改"
cancelText="取消"
>
<div className="xx-batch-classify-modal">
<p className="xx-batch-classify-hint">
{selectedIds.size}
</p>
<AntSelect
value={batchCategory || undefined}
onChange={(v) => setBatchCategory(v)}
placeholder="请选择分类"
style={{ width: "100%" }}
options={[
{ value: "person", label: "人物" },
{ value: "scenic", label: "风景" },
{ value: "product", label: "产品" },
{ value: "food", label: "美食" },
{ value: "animal", label: "动物" },
{ value: "architecture", label: "建筑" },
{ value: "other", label: "其他" },
]}
/>
</div>
</AntModal>
{/* ─── 批量智能标记弹窗 ─── */}
<AntModal
title={`批量智能标记(${selectedIds.size} 个素材)`}
open={markModalOpen}
onCancel={() => setMarkModalOpen(false)}
onOk={handleBatchMark}
confirmLoading={batchLoading}
okText="确认标记"
cancelText="取消"
>
<div className="xx-batch-mark-modal">
<p className="xx-batch-mark-hint">
{selectedIds.size}
</p>
<Radio.Group
value={batchSmartView}
onChange={(e) => setBatchSmartView(e.target.value)}
className="xx-batch-mark-options"
>
<div className="xx-batch-mark-option">
<Radio value="recommended">
<Tag color="success"></Tag>
<span className="xx-batch-mark-desc">
</span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="caution">
<Tag color="warning"></Tag>
<span className="xx-batch-mark-desc">
使
</span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="high_risk">
<Tag color="error"></Tag>
<span className="xx-batch-mark-desc">
使
</span>
</Radio>
</div>
</Radio.Group>
</div>
</AntModal>
{/* ─── 操作结果 Drawer ─── */}
<Drawer
title={`${operationTitle} — 操作结果`}
open={resultDrawerOpen}
onClose={() => {
setResultDrawerOpen(false);
setOperationResult(null);
}}
width={420}
>
{operationResult && (
<div className="xx-batch-result">
<div className="xx-batch-result-summary">
<div className="xx-batch-result-stat">
<span className="xx-batch-result-total">
{operationResult.total}
</span>
</div>
<div className="xx-batch-result-stat success">
<CheckCircleOutlined />
<span> {operationResult.success_count} </span>
</div>
{operationResult.failure_count > 0 && (
<div className="xx-batch-result-stat fail">
<CloseCircleOutlined />
<span> {operationResult.failure_count} </span>
</div>
)}
</div>
{operationResult.succeeded.length > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title success">
<CheckCircleOutlined />
</h4>
<div className="xx-batch-result-ids">
{operationResult.succeeded.map((id) => (
<div key={id} className="xx-batch-result-id">
{id}
</div>
))}
</div>
</div>
)}
{operationResult.failed.length > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title fail">
<CloseCircleOutlined />
</h4>
<div className="xx-batch-result-ids">
{operationResult.failed.map((id) => (
<div key={id} className="xx-batch-result-id fail">
{id}
</div>
))}
</div>
</div>
)}
</div>
)}
</Drawer>
</div>
);
};
-172
View File
@@ -653,175 +653,3 @@
font-size: 13px;
color: var(--text-secondary, #6b7280);
}
/* ─── 批量打标签弹窗 ─── */
.xx-batch-tag-modal {
display: flex;
flex-direction: column;
gap: 16px;
}
.xx-batch-tag-mode {
display: flex;
align-items: center;
gap: 8px;
}
.xx-batch-tag-mode-label {
font-size: 14px;
color: var(--text-primary, #111827);
font-weight: 500;
}
.xx-batch-tag-input-row {
display: flex;
gap: 8px;
}
.xx-batch-tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.xx-batch-tag-warning {
padding: 10px 12px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: var(--radius-md, 8px);
color: #c2410c;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
/* ─── 批量改分类弹窗 ─── */
.xx-batch-classify-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-batch-classify-hint {
font-size: 14px;
color: var(--text-secondary, #6b7280);
margin: 0;
}
/* ─── 批量智能标记弹窗 ─── */
.xx-batch-mark-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-batch-mark-hint {
font-size: 14px;
color: var(--text-secondary, #6b7280);
margin: 0;
}
.xx-batch-mark-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-batch-mark-option {
display: flex;
flex-direction: column;
}
.xx-batch-mark-desc {
margin-left: 8px;
font-size: 13px;
color: var(--text-secondary, #6b7280);
}
/* ─── 操作结果 Drawer ─── */
.xx-batch-result {
display: flex;
flex-direction: column;
gap: 20px;
}
.xx-batch-result-summary {
display: flex;
gap: 16px;
padding: 16px;
background: var(--bg-secondary, #f9fafb);
border-radius: var(--radius-md, 8px);
}
.xx-batch-result-stat {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--text-primary, #111827);
}
.xx-batch-result-stat.success {
color: #059669;
}
.xx-batch-result-stat.fail {
color: #dc2626;
}
.xx-batch-result-total {
font-weight: 600;
}
.xx-batch-result-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-batch-result-section-title {
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
margin: 0;
}
.xx-batch-result-section-title.success {
color: #059669;
}
.xx-batch-result-section-title.fail {
color: #dc2626;
}
.xx-batch-result-ids {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 300px;
overflow-y: auto;
}
.xx-batch-result-id {
padding: 6px 10px;
background: var(--bg-surface, #fff);
border: 1px solid var(--border-primary, #e5e7eb);
border-radius: var(--radius-sm, 4px);
font-size: 12px;
font-family: monospace;
color: var(--text-secondary, #6b7280);
word-break: break-all;
}
.xx-batch-result-id.fail {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
+4 -16
View File
@@ -39,11 +39,7 @@ const Dashboard: React.FC = () => {
<section className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => navigate("/app/history")}
>
<Button buttonType="ghost" buttonSize="sm" onClick={() => navigate("/app/history")}>
</Button>
</div>
@@ -55,10 +51,7 @@ const Dashboard: React.FC = () => {
</section>
{/* 使用统计 */}
<section
className="xx-dashboard-section"
style={{ marginTop: "var(--space-md)" }}
>
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
<div className="xx-dashboard-section-header">
<h3>使</h3>
</div>
@@ -73,18 +66,13 @@ const Dashboard: React.FC = () => {
</section>
{/* 公告 */}
<section
className="xx-dashboard-section"
style={{ marginTop: "var(--space-md)" }}
>
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
<div className="xx-dashboard-section-header">
<h3></h3>
</div>
<div className="xx-announcement-list">
<div className="xx-announcement-item">
<span className="xx-announcement-tag xx-announcement-tag--notice">
</span>
<span className="xx-announcement-tag xx-announcement-tag--notice"></span>
<div className="xx-announcement-content">
<h4>使 SaaS </h4>
<time></time>
-433
View File
@@ -1,433 +0,0 @@
/**
*
*
*/
import { useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Table,
Tabs,
Select,
Tag,
Button,
message,
Popconfirm,
Tooltip,
} from "antd";
import {
CheckCircleOutlined,
ClockCircleOutlined,
SyncOutlined,
CloseCircleOutlined,
EditOutlined,
DeleteOutlined,
FileTextOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getEditPlans,
deleteEditPlan,
generateEditPlan,
type EditPlan,
type EditPlanStatus,
type EditPlanListParams,
} from "@/api/editPlans";
import { getTemplatesList, type TemplateItem } from "@/api/templates";
import "./edit-plans.css";
/* ──────────── 常量 ──────────── */
/** 状态 Tab 配置 */
const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "draft", label: "草稿" },
{ key: "editing", label: "编辑中" },
{ key: "rendering", label: "渲染中" },
{ key: "completed", label: "已完成" },
{ key: "failed", label: "失败" },
];
/** 状态标签配置 */
const STATUS_CONFIG: Record<
EditPlanStatus,
{ label: string; color: string; icon: React.ReactNode }
> = {
draft: {
label: "草稿",
color: "default",
icon: <FileTextOutlined />,
},
editing: {
label: "编辑中",
color: "processing",
icon: <EditOutlined />,
},
rendering: {
label: "渲染中",
color: "warning",
icon: <SyncOutlined spin />,
},
completed: {
label: "已完成",
color: "success",
icon: <CheckCircleOutlined />,
},
failed: {
label: "失败",
color: "error",
icon: <CloseCircleOutlined />,
},
};
/* ──────────── 工具函数 ──────────── */
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
if (seconds <= 0) return "-";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m === 0) return `${s}`;
return `${m}${s > 0 ? `${s}` : ""}`;
};
/** 格式化时间 */
const formatTime = (dateStr?: string | null): string => {
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
/* ──────────── 主组件 ──────────── */
export default function EditPlans() {
const navigate = useNavigate();
const queryClient = useQueryClient();
// 筛选状态
const [statusFilter, setStatusFilter] = useState<EditPlanStatus | "all">(
"all",
);
const [templateFilter, setTemplateFilter] = useState<string>("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
// 查询参数
const queryParams: EditPlanListParams = {
page,
page_size: pageSize,
...(statusFilter !== "all" && { status: statusFilter }),
...(templateFilter !== "all" && { template_id: templateFilter }),
};
// 获取剪辑计划列表
const {
data: planData,
isLoading,
error,
} = useQuery({
queryKey: ["edit-plans", queryParams],
queryFn: () => getEditPlans(queryParams),
refetchInterval: (query) => {
// 有进行中的计划时自动刷新
const plans = query.state.data?.items ?? [];
const hasRunning = plans.some(
(p) => p.status === "rendering" || p.status === "editing",
);
return hasRunning ? 5000 : false;
},
});
// 获取模板列表(用于筛选下拉)
const { data: templates } = useQuery({
queryKey: ["templates-list-simple"],
queryFn: getTemplatesList,
});
const plans = planData?.items ?? [];
const total = planData?.total ?? 0;
// 模板名称映射
const templateNameMap = new Map<string, string>();
(templates ?? []).forEach((t: TemplateItem) => {
templateNameMap.set(t.id, t.name);
});
// 删除计划
const deleteMutation = useMutation({
mutationFn: deleteEditPlan,
onSuccess: () => {
message.success("剪辑计划已删除");
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
},
onError: () => {
message.error("删除失败,请稍后重试");
},
});
// 重新生成
const regenerateMutation = useMutation({
mutationFn: generateEditPlan,
onSuccess: () => {
message.success("已重新提交生成");
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
},
onError: () => {
message.error("重新生成失败,请稍后重试");
},
});
// 跳转到剪辑编辑器
const handleEdit = useCallback(
(plan: EditPlan) => {
navigate(`/app/editing-planner?planId=${plan.id}`);
},
[navigate],
);
// 表格列定义
const columns: ColumnsType<EditPlan> = [
{
title: "计划名称",
dataIndex: "name",
key: "name",
width: 240,
ellipsis: true,
render: (name: string, record: EditPlan) => (
<Tooltip title={name}>
<span className="plan-name" onClick={() => handleEdit(record)}>
{name}
</span>
</Tooltip>
),
},
{
title: "模板",
dataIndex: "template_id",
key: "template_id",
width: 140,
ellipsis: true,
render: (templateId: string) => {
const name = templateNameMap.get(templateId);
return (
<Tag color="blue" className="plan-template-tag">
{name || templateId.slice(0, 8)}
</Tag>
);
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: EditPlanStatus) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
};
return (
<Tag
color={config.color}
icon={config.icon}
className="plan-status-tag"
>
{config.label}
</Tag>
);
},
},
{
title: "时长",
dataIndex: "total_duration",
key: "total_duration",
width: 100,
render: (seconds: number) => (
<span className="plan-duration">{formatDuration(seconds)}</span>
),
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 130,
render: (time: string) => (
<span className="plan-time">{formatTime(time)}</span>
),
},
{
title: "更新时间",
dataIndex: "updated_at",
key: "updated_at",
width: 130,
render: (time: string) => (
<span className="plan-time">{formatTime(time)}</span>
),
},
{
title: "操作",
key: "action",
width: 180,
fixed: "right",
render: (_: unknown, record: EditPlan) => (
<div className="plan-actions">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
className="plan-action-btn"
>
</Button>
{(record.status === "failed" || record.status === "completed") && (
<Popconfirm
title="确认重新生成"
description="确定要重新生成这个剪辑计划吗?"
onConfirm={() => regenerateMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<ThunderboltOutlined />}
loading={regenerateMutation.isPending}
className="plan-action-btn plan-regenerate-btn"
>
</Button>
</Popconfirm>
)}
<Popconfirm
title="确认删除"
description="确定要删除这个剪辑计划吗?此操作不可恢复。"
onConfirm={() => deleteMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
loading={deleteMutation.isPending}
className="plan-action-btn"
>
</Button>
</Popconfirm>
</div>
),
},
];
// 错误处理
if (error) {
return (
<div className="edit-plans-page">
<div className="edit-plans-error">
<CloseCircleOutlined />
<p></p>
<Button onClick={() => window.location.reload()}></Button>
</div>
</div>
);
}
return (
<div className="edit-plans-page">
{/* 页面标题 */}
<div className="edit-plans-header">
<div className="edit-plans-header-text">
<h2></h2>
<p></p>
</div>
<Button type="primary" onClick={() => navigate("/app/templates")}>
</Button>
</div>
{/* 筛选栏 */}
<div className="edit-plans-filters">
{/* 状态 Tab */}
<Tabs
activeKey={statusFilter}
onChange={(key) => {
setStatusFilter(key as EditPlanStatus | "all");
setPage(1);
}}
items={STATUS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
}))}
className="edit-plans-status-tabs"
/>
{/* 模板筛选 */}
<Select
value={templateFilter}
onChange={(value) => {
setTemplateFilter(value);
setPage(1);
}}
options={[
{ value: "all", label: "全部模板" },
...(templates ?? []).map((t: TemplateItem) => ({
value: t.id,
label: t.name,
})),
]}
style={{ minWidth: 180 }}
placeholder="选择模板"
className="edit-plans-template-filter"
/>
</div>
{/* 计划表格 */}
<Table
columns={columns}
dataSource={plans}
rowKey="id"
loading={isLoading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
scroll={{ x: 900 }}
className="edit-plans-table"
locale={{
emptyText: (
<div className="edit-plans-empty">
<ClockCircleOutlined />
<p></p>
<Button
type="primary"
style={{ marginTop: 12 }}
onClick={() => navigate("/app/templates")}
>
</Button>
</div>
),
}}
/>
</div>
);
}
@@ -1,260 +0,0 @@
/**
* 剪辑计划管理页面样式
*/
/* ── 页面容器 ──────────────────────────────────────────── */
.edit-plans-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
/* ── 页面头部 ──────────────────────────────────────────── */
.edit-plans-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
}
.edit-plans-header-text h2 {
margin: 0 0 4px;
font-size: 22px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.edit-plans-header-text p {
margin: 0;
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── 筛选栏 ────────────────────────────────────────────── */
.edit-plans-filters {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.edit-plans-status-tabs {
flex: 1;
}
.edit-plans-status-tabs .ant-tabs-nav {
margin-bottom: 0 !important;
}
.edit-plans-status-tabs .ant-tabs-tab {
padding: 8px 16px !important;
font-size: 14px;
}
.edit-plans-status-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--primary-500, #6366f1) !important;
font-weight: 500;
}
.edit-plans-status-tabs .ant-tabs-ink-bar {
background: var(--primary-500, #6366f1) !important;
}
.edit-plans-template-filter {
min-width: 180px;
}
/* ── 表格 ──────────────────────────────────────────────── */
.edit-plans-table {
background: var(--bg-surface, #fff);
border-radius: var(--radius-lg, 12px);
overflow: hidden;
border: 1px solid var(--border-primary, #e2e8f0);
}
.edit-plans-table .ant-table {
font-size: 14px;
}
.edit-plans-table .ant-table-thead > tr > th {
background: var(--bg-tertiary, #f8fafc) !important;
border-bottom: 1px solid var(--border-primary, #e2e8f0);
font-weight: 500;
color: var(--text-secondary, #64748b);
font-size: 13px;
padding: 12px 16px;
}
.edit-plans-table .ant-table-tbody > tr > td {
padding: 14px 16px;
border-bottom: 1px solid var(--border-light, #f1f5f9);
}
.edit-plans-table .ant-table-tbody > tr:hover > td {
background: var(--bg-hover, #f8fafc) !important;
}
/* ── 计划名称 ──────────────────────────────────────────── */
.plan-name {
font-weight: 500;
color: var(--text-primary, #1e293b);
cursor: pointer;
transition: color 0.2s;
}
.plan-name:hover {
color: var(--primary-500, #6366f1);
}
/* ── 状态标签 ──────────────────────────────────────────── */
.plan-status-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
}
.plan-status-tag.ant-tag-default {
background: #f1f5f9;
color: #64748b;
border-color: transparent;
}
.plan-status-tag.ant-tag-processing {
background: #eff6ff;
color: #2563eb;
border-color: transparent;
}
.plan-status-tag.ant-tag-success {
background: #f0fdf4;
color: #16a34a;
border-color: transparent;
}
.plan-status-tag.ant-tag-error {
background: #fef2f2;
color: #dc2626;
border-color: transparent;
}
.plan-status-tag.ant-tag-warning {
background: #fffbeb;
color: #d97706;
border-color: transparent;
}
/* ── 时长 ──────────────────────────────────────────────── */
.plan-duration {
font-variant-numeric: tabular-nums;
color: var(--text-secondary, #64748b);
}
/* ── 时间 ──────────────────────────────────────────────── */
.plan-time {
color: var(--text-secondary, #64748b);
font-size: 13px;
}
/* ── 操作按钮 ──────────────────────────────────────────── */
.plan-actions {
display: flex;
gap: 4px;
}
.plan-action-btn {
padding: 4px 8px !important;
font-size: 13px !important;
}
.plan-action-btn.ant-btn-link {
color: var(--primary-500, #6366f1);
}
.plan-action-btn.ant-btn-link:hover {
color: var(--primary-600, #4f46e5);
}
.plan-regenerate-btn {
color: var(--primary-500, #6366f1) !important;
}
.plan-regenerate-btn:hover {
color: var(--primary-600, #4f46e5) !important;
}
/* ── 空状态 ────────────────────────────────────────────── */
.edit-plans-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.edit-plans-empty .anticon {
font-size: 48px;
color: var(--text-disabled, #cbd5e1);
margin-bottom: 16px;
}
.edit-plans-empty p {
margin: 0;
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── 错误状态 ──────────────────────────────────────────── */
.edit-plans-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
background: var(--bg-surface, #fff);
border-radius: var(--radius-lg, 12px);
border: 1px solid var(--border-primary, #e2e8f0);
}
.edit-plans-error .anticon {
font-size: 48px;
color: #ef4444;
margin-bottom: 16px;
}
.edit-plans-error p {
margin: 0 0 16px;
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── 响应式 ────────────────────────────────────────────── */
@media (max-width: 768px) {
.edit-plans-page {
padding: 16px;
}
.edit-plans-header {
flex-direction: column;
gap: 12px;
}
.edit-plans-filters {
flex-direction: column;
align-items: stretch;
}
.edit-plans-status-tabs {
width: 100%;
}
.edit-plans-template-filter {
width: 100%;
}
}
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
* V8 1:1
* (42px) (48px) (40px)
*/
import React, { useState, useCallback, useEffect, useRef } from "react";
import React, { useState, useCallback, useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { message } from "antd";
import { useQuery } from "@tanstack/react-query";
@@ -18,6 +18,7 @@ import {
createEditingTemplate,
updateEditingTemplate,
getTemplateCategories,
generateFromTemplate,
MODE_LABELS,
} from "@/api/editingPlanner";
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
@@ -27,34 +28,9 @@ import {
generateCover,
} from "@/api/editPlans";
import { useUndoRedo } from "./hooks/useUndoRedo";
import type {
ClipData,
ClipType,
TransitionConfig,
SpeedConfig,
TtsConfig,
TrimConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
TitleSettings,
} from "./types";
import {
DEFAULT_TRANSITION,
DEFAULT_SPEED,
DEFAULT_TTS_CONFIG,
DEFAULT_WATERMARK,
DEFAULT_INTRO_OUTRO,
DEFAULT_PIP_CONFIG,
DEFAULT_FILTER_CONFIG,
DEFAULT_CHROMA_KEY_CONFIG,
DEFAULT_STICKER_CONFIG,
DEFAULT_COVER_CONFIG,
} from "./types";
import type { TaskItem } from "@/api/tasks";
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
import type { ClipData, ClipType } from "./types";
import {
ensureDefaultLibrary,
getAssetsByKind,
@@ -66,23 +42,10 @@ import MediaPanel from "./components/MediaPanel";
import PreviewPlayer from "./components/PreviewPlayer";
import TimelinePanel from "./components/TimelinePanel";
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
import BgmSelector from "./components/BgmSelector";
import SubtitleStylePanel from "./components/SubtitleStylePanel";
import type { SubtitleStyleConfig } from "./components/SubtitleStylePanel";
import { DEFAULT_SUBTITLE_STYLE } from "./components/SubtitleStylePanel";
import TransitionSelector from "./components/TransitionSelector";
import SpeedPanel from "./components/SpeedPanel";
import TtsPanel from "./components/TtsPanel";
import WatermarkPanel from "./components/WatermarkPanel";
import IntroOutroPanel from "./components/IntroOutroPanel";
import PipConfigPanel from "./components/PipConfigPanel";
import FilterPanel from "./components/FilterPanel";
import GreenScreenPanel from "./components/GreenScreenPanel";
import StickerPanel from "./components/StickerPanel";
import CoverSelector from "./components/CoverSelector";
import SaveModal from "./components/SaveModal";
import GenerationProgressModal from "./components/GenerationProgressModal";
import type { GenPhase } from "./components/GenerationProgressModal";
import GenerationHistoryModal from "./components/GenerationHistoryModal";
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm";
import "./EditingPlanner.css";
/* ──────────── 常量 ──────────── */
@@ -144,7 +107,7 @@ const EditingPlanner: React.FC = () => {
const [searchQuery, setSearchQuery] = useState("");
/* ── 标题/字幕/BGM 设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>({
const [titleSettings, setTitleSettings] = useState({
aiAutoSelect: false,
title: "",
position: "top",
@@ -157,72 +120,17 @@ const EditingPlanner: React.FC = () => {
color: "#ffffff",
});
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>(
{
...DEFAULT_SUBTITLE_STYLE,
},
);
const [bgmSettings, setBgmSettings] = useState<BgmMixConfig>({
...DEFAULT_BGM_MIX_CONFIG,
const [subtitleSettings, setSubtitleSettings] = useState({
enabled: true,
position: "bottom",
font: "思源黑体",
size: 16,
animation: "none",
});
/* ── Drawer 开关 ── */
const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false);
const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false);
const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false);
const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false);
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
const [transitionTargetClipId, setTransitionTargetClipId] = useState<
string | null
>(null);
/** 当前正在调速的片段 ID */
const [speedTargetClipId, setSpeedTargetClipId] = useState<string | null>(
null,
);
/** TTS 配音面板是否打开 */
const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false);
/** 当前正在配置 TTS 的片段 ID */
const [ttsTargetClipId, setTtsTargetClipId] = useState<string | null>(null);
/* ── 水印 / 片头片尾 ── */
const [watermarkSettings, setWatermarkSettings] = useState<WatermarkConfig>({
...DEFAULT_WATERMARK,
const [bgmSettings, setBgmSettings] = useState({
music: "none",
});
const [introOutroSettings, setIntroOutroSettings] =
useState<IntroOutroConfig>({ ...DEFAULT_INTRO_OUTRO });
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false);
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false);
/* ── 画中画 ── */
const [pipSettings, setPipSettings] = useState<PipConfig>({
...DEFAULT_PIP_CONFIG,
});
const [pipDrawerOpen, setPipDrawerOpen] = useState(false);
/* ── 滤镜调色 ── */
const [filterSettings, setFilterSettings] = useState<FilterConfig>({
...DEFAULT_FILTER_CONFIG,
});
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
/* ── 绿幕抠像 ── */
const [chromaKeySettings, setChromaKeySettings] = useState<ChromaKeyConfig>({
...DEFAULT_CHROMA_KEY_CONFIG,
});
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false);
/* ── 贴纸 ── */
const [stickerSettings, setStickerSettings] = useState<StickerConfig>({
...DEFAULT_STICKER_CONFIG,
});
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false);
/* ── 封面 ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>({
...DEFAULT_COVER_CONFIG,
});
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false);
/* ── 保存弹窗 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false);
@@ -231,6 +139,15 @@ const EditingPlanner: React.FC = () => {
const [draftTags, setDraftTags] = useState("");
const [saveLoading, setSaveLoading] = useState(false);
/* ── 生成弹窗 ── */
const [genModalOpen, setGenModalOpen] = useState(false);
const [genPhase, setGenPhase] = useState<GenPhase>("setup");
const [genTask, setGenTask] = useState<TaskItem | null>(null);
const [genSubmitting, setGenSubmitting] = useState(false);
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(
null,
);
/* ── 素材库 ── */
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([]);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
@@ -246,19 +163,6 @@ const EditingPlanner: React.FC = () => {
/* ── 播放 ── */
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [pixelsPerSecond, setPixelsPerSecond] = useState(40);
const prevFrameTimeRef = useRef<number | null>(null);
/** 播放头跳转 */
const handleSeek = useCallback((time: number) => {
setCurrentTime(Math.max(0, time));
}, []);
/** 轨道缩放 */
const handleZoomChange = useCallback((pps: number) => {
setPixelsPerSecond(pps);
}, []);
/* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
const voiceMaterialsQuery = useQuery({
@@ -352,21 +256,16 @@ const EditingPlanner: React.FC = () => {
size: tpl.title_config.font_size,
color: tpl.title_config.font_color || "#ffffff",
}));
setSubtitleSettings((prev) => ({
...prev,
setSubtitleSettings({
enabled: tpl.subtitle_config.enabled,
position: (tpl.subtitle_config.position ||
"bottom") as SubtitleStyleConfig["position"],
position: tpl.subtitle_config.position,
font: tpl.subtitle_config.font,
fontSize: tpl.subtitle_config.size,
fontColor: tpl.subtitle_config.color || "#ffffff",
size: tpl.subtitle_config.size,
animation: tpl.subtitle_config.animation,
}));
setBgmSettings((prev) => ({
...prev,
enabled: tpl.bgm_config.enabled,
music_id: tpl.bgm_config.music_id || "",
}));
});
setBgmSettings({
music: tpl.bgm_config.music_id || "none",
});
setDraftName(tpl.name);
setDraftCategory(tpl.category);
setDraftTags(tpl.tags.join(", "));
@@ -380,31 +279,6 @@ const EditingPlanner: React.FC = () => {
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0);
const selectedClip = clips.find((c) => c.id === selectedClipId) || null;
/* rAF 帧推进 — 播放时平滑更新播放头位置 */
useEffect(() => {
if (!isPlaying) {
prevFrameTimeRef.current = null;
return;
}
let rafId: number;
const tick = (timestamp: number) => {
if (prevFrameTimeRef.current !== null) {
const delta = (timestamp - prevFrameTimeRef.current) / 1000;
setCurrentTime((prev) => {
const next = prev + delta;
return next >= totalDuration ? totalDuration : next;
});
}
prevFrameTimeRef.current = timestamp;
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(rafId);
prevFrameTimeRef.current = null;
};
}, [isPlaying, totalDuration]);
const filteredTemplates = templates.filter((t) => {
if (currentFilter !== "全部" && t.category !== currentFilter) return false;
if (
@@ -475,175 +349,6 @@ const EditingPlanner: React.FC = () => {
[clips.length, setClips],
);
/* ── 裁剪更新:调整片段的 trim_config 和 duration ── */
const handleClipTrim = useCallback(
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId
? { ...c, trim_config: trimConfig, duration: newDuration }
: c,
),
);
},
[setClips],
);
/* ── 片段分割:在指定比例位置将片段一分为二 ── */
const handleClipSplit = useCallback(
(clipId: string, splitRatio: number) => {
setClips((prev) => {
const idx = prev.findIndex((c) => c.id === clipId);
if (idx === -1) return prev;
const clip = prev[idx];
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10;
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev;
// 前半段
const firstHalf: ClipData = {
...clip,
duration: splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
end_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
};
// 后半段
const secondHalf: ClipData = {
...clip,
id: `clip-${Date.now()}`,
duration: clip.duration - splitPoint,
startOffset: clip.startOffset + splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
start_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
order: (clip.order ?? idx) + 1,
};
const updated = [...prev];
updated[idx] = firstHalf;
updated.splice(idx + 1, 0, secondHalf);
return updated.map((c, i) => ({ ...c, order: i }));
});
},
[setClips],
);
/* ── 恢复片段原始长度 ── */
const handleClipResetTrim = useCallback(
(clipId: string) => {
setClips((prev) =>
prev.map((c) => {
if (c.id !== clipId || !c.trim_config) return c;
const originalDuration =
c.trim_config.original_duration ?? c.duration;
return {
...c,
duration: originalDuration,
trim_config: undefined,
};
}),
);
},
[setClips],
);
/* ── 转场特效变更 ── */
const handleTransitionChange = useCallback(
(config: TransitionConfig) => {
if (transitionTargetClipId) {
// 更新指定片段的转场
handleClipUpdate(transitionTargetClipId, { transition: config });
}
// 同时更新全局默认转场(供新片段使用)
},
[transitionTargetClipId],
);
/* ── 打开转场选择器 ── */
const handleOpenTransitionDrawer = useCallback((clipId?: string) => {
setTransitionTargetClipId(clipId ?? null);
setTransitionDrawerOpen(true);
}, []);
/* ── 调速变更 ── */
const handleSpeedChange = useCallback(
(config: SpeedConfig) => {
if (speedTargetClipId) {
handleClipUpdate(speedTargetClipId, { speed: config });
}
},
[speedTargetClipId],
);
/* ── 打开调速面板 ── */
const handleOpenSpeedDrawer = useCallback((clipId: string) => {
setSpeedTargetClipId(clipId);
setSpeedDrawerOpen(true);
}, []);
/* ── TTS 配音变更 ── */
const handleTtsChange = useCallback(
(ttsConfig: TtsConfig) => {
if (!ttsTargetClipId) return;
handleClipUpdate(ttsTargetClipId, { tts_config: ttsConfig });
},
[ttsTargetClipId],
);
/* ── 打开 TTS 配音面板 ── */
const handleOpenTtsDrawer = useCallback((clipId: string) => {
setTtsTargetClipId(clipId);
setTtsDrawerOpen(true);
}, []);
/* ── 调速应用到所有片段 ── */
const handleApplySpeedAll = useCallback((config: SpeedConfig) => {
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
message.success("已应用到所有片段");
}, []);
/* ── 水印配置变更 ── */
const handleWatermarkChange = useCallback((config: WatermarkConfig) => {
setWatermarkSettings(config);
}, []);
/* ── 片头片尾配置变更 ── */
const handleIntroOutroChange = useCallback((config: IntroOutroConfig) => {
setIntroOutroSettings(config);
}, []);
/* ── 画中画配置变更 ── */
const handlePipChange = useCallback((config: PipConfig) => {
setPipSettings(config);
}, []);
/* ── 滤镜调色配置变更 ── */
const handleFilterChange = useCallback((config: FilterConfig) => {
setFilterSettings(config);
}, []);
/* ── 绿幕抠像配置变更 ── */
const handleChromaKeyChange = useCallback((config: ChromaKeyConfig) => {
setChromaKeySettings(config);
}, []);
/* ── 贴纸配置变更 ── */
const handleStickerChange = useCallback((config: StickerConfig) => {
setStickerSettings(config);
}, []);
/* ── 封面配置变更 ── */
const handleCoverChange = useCallback((config: CoverConfig) => {
setCoverSettings(config);
}, []);
/* AI 封面生成 */
const handleAiGenerateCover = async (
coverType: "ai_frame" | "ai_regenerate",
@@ -703,13 +408,13 @@ const EditingPlanner: React.FC = () => {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
color: "#ffffff",
size: subtitleSettings.size,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
enabled: bgmSettings.music !== "none",
music_id: bgmSettings.music,
},
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
@@ -717,35 +422,7 @@ const EditingPlanner: React.FC = () => {
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.type === "voice" ? "voiceover" : "video",
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverSettings },
};
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload);
@@ -785,13 +462,12 @@ const EditingPlanner: React.FC = () => {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
size: subtitleSettings.size,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
enabled: bgmSettings.music !== "none",
music_id: bgmSettings.music,
},
mode: currentMode,
total_duration: totalDuration,
@@ -803,35 +479,7 @@ const EditingPlanner: React.FC = () => {
script_text: c.script_text,
voice_asset_id: c.voice_asset_id,
voice_file_url: c.voice_file_url,
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverSettings },
};
const params = new URLSearchParams();
if (loadedTemplateId) {
@@ -841,6 +489,87 @@ const EditingPlanner: React.FC = () => {
navigate(`/app/generate?${params.toString()}`);
};
/**
*
* 1. generateFromTemplate
* 2. createGenerationTask
* getTask TaskItem 使
*/
const handleGenerate = async () => {
if (!loadedTemplateId) return;
setGenSubmitting(true);
try {
await generateFromTemplate(loadedTemplateId, {
voiceover_duration: voiceoverDuration || totalDuration,
});
// 收集所有 voice 类型片段的配音素材 ID
const voiceIds = clips
.filter((c) => c.type === "voice" && c.voice_asset_id)
.map((c) => c.voice_asset_id as string);
const res = await createGenerationTask({
template_id: loadedTemplateId,
asset_ids: [],
title_ids: [],
voice_ids: voiceIds,
});
/* 创建接口返回的是精简响应,需查询完整 TaskItem 用于轮询 */
const task = await getTask(res.id);
setGenTask(task);
setGenPhase("progress");
message.info("生成任务已创建");
} catch {
message.error("创建生成任务失败");
} finally {
setGenSubmitting(false);
}
};
/**
* 3
* genPhase === "progress" ID
* /
*/
useEffect(() => {
if (genPhase !== "progress" || !genTask?.id) return;
const timer = setInterval(async () => {
try {
const t = await getTask(genTask.id);
setGenTask(t);
if (t.status === "completed") {
setGenPhase("completed");
clearInterval(timer);
} else if (t.status === "failed") {
setGenPhase("failed");
clearInterval(timer);
}
} catch {
/* ignore */
}
}, 3000);
return () => clearInterval(timer);
}, [genPhase, genTask?.id]);
const handleRetry = async () => {
if (!genTask?.id) return;
setGenSubmitting(true);
try {
const t = await retryTask(genTask.id);
setGenTask(t);
setGenPhase("progress");
} catch {
message.error("重试失败");
} finally {
setGenSubmitting(false);
}
};
const handleCancelGen = () => {
setGenModalOpen(false);
setGenPhase("setup");
setGenTask(null);
setVoiceoverDuration(null);
};
/* 查看生成历史 */
const handleViewGenHistory = async () => {
if (!loadedTemplateId) {
@@ -949,13 +678,7 @@ const EditingPlanner: React.FC = () => {
coverSchemes={COVER_SCHEMES}
aiCoverLoading={aiCoverLoading}
titleSettings={titleSettings}
subtitleSettings={{
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
}}
subtitleSettings={subtitleSettings}
onClipSelect={handleClipSelect}
onCoverSchemeChange={setCurrentCoverScheme}
onPlayPause={() => setIsPlaying(!isPlaying)}
@@ -971,14 +694,6 @@ const EditingPlanner: React.FC = () => {
onClipReorder={handleClipReorder}
onClipRemove={handleClipRemove}
onAddClip={handleAddClip}
onClipTrim={handleClipTrim}
onClipSplit={handleClipSplit}
onClipResetTrim={handleClipResetTrim}
currentTime={currentTime}
pixelsPerSecond={pixelsPerSecond}
onZoomChange={handleZoomChange}
onSeek={handleSeek}
totalDuration={totalDuration}
/>
</div>
@@ -995,30 +710,16 @@ const EditingPlanner: React.FC = () => {
setTitleSettings((prev) => ({ ...prev, ...partial }))
}
onSubtitleSettingsChange={(partial) =>
setSubtitleSettings(
(prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig,
)
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
}
onBgmSettingsChange={(partial) =>
setBgmSettings((prev) => ({ ...prev, ...partial }))
}
onClipUpdate={handleClipUpdate}
onOpenBgmDrawer={() => setBgmDrawerOpen(true)}
onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
onClipVoiceSelect={handleClipVoiceSelect}
onOpenTransitionDrawer={handleOpenTransitionDrawer}
onOpenSpeedDrawer={handleOpenSpeedDrawer}
onOpenTtsDrawer={handleOpenTtsDrawer}
onOpenWatermarkDrawer={() => setWatermarkDrawerOpen(true)}
onOpenIntroOutroDrawer={() => setIntroOutroDrawerOpen(true)}
onOpenPipDrawer={() => setPipDrawerOpen(true)}
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
/>
</div>
@@ -1057,6 +758,20 @@ const EditingPlanner: React.FC = () => {
onCancel={() => setSaveModalOpen(false)}
/>
<GenerationProgressModal
open={genModalOpen}
phase={genPhase}
voiceoverDuration={voiceoverDuration}
estimatedDuration={totalDuration}
onDurationChange={setVoiceoverDuration}
onGenerate={handleGenerate}
task={genTask}
submitting={genSubmitting}
onCancel={handleCancelGen}
onRetry={handleRetry}
onClose={handleCancelGen}
/>
{/* ═══ 生成历史弹窗 ═══ */}
<GenerationHistoryModal
open={genHistoryOpen}
@@ -1064,122 +779,6 @@ const EditingPlanner: React.FC = () => {
history={genHistory}
onClose={() => setGenHistoryOpen(false)}
/>
{/* ═══ BGM 选择器 Drawer ═══ */}
<BgmSelector
open={bgmDrawerOpen}
onClose={() => setBgmDrawerOpen(false)}
config={bgmSettings}
onChange={setBgmSettings}
/>
{/* ═══ 字幕样式配置 Drawer ═══ */}
<SubtitleStylePanel
open={subtitleDrawerOpen}
onClose={() => setSubtitleDrawerOpen(false)}
config={subtitleSettings}
onChange={setSubtitleSettings}
/>
{/* ═══ 转场特效选择器 Drawer ═══ */}
<TransitionSelector
open={transitionDrawerOpen}
onClose={() => setTransitionDrawerOpen(false)}
config={
transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ??
DEFAULT_TRANSITION)
: DEFAULT_TRANSITION
}
onChange={handleTransitionChange}
title={transitionTargetClipId ? "片段转场设置" : "全局默认转场"}
/>
{/* ═══ 片段调速面板 Drawer ═══ */}
{speedTargetClipId && (
<SpeedPanel
open={speedDrawerOpen}
onClose={() => setSpeedDrawerOpen(false)}
config={
clips.find((c) => c.id === speedTargetClipId)?.speed ??
DEFAULT_SPEED
}
onChange={handleSpeedChange}
onApplyAll={handleApplySpeedAll}
/>
)}
{/* ═══ TTS 配音面板 Drawer ═══ */}
{ttsTargetClipId && (
<TtsPanel
open={ttsDrawerOpen}
onClose={() => setTtsDrawerOpen(false)}
config={
clips.find((c) => c.id === ttsTargetClipId)?.tts_config ??
DEFAULT_TTS_CONFIG
}
onChange={handleTtsChange}
/>
)}
{/* ═══ 水印配置面板 ═══ */}
<WatermarkPanel
open={watermarkDrawerOpen}
onClose={() => setWatermarkDrawerOpen(false)}
config={watermarkSettings}
onChange={handleWatermarkChange}
/>
{/* ═══ 片头片尾配置面板 ═══ */}
<IntroOutroPanel
open={introOutroDrawerOpen}
onClose={() => setIntroOutroDrawerOpen(false)}
config={introOutroSettings}
onChange={handleIntroOutroChange}
/>
{/* ═══ 画中画配置面板 ═══ */}
<PipConfigPanel
open={pipDrawerOpen}
onClose={() => setPipDrawerOpen(false)}
config={pipSettings}
onChange={handlePipChange}
totalDuration={totalDuration}
/>
{/* ═══ 滤镜调色面板 ═══ */}
<FilterPanel
open={filterDrawerOpen}
onClose={() => setFilterDrawerOpen(false)}
config={filterSettings}
onChange={handleFilterChange}
/>
{/* ═══ 绿幕抠像面板 ═══ */}
<GreenScreenPanel
open={chromaKeyDrawerOpen}
onClose={() => setChromaKeyDrawerOpen(false)}
config={chromaKeySettings}
onChange={handleChromaKeyChange}
/>
{/* ═══ 贴纸面板 ═══ */}
<StickerPanel
open={stickerDrawerOpen}
onClose={() => setStickerDrawerOpen(false)}
config={stickerSettings}
onChange={handleStickerChange}
totalDuration={totalDuration}
/>
{/* ═══ 封面选择器 ═══ */}
<CoverSelector
open={coverDrawerOpen}
onClose={() => setCoverDrawerOpen(false)}
config={coverSettings}
onChange={handleCoverChange}
totalDuration={totalDuration}
/>
</div>
);
};
@@ -1,292 +0,0 @@
/**
* BGM Drawer
* BGM //
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import { Drawer, Slider, Input, Tag, message } from "antd";
import {
getBgmPresets,
type BgmPreset,
type BgmCategory,
type BgmMixConfig,
DEFAULT_BGM_MIX_CONFIG,
} from "@/api/bgm";
const { Search } = Input;
/* ──────────── 分类标签 ──────────── */
const CATEGORY_LIST: {
key: BgmCategory | "all";
label: string;
icon: string;
}[] = [
{ key: "all", label: "全部", icon: "🎶" },
{ key: "轻快", label: "轻快", icon: "🎉" },
{ key: "治愈", label: "治愈", icon: "🌿" },
{ key: "科技", label: "科技", icon: "🔬" },
{ key: "电商", label: "电商", icon: "🛒" },
];
/* ──────────── Props ──────────── */
interface BgmSelectorProps {
open: boolean;
onClose: () => void;
config: BgmMixConfig;
onChange: (config: BgmMixConfig) => void;
}
const BgmSelector: React.FC<BgmSelectorProps> = ({
open,
onClose,
config,
onChange,
}) => {
const [presets, setPresets] = useState<BgmPreset[]>([]);
const [loading, setLoading] = useState(false);
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">(
"all",
);
const [keyword, setKeyword] = useState("");
const [previewingId, setPreviewingId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
/* ── 加载 BGM 列表 ── */
const loadPresets = useCallback(async () => {
setLoading(true);
try {
const params: { category?: string; keyword?: string } = {};
if (activeCategory !== "all") params.category = activeCategory;
if (keyword.trim()) params.keyword = keyword.trim();
const data = await getBgmPresets(params);
setPresets(data);
} catch {
message.error("加载 BGM 列表失败");
} finally {
setLoading(false);
}
}, [activeCategory, keyword]);
useEffect(() => {
if (open) loadPresets();
}, [open, loadPresets]);
/* ── 试听 ── */
const handlePreview = useCallback(
(bgm: BgmPreset) => {
if (previewingId === bgm.id) {
audioRef.current?.pause();
setPreviewingId(null);
return;
}
audioRef.current?.pause();
const audio = new Audio(bgm.url);
audioRef.current = audio;
audio.play().catch(() => {});
audio.onended = () => setPreviewingId(null);
setPreviewingId(bgm.id);
},
[previewingId],
);
/* ── 选中 BGM ── */
const handleSelect = useCallback(
(bgm: BgmPreset) => {
onChange({
...config,
enabled: true,
music_id: bgm.id,
});
},
[config, onChange],
);
/* ── 关闭时停止播放 ── */
const handleClose = useCallback(() => {
audioRef.current?.pause();
setPreviewingId(null);
onClose();
}, [onClose]);
/* ── 移除 BGM ── */
const handleClear = useCallback(() => {
audioRef.current?.pause();
setPreviewingId(null);
onChange({ ...DEFAULT_BGM_MIX_CONFIG });
}, [onChange]);
/* ── 当前选中的 BGM ── */
const selectedBgm = presets.find((p) => p.id === config.music_id);
return (
<Drawer
title="🎵 BGM 音乐选择"
placement="right"
width={420}
open={open}
onClose={handleClose}
className="bgm-selector-drawer"
>
{/* ── 搜索框 ── */}
<div className="bgm-search-row">
<Search
placeholder="搜索 BGM 名称..."
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadPresets()}
/>
</div>
{/* ── 分类标签 ── */}
<div className="bgm-category-bar">
{CATEGORY_LIST.map((cat) => (
<Tag
key={cat.key}
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
onClick={() => setActiveCategory(cat.key)}
>
{cat.icon} {cat.label}
</Tag>
))}
</div>
{/* ── BGM 列表 ── */}
<div className="bgm-list">
{loading && <div className="bgm-loading">...</div>}
{!loading && presets.length === 0 && (
<div className="bgm-empty"> BGM </div>
)}
{presets.map((bgm) => {
const isSelected = config.music_id === bgm.id;
const isPlaying = previewingId === bgm.id;
return (
<div
key={bgm.id}
className={`bgm-item${isSelected ? " selected" : ""}`}
onClick={() => handleSelect(bgm)}
>
<div className="bgm-item-cover">
{bgm.cover_url ? (
<img src={bgm.cover_url} alt={bgm.name} />
) : (
<span className="bgm-item-cover-icon">🎵</span>
)}
</div>
<div className="bgm-item-info">
<div className="bgm-item-name">{bgm.name}</div>
<div className="bgm-item-meta">
<span className="bgm-item-category">{bgm.category}</span>
<span className="bgm-item-duration">
{Math.floor(bgm.duration / 60)}:
{String(Math.floor(bgm.duration % 60)).padStart(2, "0")}
</span>
</div>
{bgm.tags.length > 0 && (
<div className="bgm-item-tags">
{bgm.tags.slice(0, 3).map((t) => (
<span key={t} className="bgm-item-tag">
{t}
</span>
))}
</div>
)}
</div>
<button
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
onClick={(e) => {
e.stopPropagation();
handlePreview(bgm);
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? "⏸" : "▶️"}
</button>
{isSelected && <span className="bgm-item-check"></span>}
</div>
);
})}
</div>
{/* ── 混音配置 ── */}
{config.enabled && config.music_id && (
<div className="bgm-mix-config">
<div className="bgm-mix-header">
<span></span>
<button className="bgm-mix-clear" onClick={handleClear}>
BGM
</button>
</div>
<div className="bgm-mix-selected">
{selectedBgm
? `当前:${selectedBgm.name}`
: `当前:${config.music_id}`}
</div>
{/* 音量 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.volume}%</span>
</label>
<Slider
min={0}
max={100}
value={config.volume}
onChange={(v) => onChange({ ...config, volume: v })}
/>
</div>
{/* 淡入 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
{" "}
<span className="bgm-mix-value">
{config.fade_in.toFixed(1)}s
</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_in}
onChange={(v) => onChange({ ...config, fade_in: v })}
/>
</div>
{/* 淡出 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
{" "}
<span className="bgm-mix-value">
{config.fade_out.toFixed(1)}s
</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_out}
onChange={(v) => onChange({ ...config, fade_out: v })}
/>
</div>
{/* 人声闪避 */}
<div className="bgm-mix-field bgm-mix-toggle-row">
<label className="bgm-mix-label">sidechain</label>
<div
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
onClick={() =>
onChange({ ...config, voice_dodge: !config.voice_dodge })
}
>
<div className="ep-toggle-knob" />
</div>
</div>
</div>
)}
</Drawer>
);
};
export default BgmSelector;
@@ -5,30 +5,32 @@
import React, { useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import type { TemplateMode } from "@/api/editingPlanner";
import type { ClipData, ClipType, TitleSettings } from "../types";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
import type { ClipData, ClipType } from "../types";
import type { AssetItem } from "@/api/assets";
interface TitleSettings {
aiAutoSelect: boolean;
title: string;
position: string;
font: string;
size: number;
bold: boolean;
italic: boolean;
stroke: boolean;
shadow: boolean;
color: string;
}
interface SubtitleSettings {
enabled: boolean;
position: string;
font: string;
fontSize: number;
fontColor: string;
size: number;
animation: string;
mode?: string;
stroke?: boolean;
shadow?: boolean;
asrLanguage?: string;
}
interface BgmSettings {
enabled: boolean;
music_id: string;
volume?: number;
fade_in?: number;
fade_out?: number;
voice_dodge?: boolean;
music: string;
}
interface ClipPropertiesPanelProps {
@@ -43,10 +45,6 @@ interface ClipPropertiesPanelProps {
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
/** 打开 BGM 选择器 Drawer */
onOpenBgmDrawer?: () => void;
/** 打开字幕样式配置 Drawer */
onOpenSubtitleDrawer?: () => void;
/** 配音素材列表(从配音素材库 API 获取) */
voiceMaterials?: AssetItem[];
/** 配音素材加载中 */
@@ -55,26 +53,6 @@ interface ClipPropertiesPanelProps {
onRefreshVoiceMaterials?: () => void;
/** 为片段选择配音素材 */
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void;
/** 打开转场特效选择器 Drawer */
onOpenTransitionDrawer?: (clipId: string) => void;
/** 打开片段调速面板 Drawer */
onOpenSpeedDrawer?: (clipId: string) => void;
/** 打开 TTS 配音面板 Drawer */
onOpenTtsDrawer?: (clipId: string) => void;
/** 打开水印设置面板 Drawer */
onOpenWatermarkDrawer?: () => void;
/** 打开片头片尾设置面板 Drawer */
onOpenIntroOutroDrawer?: () => void;
/** 打开画中画设置面板 Drawer */
onOpenPipDrawer?: () => void;
/** 打开滤镜调色面板 Drawer */
onOpenFilterDrawer?: () => void;
/** 打开绿幕抠像面板 Drawer */
onOpenGreenScreenDrawer?: () => void;
/** 打开贴纸面板 Drawer */
onOpenStickerDrawer?: () => void;
/** 打开封面选择器 Drawer */
onOpenCoverDrawer?: () => void;
}
const POSITION_OPTIONS = [
@@ -100,6 +78,14 @@ const ANIMATION_OPTIONS = [
{ value: "typewriter", label: "打字机" },
];
const BGM_OPTIONS = [
{ value: "none", label: "无背景音乐" },
{ value: "bgm_01", label: "🎵 轻快节奏" },
{ value: "bgm_02", label: "🎵 温馨舒缓" },
{ value: "bgm_03", label: "🎵 动感活力" },
{ value: "bgm_04", label: "🎵 科技感" },
];
/**
* ++++
*
@@ -286,24 +272,12 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
currentMode,
onTitleSettingsChange,
onSubtitleSettingsChange,
onBgmSettingsChange: _onBgmSettingsChange,
onBgmSettingsChange,
onClipUpdate,
onOpenBgmDrawer,
onOpenSubtitleDrawer,
voiceMaterials = [],
voiceMaterialsLoading = false,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
onOpenWatermarkDrawer,
onOpenIntroOutroDrawer,
onOpenPipDrawer,
onOpenFilterDrawer,
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
onOpenCoverDrawer,
}) => {
const navigate = useNavigate();
@@ -560,17 +534,15 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
<input
className="ep-slider"
type="range"
min={12}
max={48}
value={subtitleSettings.fontSize}
min={10}
max={32}
value={subtitleSettings.size}
onChange={(e) =>
onSubtitleSettingsChange({
fontSize: Number(e.target.value),
})
onSubtitleSettingsChange({ size: Number(e.target.value) })
}
/>
<span className="ep-slider-value">
{subtitleSettings.fontSize}px
{subtitleSettings.size}px
</span>
</div>
</div>
@@ -591,16 +563,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
))}
</select>
</div>
{/* 高级配置按钮 */}
{onOpenSubtitleDrawer && (
<button
className="ep-advanced-btn"
onClick={onOpenSubtitleDrawer}
>
🎨
</button>
)}
</>
)}
</div>
@@ -612,130 +574,20 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
BGM
</div>
{bgmSettings.enabled && bgmSettings.music_id ? (
<div className="ep-bgm-current">
<span className="ep-bgm-current-label">🎵 BGM</span>
<span className="ep-bgm-current-id">{bgmSettings.music_id}</span>
{bgmSettings.volume !== undefined && (
<span className="ep-bgm-current-vol">
{bgmSettings.volume}%
</span>
)}
</div>
) : (
<div className="ep-bgm-empty"></div>
)}
{onOpenBgmDrawer && (
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
🎵 {bgmSettings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
</button>
)}
</div>
{/* ═══ 水印设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🔖</span>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={bgmSettings.music}
onChange={(e) => onBgmSettingsChange({ music: e.target.value })}
>
{BGM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{onOpenWatermarkDrawer && (
<button className="ep-advanced-btn" onClick={onOpenWatermarkDrawer}>
<span className="ep-advanced-btn-icon">🔖</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 片头片尾设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎬</span>
</div>
{onOpenIntroOutroDrawer && (
<button className="ep-advanced-btn" onClick={onOpenIntroOutroDrawer}>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 画中画 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🖼</span>
</div>
{onOpenPipDrawer && (
<button className="ep-advanced-btn" onClick={onOpenPipDrawer}>
<span className="ep-advanced-btn-icon">🖼</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 滤镜调色 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎨</span>
</div>
{onOpenFilterDrawer && (
<button className="ep-advanced-btn" onClick={onOpenFilterDrawer}>
<span className="ep-advanced-btn-icon">🎨</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 绿幕抠像 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🟩</span>
绿
</div>
{onOpenGreenScreenDrawer && (
<button className="ep-advanced-btn" onClick={onOpenGreenScreenDrawer}>
<span className="ep-advanced-btn-icon">🟩</span>
<span className="ep-advanced-btn-label">绿</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 贴纸 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🏷</span>
</div>
{onOpenStickerDrawer && (
<button className="ep-advanced-btn" onClick={onOpenStickerDrawer}>
<span className="ep-advanced-btn-icon">🏷</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 封面 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🖼</span>
</div>
{onOpenCoverDrawer && (
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
<span className="ep-advanced-btn-icon">🖼</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 片段详情(选中时显示) ═══ */}
@@ -797,71 +649,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
</div>
</div>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">
{(() => {
const t = selectedClip.transition;
if (!t || t.type === "none") return "无转场";
const opt = TRANSITION_OPTIONS.find(
(o) => o.value === t.type,
);
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`;
})()}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">
{selectedClip.speed
? `${selectedClip.speed.rate.toFixed(2)}x`
: "1.00x"}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">
{(() => {
const tts = selectedClip.tts_config;
if (!tts || tts.mode === "none") return "无配音";
if (tts.mode === "upload") return "上传配音";
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`;
})()}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 素材起始时间 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
@@ -1,301 +0,0 @@
/**
*
* + +
*/
import React, { useCallback, useRef, useState } from "react";
import { Drawer } from "antd";
import type { CoverConfig, CoverMode } from "../types";
import { DEFAULT_COVER_CONFIG } from "../types";
interface CoverSelectorProps {
open: boolean;
onClose: () => void;
config: CoverConfig;
onChange: (config: CoverConfig) => void;
totalDuration: number;
}
/** 封面模式标签 */
const MODE_LABELS: Record<CoverMode, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
};
/** 封面模式图标 */
const MODE_ICONS: Record<CoverMode, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
};
const CoverSelector: React.FC<CoverSelectorProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const update = useCallback(
(partial: Partial<CoverConfig>) => {
onChange({ ...config, ...partial });
},
[config, onChange],
);
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled });
}, [config.enabled, onChange]);
/** 切换模式 */
const handleModeChange = useCallback(
(mode: CoverMode) => {
update({ mode });
},
[update],
);
/** 处理文件上传 */
const handleFileUpload = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = (e) => {
const url = e.target?.result as string;
update({ upload_url: url, thumbnail_url: url, mode: "upload" });
};
reader.readAsDataURL(file);
},
[update],
);
/** 拖拽上传 */
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFileUpload(file);
},
[handleFileUpload],
);
/** 使用 AI 推荐时间 */
const handleUseAiSuggestion = useCallback(() => {
if (config.ai_suggested_time !== null) {
update({ frame_time: config.ai_suggested_time, mode: "frame" });
}
}, [config.ai_suggested_time, update]);
/** 格式化时间 */
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 10);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`;
};
return (
<Drawer
title="封面选择"
placement="right"
width={440}
open={open}
onClose={onClose}
className="cover-selector-drawer"
>
{/* 顶部开关 */}
<div className="cover-header">
<span className="cover-header-label"></span>
<label className="cover-switch">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
/>
<span className="cover-switch-slider" />
</label>
</div>
{/* 模式选择 */}
<div className="cover-mode-section">
<div className="cover-section-title"></div>
<div className="cover-mode-tabs">
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
<button
key={m}
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
onClick={() => handleModeChange(m)}
>
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
</button>
))}
</div>
</div>
{/* 模式内容区 */}
<div className="cover-mode-content">
{/* 智能封面 */}
{config.mode === "auto" && (
<div className="cover-auto-section">
<div className="cover-auto-desc">
AI
</div>
{config.ai_suggested_time !== null ? (
<div className="cover-auto-suggestion">
<div className="cover-auto-badge">AI </div>
<div className="cover-auto-time">
{formatTime(config.ai_suggested_time)}
</div>
<button
className="cover-auto-use-btn"
onClick={handleUseAiSuggestion}
>
使
</button>
</div>
) : (
<div className="cover-auto-pending">
<div className="cover-auto-spinner" />
<span>AI ...</span>
</div>
)}
</div>
)}
{/* 抽帧选封面 */}
{config.mode === "frame" && (
<div className="cover-frame-section">
<div className="cover-frame-preview">
<div className="cover-frame-placeholder">
<span className="cover-frame-icon">🎞</span>
<span className="cover-frame-time">
{formatTime(config.frame_time)}
</span>
</div>
</div>
<div className="cover-frame-timeline">
<div className="cover-frame-slider-header">
<span className="cover-frame-slider-label"></span>
<span className="cover-frame-slider-value">
{formatTime(config.frame_time)}
</span>
</div>
<input
type="range"
className="cover-frame-slider"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={config.frame_time}
onChange={(e) => update({ frame_time: Number(e.target.value) })}
/>
<div className="cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
{/* 快捷时间点 */}
<div className="cover-frame-quick">
<span className="cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio;
return (
<button
key={ratio}
className="cover-quick-btn"
onClick={() => update({ frame_time: t })}
>
{formatTime(t)}
</button>
);
})}
</div>
</div>
)}
{/* 上传封面 */}
{config.mode === "upload" && (
<div className="cover-upload-section">
<div
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
{config.upload_url ? (
<div className="cover-upload-preview">
<img src={config.upload_url} alt="封面预览" />
<div className="cover-upload-overlay"></div>
</div>
) : (
<div className="cover-upload-placeholder">
<span className="cover-upload-icon">📤</span>
<span className="cover-upload-text">
</span>
<span className="cover-upload-hint">
JPG / PNG 16:9
</span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
}}
/>
</div>
</div>
)}
</div>
{/* 封面预览 */}
<div className="cover-preview-section">
<div className="cover-section-title"></div>
<div className="cover-preview-box">
{config.upload_url ? (
<img
src={config.upload_url}
alt="封面预览"
className="cover-preview-img"
/>
) : (
<div className="cover-preview-placeholder">
<span className="cover-preview-icon">🖼</span>
<span className="cover-preview-text">
{config.mode === "auto"
? "AI 智能选择"
: config.mode === "frame"
? `${formatTime(config.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="cover-preview-ratio">16:9</div>
</div>
</div>
{/* 底部 */}
<div className="cover-footer">
<button className="cover-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default CoverSelector;
@@ -1,234 +0,0 @@
/**
*
* + /////
*/
import React, { useCallback } from "react";
import { Drawer, Switch } from "antd";
import type { FilterConfig, FilterPreset } from "../types";
import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types";
interface FilterPanelProps {
open: boolean;
onClose: () => void;
config: FilterConfig;
onChange: (config: FilterConfig) => void;
}
/** 所有预设列表 */
const PRESET_LIST: FilterPreset[] = [
"none",
"original",
"fresh",
"warm",
"cool",
"vintage",
"cinema",
"bw",
"sunshine",
"film",
];
/** 预设对应的示例渐变色(用于视觉预览) */
const PRESET_GRADIENTS: Record<FilterPreset, string> = {
none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)",
warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)",
cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)",
bw: "linear-gradient(135deg, #434343 0%, #000000 100%)",
sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)",
film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)",
};
const FilterPanel: React.FC<FilterPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
const update = useCallback(
(partial: Partial<FilterConfig>) => {
onChange({ ...config, ...partial });
},
[config, onChange],
);
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
}, [config.enabled, onChange]);
/** 选择预设时重置手动参数 */
const handlePresetSelect = useCallback(
(preset: FilterPreset) => {
if (preset === "none") {
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
} else {
onChange({
...DEFAULT_FILTER_CONFIG,
enabled: config.enabled,
preset,
});
}
},
[config.enabled, onChange],
);
return (
<Drawer
title="滤镜调色"
placement="right"
width={420}
open={open}
onClose={onClose}
className="filter-panel-drawer"
>
{/* 顶部开关 */}
<div className="filter-header">
<span className="filter-header-label"></span>
<Switch
size="small"
checked={config.enabled}
onChange={(checked) => update({ enabled: checked })}
/>
</div>
{/* 预设滤镜选择 */}
<div className="filter-section">
<div className="filter-section-title"></div>
<div className="filter-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`filter-preset-item${config.preset === p ? " active" : ""}`}
onClick={() => handlePresetSelect(p)}
>
<div
className="filter-preset-preview"
style={{ background: PRESET_GRADIENTS[p] }}
/>
<span className="filter-preset-label">
{FILTER_PRESET_LABELS[p]}
</span>
</button>
))}
</div>
</div>
{/* 手动调节 */}
<div className="filter-section">
<div className="filter-section-title"></div>
{/* 亮度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.brightness}
onChange={(e) => update({ brightness: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.brightness}</span>
</div>
{/* 对比度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.contrast}
onChange={(e) => update({ contrast: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.contrast}</span>
</div>
{/* 饱和度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.saturation}
onChange={(e) => update({ saturation: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.saturation}</span>
</div>
{/* 色温 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.temperature}
onChange={(e) => update({ temperature: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.temperature}</span>
</div>
{/* 色调 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.tint}
onChange={(e) => update({ tint: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.tint}</span>
</div>
{/* 锐度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={0}
max={100}
value={config.sharpness}
onChange={(e) => update({ sharpness: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.sharpness}</span>
</div>
</div>
{/* 预览色块 */}
<div className="filter-section">
<div className="filter-section-title"></div>
<div
className="filter-preview-block"
style={{
background: PRESET_GRADIENTS[config.preset],
filter: [
`brightness(${100 + config.brightness}%)`,
`contrast(${100 + config.contrast}%)`,
`saturate(${100 + config.saturation}%)`,
].join(" "),
}}
/>
</div>
{/* 底部重置 */}
<div className="filter-footer">
<button className="filter-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default FilterPanel;
@@ -1,219 +0,0 @@
/**
* 绿
* 5 + + //
*/
import React, { useCallback } from "react";
import { Drawer, Switch } from "antd";
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types";
import {
DEFAULT_CHROMA_KEY_CONFIG,
CHROMA_KEY_PRESET_LABELS,
CHROMA_KEY_PRESET_COLORS,
} from "../types";
interface GreenScreenPanelProps {
open: boolean;
onClose: () => void;
config: ChromaKeyConfig;
onChange: (config: ChromaKeyConfig) => void;
}
/** 预设列表 */
const PRESET_LIST: ChromaKeyColorPreset[] = [
"green",
"blue",
"red",
"pure_green",
"soft_green",
];
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
const update = useCallback(
(partial: Partial<ChromaKeyConfig>) => {
onChange({ ...config, ...partial });
},
[config, onChange],
);
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled });
}, [config.enabled, onChange]);
/** 选择颜色预设时同步更新 color 字段 */
const handlePresetSelect = useCallback(
(preset: ChromaKeyColorPreset) => {
update({
color_preset: preset,
color: CHROMA_KEY_PRESET_COLORS[preset],
});
},
[update],
);
/** 自定义颜色变化时清除预设标记 */
const handleColorChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
update({ color: e.target.value });
},
[update],
);
return (
<Drawer
title="绿幕抠像"
placement="right"
width={420}
open={open}
onClose={onClose}
className="green-screen-panel-drawer"
>
{/* 顶部开关 */}
<div className="green-header">
<span className="green-header-label">绿</span>
<Switch
size="small"
checked={config.enabled}
onChange={(checked) => update({ enabled: checked })}
/>
</div>
{/* 颜色预设 */}
<div className="green-section">
<div className="green-section-title"></div>
<div className="green-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`green-preset-btn${config.color_preset === p ? " active" : ""}`}
onClick={() => handlePresetSelect(p)}
>
<span
className="green-preset-dot"
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
/>
<span className="green-preset-label">
{CHROMA_KEY_PRESET_LABELS[p]}
</span>
</button>
))}
</div>
</div>
{/* 自定义颜色 */}
<div className="green-section">
<div className="green-section-title"></div>
<div className="green-color-row">
<input
type="color"
className="green-color-picker"
value={config.color}
onChange={handleColorChange}
/>
<input
type="text"
className="green-color-hex"
value={config.color}
onChange={handleColorChange}
placeholder="#00FF00"
/>
<div
className="green-color-swatch"
style={{ background: config.color }}
/>
</div>
</div>
{/* 参数调节 */}
<div className="green-section">
<div className="green-section-title"></div>
{/* 相似度 */}
<div className="green-slider-row">
<div className="green-slider-header">
<span className="green-slider-label"></span>
<span className="green-slider-value">{config.similarity}%</span>
</div>
<input
type="range"
className="green-slider"
min={0}
max={100}
value={config.similarity}
onChange={(e) => update({ similarity: Number(e.target.value) })}
/>
<div className="green-slider-desc">广</div>
</div>
{/* 边缘平滑 */}
<div className="green-slider-row">
<div className="green-slider-header">
<span className="green-slider-label"></span>
<span className="green-slider-value">{config.blend}%</span>
</div>
<input
type="range"
className="green-slider"
min={0}
max={100}
value={config.blend}
onChange={(e) => update({ blend: Number(e.target.value) })}
/>
<div className="green-slider-desc"></div>
</div>
{/* 溢色抑制 */}
<div className="green-slider-row">
<div className="green-slider-header">
<span className="green-slider-label"></span>
<span className="green-slider-value">{config.spill}%</span>
</div>
<input
type="range"
className="green-slider"
min={0}
max={100}
value={config.spill}
onChange={(e) => update({ spill: Number(e.target.value) })}
/>
<div className="green-slider-desc"></div>
</div>
</div>
{/* 预览 */}
<div className="green-section">
<div className="green-section-title"></div>
<div className="green-preview-box">
<div
className="green-preview-bg"
style={{ background: config.color, opacity: 0.3 }}
/>
<div className="green-preview-subject">
<div className="green-preview-circle" />
<div className="green-preview-text"></div>
</div>
<div
className="green-preview-edge"
style={{
borderColor: config.color,
filter: `blur(${config.blend / 10}px)`,
}}
/>
</div>
</div>
{/* 底部重置 */}
<div className="green-footer">
<button className="green-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default GreenScreenPanel;
@@ -1,309 +0,0 @@
/**
* Drawer
* Intro/ Outro
* // URL
*/
import React, { useCallback } from "react";
import { Drawer } from "antd";
import type {
IntroOutroConfig,
IntroOutroItem,
IntroOutroKind,
TransitionType,
} from "../types";
import { DEFAULT_INTRO_OUTRO } from "../types";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
/* ──────────── 常量 ──────────── */
const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
{ value: "none", label: "无", icon: "🚫" },
{ value: "video", label: "视频", icon: "🎬" },
{ value: "image", label: "图片", icon: "🖼️" },
];
/* ──────────── Props ──────────── */
interface IntroOutroPanelProps {
open: boolean;
onClose: () => void;
config: IntroOutroConfig;
onChange: (config: IntroOutroConfig) => void;
}
const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
/* ── 更新片头 ── */
const handleIntroChange = useCallback(
(partial: Partial<IntroOutroItem>) => {
onChange({ ...config, intro: { ...config.intro, ...partial } });
},
[config, onChange],
);
/* ── 更新片尾 ── */
const handleOutroChange = useCallback(
(partial: Partial<IntroOutroItem>) => {
onChange({ ...config, outro: { ...config.outro, ...partial } });
},
[config, onChange],
);
/* ── 切换片头类型 ── */
const handleIntroKindChange = useCallback(
(kind: IntroOutroKind) => {
handleIntroChange({ kind, url: kind === "none" ? undefined : "" });
},
[handleIntroChange],
);
/* ── 切换片尾类型 ── */
const handleOutroKindChange = useCallback(
(kind: IntroOutroKind) => {
handleOutroChange({ kind, url: kind === "none" ? undefined : "" });
},
[handleOutroChange],
);
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_INTRO_OUTRO });
}, [onChange]);
return (
<Drawer
title="🎬 片头片尾设置"
placement="right"
width={420}
open={open}
onClose={onClose}
className="intro-outro-panel-drawer"
>
{/* ═══ 片头区块 ═══ */}
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🎞</span>
<span className="iop-block-title"></span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.intro.kind === opt.value ? " active" : ""}`}
onClick={() => handleIntroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.intro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.intro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.intro.kind === "video"
? "https://example.com/intro.mp4"
: "https://example.com/intro.png"
}
value={config.intro.url ?? ""}
onChange={(e) => handleIntroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.intro.duration}
onChange={(e) =>
handleIntroChange({ duration: Number(e.target.value) })
}
/>
<span className="iop-slider-value">
{config.intro.duration}s
</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<select
className="iop-select"
value={config.intro.transition ?? "none"}
onChange={(e) =>
handleIntroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.intro.transition && config.intro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.intro.transition_duration ?? 0.5}
onChange={(e) =>
handleIntroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.intro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
{/* ═══ 片尾区块 ═══ */}
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🏁</span>
<span className="iop-block-title"></span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.outro.kind === opt.value ? " active" : ""}`}
onClick={() => handleOutroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.outro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.outro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.outro.kind === "video"
? "https://example.com/outro.mp4"
: "https://example.com/outro.png"
}
value={config.outro.url ?? ""}
onChange={(e) => handleOutroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.outro.duration}
onChange={(e) =>
handleOutroChange({ duration: Number(e.target.value) })
}
/>
<span className="iop-slider-value">
{config.outro.duration}s
</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label">退</label>
<select
className="iop-select"
value={config.outro.transition ?? "none"}
onChange={(e) =>
handleOutroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.outro.transition && config.outro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.outro.transition_duration ?? 0.5}
onChange={(e) =>
handleOutroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.outro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
{/* ── 底部操作 ── */}
<div className="iop-footer">
<button className="iop-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default IntroOutroPanel;
@@ -1,582 +0,0 @@
/**
* Drawer
* + +
*/
import React, { useCallback, useMemo } from "react";
import { Drawer, Switch } from "antd";
import type {
PipConfig,
PipLayer,
PipGridPosition,
PipAnimType,
PipSlideDirection,
} from "../types";
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types";
/* ──────────── 常量 ──────────── */
/** 九宫格位置 → 百分比坐标映射 */
const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
top_left: { x: 5, y: 5 },
top_center: { x: 37.5, y: 5 },
top_right: { x: 70, y: 5 },
center_left: { x: 5, y: 37.5 },
center: { x: 37.5, y: 37.5 },
center_right: { x: 70, y: 37.5 },
bottom_left: { x: 5, y: 70 },
bottom_center: { x: 37.5, y: 70 },
bottom_right: { x: 70, y: 70 },
};
/** 九宫格位置选项 */
const GRID_POSITIONS: PipGridPosition[] = [
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
];
/** 入场动画选项 */
const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
{ value: "none", label: "无" },
{ value: "fade_in", label: "淡入" },
{ value: "slide_in", label: "滑入" },
];
/** 滑入方向选项 */
const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
{ value: "left", label: "← 左" },
{ value: "right", label: "→ 右" },
{ value: "up", label: "↑ 上" },
{ value: "down", label: "↓ 下" },
];
/** 预览图层颜色池 */
const LAYER_COLORS = [
"rgba(22,119,255,0.5)",
"rgba(82,196,26,0.5)",
"rgba(250,173,20,0.5)",
"rgba(255,77,79,0.5)",
"rgba(114,46,209,0.5)",
"rgba(19,194,194,0.5)",
];
/* ──────────── Props ──────────── */
interface PipConfigPanelProps {
open: boolean;
onClose: () => void;
config: PipConfig;
onChange: (config: PipConfig) => void;
totalDuration: number;
}
/* ──────────── 辅助函数 ──────────── */
let layerIdCounter = 0;
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`;
/* ──────────── 组件 ──────────── */
const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
/** 当前选中图层 ID */
const [selectedId, setSelectedId] = React.useState<string>("");
/** 当前选中图层 */
const selectedLayer = useMemo(
() => config.layers.find((l) => l.id === selectedId) ?? null,
[config.layers, selectedId],
);
/* ── 添加图层 ── */
const handleAddLayer = useCallback(() => {
const newLayer: PipLayer = {
...DEFAULT_PIP_LAYER,
id: genLayerId(),
name: `图层 ${config.layers.length + 1}`,
z_index: config.layers.length + 1,
};
onChange({
...config,
layers: [...config.layers, newLayer],
});
setSelectedId(newLayer.id);
}, [config, onChange]);
/* ── 删除图层 ── */
const handleDeleteLayer = useCallback(
(id: string) => {
const newLayers = config.layers.filter((l) => l.id !== id);
onChange({ ...config, layers: newLayers });
if (selectedId === id) {
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "");
}
},
[config, onChange, selectedId],
);
/* ── 更新图层 ── */
const updateLayer = useCallback(
(id: string, partial: Partial<PipLayer>) => {
onChange({
...config,
layers: config.layers.map((l) =>
l.id === id ? { ...l, ...partial } : l,
),
});
},
[config, onChange],
);
/* ── 切换启用 ── */
const handleEnableToggle = useCallback(
(checked: boolean) => {
onChange({ ...config, enabled: checked });
},
[config, onChange],
);
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_PIP_CONFIG });
setSelectedId("");
}, [onChange]);
/* ── 九宫格点击 ── */
const handleGridClick = useCallback(
(pos: PipGridPosition) => {
if (!selectedLayer) return;
const coords = GRID_POSITION_MAP[pos];
updateLayer(selectedLayer.id, {
grid_position: pos,
x: coords.x,
y: coords.y,
});
},
[selectedLayer, updateLayer],
);
/* ── 宽高比锁定 ── */
const handleWidthChange = useCallback(
(val: number) => {
if (!selectedLayer) return;
const partial: Partial<PipLayer> = { width: val };
if (selectedLayer.aspect_lock) {
// 保持宽高比 1:1(百分比相同)
partial.height = val;
}
updateLayer(selectedLayer.id, partial);
},
[selectedLayer, updateLayer],
);
const handleHeightChange = useCallback(
(val: number) => {
if (!selectedLayer) return;
const partial: Partial<PipLayer> = { height: val };
if (selectedLayer.aspect_lock) {
partial.width = val;
}
updateLayer(selectedLayer.id, partial);
},
[selectedLayer, updateLayer],
);
return (
<Drawer
title="🖼️ 画中画设置"
placement="right"
width={520}
open={open}
onClose={onClose}
className="pip-config-panel-drawer"
>
{/* ═══ 顶部工具栏 ═══ */}
<div className="pip-toolbar">
<div className="pip-toolbar-left">
<button className="pip-add-btn" onClick={handleAddLayer}>
+
</button>
</div>
<div className="pip-enable-switch">
<span></span>
<Switch
size="small"
checked={config.enabled}
onChange={handleEnableToggle}
/>
</div>
</div>
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
<div className="pip-body">
{/* 左侧图层列表 */}
<div className="pip-layer-list">
{config.layers.length === 0 ? (
<div className="pip-layer-empty"></div>
) : (
config.layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
onClick={() => setSelectedId(layer.id)}
>
{layer.thumbnail_url || layer.material_url ? (
<img
className="pip-layer-thumb"
src={layer.thumbnail_url || layer.material_url}
alt={layer.name}
/>
) : (
<div
className="pip-layer-thumb"
style={{
background: LAYER_COLORS[idx % LAYER_COLORS.length],
}}
/>
)}
<span className="pip-layer-name">{layer.name}</span>
<button
className="pip-layer-delete"
onClick={(e) => {
e.stopPropagation();
handleDeleteLayer(layer.id);
}}
title="删除图层"
>
</button>
</div>
))
)}
</div>
{/* 右侧配置区 */}
<div className="pip-config-area">
{!selectedLayer ? (
<div className="pip-config-empty"></div>
) : (
<>
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{config.layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
style={{
left: `${layer.x}%`,
top: `${layer.y}%`,
width: `${layer.width}%`,
height: `${layer.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: layer.opacity / 100,
borderRadius: `${layer.border_radius}%`,
}}
>
<span className="pip-preview-label">{layer.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${selectedLayer.material_type === "image" ? " active" : ""}`}
onClick={() =>
updateLayer(selectedLayer.id, { material_type: "image" })
}
>
🖼
</button>
<button
className={`pip-type-btn${selectedLayer.material_type === "video" ? " active" : ""}`}
onClick={() =>
updateLayer(selectedLayer.id, { material_type: "video" })
}
>
🎬
</button>
</div>
</div>
{/* ── 素材 URL ── */}
<div className="pip-field">
<label className="pip-field-label">
{selectedLayer.material_type === "image" ? "图片" : "视频"}{" "}
URL
</label>
<input
className="pip-input"
type="text"
placeholder={
selectedLayer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={selectedLayer.material_url}
onChange={(e) =>
updateLayer(selectedLayer.id, {
material_url: e.target.value,
})
}
/>
</div>
{/* ── 位置:九宫格 + 坐标 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div
style={{ display: "flex", gap: 16, alignItems: "flex-start" }}
>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${selectedLayer.grid_position === pos ? " active" : ""}`}
onClick={() => handleGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={selectedLayer.x}
onChange={(e) =>
updateLayer(selectedLayer.id, {
x: Number(e.target.value),
})
}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={selectedLayer.y}
onChange={(e) =>
updateLayer(selectedLayer.id, {
y: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
</div>
{/* ── 尺寸 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}>
</span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={selectedLayer.width}
onChange={(e) => handleWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">
{selectedLayer.width}%
</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}>
</span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={selectedLayer.height}
onChange={(e) => handleHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">
{selectedLayer.height}%
</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() =>
updateLayer(selectedLayer.id, {
aspect_lock: !selectedLayer.aspect_lock,
})
}
>
<span className="pip-lock-icon">
{selectedLayer.aspect_lock ? "🔒" : "🔓"}
</span>
<span>
{selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"}
</span>
</div>
</div>
{/* ── 圆角 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={selectedLayer.border_radius}
onChange={(e) =>
updateLayer(selectedLayer.id, {
border_radius: Number(e.target.value),
})
}
/>
<span className="pip-slider-value">
{selectedLayer.border_radius}%
</span>
</div>
</div>
{/* ── 透明度 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={selectedLayer.opacity}
onChange={(e) =>
updateLayer(selectedLayer.id, {
opacity: Number(e.target.value),
})
}
/>
<span className="pip-slider-value">
{selectedLayer.opacity}%
</span>
</div>
</div>
{/* ── 时间 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={selectedLayer.start_time}
onChange={(e) =>
updateLayer(selectedLayer.id, {
start_time: Number(e.target.value),
})
}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={selectedLayer.duration}
onChange={(e) =>
updateLayer(selectedLayer.id, {
duration: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
{/* ── 入场动画 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={selectedLayer.animation}
onChange={(e) =>
updateLayer(selectedLayer.id, {
animation: e.target.value as PipAnimType,
})
}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{selectedLayer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={selectedLayer.slide_direction}
onChange={(e) =>
updateLayer(selectedLayer.id, {
slide_direction: e.target.value as PipSlideDirection,
})
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</>
)}
</div>
</div>
{/* ── 底部操作 ── */}
<div className="pip-footer">
<button className="pip-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default PipConfigPanel;
@@ -4,13 +4,25 @@
* 4
*/
import React from "react";
import type { ClipData, ClipType, TitleSettings } from "../types";
import type { ClipData, ClipType } from "../types";
interface CoverScheme {
key: string;
label: string;
}
interface TitleSettings {
aiAutoSelect: boolean;
title: string;
position: string;
font: string;
size: number;
bold: boolean;
italic: boolean;
stroke: boolean;
shadow: boolean;
}
interface SubtitleSettings {
enabled: boolean;
position: string;
@@ -1,157 +0,0 @@
/**
* Drawer
* 0.25x ~ 4x+ +
* /
*/
import React, { useCallback } from "react";
import { Drawer, Slider } from "antd";
import type { SpeedConfig } from "../types";
import { DEFAULT_SPEED } from "../types";
/* ──────────── 预设速度 ──────────── */
const SPEED_PRESETS: { rate: number; label: string }[] = [
{ rate: 0.5, label: "0.5x" },
{ rate: 1.0, label: "1x" },
{ rate: 1.5, label: "1.5x" },
{ rate: 2.0, label: "2x" },
];
/* ──────────── Props ──────────── */
interface SpeedPanelProps {
open: boolean;
onClose: () => void;
/** 当前片段调速配置 */
config: SpeedConfig;
onChange: (config: SpeedConfig) => void;
/** 应用到所有片段 */
onApplyAll?: (config: SpeedConfig) => void;
}
const SpeedPanel: React.FC<SpeedPanelProps> = ({
open,
onClose,
config,
onChange,
onApplyAll,
}) => {
/* ── 修改速度 ── */
const handleChangeRate = useCallback(
(rate: number) => {
onChange({ ...config, rate });
},
[config, onChange],
);
/* ── 切换音调修正 ── */
const handleTogglePitch = useCallback(() => {
onChange({ ...config, pitchCorrection: !config.pitchCorrection });
}, [config, onChange]);
/* ── 选择预设 ── */
const handlePreset = useCallback(
(rate: number) => {
onChange({ ...config, rate });
},
[config, onChange],
);
/* ── 应用到所有片段 ── */
const handleApplyAll = useCallback(() => {
onApplyAll?.(config);
}, [config, onApplyAll]);
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_SPEED });
}, [onChange]);
/* ── 速度描述文字 ── */
const speedLabel =
config.rate < 1
? "慢速(慢动作)"
: config.rate === 1
? "原速"
: config.rate < 2
? "快速"
: "极速";
return (
<Drawer
title="⚡ 片段调速"
placement="right"
width={380}
open={open}
onClose={onClose}
className="speed-panel-drawer"
>
{/* ── 速度滑块 ── */}
<div className="sp-speed-section">
<div className="sp-speed-header">
<span className="sp-speed-label"></span>
<span className="sp-speed-value">{config.rate.toFixed(2)}x</span>
</div>
<Slider
min={0.25}
max={4.0}
step={0.05}
value={config.rate}
onChange={handleChangeRate}
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
/>
<div className="sp-speed-marks">
<span>0.25x</span>
<span>1x</span>
<span>2x</span>
<span>4x</span>
</div>
<div className="sp-speed-desc">{speedLabel}</div>
</div>
{/* ── 预设快捷按钮 ── */}
<div className="sp-presets">
<div className="sp-presets-label"></div>
<div className="sp-presets-row">
{SPEED_PRESETS.map((p) => (
<button
key={p.rate}
className={`sp-preset-btn${Math.abs(config.rate - p.rate) < 0.01 ? " active" : ""}`}
onClick={() => handlePreset(p.rate)}
>
{p.label}
</button>
))}
</div>
</div>
{/* ── 音调修正开关 ── */}
<div className="sp-pitch-section">
<div className="sp-pitch-info">
<span className="sp-pitch-label"></span>
<span className="sp-pitch-desc">
{config.pitchCorrection ? "变速不变调(推荐)" : "变速同时变调"}
</span>
</div>
<div
className={`ep-toggle${config.pitchCorrection ? " active" : ""}`}
onClick={handleTogglePitch}
>
<div className="ep-toggle-knob" />
</div>
</div>
{/* ── 底部操作 ── */}
<div className="sp-footer">
<button className="sp-reset-btn" onClick={handleReset}>
</button>
{onApplyAll && (
<button className="sp-apply-all-btn" onClick={handleApplyAll}>
</button>
)}
</div>
</Drawer>
);
};
export default SpeedPanel;
@@ -1,561 +0,0 @@
/**
*
* emoji / + +
*/
import React, { useCallback, useState } from "react";
import { Drawer, Switch } from "antd";
import type {
StickerConfig,
StickerItem,
StickerType,
TextStickerPreset,
} from "../types";
import {
DEFAULT_STICKER_CONFIG,
DEFAULT_STICKER_ITEM,
TEXT_STICKER_PRESET_LABELS,
} from "../types";
interface StickerPanelProps {
open: boolean;
onClose: () => void;
config: StickerConfig;
onChange: (config: StickerConfig) => void;
totalDuration: number;
}
/** 常用 emoji 素材 */
const EMOJI_LIST = [
"😀",
"😂",
"🥰",
"😎",
"🤩",
"😱",
"🤔",
"😴",
"🥳",
"😍",
"❤️",
"🔥",
"⭐",
"✨",
"💯",
"👍",
"👏",
"🎉",
"🎵",
"💪",
"📌",
"💡",
"🎯",
"✅",
"❌",
"⬆️",
"⬇️",
"➡️",
"⭕",
"🔔",
];
/** 文字花字预设对应的 CSS 样式预览 */
const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
normal: { color: "#fff", textShadow: "none" },
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
gradient: {
color: "transparent",
background: "linear-gradient(90deg,#f093fb,#f5576c)",
WebkitBackgroundClip: "text",
},
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
};
/** 生成唯一 ID */
const genId = () =>
`sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const StickerPanel: React.FC<StickerPanelProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<StickerType>("emoji");
const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null;
/** 更新单个贴纸 */
const updateItem = useCallback(
(id: string, partial: Partial<StickerItem>) => {
onChange({
...config,
items: config.items.map((s) =>
s.id === id ? { ...s, ...partial } : s,
),
});
},
[config, onChange],
);
/** 添加贴纸 */
const addSticker = useCallback(
(type: StickerType, content: string) => {
const newItem: StickerItem = {
...DEFAULT_STICKER_ITEM,
id: genId(),
type,
content,
duration: totalDuration > 0 ? totalDuration : 5,
z_index: config.items.length + 1,
};
onChange({
...config,
enabled: true,
items: [...config.items, newItem],
});
setSelectedId(newItem.id);
},
[config, onChange, totalDuration],
);
/** 删除贴纸 */
const removeSticker = useCallback(
(id: string) => {
onChange({
...config,
items: config.items.filter((s) => s.id !== id),
});
if (selectedId === id) setSelectedId(null);
},
[config, onChange, selectedId],
);
/** 重置所有 */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled });
setSelectedId(null);
}, [config.enabled, onChange]);
/** 文字花字输入 */
const [textInput, setTextInput] = useState("");
return (
<Drawer
title="贴纸"
placement="right"
width={460}
open={open}
onClose={onClose}
className="sticker-panel-drawer"
>
{/* 顶部开关 */}
<div className="sticker-header">
<span className="sticker-header-label"></span>
<Switch
size="small"
checked={config.enabled}
onChange={(checked) => onChange({ ...config, enabled: checked })}
/>
</div>
{/* 类型 Tab */}
<div className="sticker-tabs">
{(["emoji", "image", "text"] as StickerType[]).map((t) => (
<button
key={t}
className={`sticker-tab${activeTab === t ? " active" : ""}`}
onClick={() => setActiveTab(t)}
>
{t === "emoji"
? "表情贴纸"
: t === "image"
? "图片贴纸"
: "文字花字"}
</button>
))}
</div>
{/* Tab 内容区 */}
<div className="sticker-tab-content">
{/* Emoji 素材库 */}
{activeTab === "emoji" && (
<div className="sticker-emoji-grid">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
className="sticker-emoji-btn"
onClick={() => addSticker("emoji", emoji)}
>
{emoji}
</button>
))}
</div>
)}
{/* 图片贴纸 */}
{activeTab === "image" && (
<div className="sticker-image-input">
<input
type="text"
className="sticker-url-input"
placeholder="输入图片 URL 添加贴纸..."
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
addSticker("image", e.currentTarget.value.trim());
e.currentTarget.value = "";
}
}}
/>
<button
className="sticker-url-add-btn"
onClick={() => {
const input =
document.querySelector<HTMLInputElement>(
".sticker-url-input",
);
if (input?.value.trim()) {
addSticker("image", input.value.trim());
input.value = "";
}
}}
>
</button>
</div>
)}
{/* 文字花字 */}
{activeTab === "text" && (
<div className="sticker-text-section">
<div className="sticker-text-input-row">
<input
type="text"
className="sticker-text-input"
placeholder="输入文字内容..."
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
/>
<button
className="sticker-text-add-btn"
disabled={!textInput.trim()}
onClick={() => {
if (textInput.trim()) {
addSticker("text", textInput.trim());
setTextInput("");
}
}}
>
</button>
</div>
<div className="sticker-text-presets">
<div className="sticker-preset-title"></div>
<div className="sticker-preset-grid">
{(
Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]
).map((p) => (
<div
key={p}
className="sticker-preset-preview"
style={{
...TEXT_PRESET_STYLES[p],
background:
p === "bubble"
? "rgba(0,0,0,0.5)"
: p === "gradient"
? "linear-gradient(90deg,#f093fb,#f5576c)"
: "#1a1a2e",
}}
>
<span style={TEXT_PRESET_STYLES[p]}></span>
<div className="sticker-preset-name">
{TEXT_STICKER_PRESET_LABELS[p]}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
{/* 已添加贴纸列表 */}
{config.items.length > 0 && (
<div className="sticker-list-section">
<div className="sticker-section-title">
({config.items.length})
</div>
<div className="sticker-list">
{config.items.map((item) => (
<div
key={item.id}
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
onClick={() => setSelectedId(item.id)}
>
<span className="sticker-list-icon">
{item.type === "emoji"
? item.content
: item.type === "text"
? "T"
: "🖼"}
</span>
<span className="sticker-list-name">
{item.type === "text"
? item.content.slice(0, 10)
: item.type === "emoji"
? "表情贴纸"
: "图片贴纸"}
</span>
<button
className="sticker-list-delete"
onClick={(e) => {
e.stopPropagation();
removeSticker(item.id);
}}
>
</button>
</div>
))}
</div>
</div>
)}
{/* 选中贴纸的属性编辑 */}
{selectedSticker && (
<div className="sticker-props-section">
<div className="sticker-section-title"></div>
{/* 位置 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"> X</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.x}
onChange={(e) =>
updateItem(selectedSticker.id, { x: Number(e.target.value) })
}
/>
<span className="sticker-prop-value">{selectedSticker.x}%</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"> Y</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.y}
onChange={(e) =>
updateItem(selectedSticker.id, { y: Number(e.target.value) })
}
/>
<span className="sticker-prop-value">{selectedSticker.y}%</span>
</div>
{/* 尺寸 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={5}
max={50}
value={selectedSticker.width}
onChange={(e) =>
updateItem(selectedSticker.id, {
width: Number(e.target.value),
height: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.width}%</span>
</div>
{/* 旋转 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={-180}
max={180}
value={selectedSticker.rotation}
onChange={(e) =>
updateItem(selectedSticker.id, {
rotation: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">
{selectedSticker.rotation}°
</span>
</div>
{/* 透明度 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.opacity}
onChange={(e) =>
updateItem(selectedSticker.id, {
opacity: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">
{selectedSticker.opacity}%
</span>
</div>
{/* 时间 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={selectedSticker.start_time}
onChange={(e) =>
updateItem(selectedSticker.id, {
start_time: Number(e.target.value),
})
}
/>
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={selectedSticker.duration}
onChange={(e) =>
updateItem(selectedSticker.id, {
duration: Number(e.target.value),
})
}
/>
</div>
{/* 文字贴纸特有属性 */}
{selectedSticker.type === "text" && (
<>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<select
className="sticker-prop-select"
value={selectedSticker.text_preset}
onChange={(e) =>
updateItem(selectedSticker.id, {
text_preset: e.target.value as TextStickerPreset,
})
}
>
{(
Object.keys(
TEXT_STICKER_PRESET_LABELS,
) as TextStickerPreset[]
).map((p) => (
<option key={p} value={p}>
{TEXT_STICKER_PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={12}
max={72}
value={selectedSticker.font_size}
onChange={(e) =>
updateItem(selectedSticker.id, {
font_size: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">
{selectedSticker.font_size}px
</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="color"
className="sticker-prop-color"
value={selectedSticker.text_color}
onChange={(e) =>
updateItem(selectedSticker.id, {
text_color: e.target.value,
})
}
/>
</div>
</>
)}
{/* 预览 */}
<div className="sticker-preview-box">
<div
className="sticker-preview-item"
style={{
left: `${selectedSticker.x}%`,
top: `${selectedSticker.y}%`,
width: `${selectedSticker.width}%`,
height: `${selectedSticker.width}%`,
transform: `translate(-50%, -50%) rotate(${selectedSticker.rotation}deg)`,
opacity: selectedSticker.opacity / 100,
fontSize:
selectedSticker.type === "text"
? `${selectedSticker.font_size}px`
: undefined,
...TEXT_PRESET_STYLES[selectedSticker.text_preset],
}}
>
{selectedSticker.type === "emoji" && selectedSticker.content}
{selectedSticker.type === "text" && selectedSticker.content}
{selectedSticker.type === "image" && (
<img
src={selectedSticker.content}
alt="sticker"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
)}
</div>
</div>
</div>
)}
{/* 底部重置 */}
<div className="sticker-footer">
<button className="sticker-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default StickerPanel;
@@ -1,274 +0,0 @@
/**
* Drawer
* / ASR /ASR
*/
import React from "react";
import { Drawer, Slider, ColorPicker, Select } from "antd";
import type { Color } from "antd/es/color-picker";
/* ──────────── 类型 ──────────── */
export type SubtitleMode = "manual" | "asr";
export interface SubtitleStyleConfig {
/** 是否启用字幕 */
enabled: boolean;
/** 字幕模式:手动输入 / ASR 自动识别 */
mode: SubtitleMode;
/** 字体大小 px */
fontSize: number;
/** 字体颜色 */
fontColor: string;
/** 描边 */
stroke: boolean;
/** 阴影 */
shadow: boolean;
/** 字幕位置 */
position: "top" | "center" | "bottom";
/** 字体 */
font: string;
/** 动画效果 */
animation: string;
/** ASR 语言(仅 ASR 模式) */
asrLanguage: "zh" | "en";
}
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
enabled: true,
mode: "asr",
fontSize: 16,
fontColor: "#ffffff",
stroke: true,
shadow: false,
position: "bottom",
font: "思源黑体",
animation: "none",
asrLanguage: "zh",
};
/* ──────────── 选项常量 ──────────── */
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
];
const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
];
const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
];
const ASR_LANGUAGE_OPTIONS = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
];
/* ──────────── Props ──────────── */
interface SubtitleStylePanelProps {
open: boolean;
onClose: () => void;
config: SubtitleStyleConfig;
onChange: (config: SubtitleStyleConfig) => void;
}
const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
const update = (partial: Partial<SubtitleStyleConfig>) => {
onChange({ ...config, ...partial });
};
return (
<Drawer
title="💬 字幕样式配置"
placement="right"
width={380}
open={open}
onClose={onClose}
className="subtitle-style-drawer"
>
{/* ── 字幕开关 ── */}
<div className="sub-field">
<div className="sub-toggle-row">
<span className="sub-label"></span>
<div
className={`ep-toggle${config.enabled ? " active" : ""}`}
onClick={() => update({ enabled: !config.enabled })}
>
<div className="ep-toggle-knob" />
</div>
</div>
</div>
{config.enabled && (
<>
{/* ── 模式切换 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-mode-switch">
<button
className={`sub-mode-btn${config.mode === "manual" ? " active" : ""}`}
onClick={() => update({ mode: "manual" })}
>
</button>
<button
className={`sub-mode-btn${config.mode === "asr" ? " active" : ""}`}
onClick={() => update({ mode: "asr" })}
>
🤖 ASR
</button>
</div>
</div>
{/* ── ASR 语言(仅 ASR 模式) ── */}
{config.mode === "asr" && (
<div className="sub-field">
<label className="sub-label"></label>
<Select
className="sub-select"
value={config.asrLanguage}
onChange={(v) => update({ asrLanguage: v })}
options={ASR_LANGUAGE_OPTIONS}
popupMatchSelectWidth={false}
/>
</div>
)}
{/* ── 字体大小 ── */}
<div className="sub-field">
<label className="sub-label">
<span className="sub-value">{config.fontSize}px</span>
</label>
<Slider
min={12}
max={48}
value={config.fontSize}
onChange={(v) => update({ fontSize: v })}
/>
</div>
{/* ── 字体颜色 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-color-row">
<ColorPicker
value={config.fontColor}
onChange={(_color: Color, hex: string) =>
update({ fontColor: hex })
}
showText
/>
</div>
</div>
{/* ── 字体 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<Select
className="sub-select"
value={config.font}
onChange={(v) => update({ font: v })}
options={FONT_OPTIONS.map((f) => ({ value: f, label: f }))}
popupMatchSelectWidth={false}
/>
</div>
{/* ── 字幕位置 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-position-group">
{POSITION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`sub-position-btn${config.position === opt.value ? " active" : ""}`}
onClick={() =>
update({
position: opt.value as SubtitleStyleConfig["position"],
})
}
>
{opt.label}
</button>
))}
</div>
</div>
{/* ── 描边 / 阴影 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-effect-btns">
<button
className={`sub-effect-btn${config.stroke ? " active" : ""}`}
onClick={() => update({ stroke: !config.stroke })}
>
S
</button>
<button
className={`sub-effect-btn${config.shadow ? " active" : ""}`}
onClick={() => update({ shadow: !config.shadow })}
>
</button>
</div>
</div>
{/* ── 动画 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<Select
className="sub-select"
value={config.animation}
onChange={(v) => update({ animation: v })}
options={ANIMATION_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
popupMatchSelectWidth={false}
/>
</div>
{/* ── 预览 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-preview-box">
<span
className="sub-preview-text"
style={{
fontSize: `${Math.min(config.fontSize, 28)}px`,
color: config.fontColor,
fontFamily: config.font,
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
textShadow: config.shadow
? "2px 2px 4px rgba(0,0,0,0.8)"
: undefined,
}}
>
</span>
</div>
</div>
</>
)}
</Drawer>
);
};
export default SubtitleStylePanel;
@@ -1,12 +1,7 @@
/**
* 线
* 线 = +
* (20px) + (100x100) + HTML5拖拽排序
* "+" +
*
*
* - /
* - //
* - / /
*/
import React, {
useState,
@@ -16,8 +11,7 @@ import React, {
useLayoutEffect,
useMemo,
} from "react";
import type { ClipData, ClipType, TrimConfig } from "../types";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
import type { ClipData, ClipType } from "../types";
interface TimelinePanelProps {
clips: ClipData[];
@@ -27,26 +21,6 @@ interface TimelinePanelProps {
onClipReorder: (fromIdx: number, toIdx: number) => void;
onClipRemove: (clipId: string) => void;
onAddClip: (type: ClipType, duration: number) => void;
/** 裁剪更新:调整片段的 trim_config 和 duration */
onClipTrim?: (
clipId: string,
trimConfig: TrimConfig,
newDuration: number,
) => void;
/** 在指定位置分割片段 */
onClipSplit?: (clipId: string, splitRatio: number) => void;
/** 恢复片段原始长度 */
onClipResetTrim?: (clipId: string) => void;
/** 当前播放时间(秒) */
currentTime?: number;
/** 缩放:每秒像素数 */
pixelsPerSecond?: number;
/** 缩放变更回调 */
onZoomChange?: (pps: number) => void;
/** 播放头跳转回调 */
onSeek?: (time: number) => void;
/** 总时长(秒),可选(默认由 clips 计算) */
totalDuration?: number;
}
/** 片段类型图标 */
@@ -61,25 +35,6 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
pip: "画中画",
};
/** 裁剪拖拽方向 */
type TrimDirection = "left" | "right";
/** 裁剪拖拽状态 */
interface TrimDragState {
clipId: string;
direction: TrimDirection;
startX: number;
originalTrim: TrimConfig;
originalDuration: number;
}
/** 右键菜单状态 */
interface ContextMenuState {
x: number;
y: number;
clipId: string;
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
clips,
selectedClipId,
@@ -88,14 +43,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onClipReorder,
onClipRemove,
onAddClip,
onClipTrim,
onClipSplit,
onClipResetTrim,
currentTime = 0,
pixelsPerSecond = 40,
onZoomChange,
onSeek,
totalDuration: totalDurationProp,
}) => {
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
@@ -108,28 +55,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
right: 0,
});
/* ── 裁剪拖拽状态 ── */
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null);
const [trimPreview, setTrimPreview] = useState<{
clipId: string;
startTime: number;
endTime: number;
duration: number;
x: number;
y: number;
} | null>(null);
/* ── 右键菜单 ── */
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null);
/* ── 播放头拖拽状态 ── */
const [playheadDragging, setPlayheadDragging] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
@@ -141,7 +66,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
[currentMode],
);
/* ── 默认添加类型:跟随模式 ── */
/* ── 默认添加类型:跟随模式(纯单类型模式直接用该类型,混合模式默认 voice) ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice";
if (currentMode === "pip") return "pip";
@@ -158,28 +83,33 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setAddType(defaultAddType);
}
}, [currentMode, addType, availableTypes, defaultAddType]);
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
const GAP = 6; // 面板与"+"卡片的间距
/* ── 面板尺寸 ── */
const PICKER_W = 240;
const GAP = 6;
/* ── 计算 picker 初始位置 ── */
/* ── 计算 picker 初始位置(默认从"+"按钮上方弹出) ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return;
const rect = addCardRef.current.getBoundingClientRect();
const vw = window.innerWidth;
const roughHeight = 180;
/* 垂直方向:默认向上弹出(上方空间永远比下方大) */
const roughHeight = 180; // 粗略估算,useLayoutEffect 会用实际高度校正
let top = rect.top - GAP - roughHeight;
if (top < 8) top = 8;
/* 水平方向:右对齐"+"卡片;太靠右超出视口则左移 */
let right = vw - rect.right;
if (rect.right - PICKER_W < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, []);
const handleTogglePicker = () => {
if (!showAddPicker) {
// 打开面板时,默认选中当前模式下的第一个可用类型
const defaultType =
currentMode === "pip"
? "pip"
@@ -192,27 +122,35 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setShowAddPicker((v) => !v);
};
/* ── 渲染后精确边界校正 ── */
/* ── 渲染后用实际 offsetHeight 做精确边界校正(useLayoutEffect 确保 paint 前完成) ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return;
const pickerEl = pickerRef.current;
const addRect = addCardRef.current.getBoundingClientRect();
const pickerH = pickerEl.offsetHeight;
const pickerH = pickerEl.offsetHeight; // 实际高度,不用硬编码
const vh = window.innerHeight;
const vw = window.innerWidth;
/* 默认:面板在"+"按钮上方 */
let top = addRect.top - GAP - pickerH;
/* 上方空间也不够(极端情况)→ 翻转到下方 */
if (top < 8) {
top = addRect.bottom + GAP;
/* 下方也溢出 → clamp */
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH;
if (top < 8) top = 8;
}
}
/* 水平方向:右对齐"+"卡片;左侧溢出保护 */
let right = vw - addRect.right;
const pickerRect = pickerEl.getBoundingClientRect();
if (pickerRect.left < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, [showAddPicker]);
@@ -229,70 +167,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAddPicker]);
/* ── 点击外部关闭右键菜单 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(e.target as Node)
) {
setContextMenu(null);
}
};
if (contextMenu) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [contextMenu]);
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? 40;
const totalDuration =
totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0);
/* ── 播放头拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!playheadDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const trackEl = trackRef.current;
if (!trackEl) return;
const rect = trackEl.getBoundingClientRect();
const x = e.clientX - rect.left + trackEl.scrollLeft;
const time = Math.max(0, Math.min(x / pps, totalDuration));
onSeek?.(Math.round(time * 10) / 10);
};
const handleMouseUp = () => {
setPlayheadDragging(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [playheadDragging, pps, totalDuration, onSeek]);
/* ── 标尺点击跳转播放头 ── */
const handleRulerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const time = Math.max(0, Math.min(x / pps, totalDuration));
onSeek?.(Math.round(time * 10) / 10);
},
[pps, totalDuration, onSeek],
);
/* ── 播放头拖拽开始 ── */
const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setPlayheadDragging(true);
}, []);
/* ── 确认添加片段 ── */
const handleConfirmAdd = () => {
onAddClip(addType, addDuration);
@@ -301,8 +175,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
/* ── 片段拖拽排序 ── */
const handleDragStart = (e: React.DragEvent, idx: number) => {
// 如果正在裁剪拖拽,不允许排序拖拽
if (trimDrag) return;
dragRef.current = idx;
setDragIdx(idx);
e.dataTransfer.setData("application/x-clip-drag", String(idx));
@@ -338,132 +210,8 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
e.preventDefault();
};
/* ── 裁剪手柄拖拽 ── */
const handleTrimHandleMouseDown = useCallback(
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
e.preventDefault();
e.stopPropagation();
const clip = clips.find((c) => c.id === clipId);
if (!clip) return;
const trim: TrimConfig = clip.trim_config ?? {
start_time: 0,
end_time: clip.duration,
original_duration: clip.duration,
};
setTrimDrag({
clipId,
direction,
startX: e.clientX,
originalTrim: { ...trim },
originalDuration: clip.duration,
});
},
[clips],
);
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!trimDrag) return;
const PX_PER_SECOND = pixelsPerSecond ?? 40; // 与缩放级别同步
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX;
const dtSec = dx / PX_PER_SECOND;
const clip = clips.find((c) => c.id === trimDrag.clipId);
if (!clip) return;
const origTrim = trimDrag.originalTrim;
const origDur = origTrim.original_duration ?? trimDrag.originalDuration;
let newStart = origTrim.start_time;
let newEnd = origTrim.end_time;
if (trimDrag.direction === "left") {
// 左手柄:调整入点
newStart = Math.max(
0,
Math.min(origTrim.start_time + dtSec, newEnd - 1),
);
} else {
// 右手柄:调整出点
newEnd = Math.max(
origTrim.start_time + 1,
Math.min(origTrim.end_time + dtSec, origDur),
);
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10;
setTrimPreview({
clipId: trimDrag.clipId,
startTime: Math.round(newStart * 10) / 10,
endTime: Math.round(newEnd * 10) / 10,
duration: newDuration,
x: e.clientX,
y: e.clientY,
});
};
const handleMouseUp = () => {
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
const newTrim: TrimConfig = {
start_time: trimPreview.startTime,
end_time: trimPreview.endTime,
original_duration:
trimDrag.originalTrim.original_duration ??
trimDrag.originalDuration,
};
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration);
}
setTrimDrag(null);
setTrimPreview(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond]);
/* ── 右键菜单 ── */
const handleContextMenu = useCallback(
(e: React.MouseEvent, clipId: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, clipId });
},
[],
);
/* ── 右键菜单操作 ── */
const handleContextSplit = useCallback(() => {
if (!contextMenu) return;
if (onClipSplit) {
onClipSplit(contextMenu.clipId, 0.5); // 在中间分割
}
setContextMenu(null);
}, [contextMenu, onClipSplit]);
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return;
if (onClipResetTrim) {
onClipResetTrim(contextMenu.clipId);
}
setContextMenu(null);
}, [contextMenu, onClipResetTrim]);
const handleContextDelete = useCallback(() => {
if (!contextMenu) return;
onClipRemove(contextMenu.clipId);
setContextMenu(null);
}, [contextMenu, onClipRemove]);
/* ── 时间标尺 ── */
const trackWidth = Math.max(totalDuration * pps, 300);
const totalDuration = clips.reduce((s, c) => s + c.duration, 0);
const rulerMarks: number[] = [];
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15;
for (let t = 0; t <= totalDuration + step; t += step) {
@@ -476,11 +224,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
return `${m}:${s.toString().padStart(2, "0")}`;
};
/** 格式化裁剪时间(精确到0.1秒) */
const formatTrimTime = (sec: number) => {
return `${sec.toFixed(1)}s`;
};
return (
<div className="ep-timeline-area">
{/* 时间线头部 */}
@@ -492,33 +235,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
</span>
</div>
<div className="ep-timeline-actions">
{/* 缩放控件 */}
<div className="ep-timeline-zoom">
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
title="缩小"
>
</button>
<input
type="range"
className="ep-zoom-slider"
min={10}
max={120}
step={5}
value={pps}
onChange={(e) => onZoomChange?.(Number(e.target.value))}
/>
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
title="放大"
>
+
</button>
<span className="ep-zoom-label">{pps}px/s</span>
</div>
<button
className="ep-timeline-action-btn"
onClick={() => {
@@ -544,13 +260,21 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 时间标尺 */}
{currentMode !== "one_take" && (
<div className="ep-time-ruler" onClick={handleRulerClick}>
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
<div className="ep-time-ruler">
<div
className="ep-time-ruler-inner"
style={{ width: Math.max(clips.length * 108, 300) }}
>
{rulerMarks.map((t) => (
<span
key={t}
className="ep-time-mark"
style={{ left: `${t * pps}px` }}
style={{
left:
totalDuration > 0
? `${(t / totalDuration) * clips.length * 108}px`
: `${t * 20}px`,
}}
>
{t}s
</span>
@@ -561,151 +285,52 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 水平片段轨道 */}
{currentMode !== "one_take" && (
<div
className="ep-clip-track"
ref={trackRef}
onDragOver={handleEmptyDragOver}
>
{/* 播放头 */}
{currentMode !== "one_take" && totalDuration > 0 && (
<div
className="ep-playhead"
style={{ left: currentTime * pps }}
onMouseDown={handlePlayheadMouseDown}
>
<div className="ep-playhead-handle" />
</div>
)}
<div className="ep-clip-track" onDragOver={handleEmptyDragOver}>
{clips.length === 0 ? (
<div className="ep-track-empty">
<div className="ep-track-empty-icon">🎬</div>
<div className="ep-track-empty-text"> + </div>
</div>
) : (
clips.map((clip, idx) => {
/* 转场指示器 */
const trans = clip.transition;
const showTransition = idx > 0 && trans && trans.type !== "none";
const transOpt = showTransition
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
: undefined;
clips.map((clip, idx) => (
<div
key={clip.id}
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
>
{/* 类型图标 */}
<div className="ep-clip-thumbnail">
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
</div>
/* 速度徽章 */
const speed = clip.speed;
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01;
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">{clip.duration}s</span>
</div>
/* 裁剪状态 */
const hasTrim = !!clip.trim_config;
const isHovered = hoveredClipId === clip.id;
return (
<React.Fragment key={clip.id}>
{/* 转场指示器 */}
{showTransition && transOpt && (
<div
className="ep-transition-indicator"
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
>
<span className="ep-trans-icon">{transOpt.icon}</span>
<span className="ep-trans-duration">
{trans!.duration.toFixed(1)}s
</span>
</div>
)}
<div
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
style={{ width: Math.max(clip.duration * pps, 60) }}
draggable={!trimDrag}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
onContextMenu={(e) => handleContextMenu(e, clip.id)}
onMouseEnter={() => setHoveredClipId(clip.id)}
onMouseLeave={() => setHoveredClipId(null)}
>
{/* 左裁剪手柄 */}
{isHovered && onClipTrim && (
<div
className="ep-trim-handle ep-trim-handle-left"
onMouseDown={(e) =>
handleTrimHandleMouseDown(e, clip.id, "left")
}
title="拖动调整入点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 类型图标 */}
<div className="ep-clip-thumbnail">
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">
{clip.duration}s
{hasTrim && (
<span className="ep-trim-indicator" title="已裁剪">
</span>
)}
</span>
</div>
{/* 速度徽章 */}
{showSpeed && (
<span className="ep-speed-badge">
{speed!.rate.toFixed(1)}x
</span>
)}
{/* 裁剪徽章 */}
{hasTrim && (
<span
className="ep-trim-badge"
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
>
</span>
)}
{/* 右裁剪手柄 */}
{isHovered && onClipTrim && (
<div
className="ep-trim-handle ep-trim-handle-right"
onMouseDown={(e) =>
handleTrimHandleMouseDown(e, clip.id, "right")
}
title="拖动调整出点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
}}
>
</button>
</div>
</React.Fragment>
);
})
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
}}
>
</button>
</div>
))
)}
{/* ── 轨道末尾 "+" 添加卡片 ── */}
{/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */}
<div className="ep-track-add-card-wrapper">
<div
ref={addCardRef}
@@ -719,73 +344,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
</div>
)}
{/* 裁剪预览 tooltip */}
{trimPreview && (
<div
className="ep-trim-preview"
style={{
position: "fixed",
left: trimPreview.x + 12,
top: trimPreview.y - 40,
}}
>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">
{formatTrimTime(trimPreview.startTime)}
</span>
</div>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">
{formatTrimTime(trimPreview.endTime)}
</span>
</div>
<div className="ep-trim-preview-row ep-trim-preview-duration">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">
{formatTrimTime(trimPreview.duration)}
</span>
</div>
</div>
)}
{/* 右键菜单 */}
{contextMenu && (
<div
ref={contextMenuRef}
className="ep-context-menu"
style={{
position: "fixed",
left: contextMenu.x,
top: contextMenu.y,
}}
>
<div className="ep-context-menu-item" onClick={handleContextSplit}>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && (
<div
className="ep-context-menu-item"
onClick={handleContextResetTrim}
>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
)}
<div className="ep-context-menu-divider" />
<div
className="ep-context-menu-item ep-context-menu-item-danger"
onClick={handleContextDelete}
>
<span className="ep-context-menu-icon">🗑</span>
<span></span>
</div>
</div>
)}
{/* 类型+时长选择面板 */}
{/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */}
{showAddPicker && (
<div
ref={pickerRef}
@@ -1,118 +0,0 @@
/**
* Drawer
* 14 +
* +
*/
import React, { useCallback } from "react";
import { Drawer, Slider } from "antd";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
import type { TransitionConfig, TransitionType } from "../types";
import { DEFAULT_TRANSITION } from "../types";
/* ──────────── Props ──────────── */
interface TransitionSelectorProps {
open: boolean;
onClose: () => void;
/** 当前转场配置 */
config: TransitionConfig;
onChange: (config: TransitionConfig) => void;
/** 标题提示(区分全局 / 片段间) */
title?: string;
}
const TransitionSelector: React.FC<TransitionSelectorProps> = ({
open,
onClose,
config,
onChange,
title = "转场特效",
}) => {
/* ── 选择转场类型 ── */
const handleSelectType = useCallback(
(type: TransitionType) => {
onChange({ ...config, type });
},
[config, onChange],
);
/* ── 修改时长 ── */
const handleChangeDuration = useCallback(
(duration: number) => {
onChange({ ...config, duration });
},
[config, onChange],
);
/* ── 重置为无转场 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_TRANSITION });
}, [onChange]);
return (
<Drawer
title={`🎬 ${title}`}
placement="right"
width={480}
open={open}
onClose={onClose}
className="transition-selector-drawer"
>
{/* ── 时长滑块 ── */}
<div className="ts-duration-section">
<div className="ts-duration-header">
<span className="ts-duration-label"></span>
<span className="ts-duration-value">
{config.duration.toFixed(1)}s
</span>
</div>
<Slider
min={0.3}
max={2.0}
step={0.1}
value={config.duration}
onChange={handleChangeDuration}
tooltip={{ formatter: (v) => `${(v as number).toFixed(1)}s` }}
/>
<div className="ts-duration-marks">
<span>0.3s</span>
<span>1.0s</span>
<span>2.0s</span>
</div>
</div>
{/* ── 转场类型卡片网格 ── */}
<div className="ts-grid">
{TRANSITION_OPTIONS.map((opt) => {
const isActive = config.type === opt.value;
return (
<div
key={opt.value}
className={`ts-card${isActive ? " active" : ""}`}
onClick={() => handleSelectType(opt.value)}
>
<div className="ts-card-icon">{opt.icon}</div>
<div className="ts-card-name">{opt.label}</div>
{isActive && <span className="ts-card-check"></span>}
</div>
);
})}
</div>
{/* ── 底部操作 ── */}
<div className="ts-footer">
<button className="ts-reset-btn" onClick={handleReset}>
</button>
<div className="ts-current">
{TRANSITION_OPTIONS.find((o) => o.value === config.type)?.label ??
"无转场"}
{" · "}
{config.duration.toFixed(1)}s
</div>
</div>
</Drawer>
);
};
export default TransitionSelector;
@@ -1,349 +0,0 @@
/**
* TTS Drawer
* + + + // + +
*/
import React, { useState, useCallback, useEffect, useRef } from "react";
import { Drawer, Slider, message } from "antd";
import type { TtsConfig, TtsMode } from "../types";
import { DEFAULT_TTS_CONFIG } from "../types";
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts";
/* ──────────── 音色卡片分类图标 ──────────── */
const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
male: { icon: "👨", label: "男声" },
female: { icon: "👩", label: "女声" },
young: { icon: "🧑", label: "少年" },
service: { icon: "🎧", label: "客服" },
news: { icon: "📰", label: "新闻" },
emotion: { icon: "🎭", label: "情感" },
};
/* ──────────── Props ──────────── */
interface TtsPanelProps {
open: boolean;
onClose: () => void;
/** 当前片段 TTS 配置 */
config: TtsConfig;
onChange: (config: TtsConfig) => void;
}
const TtsPanel: React.FC<TtsPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
/* ── 音色列表 ── */
const [voices, setVoices] = useState<TTSVoice[]>([]);
const [voicesLoading, setVoicesLoading] = useState(false);
/* ── 试听状态 ── */
const [previewLoading, setPreviewLoading] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
/* ── 加载音色列表 ── */
useEffect(() => {
if (!open) return;
setVoicesLoading(true);
getTtsVoices()
.then((v) => setVoices(v))
.catch(() => message.error("加载音色列表失败"))
.finally(() => setVoicesLoading(false));
}, [open]);
/* ── 切换配音模式 ── */
const handleModeChange = useCallback(
(mode: TtsMode) => {
onChange({ ...config, mode });
},
[config, onChange],
);
/* ── 文本输入 ── */
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value.slice(0, 5000);
onChange({ ...config, text });
},
[config, onChange],
);
/* ── 选择音色 ── */
const handleVoiceSelect = useCallback(
(voiceId: string) => {
onChange({ ...config, voice_id: voiceId });
},
[config, onChange],
);
/* ── 语速 ── */
const handleSpeedChange = useCallback(
(speed: number) => {
onChange({ ...config, speed });
},
[config, onChange],
);
/* ── 语调 ── */
const handlePitchChange = useCallback(
(pitch: number) => {
onChange({ ...config, pitch });
},
[config, onChange],
);
/* ── 音量 ── */
const handleVolumeChange = useCallback(
(volume: number) => {
onChange({ ...config, volume });
},
[config, onChange],
);
/* ── 字幕联动 ── */
const handleSubtitleSyncToggle = useCallback(() => {
onChange({ ...config, subtitle_sync: !config.subtitle_sync });
}, [config, onChange]);
/* ── 试听 ── */
const handlePreview = useCallback(async () => {
if (!config.text.trim()) {
message.warning("请先输入合成文本");
return;
}
if (!config.voice_id) {
message.warning("请先选择音色");
return;
}
setPreviewLoading(true);
try {
const res = await previewTts({
text: config.text.slice(0, 200), // 试听截取前200字
voice_id: config.voice_id,
speed: config.speed,
pitch: config.pitch,
});
// 停止上一个
audioRef.current?.pause();
const audio = new Audio(res.audio_url);
audioRef.current = audio;
audio.play().catch(() => message.error("播放失败"));
audio.onended = () => {
audioRef.current = null;
};
message.success("试听播放中");
} catch {
message.error("试听生成失败");
} finally {
setPreviewLoading(false);
}
}, [config]);
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_TTS_CONFIG });
}, [onChange]);
/* ── 关闭时停止音频 ── */
const handleClose = useCallback(() => {
audioRef.current?.pause();
audioRef.current = null;
onClose();
}, [onClose]);
/* ── 音色分类分组 ── */
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP);
return (
<Drawer
title="🎙️ TTS 配音"
placement="right"
width={400}
open={open}
onClose={handleClose}
className="tts-panel-drawer"
>
{/* ── 配音模式切换 ── */}
<div className="tts-mode-section">
<div className="tts-mode-label"></div>
<div className="tts-mode-group">
{[
{ mode: "none" as TtsMode, icon: "🔇", label: "无配音" },
{ mode: "upload" as TtsMode, icon: "📁", label: "上传配音" },
{ mode: "tts" as TtsMode, icon: "🤖", label: "TTS 合成" },
].map((m) => (
<button
key={m.mode}
className={`tts-mode-btn${config.mode === m.mode ? " active" : ""}`}
onClick={() => handleModeChange(m.mode)}
>
<span className="tts-mode-icon">{m.icon}</span>
<span className="tts-mode-text">{m.label}</span>
</button>
))}
</div>
</div>
{/* ── TTS 配置(仅 tts 模式显示) ── */}
{config.mode === "tts" && (
<>
{/* 文本输入 */}
<div className="tts-text-section">
<div className="tts-text-header">
<span className="tts-text-label"></span>
<span className="tts-text-count">{config.text.length}/5000</span>
</div>
<textarea
className="tts-text-input"
placeholder="请输入需要合成的文本内容..."
value={config.text}
onChange={handleTextChange}
maxLength={5000}
rows={5}
/>
</div>
{/* 音色选择 */}
<div className="tts-voice-section">
<div className="tts-voice-label">
{voicesLoading && (
<span className="tts-voice-loading">...</span>
)}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat);
const isSelected = voice && config.voice_id === voice.id;
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && handleVoiceSelect(voice.id)}
disabled={!voice || voicesLoading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">
{voice?.name || info.label}
</span>
</button>
);
})}
</div>
</div>
{/* 语速滑块 */}
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">
{config.speed.toFixed(2)}x
</span>
</div>
<Slider
min={0.5}
max={2.0}
step={0.05}
value={config.speed}
onChange={handleSpeedChange}
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
/>
<div className="tts-slider-marks">
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 语调滑块 */}
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">
{config.pitch > 0 ? "+" : ""}
{config.pitch}
</span>
</div>
<Slider
min={-12}
max={12}
step={1}
value={config.pitch}
onChange={handlePitchChange}
tooltip={{ formatter: (v) => `${v}半音` }}
/>
<div className="tts-slider-marks">
<span>-12</span>
<span>0</span>
<span>+12</span>
</div>
</div>
{/* 音量滑块 */}
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">{config.volume}%</span>
</div>
<Slider
min={0}
max={100}
step={1}
value={config.volume}
onChange={handleVolumeChange}
tooltip={{ formatter: (v) => `${v}%` }}
/>
</div>
{/* 试听按钮 */}
<div className="tts-preview-section">
<button
className="tts-preview-btn"
onClick={handlePreview}
disabled={previewLoading}
>
{previewLoading ? "⏳ 生成中..." : "🔊 试听"}
</button>
</div>
{/* 字幕联动 */}
<div className="tts-subtitle-section">
<div className="tts-subtitle-info">
<span className="tts-subtitle-label"></span>
<span className="tts-subtitle-desc">
{config.subtitle_sync
? "TTS 文本自动同步到字幕"
: "字幕需手动编辑"}
</span>
</div>
<div
className={`ep-toggle${config.subtitle_sync ? " active" : ""}`}
onClick={handleSubtitleSyncToggle}
>
<div className="ep-toggle-knob" />
</div>
</div>
</>
)}
{/* ── 上传配音模式提示 ── */}
{config.mode === "upload" && (
<div className="tts-upload-hint">
<p></p>
<p></p>
</div>
)}
{/* ── 底部操作 ── */}
{config.mode === "tts" && (
<div className="tts-footer">
<button className="tts-reset-btn" onClick={handleReset}>
</button>
</div>
)}
</Drawer>
);
};
export default TtsPanel;
@@ -1,419 +0,0 @@
/**
* Drawer
* Tab / /
*
*/
import React, { useCallback, useState } from "react";
import { Drawer } from "antd";
import type {
WatermarkConfig,
WatermarkType,
WatermarkPosition,
ScrollDirection,
} from "../types";
import { DEFAULT_WATERMARK } from "../types";
/* ──────────── 常量 ──────────── */
const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [
{ key: "none", label: "无水印", icon: "🚫" },
{ key: "image", label: "图片水印", icon: "🖼️" },
{ key: "text", label: "文字水印", icon: "📝" },
{ key: "scroll", label: "滚动水印", icon: "📜" },
];
const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [
{ value: "top_left", label: "左上角" },
{ value: "top_right", label: "右上角" },
{ value: "bottom_left", label: "左下角" },
{ value: "bottom_right", label: "右下角" },
{ value: "center", label: "居中" },
];
const SCROLL_DIRECTION_OPTIONS: {
value: ScrollDirection;
label: string;
}[] = [
{ value: "horizontal", label: "水平滚动" },
{ value: "vertical", label: "垂直滚动" },
{ value: "diagonal", label: "对角滚动" },
];
/* ──────────── Props ──────────── */
interface WatermarkPanelProps {
open: boolean;
onClose: () => void;
config: WatermarkConfig;
onChange: (config: WatermarkConfig) => void;
}
const WatermarkPanel: React.FC<WatermarkPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
/* ── 图片上传预览 URL(本地预览用) ── */
const [localImageUrl, setLocalImageUrl] = useState<string>("");
/* ── 切换水印类型 ── */
const handleTypeChange = useCallback(
(type: WatermarkType) => {
onChange({ ...DEFAULT_WATERMARK, type });
},
[onChange],
);
/* ── 通用设置变更 ── */
const handlePositionChange = useCallback(
(position: WatermarkPosition) => {
onChange({ ...config, position });
},
[config, onChange],
);
const handleOpacityChange = useCallback(
(opacity: number) => {
onChange({ ...config, opacity });
},
[config, onChange],
);
/* ── 图片水印设置 ── */
const handleImageUrlChange = useCallback(
(url: string) => {
setLocalImageUrl(url);
onChange({ ...config, image_url: url });
},
[config, onChange],
);
const handleImageWidthChange = useCallback(
(width: number) => {
onChange({ ...config, width });
},
[config, onChange],
);
const handleImageHeightChange = useCallback(
(height: number) => {
onChange({ ...config, height });
},
[config, onChange],
);
/* ── 文字水印设置 ── */
const handleTextChange = useCallback(
(text: string) => {
onChange({ ...config, text });
},
[config, onChange],
);
const handleFontSizeChange = useCallback(
(font_size: number) => {
onChange({ ...config, font_size });
},
[config, onChange],
);
const handleColorChange = useCallback(
(color: string) => {
onChange({ ...config, color });
},
[config, onChange],
);
/* ── 滚动水印设置 ── */
const handleScrollDirectionChange = useCallback(
(scroll_direction: ScrollDirection) => {
onChange({ ...config, scroll_direction });
},
[config, onChange],
);
const handleScrollSpeedChange = useCallback(
(scroll_speed: number) => {
onChange({ ...config, scroll_speed });
},
[config, onChange],
);
/* ── 重置 ── */
const handleReset = useCallback(() => {
setLocalImageUrl("");
onChange({ ...DEFAULT_WATERMARK });
}, [onChange]);
/* ── 当前激活的 Tab ── */
const activeTab = config.type;
return (
<Drawer
title="🔖 水印设置"
placement="right"
width={400}
open={open}
onClose={onClose}
className="watermark-panel-drawer"
>
{/* ── Tab 切换 ── */}
<div className="wp-tabs">
{WATERMARK_TABS.map((tab) => (
<button
key={tab.key}
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => handleTypeChange(tab.key)}
>
<span className="wp-tab-icon">{tab.icon}</span>
<span className="wp-tab-label">{tab.label}</span>
</button>
))}
</div>
{/* ── 无水印提示 ── */}
{config.type === "none" && (
<div className="wp-empty-hint">
<span className="wp-empty-icon">🚫</span>
<p></p>
<p className="wp-empty-desc"></p>
</div>
)}
{/* ── 图片水印配置 ── */}
{config.type === "image" && (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"> URL</label>
<input
className="wp-input"
type="text"
placeholder="https://example.com/logo.png"
value={localImageUrl || config.image_url || ""}
onChange={(e) => handleImageUrlChange(e.target.value)}
/>
{(localImageUrl || config.image_url) && (
<div className="wp-image-preview">
<img
src={localImageUrl || config.image_url}
alt="水印预览"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
)}
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={config.width ?? 0}
onChange={(e) => handleImageWidthChange(Number(e.target.value))}
/>
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={config.height ?? 0}
onChange={(e) => handleImageHeightChange(Number(e.target.value))}
/>
</div>
</div>
)}
{/* ── 文字水印配置 ── */}
{config.type === "text" && (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入水印文字内容"
rows={3}
value={config.text ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={config.font_size ?? 24}
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">
{config.font_size ?? 24}px
</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={config.color ?? "#ffffff"}
onChange={(e) => handleColorChange(e.target.value)}
/>
<span className="wp-color-value">
{config.color ?? "#ffffff"}
</span>
</div>
</div>
</div>
)}
{/* ── 滚动水印配置 ── */}
{config.type === "scroll" && (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入滚动水印文字"
rows={2}
value={config.text ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={config.scroll_direction ?? "horizontal"}
onChange={(e) =>
handleScrollDirectionChange(e.target.value as ScrollDirection)
}
>
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={10}
max={200}
value={config.scroll_speed ?? 50}
onChange={(e) =>
handleScrollSpeedChange(Number(e.target.value))
}
/>
<span className="wp-slider-value">
{config.scroll_speed ?? 50}px/s
</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={config.font_size ?? 24}
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">
{config.font_size ?? 24}px
</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={config.color ?? "#ffffff"}
onChange={(e) => handleColorChange(e.target.value)}
/>
<span className="wp-color-value">
{config.color ?? "#ffffff"}
</span>
</div>
</div>
</div>
)}
{/* ── 通用设置(非 none 时显示) ── */}
{config.type !== "none" && (
<div className="wp-section wp-common-section">
<div className="wp-section-divider" />
<div className="wp-common-title"></div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={config.position}
onChange={(e) =>
handlePositionChange(e.target.value as WatermarkPosition)
}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={0}
max={1}
step={0.05}
value={config.opacity}
onChange={(e) => handleOpacityChange(Number(e.target.value))}
/>
<span className="wp-slider-value">
{Math.round(config.opacity * 100)}%
</span>
</div>
</div>
</div>
)}
{/* ── 底部操作 ── */}
<div className="wp-footer">
<button className="wp-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default WatermarkPanel;
-529
View File
@@ -5,508 +5,6 @@
export type ClipType = "voice" | "pip";
/* ──────── 转场特效 ──────── */
/** 14 种转场类型 */
export type TransitionType =
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop";
/** 片段间转场配置 */
export interface TransitionConfig {
/** 转场类型 */
type: TransitionType;
/** 转场时长(秒),0.3 ~ 2.0 */
duration: number;
}
/** 默认转场配置 */
export const DEFAULT_TRANSITION: TransitionConfig = {
type: "none",
duration: 0.5,
};
/* ──────── 片段调速 ──────── */
/** 片段调速配置 */
export interface SpeedConfig {
/** 播放速度,0.25 ~ 4.0 */
rate: number;
/** 音调修正(变速不变调) */
pitchCorrection: boolean;
}
/** 默认调速配置 */
export const DEFAULT_SPEED: SpeedConfig = {
rate: 1.0,
pitchCorrection: true,
};
/* ──────── TTS 配音 ──────── */
/** 配音模式 */
export type TtsMode = "none" | "upload" | "tts";
/** TTS 配音配置 */
export interface TtsConfig {
/** 配音模式 */
mode: TtsMode;
/** TTS 合成文本 */
text: string;
/** 音色 ID */
voice_id: string;
/** 语速 0.5 ~ 2.0 */
speed: number;
/** 语调(半音)-12 ~ +12 */
pitch: number;
/** 音量 0 ~ 100 */
volume: number;
/** 字幕联动 */
subtitle_sync: boolean;
}
/** 默认 TTS 配置 */
export const DEFAULT_TTS_CONFIG: TtsConfig = {
mode: "none",
text: "",
voice_id: "",
speed: 1.0,
pitch: 0,
volume: 100,
subtitle_sync: true,
};
/* ──────── 裁剪配置 ──────── */
/** 片段裁剪配置 — 定义素材的入点/出点 */
export interface TrimConfig {
/** 入点(秒),素材原始时间轴上的起始位置 */
start_time: number;
/** 出点(秒),素材原始时间轴上的结束位置 */
end_time: number;
/** 素材原始总时长(秒),用于"恢复原始长度" */
original_duration?: number;
}
/* ──────── 水印配置 ──────── */
/** 水印类型 */
export type WatermarkType = "none" | "image" | "text" | "scroll";
/** 水印位置 */
export type WatermarkPosition =
"top_left" | "top_right" | "bottom_left" | "bottom_right" | "center";
/** 滚动水印方向 */
export type ScrollDirection = "horizontal" | "vertical" | "diagonal";
/** 水印配置 */
export interface WatermarkConfig {
/** 水印类型 */
type: WatermarkType;
/** 图片水印 URL */
image_url?: string;
/** 水印宽度(像素或百分比 0~1) */
width?: number;
/** 水印高度(像素或百分比 0~1) */
height?: number;
/** 水印位置 */
position: WatermarkPosition;
/** 水印不透明度 0~1 */
opacity: number;
/** 文字水印内容 */
text?: string;
/** 文字水印字号 */
font_size?: number;
/** 文字水印颜色 */
color?: string;
/** 滚动水印方向 */
scroll_direction?: ScrollDirection;
/** 滚动水印速度(像素/秒) */
scroll_speed?: number;
}
/** 默认水印配置 */
export const DEFAULT_WATERMARK: WatermarkConfig = {
type: "none",
position: "bottom_right",
opacity: 0.7,
};
/* ──────── 片头片尾配置 ──────── */
/** 片头片尾素材类型 */
export type IntroOutroKind = "none" | "video" | "image";
/** 片头/片尾单项配置 */
export interface IntroOutroItem {
/** 素材类型 */
kind: IntroOutroKind;
/** 素材 URL */
url?: string;
/** 显示时长(秒) */
duration: number;
/** 过渡动画 */
transition?: TransitionType;
/** 过渡时长(秒) */
transition_duration?: number;
}
/** 片头片尾完整配置 */
export interface IntroOutroConfig {
intro: IntroOutroItem;
outro: IntroOutroItem;
}
/** 默认片头片尾配置 */
export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
intro: { kind: "none", duration: 3 },
outro: { kind: "none", duration: 3 },
};
/* ──────── 画中画配置 ──────── */
/** 九宫格位置 */
export type PipGridPosition =
| "top_left"
| "top_center"
| "top_right"
| "center_left"
| "center"
| "center_right"
| "bottom_left"
| "bottom_center"
| "bottom_right";
/** 入场动画类型 */
export type PipAnimType = "none" | "fade_in" | "slide_in";
/** 入场方向 */
export type PipSlideDirection = "left" | "right" | "up" | "down";
/** 画中画图层 */
export interface PipLayer {
id: string;
/** 图层名称(用户可编辑) */
name: string;
/** 素材类型 */
material_type: "image" | "video";
/** 素材 URL */
material_url: string;
/** 素材缩略图 */
thumbnail_url?: string;
/** 九宫格快捷位置 */
grid_position: PipGridPosition;
/** 精确 X 坐标(百分比 0~100 */
x: number;
/** 精确 Y 坐标(百分比 0~100 */
y: number;
/** 宽度(百分比 0~100,相对主画面) */
width: number;
/** 高度(百分比 0~100,相对主画面) */
height: number;
/** 锁定宽高比 */
aspect_lock: boolean;
/** 圆角(百分比 0~50 */
border_radius: number;
/** 不透明度(0~100 */
opacity: number;
/** 开始时间(秒) */
start_time: number;
/** 持续时长(秒) */
duration: number;
/** 入场动画 */
animation: PipAnimType;
/** 入场方向 */
slide_direction: PipSlideDirection;
/** 图层顺序(z-index */
z_index: number;
}
/** 画中画配置 */
export interface PipConfig {
/** 是否启用画中画 */
enabled: boolean;
/** 图层列表 */
layers: PipLayer[];
}
/** 默认 PiP 图层 */
export const DEFAULT_PIP_LAYER: PipLayer = {
id: "",
name: "图层",
material_type: "image",
material_url: "",
grid_position: "top_right",
x: 70,
y: 5,
width: 25,
height: 25,
aspect_lock: true,
border_radius: 0,
opacity: 100,
start_time: 0,
duration: 5,
animation: "none",
slide_direction: "right",
z_index: 1,
};
/** 默认 PiP 配置 */
export const DEFAULT_PIP_CONFIG: PipConfig = {
enabled: false,
layers: [],
};
/* ──────── 滤镜调色 ──────── */
/** 预设滤镜 */
export type FilterPreset =
| "none"
| "original"
| "fresh"
| "warm"
| "cool"
| "vintage"
| "cinema"
| "bw"
| "sunshine"
| "film";
/** 预设滤镜标签 */
export const FILTER_PRESET_LABELS: Record<FilterPreset, string> = {
none: "无",
original: "原片",
fresh: "清新",
warm: "暖调",
cool: "冷色",
vintage: "复古",
cinema: "电影",
bw: "黑白",
sunshine: "暖阳",
film: "胶片",
};
/** 滤镜调色配置 */
export interface FilterConfig {
/** 是否启用滤镜 */
enabled: boolean;
/** 预设滤镜 */
preset: FilterPreset;
/** 亮度(-100 ~ 100 */
brightness: number;
/** 对比度(-100 ~ 100 */
contrast: number;
/** 饱和度(-100 ~ 100 */
saturation: number;
/** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */
temperature: number;
/** 色调(-100 ~ 100,负值偏绿,正值偏品红) */
tint: number;
/** 锐度(0 ~ 100 */
sharpness: number;
}
/** 默认滤镜调色配置 */
export const DEFAULT_FILTER_CONFIG: FilterConfig = {
enabled: false,
preset: "none",
brightness: 0,
contrast: 0,
saturation: 0,
temperature: 0,
tint: 0,
sharpness: 0,
};
/* ──────── 绿幕抠像 ──────── */
/** 绿幕抠像颜色预设 */
export type ChromaKeyColorPreset =
"green" | "blue" | "red" | "pure_green" | "soft_green";
/** 颜色预设标签 */
export const CHROMA_KEY_PRESET_LABELS: Record<ChromaKeyColorPreset, string> = {
green: "绿",
blue: "蓝",
red: "红",
pure_green: "精绿",
soft_green: "柔绿",
};
/** 颜色预设对应的默认色值 */
export const CHROMA_KEY_PRESET_COLORS: Record<ChromaKeyColorPreset, string> = {
green: "#00FF00",
blue: "#0000FF",
red: "#FF0000",
pure_green: "#00C800",
soft_green: "#40E040",
};
/** 绿幕抠像配置 */
export interface ChromaKeyConfig {
/** 是否启用绿幕抠像 */
enabled: boolean;
/** 颜色预设 */
color_preset: ChromaKeyColorPreset;
/** 抠像目标颜色(HEX */
color: string;
/** 相似度(0 ~ 100,越大容忍的色差范围越广) */
similarity: number;
/** 边缘平滑(0 ~ 100,越大边缘越柔和) */
blend: number;
/** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */
spill: number;
}
/** 默认绿幕抠像配置 */
export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = {
enabled: false,
color_preset: "green",
color: "#00FF00",
similarity: 30,
blend: 10,
spill: 20,
};
/* ──────── 贴纸配置 ──────── */
/** 贴纸类型 */
export type StickerType = "emoji" | "image" | "text";
/** 文字花字预设 */
export type TextStickerPreset =
| "normal" // 普通
| "highlight" // 高亮
| "bubble" // 气泡
| "neon" // 霓虹
| "shadow" // 投影
| "outline" // 描边
| "gradient" // 渐变
| "handwrite"; // 手写
/** 贴纸项 */
export interface StickerItem {
id: string;
/** 贴纸类型 */
type: StickerType;
/** 内容(emoji 字符 / 图片 URL / 文字内容) */
content: string;
/** X 坐标(百分比 0~100 */
x: number;
/** Y 坐标(百分比 0~100 */
y: number;
/** 宽度(百分比 0~100 */
width: number;
/** 高度(百分比 0~100 */
height: number;
/** 旋转角度(度 -180~180 */
rotation: number;
/** 不透明度(0~100 */
opacity: number;
/** 开始时间(秒) */
start_time: number;
/** 持续时长(秒,0 表示全程显示) */
duration: number;
/** 图层顺序 */
z_index: number;
/** 文字花字预设(仅 type=text 时有效) */
text_preset: TextStickerPreset;
/** 文字颜色(仅 type=text 时有效) */
text_color: string;
/** 文字大小(px,仅 type=text 时有效) */
font_size: number;
}
/** 贴纸配置 */
export interface StickerConfig {
enabled: boolean;
items: StickerItem[];
}
/** 默认贴纸项 */
export const DEFAULT_STICKER_ITEM: StickerItem = {
id: "",
type: "emoji",
content: "😀",
x: 50,
y: 50,
width: 15,
height: 15,
rotation: 0,
opacity: 100,
start_time: 0,
duration: 0,
z_index: 1,
text_preset: "normal",
text_color: "#FFFFFF",
font_size: 24,
};
/** 默认贴纸配置 */
export const DEFAULT_STICKER_CONFIG: StickerConfig = {
enabled: false,
items: [],
};
/** 文字花字预设标签 */
export const TEXT_STICKER_PRESET_LABELS: Record<TextStickerPreset, string> = {
normal: "普通",
highlight: "高亮",
bubble: "气泡",
neon: "霓虹",
shadow: "投影",
outline: "描边",
gradient: "渐变",
handwrite: "手写",
};
/* ──────── 封面配置 ──────── */
/** 封面来源模式 */
export type CoverMode = "auto" | "frame" | "upload";
/** 封面配置 */
export interface CoverConfig {
/** 是否启用自定义封面 */
enabled: boolean;
/** 封面来源模式 */
mode: CoverMode;
/** 抽帧时间点(秒,mode=frame 时使用) */
frame_time: number;
/** 上传的封面 URLmode=upload 时使用) */
upload_url: string;
/** AI 智能推荐的抽帧时间(由后端分析得出) */
ai_suggested_time: number | null;
/** 封面缩略图 URL */
thumbnail_url: string;
}
/** 默认封面配置 */
export const DEFAULT_COVER_CONFIG: CoverConfig = {
enabled: false,
mode: "auto",
frame_time: 0,
upload_url: "",
ai_suggested_time: null,
thumbnail_url: "",
};
/* ──────── 片段数据 ──────── */
export interface ClipData {
id: string;
type: ClipType; // 片段类型:voice(口播)或 pip(画中画)
@@ -520,31 +18,4 @@ export interface ClipData {
voice_asset_id?: string;
/** 配音素材文件 URL(voice 类型片段使用) */
voice_file_url?: string;
/** 与前一片段之间的转场效果 */
transition?: TransitionConfig;
/** 播放速度配置 */
speed?: SpeedConfig;
/** TTS 配音配置 */
tts_config?: TtsConfig;
/** 裁剪配置 — 定义素材入点/出点 */
trim_config?: TrimConfig;
}
/* ──────── 标题设置 ──────── */
/**
* title_config
* UI 使 camelCase snake_case
*/
export interface TitleSettings {
aiAutoSelect: boolean;
title: string;
position: string;
font: string;
size: number;
bold: boolean;
italic: boolean;
stroke: boolean;
shadow: boolean;
color: string;
}
+10 -102
View File
@@ -29,9 +29,7 @@ import {
createEditPlan,
generateEditPlan,
updateEditPlan,
getGenerationTaskResults,
} from "@/api/editPlans";
import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans";
import { getEditingTemplates } from "@/api/editingPlanner";
import { getTitles } from "@/api/titles";
import apiClient from "@/api/client";
@@ -154,9 +152,6 @@ const GeneratePage: React.FC = () => {
const [progress, setProgress] = useState(0);
const [generated, setGenerated] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [videoUrl, setVideoUrl] = useState<string>("");
const [thumbnailUrl, setThumbnailUrl] = useState<string>("");
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -202,8 +197,8 @@ const GeneratePage: React.FC = () => {
try {
const plan = await getEditPlan(editPlanId);
if (plan.name) setTitle(plan.name);
const cfg = plan.config;
if (cfg?.asset_ids) {
const cfg = plan.config as Record<string, unknown>;
if (cfg && Array.isArray(cfg.asset_ids)) {
setSelectedMaterials(
cfg.asset_ids.filter((v): v is string => typeof v === "string"),
);
@@ -477,13 +472,7 @@ const GeneratePage: React.FC = () => {
setGenerateError(null);
try {
const voiceConfig: Pick<
EditPlanConfig,
| "voice_id"
| "voice_clone_profile_id"
| "custom_audio_url"
| "custom_text"
> = {};
const voiceConfig: Record<string, unknown> = {};
if (voiceMode === "preset") {
voiceConfig.voice_id = selectedVoice || undefined;
} else if (voiceMode === "clone") {
@@ -529,25 +518,6 @@ const GeneratePage: React.FC = () => {
setProgress(100);
setGenerating(false);
setGenerated(true);
// 获取生成的视频结果
if (data.generation_task_id) {
try {
const videos = await getGenerationTaskResults(
data.generation_task_id,
);
setGeneratedVideos(videos);
if (videos.length > 0) {
setVideoUrl(
videos[0].file_url || videos[0].download_url || "",
);
setThumbnailUrl(videos[0].thumbnail_url || "");
}
} catch (err) {
console.error("[获取生成结果失败]", err);
}
}
message.success("视频生成完成!");
return;
}
@@ -567,8 +537,7 @@ const GeneratePage: React.FC = () => {
const safeExtract = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -628,8 +597,7 @@ const GeneratePage: React.FC = () => {
const extractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -659,8 +627,7 @@ const GeneratePage: React.FC = () => {
const safeExtractErr = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -741,42 +708,6 @@ const GeneratePage: React.FC = () => {
materialMode,
]);
/* ── 下载视频 ── */
const handleDownload = useCallback(async () => {
if (!generatedVideos.length) return;
const video = generatedVideos[0];
try {
// 优先使用 download_url(签名 URL),回退到 file_url
const url = video.download_url || video.file_url;
if (url) {
const a = document.createElement("a");
a.href = url;
a.download = video.name || "generated-video.mp4";
a.target = "_blank";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
} catch (err) {
console.error("[下载失败]", err);
message.error("下载失败,请重试");
}
}, [generatedVideos]);
/* ── 分享视频 ── */
const handleShare = useCallback(async () => {
if (!generatedVideos.length) return;
const video = generatedVideos[0];
const shareUrl = video.file_url || window.location.href;
try {
await navigator.clipboard.writeText(shareUrl);
message.success("视频链接已复制到剪贴板");
} catch {
// fallback: 显示 URL 让用户手动复制
message.info(`视频链接: ${shareUrl}`);
}
}, [generatedVideos]);
/* ── 步骤导航 ── */
const goNext = useCallback(() => {
if (currentStep === 1 && !selectedTemplate) {
@@ -1793,25 +1724,8 @@ const GeneratePage: React.FC = () => {
<div className="xx-generate-preview">
{/* 视频预览 */}
<div className="xx-preview-video">
{generated && videoUrl ? (
<video
src={videoUrl}
controls
preload="metadata"
poster={thumbnailUrl || undefined}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
) : generated ? (
<div
style={{
textAlign: "center",
padding: 24,
color: "var(--text-secondary)",
}}
>
<LoadingOutlined style={{ fontSize: 24, marginBottom: 8 }} />
<div></div>
</div>
{generated ? (
<video src="" controls preload="none" />
) : (
<button className="xx-play-btn" type="button">
<PlayCircleOutlined />
@@ -1864,18 +1778,12 @@ const GeneratePage: React.FC = () => {
{/* 生成完成后显示下载/分享 */}
{generated && (
<div className="xx-generate-actions" style={{ marginTop: 8 }}>
<button className="xx-btn xx-btn-ghost" onClick={handleDownload}>
<button className="xx-btn xx-btn-ghost">
<DownloadOutlined />
</button>
<button className="xx-btn xx-btn-ghost" onClick={handleShare}>
<button className="xx-btn xx-btn-ghost">
<ShareAltOutlined />
</button>
<button
className="xx-btn xx-btn-ghost"
onClick={() => navigate("/app/products")}
>
</button>
</div>
)}
</div>
+18 -185
View File
@@ -31,11 +31,7 @@ import {
getProducts,
deleteProduct,
getProductDownloadUrl,
updateReviewStatus,
batchDownload,
getBatchDownloadStatus,
type ProductItem as ApiProductItem,
type ReviewStatus,
} from "@/api/products";
import "./products.css";
@@ -57,12 +53,6 @@ interface ProductItem {
fileSize: number; // MB
videoUrl?: string;
thumbnailUrl?: string;
/** 复核状态 */
reviewStatus?: ReviewStatus;
/** 所属项目 ID */
projectId?: string;
/** 所属项目名称 */
projectName?: string;
}
/** 将后端 status 映射为前端 ProductStatus */
@@ -118,9 +108,6 @@ const mapApiProduct = (item: ApiProductItem): ProductItem => ({
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
videoUrl: item.video_url,
thumbnailUrl: item.thumbnail_url,
reviewStatus: item.review_status,
projectId: item.project_id,
projectName: item.project_name,
});
/* ============================================================
@@ -148,30 +135,6 @@ const formatSize = (mb: number): string => {
return `${mb.toFixed(1)} MB`;
};
/** 复核状态配置 */
const reviewStatusConfig: Record<
ReviewStatus,
{ text: string; className: string }
> = {
pending_review: { text: "待复核", className: "review-pending" },
approved: { text: "已通过", className: "review-approved" },
rejected: { text: "需修改", className: "review-rejected" },
};
/** 复核状态循环顺序 */
const REVIEW_STATUS_CYCLE: ReviewStatus[] = [
"pending_review",
"approved",
"rejected",
];
/** 获取下一个复核状态 */
const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
if (!current) return "approved";
const idx = REVIEW_STATUS_CYCLE.indexOf(current);
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length];
};
/* ============================================================
* ProductCard
* ============================================================ */
@@ -185,7 +148,6 @@ const ProductCard: React.FC<{
onShare: (product: ProductItem) => void;
onDelete: (id: string) => void;
onPublish: (product: ProductItem) => void;
onReviewStatusChange: (id: string) => void;
}> = ({
product,
isSelected,
@@ -196,7 +158,6 @@ const ProductCard: React.FC<{
onShare,
onDelete,
onPublish,
onReviewStatusChange,
}) => {
const st = statusConfig[product.status];
@@ -240,33 +201,6 @@ const ProductCard: React.FC<{
{/* 已发布徽章(右上角) */}
{product.isPublished && <div className="xx-product-badge"> </div>}
{/* 复核状态标签(右上角) */}
{product.reviewStatus && (
<div
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
onClick={(e) => {
e.stopPropagation();
onReviewStatusChange(product.id);
}}
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text}`}
>
{reviewStatusConfig[product.reviewStatus].text}
</div>
)}
{/* 无复核状态时显示"待复核"入口 */}
{!product.reviewStatus && (
<div
className="xx-product-review-tag review-pending"
onClick={(e) => {
e.stopPropagation();
onReviewStatusChange(product.id);
}}
title="点击设置复核状态"
>
</div>
)}
{/* 缩略图 */}
<div className="xx-product-thumb">
{product.thumbnailUrl ? (
@@ -583,7 +517,7 @@ const ProductLibrary: React.FC = () => {
refetch,
} = useQuery<ApiProductItem[], Error>({
queryKey: ["products"],
queryFn: () => getProducts(),
queryFn: getProducts,
staleTime: 30_000,
});
@@ -602,26 +536,11 @@ const ProductLibrary: React.FC = () => {
},
});
/* ── 复核状态 mutation ── */
const reviewMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
updateReviewStatus(id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
message.success("复核状态已更新");
},
onError: () => {
message.error("更新复核状态失败");
},
});
/* 筛选 */
const [searchText, setSearchText] = useState("");
const [filterStatus, setFilterStatus] = useState<string>("all");
const [filterTime, setFilterTime] = useState<string>("all");
const [filterDuration, setFilterDuration] = useState<string>("all");
const [filterProject, setFilterProject] = useState<string>("all");
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all");
/* 批量操作 */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -677,20 +596,6 @@ const ProductLibrary: React.FC = () => {
});
}
/* 项目筛选 */
if (filterProject !== "all") {
list = list.filter((p) => p.projectId === filterProject);
}
/* 复核状态筛选 */
if (filterReviewStatus !== "all") {
if (filterReviewStatus === "none") {
list = list.filter((p) => !p.reviewStatus);
} else {
list = list.filter((p) => p.reviewStatus === filterReviewStatus);
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase();
@@ -698,15 +603,7 @@ const ProductLibrary: React.FC = () => {
}
return list;
}, [
products,
filterStatus,
filterTime,
filterDuration,
filterProject,
filterReviewStatus,
searchText,
]);
}, [products, filterStatus, filterTime, filterDuration, searchText]);
/* 全选 */
const allSelected =
@@ -782,56 +679,24 @@ const ProductLibrary: React.FC = () => {
message.info("发布功能待后端 API 补齐");
};
/* 切换复核状态 */
const handleReviewStatusChange = (id: string) => {
const current = products.find((p) => p.id === id)?.reviewStatus;
const nextStatus = getNextReviewStatus(current);
reviewMutation.mutate({ id, status: nextStatus });
};
/* 批量下载 — 使用 batch-download API + 轮询 */
const [batchDownloading, setBatchDownloading] = useState(false);
/* 批量下载 */
const handleBatchDownload = async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
setBatchDownloading(true);
try {
// 发起批量下载任务
const { job_id } = await batchDownload(ids);
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`);
// 轮询下载状态(最多 60 次,每次 2 秒)
let attempts = 0;
const maxAttempts = 60;
const poll = async (): Promise<void> => {
if (attempts >= maxAttempts) {
message.warning("打包超时,请稍后在消息中心查看");
return;
}
attempts++;
await new Promise((r) => setTimeout(r, 2000));
const status = await getBatchDownloadStatus(job_id);
if (status.status === "completed" && status.download_url) {
const a = document.createElement("a");
a.href = status.download_url;
a.download = "";
a.click();
message.success(`已打包下载 ${ids.length} 个视频`);
setSelectedIds(new Set());
} else if (status.status === "failed") {
message.error("批量下载失败,请重试");
} else {
// 继续轮询
await poll();
}
};
await poll();
} catch {
message.error("发起批量下载失败");
} finally {
setBatchDownloading(false);
let successCount = 0;
for (const id of ids) {
try {
const { url } = await getProductDownloadUrl(id);
const a = document.createElement("a");
a.href = url;
a.download = "";
a.click();
successCount++;
} catch {
// 忽略单个失败
}
}
message.success(`已下载 ${successCount}/${ids.length} 个视频`);
setSelectedIds(new Set());
};
/* 批量删除 */
@@ -958,9 +823,8 @@ const ProductLibrary: React.FC = () => {
buttonSize="sm"
icon={<DownloadOutlined />}
onClick={handleBatchDownload}
disabled={batchDownloading}
>
{batchDownloading ? "打包中..." : "批量下载"}
</Button>
<Button
buttonType="primary"
@@ -1040,36 +904,6 @@ const ProductLibrary: React.FC = () => {
{ value: "long", label: ">3分钟" },
]}
/>
<Select
value={filterProject}
onChange={setFilterProject}
style={{ width: 140 }}
options={[
{ value: "all", label: "全部项目" },
...Array.from(
new Map(
products
.filter((p) => p.projectId && p.projectName)
.map((p) => [p.projectId!, p.projectName!] as const),
),
).map(([id, name]) => ({
value: id as string,
label: name as string,
})),
]}
/>
<Select
value={filterReviewStatus}
onChange={setFilterReviewStatus}
style={{ width: 130 }}
options={[
{ value: "all", label: "全部复核" },
{ value: "none", label: "未设置" },
{ value: "pending_review", label: "待复核" },
{ value: "approved", label: "已通过" },
{ value: "rejected", label: "需修改" },
]}
/>
</div>
<div className="xx-products-filters-right">
<span
@@ -1098,7 +932,6 @@ const ProductLibrary: React.FC = () => {
onShare={handleShare}
onDelete={handleDelete}
onPublish={handlePublish}
onReviewStatusChange={handleReviewStatusChange}
/>
))}
</div>
-36
View File
@@ -222,42 +222,6 @@
backdrop-filter: blur(4px);
}
/* 复核状态标签(右上角,位于已发布徽章下方) */
.xx-product-review-tag {
position: absolute;
top: 34px;
right: 10px;
z-index: 2;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
backdrop-filter: blur(4px);
cursor: pointer;
transition: var(--transition-all);
user-select: none;
}
.xx-product-review-tag:hover {
transform: scale(1.05);
filter: brightness(1.1);
}
.xx-product-review-tag.review-pending {
background: rgba(156, 163, 175, 0.9);
color: var(--text-inverse);
}
.xx-product-review-tag.review-approved {
background: rgba(16, 185, 129, 0.9);
color: var(--text-inverse);
}
.xx-product-review-tag.review-rejected {
background: rgba(239, 68, 68, 0.9);
color: var(--text-inverse);
}
/* 缩略图区域 */
.xx-product-thumb {
aspect-ratio: 9 / 16;
-450
View File
@@ -1,450 +0,0 @@
/**
*
*
*/
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Table,
Tabs,
Select,
Tag,
Button,
message,
Popconfirm,
Tooltip,
} from "antd";
import {
CheckCircleOutlined,
ClockCircleOutlined,
SyncOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
MinusCircleOutlined,
RedoOutlined,
InfoCircleOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getTasks,
retryTask,
type TaskItem,
type TaskStatus,
type TaskListParams,
} from "@/api/tasks";
import "./tasks.css";
/* ──────────── 常量 ──────────── */
/** 状态 Tab 配置 */
const STATUS_TABS: { key: TaskStatus | "all"; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "waiting", label: "等待中" },
{ key: "running", label: "进行中" },
{ key: "completed", label: "已完成" },
{ key: "failed", label: "失败" },
{ key: "cancelled", label: "已取消" },
];
/** 类型筛选选项 */
const TYPE_OPTIONS = [
{ value: "all", label: "全部类型" },
{ value: "generation", label: "生成任务" },
{ value: "ingest", label: "素材导入" },
];
/** 状态标签配置 */
const STATUS_CONFIG: Record<
TaskStatus,
{ label: string; color: string; icon: React.ReactNode }
> = {
pending: {
label: "等待中",
color: "default",
icon: <ClockCircleOutlined />,
},
waiting: {
label: "排队中",
color: "processing",
icon: <SyncOutlined spin />,
},
running: {
label: "进行中",
color: "processing",
icon: <SyncOutlined spin />,
},
completed: {
label: "已完成",
color: "success",
icon: <CheckCircleOutlined />,
},
failed: {
label: "失败",
color: "error",
icon: <CloseCircleOutlined />,
},
cancelled: {
label: "已取消",
color: "default",
icon: <MinusCircleOutlined />,
},
};
/** 任务类型标签 */
const TYPE_LABELS: Record<string, { label: string; color: string }> = {
generation: { label: "生成任务", color: "blue" },
ingest: { label: "素材导入", color: "green" },
};
/* ──────────── 工具函数 ──────────── */
/** 格式化耗时 */
const formatDuration = (seconds?: number): string => {
if (!seconds) return "-";
if (seconds < 60) return `${Math.round(seconds)}`;
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
if (minutes < 60) return `${minutes}${secs}`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}小时${mins}`;
};
/** 格式化时间 */
const formatTime = (dateStr?: string | null): string => {
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
/* ──────────── 主组件 ──────────── */
export default function TaskCenter() {
const queryClient = useQueryClient();
// 筛选状态
const [statusFilter, setStatusFilter] = useState<TaskStatus | "all">("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
const [expandedTaskDetail, setExpandedTaskDetail] = useState<TaskItem | null>(
null,
);
// 查询参数
const queryParams: TaskListParams = {
page,
page_size: pageSize,
...(statusFilter !== "all" && { status: statusFilter }),
...(typeFilter !== "all" && { task_type: typeFilter }),
};
// 获取任务列表
const { data, isLoading, error } = useQuery({
queryKey: ["tasks", queryParams],
queryFn: () => getTasks(queryParams),
refetchInterval: (query) => {
// 有进行中的任务时自动刷新
const tasks = query.state.data?.items ?? [];
const hasRunning = tasks.some(
(t) => t.status === "running" || t.status === "waiting",
);
return hasRunning ? 5000 : false;
},
});
// 重试任务
const retryMutation = useMutation({
mutationFn: retryTask,
onSuccess: () => {
message.success("任务已重新提交");
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
onError: () => {
message.error("重试失败,请检查任务状态");
},
});
// 展开查看详情
const handleExpand = async (expanded: boolean, record: TaskItem) => {
if (!expanded) {
setExpandedTaskId(null);
setExpandedTaskDetail(null);
return;
}
setExpandedTaskId(record.id);
// 如果是失败任务,获取详情(含 error_info
if (record.status === "failed" && record.error_info) {
setExpandedTaskDetail(record);
}
};
// 表格列定义
const columns: ColumnsType<TaskItem> = [
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 120,
ellipsis: true,
render: (id: string) => (
<Tooltip title={id}>
<span className="task-id">{id.slice(0, 8)}...</span>
</Tooltip>
),
},
{
title: "类型",
dataIndex: "task_type",
key: "task_type",
width: 100,
render: (type: string) => {
const config = TYPE_LABELS[type] || { label: type, color: "default" };
return <Tag color={config.color}>{config.label}</Tag>;
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: TaskStatus, record: TaskItem) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
};
return (
<Tag
color={config.color}
icon={config.icon}
className="task-status-tag"
>
{config.label}
{status === "running" && record.progress > 0 && (
<span className="task-progress"> {record.progress}%</span>
)}
</Tag>
);
},
},
{
title: "当前步骤",
dataIndex: "current_step",
key: "current_step",
width: 150,
ellipsis: true,
render: (step: string) => (
<span className="task-step">{step || "-"}</span>
),
},
{
title: "耗时",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 100,
render: (seconds: number) => (
<span className="task-duration">{formatDuration(seconds)}</span>
),
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 120,
render: (time: string) => (
<span className="task-time">{formatTime(time)}</span>
),
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right",
render: (_: unknown, record: TaskItem) => {
if (record.status === "failed" && record.retryable) {
return (
<Popconfirm
title="确认重试"
description="确定要重试这个失败的任务吗?"
onConfirm={() => retryMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryMutation.isPending}
className="task-retry-btn"
>
</Button>
</Popconfirm>
);
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => {
setExpandedTaskId(record.id);
setExpandedTaskDetail(record);
}}
>
</Button>
);
}
return <span className="task-action-placeholder">-</span>;
},
},
];
// 展开行渲染(错误详情)
const expandedRowRender = (record: TaskItem) => {
const detail = expandedTaskDetail || record;
const errorInfo = detail.error_info;
if (!errorInfo && !detail.error_message) {
return <div className="task-expand-empty"></div>;
}
return (
<div className="task-error-detail">
<div className="task-error-header">
<ExclamationCircleOutlined className="task-error-icon" />
<span></span>
</div>
<div className="task-error-body">
{errorInfo?.error_type && (
<div className="task-error-row">
<span className="task-error-label"></span>
<Tag color="error">{errorInfo.error_type}</Tag>
</div>
)}
{(errorInfo?.error_message || detail.error_message) && (
<div className="task-error-row">
<span className="task-error-label"></span>
<span className="task-error-message">
{errorInfo?.error_message || detail.error_message}
</span>
</div>
)}
{errorInfo?.failed_step && (
<div className="task-error-row">
<span className="task-error-label"></span>
<span>{errorInfo.failed_step}</span>
</div>
)}
{errorInfo?.stack_trace && (
<div className="task-error-row task-error-stack">
<span className="task-error-label"></span>
<pre>{errorInfo.stack_trace}</pre>
</div>
)}
</div>
</div>
);
};
// 错误处理
if (error) {
return (
<div className="task-center">
<div className="task-error">
<CloseCircleOutlined />
<p></p>
<Button onClick={() => window.location.reload()}></Button>
</div>
</div>
);
}
return (
<div className="task-center">
{/* 页面标题 */}
<div className="task-header">
<h1 className="task-title"></h1>
<p className="task-subtitle"></p>
</div>
{/* 筛选栏 */}
<div className="task-filters">
{/* 状态 Tab */}
<Tabs
activeKey={statusFilter}
onChange={(key) => {
setStatusFilter(key as TaskStatus | "all");
setPage(1);
}}
items={STATUS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
}))}
className="task-status-tabs"
/>
{/* 类型筛选 */}
<div className="task-type-filter">
<Select
value={typeFilter}
onChange={(value) => {
setTypeFilter(value);
setPage(1);
}}
options={TYPE_OPTIONS}
style={{ width: 140 }}
placeholder="选择类型"
/>
</div>
</div>
{/* 任务表格 */}
<Table
columns={columns}
dataSource={data?.items || []}
rowKey="id"
loading={isLoading}
pagination={{
current: page,
pageSize,
total: data?.total || 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
expandable={{
expandedRowRender,
expandedRowKeys: expandedTaskId ? [expandedTaskId] : [],
onExpand: handleExpand,
rowExpandable: (record) =>
record.status === "failed" &&
(!!record.error_info || !!record.error_message),
}}
scroll={{ x: 800 }}
className="task-table"
locale={{
emptyText: (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
),
}}
/>
</div>
);
}
-268
View File
@@ -1,268 +0,0 @@
/* ──────────── 任务中心 ──────────── */
.task-center {
padding: var(--space-lg);
max-width: 1400px;
margin: 0 auto;
}
/* 页面标题 */
.task-header {
margin-bottom: var(--space-lg);
}
.task-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin: 0 0 var(--space-xs) 0;
}
.task-subtitle {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin: 0;
}
/* 筛选栏 */
.task-filters {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-lg);
padding: var(--space-md);
background: var(--bg-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--border-primary);
}
.task-status-tabs {
flex: 1;
}
.task-status-tabs .ant-tabs-nav {
margin-bottom: 0;
}
.task-status-tabs .ant-tabs-tab {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
}
.task-type-filter {
flex-shrink: 0;
margin-left: var(--space-md);
}
/* 表格 */
.task-table {
background: var(--bg-surface);
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--border-primary);
}
.task-table .ant-table {
background: transparent;
}
.task-table .ant-table-thead > tr > th {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.task-table .ant-table-tbody > tr > td {
border-bottom: 1px solid var(--border-light);
}
.task-table .ant-table-tbody > tr:hover > td {
background: var(--bg-hover);
}
/* 任务 ID */
.task-id {
font-family: var(--font-mono, monospace);
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
/* 状态标签 */
.task-status-tag {
display: inline-flex;
align-items: center;
gap: 4px;
}
.task-progress {
font-size: var(--font-size-xs);
opacity: 0.9;
}
/* 当前步骤 */
.task-step {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* 耗时 */
.task-duration {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
/* 时间 */
.task-time {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* 操作按钮 */
.task-retry-btn {
color: var(--primary-500);
}
.task-retry-btn:hover {
color: var(--primary-600);
}
.task-action-placeholder {
color: var(--text-disabled, var(--text-secondary));
font-size: var(--font-size-sm);
}
/* 展开行 - 错误详情 */
.task-error-detail {
padding: var(--space-md);
background: var(--bg-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--border-primary);
}
.task-error-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
font-weight: var(--font-weight-semibold);
color: var(--error-500, #ef4444);
}
.task-error-icon {
font-size: var(--font-size-lg);
}
.task-error-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.task-error-row {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
font-size: var(--font-size-sm);
}
.task-error-label {
color: var(--text-secondary);
flex-shrink: 0;
min-width: 80px;
}
.task-error-message {
color: var(--error-500, #ef4444);
word-break: break-all;
}
.task-error-stack {
flex-direction: column;
}
.task-error-stack pre {
margin: var(--space-xs) 0 0 0;
padding: var(--space-sm);
background: var(--bg-surface);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
color: var(--text-secondary);
overflow-x: auto;
max-height: 200px;
font-family: var(--font-mono, monospace);
}
.task-expand-empty {
padding: var(--space-md);
text-align: center;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* 空状态 */
.task-empty {
padding: var(--space-2xl) 0;
text-align: center;
color: var(--text-secondary);
}
.task-empty .anticon {
font-size: 48px;
margin-bottom: var(--space-md);
opacity: 0.5;
}
.task-empty p {
margin: 0;
font-size: var(--font-size-base);
}
/* 错误状态 */
.task-error {
padding: var(--space-2xl);
text-align: center;
color: var(--error-500, #ef4444);
}
.task-error .anticon {
font-size: 48px;
margin-bottom: var(--space-md);
}
.task-error p {
margin: 0 0 var(--space-md) 0;
font-size: var(--font-size-base);
}
/* 响应式 */
@media (max-width: 768px) {
.task-center {
padding: var(--space-md);
}
.task-filters {
flex-direction: column;
gap: var(--space-md);
}
.task-type-filter {
margin-left: 0;
width: 100%;
}
.task-type-filter .ant-select {
width: 100% !important;
}
.task-status-tabs .ant-tabs-nav {
overflow-x: auto;
}
.task-status-tabs .ant-tabs-tab {
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-xs);
}
}
+318 -421
View File
@@ -1,42 +1,106 @@
/**
* V21
* API
* - page/page_size/category/keyword/duration_range
* - BGM
* - /
* - + + +
* 2.13 EditPlan
*
* - EditTemplate
* - +
* - EditPlan UI
* - 使 useQuery APIapi/templates.ts, api/editPlans.ts
*/
import React, { useState, useMemo, useCallback } from "react";
import React, { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, message, Pagination, Tooltip, Tag, Descriptions } from "antd";
import { Button } from "@/components/ui";
import {
LoadingOutlined,
ExclamationCircleOutlined,
InboxOutlined,
SearchOutlined,
CopyOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import {
getTemplates,
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
generateFromTemplate,
type TemplateItem,
type TemplateListParams,
type TemplateSegment,
} from "@/api/templates";
import { createEditPlan } from "@/api/editPlans";
import "./templates.css";
/* ============================================================
*
* EditTemplate / EditPlan / TemplateClipConfig
* ============================================================ */
/** 模板片段配置 */
interface TemplateClipConfig {
id: string;
order: number;
clipType: string;
description: string;
duration: number; // 秒
}
/** 模板类型 */
type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog";
/** 模板数据(UI 层,映射自后端 TemplateItem */
interface EditTemplate {
id: string;
name: string;
type: EditTemplateType;
description: string;
usageCount: number;
isFavorite: boolean;
thumbnailGradient: string;
scriptContent: string;
clipConfigs: TemplateClipConfig[];
recommendedDuration: number; // 秒
tags: string[];
}
/* ============================================================
* TemplateItem EditTemplate
* ============================================================ */
/** 根据 category 推断模板类型 */
const inferTemplateType = (category: string): EditTemplateType => {
const map: Record<string, EditTemplateType> = {
: "口播",
: "种草",
: "产品",
: "品牌",
: "混剪",
Vlog: "Vlog",
};
return map[category] ?? "口播";
};
/** 根据 category 生成占位渐变色 */
const gradientForCategory = (category: string): string => {
const gradients: Record<string, string> = {
: "linear-gradient(135deg, #6366f1, #8b5cf6)",
: "linear-gradient(135deg, #10b981, #059669)",
: "linear-gradient(135deg, #0ea5e9, #0284c7)",
: "linear-gradient(135deg, #f59e0b, #d97706)",
: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
};
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
};
/** 将后端 TemplateItem 映射为前端 EditTemplate */
const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
id: item.id,
name: item.name,
type: inferTemplateType(item.category),
description: item.description ?? "",
usageCount: 0,
isFavorite: item.is_favorite ?? false,
thumbnailGradient: gradientForCategory(item.category),
scriptContent: "",
clipConfigs: [],
recommendedDuration: item.target_duration ?? 0,
tags: [item.category],
});
/* ============================================================
*
* ============================================================ */
@@ -56,116 +120,98 @@ const TEMPLATE_TYPES: Array<{
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
];
/** 时长筛选选项 */
const DURATION_OPTIONS: Array<{
value: "" | "short" | "medium" | "long";
label: string;
}> = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
];
/* ============================================================
*
* ============================================================ */
/** 获取类型对应颜色 */
const getTypeColor = (type: string): string => {
const getTypeColor = (type: EditTemplateType): string => {
const found = TEMPLATE_TYPES.find((t) => t.type === type);
return found?.color ?? "#6366f1";
};
/** 根据 category 生成占位渐变色 */
const gradientForCategory = (category: string): string => {
const gradients: Record<string, string> = {
: "linear-gradient(135deg, #6366f1, #8b5cf6)",
: "linear-gradient(135deg, #10b981, #059669)",
: "linear-gradient(135deg, #0ea5e9, #0284c7)",
: "linear-gradient(135deg, #f59e0b, #d97706)",
: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
/** 获取片段类型标签 */
const getClipTypeLabel = (clipType: string): string => clipType;
/** 获取片段类型颜色 */
const getClipTypeColor = (clipType: string): string => {
const colorMap: Record<string, string> = {
: "#6366f1",
: "#0ea5e9",
: "#10b981",
: "#f59e0b",
: "#8b5cf6",
: "#ec4899",
: "#14b8a6",
: "#f59e0b",
: "#6366f1",
: "#0ea5e9",
: "#10b981",
: "#64748b",
: "#f59e0b",
: "#0ea5e9",
: "#8b5cf6",
: "#10b981",
1: "#6366f1",
2: "#8b5cf6",
: "#ef4444",
: "#10b981",
使: "#0ea5e9",
: "#f59e0b",
: "#6366f1",
: "#0ea5e9",
: "#10b981",
: "#f59e0b",
: "#6366f1",
: "#10b981",
: "#f59e0b",
: "#0ea5e9",
: "#ef4444",
: "#64748b",
: "#ec4899",
: "#f59e0b",
: "#10b981",
: "#0ea5e9",
: "#8b5cf6",
};
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
return colorMap[clipType] ?? "#6366f1";
};
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
if (seconds <= 0) return "0秒";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m === 0) return `${s}`;
return `${m}${s > 0 ? `${s}` : ""}`;
};
/** 配置展示字段(formatConfig 提取通用配置的可读属性) */
interface ConfigDisplayFields {
font_size?: string | number;
font_family?: string;
color?: string;
position?: string;
volume?: string | number;
name?: string;
}
/** 格式化配置对象为可读文本 */
const formatConfig = (config?: object): string => {
if (!config || Object.keys(config).length === 0) return "默认";
const c = config as ConfigDisplayFields;
const parts: string[] = [];
if (c.font_size) parts.push(`字号: ${c.font_size}`);
if (c.font_family) parts.push(`字体: ${c.font_family}`);
if (c.color) parts.push(`颜色: ${c.color}`);
if (c.position) parts.push(`位置: ${c.position}`);
if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`);
if (c.name) parts.push(String(c.name));
return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config);
};
/** 素材类型标签 */
const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
subtitle: "字幕",
null: "不限",
};
/* ============================================================
*
*
* ============================================================ */
interface TemplateDetailModalProps {
template: TemplateItem;
interface TemplatePreviewModalProps {
template: EditTemplate;
isFavorite: boolean;
onClose: () => void;
onToggleFavorite: (id: string) => void;
onUse: (template: TemplateItem) => void;
onCopy: (template: TemplateItem) => void;
onUse: (template: EditTemplate) => void;
}
const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
template,
isFavorite,
onClose,
onToggleFavorite,
onUse,
onCopy,
}) => {
const segments = template.segments ?? [];
const totalSegmentDuration = segments.reduce(
(sum, s) => sum + (s.duration_min + s.duration_max) / 2,
const totalDuration = template.clipConfigs.reduce(
(sum, c) => sum + c.duration,
0,
);
return (
<div className="xx-template-modal-overlay" onClick={onClose}>
<div
className="xx-template-modal xx-template-modal-wide"
onClick={(e) => e.stopPropagation()}
>
<div className="xx-template-modal" onClick={(e) => e.stopPropagation()}>
{/* 关闭按钮 */}
<button
className="xx-template-modal-close"
@@ -178,23 +224,15 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
{/* 预览区域 */}
<div
className="xx-template-modal-preview"
style={{ background: gradientForCategory(template.category) }}
style={{ background: template.thumbnailGradient }}
>
{template.thumbnail_url ? (
<img
src={template.thumbnail_url}
alt={template.name}
className="xx-template-modal-thumb-img"
/>
) : (
<div className="xx-template-modal-preview-content">
<span className="xx-template-preview-icon">
{TEMPLATE_TYPES.find((t) => t.type === template.category)
?.icon ?? "📋"}
</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
)}
<div className="xx-template-modal-preview-content">
<span className="xx-template-preview-icon">
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon ??
"📋"}
</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
</div>
{/* 内容区域 */}
@@ -205,12 +243,12 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
<span
className="xx-template-modal-type-badge"
style={{
color: getTypeColor(template.category),
background: `${getTypeColor(template.category)}18`,
color: getTypeColor(template.type),
background: `${getTypeColor(template.type)}18`,
}}
>
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon}{" "}
{template.category}
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon}{" "}
{template.type}
</span>
</div>
@@ -218,128 +256,66 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
<p className="xx-template-modal-desc">{template.description}</p>
{/* 标签 */}
{(template.tags?.length ?? 0) > 0 && (
<div className="xx-template-modal-tags">
{template.tags!.map((tag) => (
<span key={tag} className="xx-template-modal-tag">
#{tag}
</span>
))}
<div className="xx-template-modal-tags">
{template.tags.map((tag) => (
<span key={tag} className="xx-template-modal-tag">
#{tag}
</span>
))}
</div>
{/* 脚本内容 */}
{template.scriptContent && (
<div className="xx-template-modal-section">
<h4>📝 </h4>
<pre className="xx-template-modal-script">
{template.scriptContent}
</pre>
</div>
)}
{/* 基本信息 */}
<Descriptions
column={2}
size="small"
className="xx-template-modal-desc-table"
items={[
{
key: "duration",
label: "目标时长",
children: formatDuration(template.target_duration),
},
{
key: "clips",
label: "片段数量",
children: `${template.clip_count}`,
},
{
key: "ratio",
label: "视频比例",
children: template.aspect_ratio ?? "16:9",
},
{
key: "usage",
label: "使用次数",
children: `${template.usage_count ?? 0}`,
},
]}
/>
{/* 素材规则(片段配置) */}
{segments.length > 0 && (
{/* 视频结构 */}
{template.clipConfigs.length > 0 && (
<div className="xx-template-modal-section">
<h4>🎬 </h4>
<h4>🎬 </h4>
<div className="xx-template-modal-clip-list">
{segments
.sort((a, b) => a.segment_order - b.segment_order)
.map((seg: TemplateSegment, idx: number) => (
<div
key={seg.id ?? idx}
className="xx-template-modal-clip-item"
>
<span className="xx-template-modal-clip-order">
#{seg.segment_order}
</span>
{template.clipConfigs
.sort((a, b) => a.order - b.order)
.map((clip) => (
<div key={clip.id} className="xx-template-modal-clip-item">
<span
className="xx-template-modal-clip-badge"
style={{
color: seg.material_type
? getTypeColor(seg.material_type)
: "#64748b",
background: seg.material_type
? `${getTypeColor(seg.material_type)}18`
: "#f1f5f9",
color: getClipTypeColor(clip.clipType),
background: `${getClipTypeColor(clip.clipType)}18`,
}}
>
{MATERIAL_TYPE_LABELS[seg.material_type ?? "null"] ??
seg.material_type ??
"不限"}
{getClipTypeLabel(clip.clipType)}
</span>
<span className="xx-template-modal-clip-desc">
{seg.description || `片段 ${seg.segment_order}`}
{clip.description}
</span>
<span className="xx-template-modal-clip-duration">
{clip.duration}
</span>
<Tooltip
title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}`}
>
<span className="xx-template-modal-clip-duration">
{seg.duration_min}-{seg.duration_max}
</span>
</Tooltip>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(Math.round(totalSegmentDuration))}
{formatDuration(totalDuration)}
{template.recommendedDuration !== totalDuration && (
<span>
{" "}
· {formatDuration(template.recommendedDuration)}
</span>
)}
</div>
</div>
)}
{/* 样式配置 */}
<div className="xx-template-modal-section">
<h4>🎨 </h4>
<div className="xx-template-modal-style-grid">
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{formatConfig(template.subtitle_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{formatConfig(template.title_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label">BGM </span>
<span className="xx-template-modal-style-value">
{formatConfig(template.bgm_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{template.aspect_ratio ?? "16:9"}
</span>
</div>
</div>
</div>
{/* 统计信息 */}
<div className="xx-template-modal-stats">
<span>使 {template.usage_count ?? 0} </span>
<span>使 {template.usageCount} </span>
<button
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={() => onToggleFavorite(template.id)}
@@ -350,15 +326,15 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
{/* 操作按钮 */}
<div className="xx-template-modal-actions">
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
<Button buttonType="ghost" buttonSize="md" onClick={onClose}>
</Button>
<Button
type="primary"
icon={<ThunderboltOutlined />}
buttonType="primary"
buttonSize="md"
onClick={() => onUse(template)}
>
使
使
</Button>
</div>
</div>
@@ -372,11 +348,11 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
* ============================================================ */
interface TemplateCardProps {
template: TemplateItem;
template: EditTemplate;
isFavorite: boolean;
onPreview: (template: TemplateItem) => void;
onPreview: (template: EditTemplate) => void;
onToggleFavorite: (id: string, e: React.MouseEvent) => void;
onUse: (template: TemplateItem) => void;
onUse: (template: EditTemplate) => void;
}
const TemplateCard: React.FC<TemplateCardProps> = ({
@@ -390,29 +366,15 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="xx-template-card" onClick={() => onPreview(template)}>
{/* 缩略图 */}
<div className="xx-template-thumb">
{template.thumbnail_url ? (
<img
src={template.thumbnail_url}
alt={template.name}
className="xx-template-thumb-img"
/>
) : (
<div
className="xx-template-thumb-bg"
style={{ background: gradientForCategory(template.category) }}
>
{(template.description ?? "").slice(0, 80)}
{(template.description ?? "").length > 80 ? "..." : ""}
</div>
)}
<div
className="xx-template-thumb-bg"
style={{ background: template.thumbnailGradient }}
>
{(template.description ?? "").slice(0, 80)}...
</div>
<div className="xx-template-thumb-overlay" />
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-thumb-meta">
<span className="xx-template-thumb-duration">
{formatDuration(template.target_duration)}
</span>
</div>
<div className="xx-template-preview-hint"></div>
<div className="xx-template-preview-hint"></div>
<button
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={(e) => onToggleFavorite(template.id, e)}
@@ -428,22 +390,17 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
<span
className="xx-template-category-pill"
style={{
color: getTypeColor(template.category),
background: `${getTypeColor(template.category)}18`,
color: getTypeColor(template.type),
background: `${getTypeColor(template.type)}18`,
}}
>
{template.category}
{template.type}
</span>
{(template.tags ?? []).slice(0, 2).map((tag) => (
<Tag key={tag} className="xx-template-tag-pill" bordered={false}>
{tag}
</Tag>
))}
</div>
<p className="xx-template-desc">{template.description ?? ""}</p>
<div className="xx-template-meta">
<span className="xx-template-usage">
使 {template.usage_count ?? 0}
使 {template.usageCount}
</span>
<button
className="xx-template-use-btn"
@@ -467,158 +424,98 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
const TemplateLibrary: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
// 筛选状态
const [searchText, setSearchText] = useState("");
const [activeType, setActiveType] = useState<EditTemplateType | "全部">(
"全部",
);
const [durationRange, setDurationRange] = useState<
"" | "short" | "medium" | "long"
>("");
const [page, setPage] = useState(1);
const [pageSize] = useState(12);
// 弹窗状态
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(
const [previewTemplate, setPreviewTemplate] = useState<EditTemplate | null>(
null,
);
const [detailLoading, setDetailLoading] = useState(false);
// ── 构建查询参数 ──
const queryParams: TemplateListParams = useMemo(() => {
const params: TemplateListParams = {
page,
page_size: pageSize,
};
if (activeType !== "全部") params.category = activeType;
if (searchText.trim()) params.keyword = searchText.trim();
if (durationRange) params.duration_range = durationRange;
return params;
}, [page, pageSize, activeType, searchText, durationRange]);
// ── 获取模板列表(后端分页 + 筛选) ──
// ── 获取模板列表 ──
const {
data: templateData,
data: apiTemplates = [],
isLoading,
isError,
error,
} = useQuery({
queryKey: ["templates", queryParams],
queryFn: () => getTemplates(queryParams),
staleTime: 30_000,
} = useQuery<TemplateItem[], Error>({
queryKey: ["templates"],
queryFn: getTemplates,
staleTime: 60_000,
});
const templates = templateData?.items ?? [];
const totalTemplates = templateData?.total ?? 0;
// 将后端数据映射为前端 EditTemplate
const templates = useMemo(
() => apiTemplates.map(mapTemplateItemToEditTemplate),
[apiTemplates],
);
// ── 收藏 mutation ──
const favMutation = useMutation({
mutationFn: toggleFavoriteTemplate,
onSuccess: (_data, templateId) => {
// 乐观更新:刷新模板列表
queryClient.invalidateQueries({ queryKey: ["templates"] });
// 同时更新当前预览(如果有)
if (previewTemplate && previewTemplate.id === templateId) {
setPreviewTemplate((prev) =>
prev ? { ...prev, is_favorite: !prev.is_favorite } : prev,
prev ? { ...prev, isFavorite: !prev.isFavorite } : prev,
);
}
},
});
// ── 复制模板 mutation ──
const copyMutation = useMutation({
mutationFn: copyTemplate,
onSuccess: (data) => {
message.success(`模板「${data.name}」已复制到「我的模板」`);
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
onError: () => {
message.error("复制模板失败,请稍后重试");
},
});
// ── 从模板生成剪辑计划 mutation ──
const generateMutation = useMutation({
mutationFn: ({ templateId, name }: { templateId: string; name: string }) =>
generateFromTemplate(templateId, { name }),
onSuccess: (data) => {
message.success(`剪辑计划「${data.name}」已创建`);
navigate("/app/edit-plans");
},
onError: () => {
message.error("生成剪辑计划失败,请稍后重试");
},
// ── 创建 EditPlan mutation ──
const createPlanMutation = useMutation({
mutationFn: (params: { template_id: string; name: string }) =>
createEditPlan(params),
});
/** 切换收藏 */
const toggleFavorite = useCallback(
(id: string, e?: React.MouseEvent) => {
e?.stopPropagation();
favMutation.mutate(id);
},
[favMutation],
);
const toggleFavorite = (id: string, e?: React.MouseEvent) => {
e?.stopPropagation();
favMutation.mutate(id);
};
/** 点击卡片 → 获取详情并展示弹窗 */
const handlePreview = useCallback(async (template: TemplateItem) => {
setDetailLoading(true);
setPreviewTemplate(template);
try {
const detail = await getTemplate(template.id);
setPreviewTemplate(detail);
} catch {
// 详情加载失败时使用列表数据
message.warning("模板详情加载失败,显示摘要信息");
} finally {
setDetailLoading(false);
/** 过滤模板 */
const filtered = useMemo(() => {
return templates.filter((t) => {
const matchSearch =
!searchText ||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
(t.description ?? "")
.toLowerCase()
.includes(searchText.toLowerCase()) ||
t.tags.some((tag) =>
tag.toLowerCase().includes(searchText.toLowerCase()),
);
const matchType = activeType === "全部" || t.type === activeType;
return matchSearch && matchType;
});
}, [searchText, activeType, templates]);
/** 按类型分组 */
const groupedTemplates = useMemo(() => {
const groups: Record<string, EditTemplate[]> = {};
for (const tpl of filtered) {
if (!groups[tpl.type]) groups[tpl.type] = [];
groups[tpl.type].push(tpl);
}
}, []);
return groups;
}, [filtered]);
/** 复制模板 */
const handleCopy = useCallback(
(template: TemplateItem) => {
copyMutation.mutate(template.id);
},
[copyMutation],
);
/** 使用模板 → 生成剪辑计划 */
const handleUse = useCallback(
(template: TemplateItem) => {
generateMutation.mutate({
templateId: template.id,
/** 使用模板 → 创建 EditPlan */
const handleUseTemplate = async (template: EditTemplate) => {
try {
await createPlanMutation.mutateAsync({
template_id: template.id,
name: `基于「${template.name}」的剪辑计划`,
});
},
[generateMutation, navigate],
);
/** 搜索防抖处理 */
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value);
setPage(1);
},
[],
);
/** 切换分类 */
const handleCategoryChange = useCallback(
(type: EditTemplateType | "全部") => {
setActiveType(type);
setPage(1);
},
[],
);
/** 切换时长筛选 */
const handleDurationChange = useCallback(
(value: "" | "short" | "medium" | "long") => {
setDurationRange(value);
setPage(1);
},
[],
);
navigate("/app/editing-planner");
} catch (err) {
console.error("[TemplateLibrary] createEditPlan failed:", err);
}
};
// ── Loading 状态 ──
if (isLoading) {
@@ -657,12 +554,16 @@ const TemplateLibrary: React.FC = () => {
<h2></h2>
<p></p>
</div>
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
<Button
buttonType="primary"
buttonSize="md"
onClick={() => navigate("/app/editing-planner")}
>
+
</Button>
</div>
{/* ── 工具栏:搜索 + 类型按钮组 + 时长筛选 ─────────────── */}
{/* ── 工具栏:搜索 + 类型按钮组 ─────────────────────────── */}
<div className="xx-templates-toolbar">
<div className="xx-templates-search">
<span className="xx-templates-search-icon">
@@ -673,7 +574,7 @@ const TemplateLibrary: React.FC = () => {
type="text"
placeholder="搜索模板名称、描述或标签..."
value={searchText}
onChange={handleSearchChange}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
<div className="xx-templates-categories">
@@ -681,94 +582,90 @@ const TemplateLibrary: React.FC = () => {
<button
key={cat.type}
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
onClick={() => handleCategoryChange(cat.type)}
onClick={() => setActiveType(cat.type)}
>
<span className="xx-templates-cat-icon">{cat.icon}</span>
{cat.label}
</button>
))}
</div>
{/* 时长筛选 */}
<div className="xx-templates-duration-filter">
{DURATION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
onClick={() => handleDurationChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* ── 模板展示区 ────────────────────────────────────────── */}
{templates.length === 0 ? (
{filtered.length === 0 ? (
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon">
<InboxOutlined />
</div>
<h3>
{searchText || activeType !== "全部" || durationRange
{searchText || activeType !== "全部"
? "未找到匹配的模板"
: "暂无模板"}
</h3>
<p>
{searchText || activeType !== "全部" || durationRange
{searchText || activeType !== "全部"
? "试试调整搜索条件或切换类型"
: "点击上方「创建模板」开始创作"}
</p>
</div>
) : activeType === "全部" ? (
/* 全部类型 → 按类型分组展示 */
<div className="xx-templates-grouped">
{Object.entries(groupedTemplates).map(([type, tpls]) => {
const typeConfig = TEMPLATE_TYPES.find((t) => t.type === type);
return (
<div key={type} className="xx-templates-group">
<div className="xx-templates-group-header">
<span className="xx-templates-group-icon">
{typeConfig?.icon ?? "📋"}
</span>
<h3>{type}</h3>
<span className="xx-templates-group-count">
{tpls.length}
</span>
</div>
<div className="xx-templates-grid">
{tpls.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={tpl.isFavorite}
onPreview={setPreviewTemplate}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
/>
))}
</div>
</div>
);
})}
</div>
) : (
<>
<div className="xx-templates-grid">
{templates.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={tpl.is_favorite ?? false}
onPreview={handlePreview}
onToggleFavorite={toggleFavorite}
onUse={handleUse}
/>
))}
</div>
{/* 分页 */}
{totalTemplates > pageSize && (
<div className="xx-templates-pagination">
<Pagination
current={page}
pageSize={pageSize}
total={totalTemplates}
showSizeChanger={false}
showQuickJumper
showTotal={(total) => `${total} 个模板`}
onChange={(p) => setPage(p)}
/>
</div>
)}
</>
/* 单类型 → 平铺网格 */
<div className="xx-templates-grid">
{filtered.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={tpl.isFavorite}
onPreview={setPreviewTemplate}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
/>
))}
</div>
)}
{/* ── 详情弹窗 ──────────────────────────────────────────── */}
{/* ── 预览弹窗 ──────────────────────────────────────────── */}
{previewTemplate && (
<TemplateDetailModal
<TemplatePreviewModal
template={previewTemplate}
isFavorite={previewTemplate.is_favorite ?? false}
isFavorite={previewTemplate.isFavorite}
onClose={() => setPreviewTemplate(null)}
onToggleFavorite={toggleFavorite}
onUse={handleUse}
onCopy={handleCopy}
onUse={handleUseTemplate}
/>
)}
{/* 详情加载中的提示(可选覆盖层) */}
{detailLoading && previewTemplate && (
<div className="xx-template-detail-loading">
<LoadingOutlined /> ...
</div>
)}
</div>
);
};
@@ -416,6 +416,7 @@ const TitleLibrary: React.FC = () => {
[deleteMutation],
);
/* 新建标题 */
const handleCreateTitle = () => {
if (!newTitleContent.trim()) {
@@ -502,6 +503,7 @@ const TitleLibrary: React.FC = () => {
</h4>
<span>{cat.count} </span>
</div>
</div>
</div>
))}
@@ -612,6 +614,8 @@ const TitleLibrary: React.FC = () => {
</div>
</div>
{/* ─── 新建标题弹窗 ─── */}
<AntModal
title="新建标题"
@@ -130,20 +130,12 @@ const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
};
};
/** 配音素材上传元数据(传递给 createAsset 的 metadata */
interface VoiceAssetMetadata {
gender: VoiceGender;
description: string;
duration: number;
[key: string]: unknown;
}
/** 前端表单数据 → 后端 metadata(标签走独立 API,不再写 metadata.style */
const buildMetadata = (data: {
gender: VoiceGender;
description: string;
duration?: number;
}): VoiceAssetMetadata => ({
}): Record<string, unknown> => ({
gender: data.gender,
description: data.description,
duration: data.duration || 0,

Some files were not shown because too many files have changed in this diff Show More