Merge develop → main: 阶段0-3 全量上线(V21 UI + Phase 8 + CosyVoice) #176

Merged
xiaoxia merged 102 commits from develop into main 2026-07-02 19:42:57 +08:00
184 changed files with 43649 additions and 4348 deletions
+8
View File
@@ -42,3 +42,11 @@ OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OSS_ACCESS_KEY_ID=your-access-key-id
OSS_ACCESS_KEY_SECRET=your-access-key-secret
OSS_BUCKET_NAME=xiaoxia-autocut
# ==================== CosyVoice 语音合成配置 ====================
COSYVOICE_API_KEY=your-cosyvoice-api-key
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
COSYVOICE_MODEL=cosyvoice-v1
COSYVOICE_VOICE=longxiaochun
COSYVOICE_SAMPLE_RATE=22050
COSYVOICE_FORMAT=mp3
+8
View File
@@ -41,6 +41,14 @@ OSS_BUCKET_NAME=xiaoxia-autocut
OSS_DIRECT_UPLOAD_MAX_MB=2000
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
# ==================== CosyVoice 语音合成(必须配置)====================
COSYVOICE_API_KEY=CHANGE_ME_COSYVOICE_API_KEY
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
COSYVOICE_MODEL=cosyvoice-v1
COSYVOICE_VOICE=longxiaochun
COSYVOICE_SAMPLE_RATE=22050
COSYVOICE_FORMAT=mp3
# ==================== 生成文件 ====================
GENERATED_FILES_DIR=/app/generated
GENERATED_FILES_URL_PREFIX=/generated-files
@@ -0,0 +1,115 @@
"""phase8 edit template plan
Revision ID: 016
Revises: 015
Create Date: 2026-07-01
"""
import sqlalchemy as sa
from alembic import op
revision = "016"
down_revision = "015"
branch_labels = None
depends_on = None
def upgrade() -> None:
# --- edit_templates: 替换为 Phase 8 新 schema ---
# 删除旧列
op.drop_column("edit_templates", "project_id")
op.drop_column("edit_templates", "target_duration")
op.drop_column("edit_templates", "clip_count")
op.drop_column("edit_templates", "is_active")
op.drop_column("edit_templates", "created_by_user_id")
op.drop_column("edit_templates", "metadata")
# 添加新列
op.add_column(
"edit_templates",
sa.Column("template_type", sa.String(50), nullable=False, server_default="default"),
)
op.add_column(
"edit_templates",
sa.Column("config", sa.JSON(), nullable=False, server_default="{}"),
)
op.add_column(
"edit_templates",
sa.Column("preview_url", sa.String(1000), nullable=False, server_default=""),
)
op.add_column(
"edit_templates",
sa.Column("sort_weight", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"edit_templates",
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
)
# 添加索引
op.create_index("ix_edit_templates_template_type", "edit_templates", ["template_type"])
op.create_index("ix_edit_templates_sort_weight", "edit_templates", ["sort_weight"])
op.create_index("ix_edit_templates_status", "edit_templates", ["status"])
# --- edit_plans: 重建表(在 011 中被删除) ---
op.create_table(
"edit_plans",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("template_id", sa.String(32), nullable=False, index=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="draft", index=True),
sa.Column("total_duration", sa.Float(), nullable=False, server_default="0"),
sa.Column("config", sa.JSON(), nullable=False, server_default="{}"),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("edit_plans")
op.drop_index("ix_edit_templates_status", "edit_templates")
op.drop_index("ix_edit_templates_sort_weight", "edit_templates")
op.drop_index("ix_edit_templates_template_type", "edit_templates")
op.drop_column("edit_templates", "status")
op.drop_column("edit_templates", "sort_weight")
op.drop_column("edit_templates", "preview_url")
op.drop_column("edit_templates", "config")
op.drop_column("edit_templates", "template_type")
# 恢复旧列
op.add_column(
"edit_templates",
sa.Column("project_id", sa.String(32), nullable=False, server_default=""),
)
op.add_column(
"edit_templates",
sa.Column("target_duration", sa.Float(), nullable=False, server_default="30"),
)
op.add_column(
"edit_templates",
sa.Column("clip_count", sa.Integer(), nullable=False, server_default="3"),
)
op.add_column(
"edit_templates",
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"edit_templates",
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
)
op.add_column(
"edit_templates",
sa.Column("metadata", sa.JSON(), nullable=False, server_default="{}"),
)
@@ -0,0 +1,81 @@
"""Phase 8: Create template_clip_configs and edit_plan_clips tables
Revision ID: 017
Revises: 016
Create Date: 2026-07-01
新增两张表:
- template_clip_configs: 模板片段配置(定义模板中每个片段的规则)
- edit_plan_clips: 剪辑计划片段(剪辑计划中的具体片段实例)
"""
from alembic import op
import sqlalchemy as sa
revision = "017"
down_revision = "016"
branch_labels = None
depends_on = None
def upgrade() -> None:
# template_clip_configs: 模板片段配置表
op.create_table(
"template_clip_configs",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("template_id", sa.String(32), nullable=False, index=True),
sa.Column("clip_type", sa.String(20), nullable=False, index=True),
sa.Column("order", sa.Integer, nullable=False),
sa.Column("min_duration", sa.Float, nullable=False, server_default="0.0"),
sa.Column("max_duration", sa.Float, nullable=False, server_default="0.0"),
sa.Column("text_template", sa.Text, nullable=False, server_default=""),
sa.Column("material_requirements", sa.JSON, nullable=False, server_default="{}"),
sa.Column("transition_effect", sa.String(20), nullable=False, server_default="cut"),
sa.Column("config", sa.JSON, nullable=False, server_default="{}"),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
# edit_plan_clips: 剪辑计划片段表
op.create_table(
"edit_plan_clips",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("plan_id", sa.String(32), nullable=False, index=True),
sa.Column("clip_type", sa.String(20), nullable=False, index=True),
sa.Column("order", sa.Integer, nullable=False),
sa.Column("template_clip_config_id", sa.String(32), nullable=False, server_default="", index=True),
sa.Column("asset_id", sa.String(32), nullable=False, server_default="", index=True),
sa.Column("text_content", sa.Text, nullable=False, server_default=""),
sa.Column("start_time", sa.Float, nullable=False, server_default="0.0"),
sa.Column("duration", sa.Float, nullable=False, server_default="0.0"),
sa.Column("transition_effect", sa.String(20), nullable=False, server_default="cut"),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("config", sa.JSON, nullable=False, server_default="{}"),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("edit_plan_clips")
op.drop_table("template_clip_configs")
+54
View File
@@ -0,0 +1,54 @@
"""Phase 8 任务 2.10: Create jobs table for unified async task management
Revision ID: 018
Revises: 017
Create Date: 2026-07-01
新增 jobs 表,用于统一管理异步任务(视频合成、渲染等)的生命周期。
"""
from alembic import op
import sqlalchemy as sa
revision = "018"
down_revision = "017"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"jobs",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("project_id", sa.String(32), nullable=False, index=True),
sa.Column("job_type", sa.String(30), nullable=False, index=True),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("progress", sa.Float, nullable=False, server_default="0.0"),
sa.Column("current_stage", sa.String(200), nullable=False, server_default=""),
sa.Column("payload", sa.JSON, nullable=False, server_default="{}"),
sa.Column("result", sa.JSON, nullable=False, server_default="{}"),
sa.Column("error_message", sa.Text, nullable=False, server_default=""),
sa.Column("retry_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("max_retries", sa.Integer, nullable=False, server_default="3"),
sa.Column("celery_task_id", sa.String(100), nullable=False, server_default=""),
sa.Column("source_id", sa.String(32), nullable=False, server_default="", index=True),
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default="", index=True),
sa.Column("started_at", sa.DateTime, nullable=True),
sa.Column("completed_at", sa.DateTime, nullable=True),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("jobs")
@@ -0,0 +1,52 @@
"""Task 3.05: Create voice_clone_profiles table
Revision ID: 019
Revises: 018
Create Date: 2026-07-02
新增 voice_clone_profiles 表,用于存储音色克隆档案。
"""
from alembic import op
import sqlalchemy as sa
revision = "019"
down_revision = "018"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"voice_clone_profiles",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("description", sa.Text(), nullable=False, server_default=""),
sa.Column("source_audio_url", sa.Text(), nullable=False, server_default=""),
sa.Column("voice_id", sa.String(100), nullable=False, server_default=""),
sa.Column("voice_model", sa.String(100), nullable=False, server_default=""),
sa.Column("language", sa.String(20), nullable=False, server_default="zh-CN"),
sa.Column("gender", sa.String(20), nullable=False, server_default="unknown"),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"),
sa.Column("metadata", sa.JSON(), nullable=False, server_default="{}"),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("voice_clone_profiles")
@@ -0,0 +1,58 @@
"""Task 3.06: Create tts_jobs table
Revision ID: 020
Revises: 019
Create Date: 2026-07-02
新增 tts_jobs 表,用于存储 TTS 合成任务。
"""
from alembic import op
import sqlalchemy as sa
revision = "020"
down_revision = "019"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"tts_jobs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("input_text", sa.Text(), nullable=False),
sa.Column("voice_id", sa.String(100), nullable=False, server_default=""),
sa.Column("voice_model", sa.String(100), nullable=False, server_default=""),
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
sa.Column("voice_clone_profile_id", sa.String(36), nullable=False, server_default=""),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("output_audio_url", sa.Text(), nullable=False, server_default=""),
sa.Column("output_audio_key", sa.String(500), nullable=False, server_default=""),
sa.Column("duration", sa.Float(), nullable=False, server_default="0"),
sa.Column("file_size", sa.Integer(), nullable=False, server_default="0"),
sa.Column("sample_rate", sa.Integer(), nullable=False, server_default="22050"),
sa.Column("format", sa.String(20), nullable=False, server_default="mp3"),
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"),
sa.Column("metadata", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("tts_jobs")
Regular → Executable
+29
View File
@@ -6,9 +6,12 @@ from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.dashboard import router as dashboard_router
from app.api.routes.duplication import router as duplication_router
from app.api.routes.edit_plans import router as edit_plans_router
from app.api.routes.edit_templates import router as edit_templates_router
from app.api.routes.generated_videos import router as generated_videos_router
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.health import router as health_check_router
from app.api.routes.jobs import router as jobs_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.projects import router as projects_router
from app.api.routes.recipes import router as recipes_router
@@ -17,6 +20,8 @@ from app.api.routes.task_center import router as task_center_router
from app.api.routes.templates import router as templates_router
from app.api.routes.titles import router as titles_router
from app.api.routes.upload import router as upload_router
from app.api.routes.tts import router as tts_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
@@ -76,6 +81,10 @@ api_router.include_router(
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
jobs_router,
tags=["Job"],
)
api_router.include_router(
generated_videos_router,
prefix="/generated-videos",
@@ -91,6 +100,11 @@ api_router.include_router(
prefix="/voices",
tags=["VoiceLibrary"],
)
api_router.include_router(
voice_clones_router,
prefix="/voice-clones",
tags=["VoiceClone"],
)
api_router.include_router(
duplication_router,
prefix="/duplication",
@@ -116,3 +130,18 @@ api_router.include_router(
prefix="/dashboard",
tags=["Dashboard"],
)
api_router.include_router(
edit_templates_router,
prefix="/edit-templates",
tags=["EditTemplate"],
)
api_router.include_router(
edit_plans_router,
prefix="/edit-plans",
tags=["EditPlan"],
)
api_router.include_router(
tts_router,
prefix="/tts",
tags=["TTS"],
)
+25 -6
View File
@@ -15,7 +15,7 @@ from app.schemas.duplication import (
DuplicationRecordResponse,
DuplicationUploadResponse,
)
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
from packages.application import (
DeleteDuplicationRecordUseCase,
@@ -29,7 +29,9 @@ from packages.domain.duplication import DuplicationRecord
logger = logging.getLogger(__name__)
router = APIRouter()
router = APIRouter(
tags=["查重"],
)
# 查重功能只接受视频文件
ALLOWED_VIDEO_MIME_TYPES = frozenset(
@@ -199,12 +201,19 @@ async def upload_for_duplication(
@router.get("/records", response_model=list[DuplicationRecordResponse])
def list_duplication_records(
offset: int = Query(0, ge=0, description="分页偏移量"),
limit: int = Query(50, ge=1, le=200, description="每页数量,最大 200"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
duplication_repository: Any = Depends(get_duplication_repository),
) -> list[DuplicationRecordResponse]:
"""获取当前用户的查重记录列表。"""
"""
获取当前用户的查重记录列表。
支持分页:通过 offset 和 limit 参数控制。
返回按创建时间倒序排列的记录。
"""
use_case = ListDuplicationRecordsUseCase(duplication_repository)
records = use_case.execute(authenticated_user.user.id)
records = use_case.execute(user_id=authenticated_user.user.id, offset=offset, limit=limit)
return [_to_record_response(r) for r in records]
@@ -257,7 +266,11 @@ def retry_duplication(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
duplication_repository: Any = Depends(get_duplication_repository),
) -> DuplicationUploadResponse:
"""重新提交查重。"""
"""
重新提交查重。
仅 failed 状态的记录允许重试,其他状态返回 400。
"""
# 检查记录存在且属于当前用户
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
record = detail_uc.execute(record_id)
@@ -268,7 +281,13 @@ def retry_duplication(
)
use_case = RetryDuplicationUseCase(duplication_repository)
updated = use_case.execute(record_id)
try:
updated = use_case.execute(record_id)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
if updated is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
+412
View File
@@ -0,0 +1,412 @@
"""剪辑计划管理 API — Phase 8 模板编排引擎.
RESTful CRUD for EditPlan:
- GET /api/v1/edit-plans 列表(分页 + 状态/模板筛选)
- GET /api/v1/edit-plans/{id} 详情
- POST /api/v1/edit-plans 创建
- PUT /api/v1/edit-plans/{id} 更新(含状态机流转)
- DELETE /api/v1/edit-plans/{id} 删除
- POST /api/v1/edit-plans/{id}/generate 触发剪辑渲染生成(任务 2.05)
- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05)
业务逻辑委托给 EditPlanService 服务层。
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_db_session
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from packages.domain.edit_plan import EditPlan, EditPlanStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
class EditPlanCreateRequest(BaseModel):
"""创建剪辑计划请求体"""
template_id: str = Field(..., min_length=1, max_length=32, description="关联模板 ID")
name: str = Field(..., min_length=1, max_length=200, description="计划名称")
config: dict[str, Any] = Field(default_factory=dict, description="计划配置 (JSON)")
total_duration: float = Field(default=0.0, ge=0.0, description="总时长 (秒)")
class EditPlanUpdateRequest(BaseModel):
"""更新剪辑计划请求体"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="计划名称")
config: Optional[dict[str, Any]] = Field(default=None, description="计划配置 (JSON)")
total_duration: Optional[float] = Field(default=None, ge=0.0, description="总时长 (秒)")
status: Optional[str] = Field(
default=None,
description="目标状态 (通过状态机流转): editing / rendering / completed / failed / draft",
)
class EditPlanResponse(BaseModel):
"""剪辑计划响应体"""
id: str
template_id: str
name: str
status: str
total_duration: float
config: dict[str, Any]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class EditPlanListResponse(BaseModel):
"""剪辑计划列表响应体"""
items: List[EditPlanResponse]
total: int
page: int
page_size: int
class ClipStatusItem(BaseModel):
"""片段生成状态"""
clip_id: str
clip_type: str
order: int
status: str
asset_id: str
text_content: str
duration: float
class EditPlanGenerationStatusResponse(BaseModel):
"""剪辑计划生成进度响应体"""
plan_id: str
plan_status: str
generation_task_id: Optional[str] = None
clips: List[ClipStatusItem]
class EditPlanGenerateResponse(BaseModel):
"""剪辑计划触发生成响应体"""
plan_id: str
plan_status: str
generation_task_id: str
clip_count: int
# ── Helpers ───────────────────────────────────────────────────────────────────
def _to_response(p: EditPlan) -> EditPlanResponse:
return EditPlanResponse(
id=p.id,
template_id=p.template_id,
name=p.name,
status=p.status.value if hasattr(p.status, "value") else p.status,
total_duration=p.total_duration,
config=p.config,
created_at=p.created_at,
updated_at=p.updated_at,
)
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanListResponse)
def list_plans(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
template_id: Optional[str] = Query(default=None, description="按模板 ID 筛选"),
status_filter: Optional[str] = Query(
default=None,
alias="status",
description="按状态筛选: draft / editing / rendering / completed / failed",
),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanListResponse:
"""获取剪辑计划列表(支持分页、按模板/状态筛选)"""
svc = EditPlanService(db)
# 解析状态筛选
status_enum: Optional[EditPlanStatus] = None
if status_filter:
try:
status_enum = EditPlanStatus(status_filter)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"无效的状态值: {status_filter}"
f"可选值: draft, editing, rendering, completed, failed"
),
)
skip = (page - 1) * page_size
plans = svc.list_plans(
template_id=template_id,
status=status_enum,
skip=skip,
limit=page_size,
)
total = svc.count_plans(
template_id=template_id,
status=status_enum,
)
return EditPlanListResponse(
items=[_to_response(p) for p in plans],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{plan_id}", response_model=EditPlanResponse)
def get_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanResponse:
"""获取单个剪辑计划详情"""
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),
)
return _to_response(plan)
@router.post("", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED)
def create_plan(
body: EditPlanCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanResponse:
"""创建剪辑计划"""
svc = EditPlanService(db)
try:
created = svc.create_plan(
template_id=body.template_id,
name=body.name,
config=body.config,
total_duration=body.total_duration,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
logger.info(
"创建剪辑计划: id=%s name=%s by user=%s",
created.id,
created.name,
current_user.user.id,
)
return _to_response(created)
@router.put("/{plan_id}", response_model=EditPlanResponse)
def update_plan(
plan_id: str,
body: EditPlanUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanResponse:
"""更新剪辑计划(支持状态机流转)"""
svc = EditPlanService(db)
# 基础字段更新
try:
if body.name is not None or body.config is not None or body.total_duration is not None:
svc.update_plan(
plan_id,
name=body.name,
config=body.config,
total_duration=body.total_duration,
)
# 状态机流转
if body.status is not None:
try:
target_status = EditPlanStatus(body.status)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"无效的状态值: {body.status}"
f"可选值: draft, editing, rendering, completed, failed"
),
)
svc.transition_status(plan_id, target_status)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
# 返回最新状态
result = svc.get_plan_or_raise(plan_id)
logger.info("更新剪辑计划: id=%s by user=%s", plan_id, current_user.user.id)
return _to_response(result)
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> None:
"""删除剪辑计划"""
svc = EditPlanService(db)
deleted = svc.delete_plan(plan_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
logger.info(
"删除剪辑计划: id=%s by user=%s",
plan_id,
current_user.user.id,
)
# ── 生成相关端点(任务 2.05) ─────────────────────────────────────────────────
@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),
) -> EditPlanGenerateResponse:
"""触发剪辑计划渲染生成
前置条件:计划状态必须为 editing,且至少有一个片段。
流程:
1. 验证计划状态为 editing
2. 将 pending 片段标记为 ready
3. 创建 GenerationTask
4. 调度 Celery 任务 worker.render_edit_plan
5. 将计划状态流转为 rendering
"""
svc = EditPlanService(db)
# 检查是否可生成
can_gen, reason = svc.can_generate(plan_id)
if not can_gen:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=reason,
)
# 将 pending 片段标记为 ready
clip_count = svc.mark_clips_ready(plan_id)
# 创建 GenerationTask
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
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,
)
)
# 将 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,
)
@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),
) -> EditPlanGenerationStatusResponse:
"""查询剪辑计划生成进度
返回计划状态、关联的 GenerationTask ID、以及每个片段的状态。
"""
svc = EditPlanService(db)
gen_status = svc.get_generation_status(plan_id)
plan = gen_status["plan"]
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,
)
+253
View File
@@ -0,0 +1,253 @@
"""模板管理 API — Phase 8 模板编排引擎.
RESTful CRUD for EditTemplate:
- GET /api/v1/edit-templates 列表(分页 + 类型筛选)
- GET /api/v1/edit-templates/{id} 详情
- POST /api/v1/edit-templates 创建(管理员)
- PUT /api/v1/edit-templates/{id} 更新
- DELETE /api/v1/edit-templates/{id} 删除(软删除 → inactive
业务逻辑委托给 EditTemplateService 服务层。
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.services import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
class EditTemplateCreateRequest(BaseModel):
"""创建模板请求体"""
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
description: str = Field(default="", max_length=2000, description="模板描述")
template_type: str = Field(default="default", max_length=50, description="模板类型")
config: dict[str, Any] = Field(default_factory=dict, description="模板配置 (JSON)")
preview_url: str = Field(default="", max_length=500, description="预览地址")
sort_weight: int = Field(default=0, ge=0, le=9999, description="排序权重")
class EditTemplateUpdateRequest(BaseModel):
"""更新模板请求体"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="模板名称")
description: Optional[str] = Field(default=None, max_length=2000, description="模板描述")
template_type: Optional[str] = Field(default=None, max_length=50, description="模板类型")
config: Optional[dict[str, Any]] = Field(default=None, description="模板配置 (JSON)")
preview_url: Optional[str] = Field(default=None, max_length=500, description="预览地址")
sort_weight: Optional[int] = Field(default=None, ge=0, le=9999, description="排序权重")
status: Optional[str] = Field(default=None, description="状态: active / inactive")
class EditTemplateResponse(BaseModel):
"""模板响应体"""
id: str
name: str
description: str
template_type: str
config: dict[str, Any]
preview_url: str
sort_weight: int
status: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class EditTemplateListResponse(BaseModel):
"""模板列表响应体"""
items: List[EditTemplateResponse]
total: int
page: int
page_size: int
# ── Helpers ───────────────────────────────────────────────────────────────────
def _to_response(t: EditTemplate) -> EditTemplateResponse:
return EditTemplateResponse(
id=t.id,
name=t.name,
description=t.description,
template_type=t.template_type,
config=t.config,
preview_url=t.preview_url,
sort_weight=t.sort_weight,
status=t.status.value if hasattr(t.status, "value") else t.status,
created_at=t.created_at,
updated_at=t.updated_at,
)
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("", response_model=EditTemplateListResponse)
def list_templates(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
template_type: Optional[str] = Query(default=None, description="按类型筛选"),
status_filter: Optional[str] = Query(
default=None,
alias="status",
description="按状态筛选: active / inactive",
),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateListResponse:
"""获取模板列表(支持分页、按类型/状态筛选)"""
svc = EditTemplateService(db)
# 解析状态筛选
status_enum: Optional[EditTemplateStatus] = None
if status_filter:
try:
status_enum = EditTemplateStatus(status_filter)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的状态值: {status_filter},可选值: active, inactive",
)
skip = (page - 1) * page_size
templates = svc.list_templates(
template_type=template_type,
status=status_enum,
skip=skip,
limit=page_size,
)
total = svc.count_templates(
template_type=template_type,
status=status_enum,
)
return EditTemplateListResponse(
items=[_to_response(t) for t in templates],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{template_id}", response_model=EditTemplateResponse)
def get_template(
template_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""获取单个模板详情"""
svc = EditTemplateService(db)
try:
template = svc.get_template_or_raise(template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
return _to_response(template)
@router.post("", response_model=EditTemplateResponse, status_code=status.HTTP_201_CREATED)
def create_template(
body: EditTemplateCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""创建模板(管理员)"""
svc = EditTemplateService(db)
try:
created = svc.create_template(
name=body.name,
description=body.description,
template_type=body.template_type,
config=body.config,
preview_url=body.preview_url,
sort_weight=body.sort_weight,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
logger.info("创建模板: id=%s name=%s by user=%s", created.id, created.name, current_user.user.id)
return _to_response(created)
@router.put("/{template_id}", response_model=EditTemplateResponse)
def update_template(
template_id: str,
body: EditTemplateUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""更新模板"""
svc = EditTemplateService(db)
# 解析状态
status_enum: Optional[EditTemplateStatus] = None
if body.status is not None:
try:
status_enum = EditTemplateStatus(body.status)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的状态值: {body.status},可选值: active, inactive",
)
try:
result = svc.update_template(
template_id,
name=body.name,
description=body.description,
template_type=body.template_type,
config=body.config,
preview_url=body.preview_url,
sort_weight=body.sort_weight,
status=status_enum,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
logger.info("更新模板: id=%s by user=%s", template_id, current_user.user.id)
return _to_response(result)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_template(
template_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> None:
"""删除模板(软删除 → 设为 inactive)"""
svc = EditTemplateService(db)
try:
svc.deactivate_template(template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
logger.info("删除模板(软删除): id=%s by user=%s", template_id, current_user.user.id)
+334
View File
@@ -0,0 +1,334 @@
"""Job API 路由 — Phase 8 任务 2.10.
提供统一异步任务管理 RESTful 接口:
- POST /api/v1/jobs 创建任务
- GET /api/v1/jobs/{job_id} 任务详情
- GET /api/v1/projects/{project_id}/jobs 项目任务列表
- GET /api/v1/projects/{project_id}/jobs/stats 任务统计
- PUT /api/v1/jobs/{job_id}/progress 更新进度
- POST /api/v1/jobs/{job_id}/complete 标记完成
- POST /api/v1/jobs/{job_id}/fail 标记失败
- POST /api/v1/jobs/{job_id}/retry 重试任务
- POST /api/v1/jobs/{job_id}/cancel 取消任务
- POST /api/v1/jobs/{job_id}/submit 提交执行
"""
from __future__ import annotations
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_db_session, get_job_repository, get_project_repository
from app.schemas.job import (
CompleteJobRequest,
CreateJobRequest,
FailJobRequest,
JobResponse,
JobStatisticsResponse,
ListJobsResponse,
UpdateProgressRequest,
job_to_response,
)
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from packages.application.jobs import (
CancelJobUseCase,
CompleteJobCommand,
CompleteJobUseCase,
CreateJobCommand,
CreateJobUseCase,
FailJobCommand,
FailJobUseCase,
GetJobStatisticsUseCase,
GetJobUseCase,
ListJobsUseCase,
RetryJobUseCase,
SubmitJobUseCase,
UpdateJobProgressCommand,
UpdateJobProgressUseCase,
)
from packages.domain.job import JobType
logger = logging.getLogger(__name__)
router = APIRouter()
# 任务类型 → Celery task name 映射
_JOB_TYPE_TO_CELERY_TASK: dict[str, str] = {
JobType.VIDEO_COMPOSE: "worker.compose_video",
JobType.RENDER_EDIT_PLAN: "worker.render_edit_plan",
JobType.ASSET_INGEST: "worker.ingest_asset",
JobType.CLASSIFICATION: "worker.classify_asset",
JobType.VOICE_EXTRACTION: "worker.extract_voice",
JobType.GENERATION: "worker.generate_video",
}
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限。"""
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="Access denied to project")
# ── 创建任务 ──────────────────────────────────────────────────────────────────
@router.post("/jobs", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
def create_job(
request: CreateJobRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
project_repository: Any = Depends(get_project_repository),
) -> JobResponse:
"""创建异步任务。
创建后任务处于 pending 状态,需要调用 /submit 提交执行。
"""
_check_project_access(request.project_id, authenticated_user.user.id, project_repository)
# 校验 job_type
try:
JobType(request.job_type)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"不支持的任务类型: {request.job_type}"
f"可选值: {[t.value for t in JobType]}",
)
use_case = CreateJobUseCase(job_repo)
job = use_case.execute(
CreateJobCommand(
project_id=request.project_id,
job_type=request.job_type,
payload=request.payload,
source_id=request.source_id,
created_by_user_id=authenticated_user.user.id,
max_retries=request.max_retries,
)
)
return job_to_response(job)
# ── 提交执行 ──────────────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/submit", response_model=JobResponse)
def submit_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""提交任务执行。
将任务状态从 pending 切换为 running,并 dispatch Celery 异步任务。
"""
# 权限检查:先获取任务并验证权限,再执行状态变更
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this job")
use_case = SubmitJobUseCase(job_repo)
try:
job = use_case.execute(job_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Dispatch Celery 任务
celery_task_name = _JOB_TYPE_TO_CELERY_TASK.get(job.job_type.value)
if celery_task_name:
result = celery_app.send_task(celery_task_name, args=[job.id], kwargs=job.payload)
job.celery_task_id = result.id
job_repo.update(job)
logger.info("已提交 Celery 任务: job_id=%s celery_task_id=%s", job.id, result.id)
return job_to_response(job)
# ── 查询接口 ──────────────────────────────────────────────────────────────────
@router.get("/jobs/{job_id}", response_model=JobResponse)
def get_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""获取任务详情。"""
use_case = GetJobUseCase(job_repo)
job = use_case.execute(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
return job_to_response(job)
@router.get("/projects/{project_id}/jobs", response_model=ListJobsResponse)
def list_project_jobs(
project_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
project_repository: Any = Depends(get_project_repository),
job_type: str | None = Query(default=None, description="按任务类型过滤"),
status_filter: str | None = Query(default=None, alias="status", description="按状态过滤"),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ListJobsResponse:
"""获取项目下的任务列表。"""
_check_project_access(project_id, authenticated_user.user.id, project_repository)
use_case = ListJobsUseCase(job_repo)
jobs = use_case.execute(
project_id=project_id,
job_type=job_type,
status=status_filter,
limit=limit,
offset=offset,
)
items = [job_to_response(j) for j in jobs]
return ListJobsResponse(items=items, total=len(items))
@router.get("/projects/{project_id}/jobs/stats", response_model=JobStatisticsResponse)
def get_job_statistics(
project_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
project_repository: Any = Depends(get_project_repository),
) -> JobStatisticsResponse:
"""获取项目任务统计摘要。"""
_check_project_access(project_id, authenticated_user.user.id, project_repository)
use_case = GetJobStatisticsUseCase(job_repo)
stats = use_case.execute(project_id)
return JobStatisticsResponse(**stats)
# ── 进度更新 ──────────────────────────────────────────────────────────────────
@router.put("/jobs/{job_id}/progress", response_model=JobResponse)
def update_job_progress(
job_id: str,
request: UpdateProgressRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""更新任务进度。"""
use_case = UpdateJobProgressUseCase(job_repo)
try:
job = use_case.execute(
UpdateJobProgressCommand(
job_id=job_id,
progress=request.progress,
current_stage=request.current_stage,
)
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
# ── 完成 / 失败 ────────────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/complete", response_model=JobResponse)
def complete_job(
job_id: str,
request: CompleteJobRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""标记任务完成。"""
use_case = CompleteJobUseCase(job_repo)
try:
job = use_case.execute(CompleteJobCommand(job_id=job_id, result=request.result))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
@router.post("/jobs/{job_id}/fail", response_model=JobResponse)
def fail_job(
job_id: str,
request: FailJobRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""标记任务失败。"""
use_case = FailJobUseCase(job_repo)
try:
job = use_case.execute(FailJobCommand(job_id=job_id, error_message=request.error_message))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
# ── 重试 / 取消 ────────────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/retry", response_model=JobResponse)
def retry_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""重试失败任务。
将任务重置为 pendingretry_count + 1,但不自动 dispatch。
需要再次调用 /submit 提交执行。
"""
# 权限检查:先获取任务并验证权限,再执行状态变更
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this job")
use_case = RetryJobUseCase(job_repo)
try:
job = use_case.execute(job_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
@router.post("/jobs/{job_id}/cancel", response_model=JobResponse)
def cancel_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""取消任务。"""
# 权限检查:先获取任务并验证权限,再执行状态变更
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this job")
use_case = CancelJobUseCase(job_repo)
try:
job = use_case.execute(job_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
+191
View File
@@ -0,0 +1,191 @@
"""TTS 合成 API 路由。"""
from __future__ import annotations
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_cosyvoice_service, get_db_session
from app.schemas.tts import (
ListTTSJobResponse,
TTSSynthesizeRequest,
TTSSynthesizeResponse,
TTSJobResponse,
TTSStatusResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.tts_job_repository import (
SQLAlchemyTTSJobRepository,
)
from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.tts_job.use_cases import (
CreateTTSJobUseCase,
DeleteTTSJobUseCase,
GetTTSJobStatusUseCase,
GetTTSJobUseCase,
ListTTSJobsUseCase,
TTSJobNotFoundError,
)
from packages.application.tts_job.workflow import TTSWorkflowService
router = APIRouter()
def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTSJobRepository:
return SQLAlchemyTTSJobRepository(session)
def _to_response(job) -> TTSJobResponse:
return TTSJobResponse(
id=job.id,
user_id=job.user_id,
input_text=job.input_text,
voice_id=job.voice_id,
voice_model=job.voice_model,
project_id=job.project_id,
voice_clone_profile_id=job.voice_clone_profile_id,
status=job.status,
output_audio_url=job.output_audio_url,
output_audio_key=job.output_audio_key,
duration=job.duration,
file_size=job.file_size,
sample_rate=job.sample_rate,
format=job.format,
error_message=job.error_message,
retry_count=job.retry_count,
max_retries=job.max_retries,
metadata=job.metadata,
started_at=job.started_at,
completed_at=job.completed_at,
created_at=job.created_at,
updated_at=job.updated_at,
)
@router.post("/synthesize", response_model=TTSSynthesizeResponse, status_code=status.HTTP_201_CREATED)
def synthesize(
request: TTSSynthesizeRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
) -> TTSSynthesizeResponse:
"""发起 TTS 合成任务。
创建 TTS 任务 → 提交 CosyVoice 合成 → 触发 Celery 异步轮询。
"""
user_id = authenticated_user.user.id
use_case = CreateTTSJobUseCase(repository)
job = use_case.execute(
user_id=user_id,
input_text=request.text,
voice_id=request.voice_id,
voice_model=request.voice_model,
voice_clone_profile_id=request.voice_clone_profile_id,
metadata=request.metadata_,
)
# 提交 CosyVoice 合成任务
workflow = TTSWorkflowService(
repository=repository, cosyvoice_service=cosyvoice_service,
)
job = workflow.start_synthesis(job.id)
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
if job.status.value == "processing":
task_id = (job.metadata or {}).get("cosyvoice_task_id", "")
if task_id:
try:
from worker_app.tasks import process_tts_synthesis
process_tts_synthesis.delay(job.id)
except Exception as e:
# Celery 调度失败,标记 job 为 failed
workflow.process_synthesis_failure(
job.id, f"Celery 任务调度失败: {e}"
)
return TTSSynthesizeResponse(
job_id=job.id,
status=job.status,
message="合成任务已创建",
)
@router.get("/jobs", response_model=ListTTSJobResponse)
def list_tts_jobs(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
status_filter: Optional[str] = Query(None, alias="status"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
) -> ListTTSJobResponse:
"""列出用户的 TTS 合成任务。"""
user_id = authenticated_user.user.id
use_case = ListTTSJobsUseCase(repository)
skip = (page - 1) * page_size
items, total = use_case.execute(
user_id, status=status_filter, skip=skip, limit=page_size
)
return ListTTSJobResponse(
items=[_to_response(j) for j in items],
total=total,
page=page,
page_size=page_size,
)
@router.get("/jobs/{job_id}", response_model=TTSJobResponse)
def get_tts_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
) -> TTSJobResponse:
"""获取 TTS 任务详情。"""
user_id = authenticated_user.user.id
use_case = GetTTSJobUseCase(repository)
try:
job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return _to_response(job)
@router.get("/jobs/{job_id}/status", response_model=TTSStatusResponse)
def get_tts_job_status(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
) -> TTSStatusResponse:
"""查询 TTS 合成状态(用于前端轮询)。"""
user_id = authenticated_user.user.id
use_case = GetTTSJobStatusUseCase(repository)
try:
job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return TTSStatusResponse(
id=job.id,
status=job.status,
output_audio_url=job.output_audio_url,
error_message=job.error_message,
duration=job.duration,
retry_count=job.retry_count,
created_at=job.created_at,
updated_at=job.updated_at,
)
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_tts_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
) -> Response:
"""删除 TTS 合成任务。"""
user_id = authenticated_user.user.id
use_case = DeleteTTSJobUseCase(repository)
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 Response(status_code=204)
+257
View File
@@ -0,0 +1,257 @@
"""音色克隆 API 路由。"""
from __future__ import annotations
import logging
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
from app.schemas.voice_clone import (
CreateVoiceCloneRequest,
ListVoiceCloneResponse,
VoiceCloneProfileResponse,
VoiceCloneStatusResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
SQLAlchemyVoiceCloneProfileRepository,
)
from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.voice_clone.use_cases import (
DeleteVoiceCloneUseCase,
GetVoiceCloneStatusUseCase,
GetVoiceCloneUseCase,
ListVoiceClonesUseCase,
VoiceCloneNotFoundError,
VoiceCloneNotRetryableError,
)
from packages.application.voice_clone.workflow import (
VoiceCloneWorkflowError,
VoiceCloneWorkflowService,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _to_response(profile) -> VoiceCloneProfileResponse:
return VoiceCloneProfileResponse(
id=profile.id,
user_id=profile.user_id,
name=profile.name,
description=profile.description,
source_audio_url=profile.source_audio_url,
voice_id=profile.voice_id,
voice_model=profile.voice_model,
language=profile.language,
gender=profile.gender,
status=profile.status,
error_message=profile.error_message,
retry_count=profile.retry_count,
max_retries=profile.max_retries,
metadata=profile.metadata,
created_at=profile.created_at,
updated_at=profile.updated_at,
)
def _get_workflow_service(
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(
get_voice_clone_profile_repository
),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
) -> VoiceCloneWorkflowService:
return VoiceCloneWorkflowService(
repository=repository, cosyvoice_service=cosyvoice_service
)
@router.post(
"",
response_model=VoiceCloneProfileResponse,
status_code=status.HTTP_201_CREATED,
)
def create_voice_clone(
request: CreateVoiceCloneRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service),
) -> VoiceCloneProfileResponse:
"""创建音色克隆任务。
创建 VoiceCloneProfile → 提交 CosyVoice 克隆任务 → 触发 Celery 异步轮询。
如果有 source_audio_url,状态会变为 processing;否则保持 pending。
"""
user_id = authenticated_user.user.id
profile = workflow.start_clone(
user_id=user_id,
name=request.name,
description=request.description,
source_audio_url=request.source_audio_url,
voice_model=request.voice_model,
language=request.language,
gender=request.gender,
max_retries=request.max_retries,
metadata=request.metadata_,
)
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
if profile.status == "processing" and task_id:
try:
from worker_app.tasks import process_voice_clone
process_voice_clone.delay(profile.id)
logger.info(f"Celery task dispatched for voice clone {profile.id}")
except Exception as e:
logger.error(f"Failed to dispatch Celery task: {e}")
# P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing
try:
workflow.process_clone_failure(
profile.id, f"Celery 任务调度失败: {e}"
)
except Exception as inner_e:
logger.error(
f"Failed to mark profile as failed after dispatch error: {inner_e}"
)
return _to_response(profile)
@router.get("", response_model=ListVoiceCloneResponse)
def list_voice_clones(
status_filter: Optional[str] = Query(None, alias="status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(
get_voice_clone_profile_repository
),
) -> ListVoiceCloneResponse:
"""获取用户的音色克隆列表。"""
user_id = authenticated_user.user.id
use_case = ListVoiceClonesUseCase(repository)
items, total = use_case.execute(
user_id, status=status_filter, skip=skip, limit=limit
)
return ListVoiceCloneResponse(
items=[_to_response(p) for p in items],
total=total,
)
@router.get("/{clone_id}", response_model=VoiceCloneProfileResponse)
def get_voice_clone(
clone_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(
get_voice_clone_profile_repository
),
) -> VoiceCloneProfileResponse:
"""获取音色克隆详情。"""
user_id = authenticated_user.user.id
use_case = GetVoiceCloneUseCase(repository)
try:
profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found"
)
return _to_response(profile)
@router.get("/{clone_id}/status", response_model=VoiceCloneStatusResponse)
def get_voice_clone_status(
clone_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(
get_voice_clone_profile_repository
),
) -> VoiceCloneStatusResponse:
"""查询音色克隆状态(用于前端轮询)。"""
user_id = authenticated_user.user.id
use_case = GetVoiceCloneStatusUseCase(repository)
try:
profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found"
)
return VoiceCloneStatusResponse(
id=profile.id,
status=profile.status,
error_message=profile.error_message,
voice_id=profile.voice_id,
retry_count=profile.retry_count,
)
@router.delete(
"/{clone_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
)
def delete_voice_clone(
clone_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(
get_voice_clone_profile_repository
),
) -> Response:
"""删除音色克隆档案。"""
user_id = authenticated_user.user.id
use_case = DeleteVoiceCloneUseCase(repository)
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 Response(status_code=204)
@router.post("/{clone_id}/retry", response_model=VoiceCloneProfileResponse)
def retry_voice_clone(
clone_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service),
) -> VoiceCloneProfileResponse:
"""重试失败的音色克隆。
仅当状态为 failed 时可重试,重试后重新提交 CosyVoice 克隆任务。
"""
user_id = authenticated_user.user.id
try:
profile = workflow.retry_clone(clone_id, user_id)
except VoiceCloneNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found"
)
except VoiceCloneNotRetryableError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Voice clone is not retryable (only failed clones can be retried)",
)
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
if profile.status == "processing" and task_id:
try:
from worker_app.tasks import process_voice_clone
process_voice_clone.delay(profile.id)
logger.info(f"Celery task dispatched for voice clone retry {profile.id}")
except Exception as e:
logger.error(f"Failed to dispatch Celery task: {e}")
# P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing
try:
workflow.process_clone_failure(
profile.id, f"Celery 任务调度失败: {e}"
)
except Exception as inner_e:
logger.error(
f"Failed to mark profile as failed after dispatch error: {inner_e}"
)
return _to_response(profile)
+164 -6
View File
@@ -1,11 +1,20 @@
"""Voice library CRUD routes."""
"""Voice library CRUD routes — Phase 3 增强版.
支持预置音色和克隆音色的统一列表。
"""
from __future__ import annotations
from typing import Optional
from typing import Literal, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_user_repository
from app.schemas.voice import (
PresetVoiceItemResponse,
PresetVoiceListResponse,
UnifiedVoiceItemResponse,
UnifiedVoiceListResponse,
)
from app.schemas.voice_library import (
CreateVoiceLibraryRequest,
ListVoiceLibraryResponse,
@@ -15,6 +24,7 @@ from app.schemas.voice_library import (
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand
from packages.application.voice_library.use_cases import (
@@ -26,6 +36,7 @@ from packages.application.voice_library.use_cases import (
QuotaExceededError,
UpdateVoiceLibraryUseCase,
)
from packages.domain.preset_voices import PRESET_VOICES
from packages.ports.user_repository import UserRepository
router = APIRouter()
@@ -35,6 +46,10 @@ def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlch
return SQLAlchemyVoiceLibraryRepository(session)
def _get_clone_profile_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceCloneProfileRepository:
return SQLAlchemyVoiceCloneProfileRepository(session)
def _to_response(item) -> VoiceLibraryItemResponse:
return VoiceLibraryItemResponse(
id=item.id,
@@ -55,6 +70,54 @@ def _to_response(item) -> VoiceLibraryItemResponse:
)
def _to_unified_response(item, profile_id_map: dict | None = None) -> UnifiedVoiceItemResponse:
"""将数据库音色转换为统一响应格式。
Args:
item: VoiceLibraryItem
profile_id_map: voice_id → profile_id 映射,用于填充 voice_clone_profile_id
"""
profile_id = None
if profile_id_map and item.voice_id:
profile_id = profile_id_map.get(item.voice_id)
return UnifiedVoiceItemResponse(
id=item.id,
type="clone",
name=item.name,
description=item.text,
gender="unknown",
language="zh-CN",
voice_id=item.voice_id,
voice_provider=item.voice_provider or "cosyvoice",
audio_url=item.audio_url,
duration=item.duration,
file_size=item.file_size,
status=item.status,
tags=item.tags,
user_id=item.user_id,
project_id=item.project_id,
voice_clone_profile_id=profile_id,
created_at=item.created_at,
updated_at=item.updated_at,
)
def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
"""将预置音色转换为统一响应格式。"""
return UnifiedVoiceItemResponse(
id=preset.voice_id,
type="preset",
name=preset.name,
description=preset.description,
gender=preset.gender,
language=preset.language,
voice_id=preset.voice_id,
voice_provider="cosyvoice",
preview_url=preset.preview_url,
tags=preset.tags or [],
)
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
user = user_repository.find_by_id(user_id)
if user is None:
@@ -62,18 +125,113 @@ def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
return getattr(user, "subscription_plan", "free") or "free"
@router.get("", response_model=ListVoiceLibraryResponse)
def list_voices(
# ==================== 统一配音列表(预置 + 克隆)====================
@router.get("", response_model=UnifiedVoiceListResponse)
def list_voices_unified(
type: Optional[Literal["preset", "clone"]] = Query(
None,
description="音色类型过滤:preset=仅预置,clone=仅克隆,不传=全部",
),
status_filter: Optional[str] = Query(None, alias="status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
) -> UnifiedVoiceListResponse:
"""获取配音列表(预置音色 + 用户克隆音色)。
- 不传 type:返回预置音色 + 用户克隆音色,预置音色在前
- type=preset:仅返回预置音色
- type=clone:仅返回用户克隆音色
"""
user_id = authenticated_user.user.id
items: list[UnifiedVoiceItemResponse] = []
preset_count = 0
clone_count = 0
has_preset = type is None or type == "preset"
has_clone = type is None or type == "clone"
# 获取预置音色
if has_preset:
preset_items = [_preset_to_unified_response(p) for p in PRESET_VOICES]
preset_count = len(preset_items)
# 获取克隆音色
if has_clone:
use_case = ListVoiceLibraryUseCase(voice_repository)
clone_items_raw, clone_count = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
# 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id
voice_ids = [i.voice_id for i in clone_items_raw if i.voice_id]
profile_id_map = clone_profile_repository.find_profile_ids_by_voice_ids(voice_ids) if voice_ids else {}
clone_items = [_to_unified_response(i, profile_id_map) for i in clone_items_raw]
# 组装结果
if type == "preset":
items = preset_items[skip : skip + limit]
total = preset_count
elif type == "clone":
items = clone_items
total = clone_count
else:
# 全量模式:预置在前,克隆补位
all_items = preset_items + clone_items
total = preset_count + clone_count
items = all_items[skip : skip + limit]
return UnifiedVoiceListResponse(
items=items,
total=total,
preset_count=preset_count if has_preset else 0,
clone_count=clone_count if has_clone else 0,
)
# ==================== 预置音色专用端点 ====================
@router.get("/presets", response_model=PresetVoiceListResponse)
def list_preset_voices() -> PresetVoiceListResponse:
"""获取预置音色列表。
不需要认证,返回所有系统预置的 CosyVoice 音色。
"""
items = [
PresetVoiceItemResponse(
voice_id=p.voice_id,
name=p.name,
description=p.description,
gender=p.gender,
language=p.language,
preview_url=p.preview_url,
tags=p.tags or [],
)
for p in PRESET_VOICES
]
return PresetVoiceListResponse(items=items, total=len(items))
# ==================== 原有 CRUD 端点(保持向后兼容)====================
@router.get("/legacy", response_model=ListVoiceLibraryResponse)
def list_voices_legacy(
status_filter: Optional[str] = Query(None, alias="status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
) -> ListVoiceLibraryResponse:
"""原有配音列表接口(仅返回用户克隆音色)。
保留用于向后兼容,新客户端请使用 GET /api/v1/voices。
"""
user_id = authenticated_user.user.id
use_case = ListVoiceLibraryUseCase(voice_repository)
items = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
total = voice_repository.count_by_user(user_id)
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
return ListVoiceLibraryResponse(
items=[_to_response(i) for i in items],
total=total,
Regular → Executable
+27
View File
@@ -34,6 +34,7 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import (
from packages.adapters.sqlalchemy_impl.ingest_job_repository import (
SQLAlchemyIngestJobRepository,
)
from packages.adapters.sqlalchemy_impl.job_repository import SQLAlchemyJobRepository
from packages.adapters.sqlalchemy_impl.project_repository import (
SQLAlchemyProjectRepository,
)
@@ -42,6 +43,9 @@ from packages.adapters.sqlalchemy_impl.title_library_repository import (
SQLAlchemyTitleLibraryRepository,
)
from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
SQLAlchemyVoiceCloneProfileRepository,
)
from packages.adapters.sqlalchemy_impl.voice_library_repository import (
SQLAlchemyVoiceLibraryRepository,
)
@@ -52,9 +56,11 @@ from packages.ports.duplication_repository import DuplicationRecordRepository
from packages.ports.generated_video_repository import GeneratedVideoRepository
from packages.ports.generation_task_repository import GenerationTaskRepository
from packages.ports.ingest_job_repository import IngestJobRepository
from packages.ports.job_repository import JobRepository
from packages.ports.project_repository import ProjectRepository
from packages.ports.title_library_repository import TitleLibraryRepository
from packages.ports.user_repository import UserRepository
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
from packages.ports.voice_library_repository import VoiceLibraryRepository
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
@@ -104,6 +110,13 @@ def get_generation_task_repository(
return SQLAlchemyGenerationTaskRepository(session)
def get_job_repository(
session: Session = Depends(get_db_session),
) -> SQLAlchemyJobRepository:
"""Provide the SQLAlchemy job repository implementation."""
return SQLAlchemyJobRepository(session)
def get_generated_video_repository(
session: Session = Depends(get_db_session),
) -> SQLAlchemyGeneratedVideoRepository:
@@ -169,3 +182,17 @@ def get_voice_library_repository(
) -> SQLAlchemyVoiceLibraryRepository:
"""Provide the SQLAlchemy voice library repository implementation."""
return SQLAlchemyVoiceLibraryRepository(session)
def get_voice_clone_profile_repository(
session: Session = Depends(get_db_session),
) -> SQLAlchemyVoiceCloneProfileRepository:
"""Provide the SQLAlchemy voice clone profile repository implementation."""
return SQLAlchemyVoiceCloneProfileRepository(session)
def get_cosyvoice_service() -> "CosyVoiceService":
"""Provide the CosyVoice service instance."""
from packages.application.cosyvoice_service import CosyVoiceService
return CosyVoiceService()
+109
View File
@@ -0,0 +1,109 @@
"""Job API schemas — Phase 8 任务 2.10."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class CreateJobRequest(BaseModel):
"""创建任务请求体。"""
project_id: str = Field(..., min_length=1, description="项目 ID")
job_type: str = Field(
...,
description="任务类型: video_compose / render_edit_plan / asset_ingest / classification / voice_extraction / generation",
)
payload: dict[str, Any] = Field(default_factory=dict, description="任务输入参数")
source_id: str = Field(default="", description="关联的业务实体 ID(如 edit_plan_id")
max_retries: int = Field(default=3, ge=0, le=10, description="最大重试次数")
class UpdateProgressRequest(BaseModel):
"""更新任务进度请求体。"""
progress: float = Field(..., ge=0.0, le=100.0, description="进度百分比")
current_stage: str = Field(default="", description="当前阶段描述")
class CompleteJobRequest(BaseModel):
"""完成任务请求体。"""
result: dict[str, Any] = Field(default_factory=dict, description="任务结果")
class FailJobRequest(BaseModel):
"""标记任务失败请求体。"""
error_message: str = Field(..., min_length=1, description="错误信息")
class JobResponse(BaseModel):
"""任务响应体。"""
id: str
project_id: str
job_type: str
status: str
progress: float
current_stage: str
payload: dict[str, Any]
result: dict[str, Any]
error_message: str
retry_count: int
max_retries: int
celery_task_id: str
source_id: str
created_by_user_id: str
is_retryable: bool
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ListJobsResponse(BaseModel):
"""任务列表响应体。"""
items: list[JobResponse]
total: int
class JobStatisticsResponse(BaseModel):
"""任务统计响应体。"""
project_id: str
total: int
pending: int
running: int
success: int
failed: int
def job_to_response(job) -> JobResponse:
"""将 Job 领域对象转换为 API 响应。"""
return JobResponse(
id=job.id,
project_id=job.project_id,
job_type=job.job_type.value if hasattr(job.job_type, "value") else str(job.job_type),
status=job.status.value if hasattr(job.status, "value") else str(job.status),
progress=job.progress,
current_stage=job.current_stage,
payload=job.payload,
result=job.result,
error_message=job.error_message,
retry_count=job.retry_count,
max_retries=job.max_retries,
celery_task_id=job.celery_task_id,
source_id=job.source_id,
created_by_user_id=job.created_by_user_id,
is_retryable=job.is_retryable,
started_at=job.started_at,
completed_at=job.completed_at,
created_at=job.created_at,
updated_at=job.updated_at,
)
+89
View File
@@ -0,0 +1,89 @@
"""TTS 合成 API Schema。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class TTSSynthesizeRequest(BaseModel):
"""TTS 合成请求。"""
text: str = Field(..., min_length=1, max_length=10000, description="合成文本")
voice_id: str = Field("", description="音色 ID")
output_name: str = Field("", description="输出文件名")
language: str = Field("zh-CN", description="语言")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
voice_model: str = Field("", description="语音模型名称")
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
format: str = Field("mp3", description="输出格式(mp3/wav/pcm")
metadata_: Optional[Dict[str, Any]] = Field(
default=None, alias="metadata", description="额外元数据"
)
class Config:
populate_by_name = True
class TTSJobResponse(BaseModel):
"""TTS 任务响应。"""
id: str
user_id: str
input_text: str
voice_id: str = ""
voice_model: str = ""
project_id: str = ""
voice_clone_profile_id: str = ""
status: str
output_audio_url: str = ""
output_audio_key: str = ""
duration: float = 0.0
file_size: int = 0
sample_rate: int = 22050
format: str = "mp3"
error_message: str = ""
retry_count: int = 0
max_retries: int = 3
metadata_: Optional[Dict[str, Any]] = Field(
default=None, alias="metadata", description="额外元数据"
)
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class Config:
populate_by_name = True
class TTSStatusResponse(BaseModel):
"""TTS 任务状态响应(用于轮询)。"""
id: str
status: str
output_audio_url: str = ""
error_message: str = ""
duration: float = 0.0
retry_count: int = 0
created_at: datetime
updated_at: datetime
class TTSSynthesizeResponse(BaseModel):
"""TTS 合成创建响应。"""
job_id: str
status: str
message: str = "合成任务已创建"
class ListTTSJobResponse(BaseModel):
"""TTS 任务列表响应。"""
items: List[TTSJobResponse]
total: int
page: int
page_size: int
+127
View File
@@ -0,0 +1,127 @@
"""统一配音响应 Schema — Phase 3 CosyVoice 集成.
支持预置音色和克隆音色的统一响应格式。
"""
from __future__ import annotations
from datetime import datetime
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
class UnifiedVoiceItemResponse(BaseModel):
"""统一配音项响应。
同时支持预置音色(type=preset)和克隆音色(type=clone)。
"""
id: str
"""音色 ID(预置音色为 voice_id,克隆音色为数据库 ID)"""
type: Literal["preset", "clone"]
"""音色类型:preset=预置音色,clone=用户克隆音色"""
name: str
"""音色展示名称"""
description: str = ""
"""音色描述"""
gender: str = "unknown"
"""性别:male/female/unknown"""
language: str = "zh-CN"
"""语言代码"""
voice_id: str = ""
"""CosyVoice 模型音色名"""
voice_provider: str = "cosyvoice"
"""语音服务商"""
audio_url: str = ""
"""音频 URL(克隆音色为上传的音频,预置音色为空)"""
preview_url: str = ""
"""预览音频 URL(预置音色可能有)"""
duration: float = 0
"""音频时长(秒)"""
file_size: int = 0
"""文件大小(字节)"""
status: str = "completed"
"""状态"""
tags: List[str] = Field(default_factory=list)
"""标签列表"""
# 克隆音色特有字段
user_id: Optional[str] = None
"""所属用户 ID(仅克隆音色)"""
project_id: Optional[str] = None
"""所属项目 ID(仅克隆音色)"""
voice_clone_profile_id: Optional[str] = None
"""关联的音色克隆档案 ID(仅克隆音色)"""
created_at: Optional[datetime] = None
"""创建时间(仅克隆音色)"""
updated_at: Optional[datetime] = None
"""更新时间(仅克隆音色)"""
class UnifiedVoiceListResponse(BaseModel):
"""统一配音列表响应。"""
items: list[UnifiedVoiceItemResponse]
"""音色列表(预置音色在前)"""
total: int = 0
"""总数"""
preset_count: int = 0
"""预置音色数量"""
clone_count: int = 0
"""克隆音色数量"""
class PresetVoiceItemResponse(BaseModel):
"""预置音色项响应。"""
voice_id: str
"""CosyVoice 模型音色名"""
name: str
"""中文展示名"""
description: str
"""音色描述"""
gender: str
"""性别"""
language: str = "zh-CN"
"""语言代码"""
preview_url: str = ""
"""预览音频 URL"""
tags: List[str] = Field(default_factory=list)
"""标签列表"""
class PresetVoiceListResponse(BaseModel):
"""预置音色列表响应。"""
items: list[PresetVoiceItemResponse]
"""预置音色列表"""
total: int = 0
"""总数"""
+69
View File
@@ -0,0 +1,69 @@
"""音色克隆 API Schema。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class CreateVoiceCloneRequest(BaseModel):
"""创建音色克隆请求。"""
name: str = Field(..., min_length=1, max_length=100, description="音色名称")
description: str = Field("", description="音色描述")
source_audio_url: str = Field("", description="参考音频 URL")
voice_model: str = Field("", description="语音模型名称")
language: str = Field("zh-CN", description="语言")
gender: str = Field("unknown", description="性别")
max_retries: int = Field(3, ge=1, le=10, description="最大重试次数")
metadata_: Optional[Dict[str, Any]] = Field(
default=None, alias="metadata", description="额外元数据"
)
class Config:
populate_by_name = True
class VoiceCloneProfileResponse(BaseModel):
"""音色克隆档案响应。"""
id: str
user_id: str
name: str
description: str = ""
source_audio_url: str = ""
voice_id: str = ""
voice_model: str = ""
language: str = "zh-CN"
gender: str = "unknown"
status: str
error_message: str = ""
retry_count: int = 0
max_retries: int = 3
metadata_: Optional[Dict[str, Any]] = Field(
default=None, alias="metadata", description="额外元数据"
)
created_at: datetime
updated_at: datetime
class Config:
populate_by_name = True
class VoiceCloneStatusResponse(BaseModel):
"""音色克隆状态响应(用于轮询)。"""
id: str
status: str
error_message: str = ""
voice_id: str = ""
retry_count: int = 0
class ListVoiceCloneResponse(BaseModel):
"""音色克隆列表响应。"""
items: List[VoiceCloneProfileResponse]
total: int
+15
View File
@@ -0,0 +1,15 @@
"""Service layer exports for Phase 8 模板编排引擎."""
from .auto_clip_service import AutoClipService
from .edit_plan_service import EditPlanService
from .edit_template_service import EditTemplateService
from .job_service import JobService
from .video_compose_service import VideoComposeService
__all__ = [
"AutoClipService",
"EditPlanService",
"EditTemplateService",
"JobService",
"VideoComposeService",
]
+340
View File
@@ -0,0 +1,340 @@
"""AutoClipService — 智能选片服务.
根据模板片段配置 (TemplateClipConfig) 的素材需求 (material_requirements)
自动从项目素材库中筛选、评分并分配最佳素材到剪辑计划片段 (EditPlanClip)。
评分规则:
- 质量分 (quality_score):权重 0.5
- 时长匹配度:权重 0.3(越接近目标时长得分越高)
- 分类匹配度:权重 0.2(分类完全匹配得满分,部分匹配按比例得分)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import (
SQLAlchemyAssetRepository,
SQLAlchemyEditPlanClipRepository,
SQLAlchemyEditPlanRepository,
SQLAlchemyTemplateClipConfigRepository,
)
from packages.domain.asset import AssetType
from packages.domain.classification import AssetClassification
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
logger = logging.getLogger(__name__)
# ── 评分权重 ──────────────────────────────────────────────────────────────────
_WEIGHT_QUALITY = 0.5
_WEIGHT_DURATION = 0.3
_WEIGHT_CLASSIFICATION = 0.2
@dataclass
class AutoSelectResult:
"""智能选片结果。"""
plan_id: str
total_clips: int
assigned_clips: int
unassigned_clips: int
details: list[ClipAssignDetail]
@dataclass
class ClipAssignDetail:
"""单个片段的分配详情。"""
clip_id: str
clip_type: str
assigned_asset_id: str | None
candidate_count: int
score: float | None
reason: str
class AutoClipService:
"""智能选片服务 — 自动为剪辑计划片段分配最佳素材。"""
def __init__(self, db: Session) -> None:
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
self._config_repo = SQLAlchemyTemplateClipConfigRepository(db)
self._asset_repo = SQLAlchemyAssetRepository(db)
# ── 公开方法 ──────────────────────────────────────────────────────────────
def auto_select_assets(self, plan_id: str, project_id: str) -> AutoSelectResult:
"""为剪辑计划的所有片段自动分配素材。
流程:
1. 获取剪辑计划 → 读取 template_id
2. 获取模板的所有片段配置 (TemplateClipConfig)
3. 获取计划的所有片段 (EditPlanClip)
4. 对每个片段,根据其关联的 config 筛选候选素材并评分
5. 将最佳素材分配给片段,标记为 READY
Args:
plan_id: 剪辑计划 ID
project_id: 项目 ID(素材所属项目)
Returns:
AutoSelectResult 包含分配统计和每个片段的详情
Raises:
ValueError: 计划不存在
"""
plan = self._plan_repo.get(plan_id)
if plan is None:
raise ValueError(f"剪辑计划不存在: {plan_id}")
# 获取模板片段配置(按 order 排序)
configs = self._config_repo.list_by_template(plan.template_id)
config_map = {c.id: c for c in configs}
# 获取计划的所有片段
clips = self._clip_repo.list_by_plan(plan_id)
details: list[ClipAssignDetail] = []
assigned_count = 0
for clip in clips:
detail = self._assign_single_clip(clip, project_id, config_map)
details.append(detail)
if detail.assigned_asset_id is not None:
assigned_count += 1
result = AutoSelectResult(
plan_id=plan_id,
total_clips=len(clips),
assigned_clips=assigned_count,
unassigned_clips=len(clips) - assigned_count,
details=details,
)
logger.info(
"智能选片完成: plan=%s total=%d assigned=%d unassigned=%d",
plan_id,
result.total_clips,
result.assigned_clips,
result.unassigned_clips,
)
return result
def select_for_clip(self, clip_id: str, project_id: str) -> ClipAssignDetail:
"""为单个片段选择并分配最佳素材。
Args:
clip_id: 片段 ID
project_id: 项目 ID(素材所属项目)
Returns:
ClipAssignDetail 分配详情
Raises:
ValueError: 片段不存在或缺少关联配置
"""
clip = self._clip_repo.get(clip_id)
if clip is None:
raise ValueError(f"片段不存在: {clip_id}")
# 获取关联的模板配置
config = None
if clip.template_clip_config_id:
config = self._config_repo.get(clip.template_clip_config_id)
config_map = {config.id: config} if config else {}
return self._assign_single_clip(clip, project_id, config_map)
# ── 内部方法 ──────────────────────────────────────────────────────────────
def _assign_single_clip(
self,
clip: EditPlanClip,
project_id: str,
config_map: dict[str, object],
) -> ClipAssignDetail:
"""为单个片段分配素材。"""
config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None
# 解析素材需求
requirements = self._parse_material_requirements(config)
# 搜索候选素材
candidates = self._asset_repo.search_candidates(
project_id=project_id,
file_type=requirements.get("file_type"),
min_quality_score=requirements.get("min_quality_score"),
min_duration=requirements.get("min_duration"),
max_duration=requirements.get("max_duration"),
classification_category=requirements.get("classification_category"),
tags=requirements.get("tags"),
status="completed",
limit=50,
)
if not candidates:
return ClipAssignDetail(
clip_id=clip.id,
clip_type=requirements.get("clip_type", "unknown"),
assigned_asset_id=None,
candidate_count=0,
score=None,
reason="无符合条件的候选素材",
)
# 评分并选择最佳素材
target_duration = requirements.get("target_duration")
target_category = requirements.get("classification_category")
best_asset = None
best_score = -1.0
for asset in candidates:
score = self._score_candidate(
asset,
target_duration=target_duration,
target_category=target_category,
)
if score > best_score:
best_score = score
best_asset = asset
if best_asset is None:
return ClipAssignDetail(
clip_id=clip.id,
clip_type=requirements.get("clip_type", "unknown"),
assigned_asset_id=None,
candidate_count=len(candidates),
score=None,
reason="候选素材评分均不合格",
)
# 分配素材并标记就绪
clip.assign_asset(best_asset.id)
clip.mark_ready()
self._clip_repo.update(clip)
return ClipAssignDetail(
clip_id=clip.id,
clip_type=requirements.get("clip_type", "unknown"),
assigned_asset_id=best_asset.id,
candidate_count=len(candidates),
score=round(best_score, 4),
reason=f"最佳匹配 (score={best_score:.4f})",
)
@staticmethod
def _score_candidate(
asset: object,
*,
target_duration: float | None = None,
target_category: str | None = None,
) -> float:
"""对候选素材评分 (0.0 ~ 1.0)。
评分维度:
- 质量分 (quality_score):归一化到 0-1,权重 0.5
- 时长匹配度:越接近目标时长得分越高,权重 0.3
- 分类匹配度:完全匹配得 1.0,无分类得 0.0,权重 0.2
"""
# 质量分 (0-100 → 0-1)
quality = getattr(asset, "quality_score", None)
quality_score = (quality / 100.0) if quality is not None else 0.5
# 时长匹配度
duration_score = 0.5 # 无目标时长的默认分
if target_duration is not None and target_duration > 0:
asset_duration = getattr(asset, "duration", None)
if asset_duration is not None and asset_duration > 0:
ratio = asset_duration / target_duration
# 比率越接近 1.0 得分越高,使用高斯衰减
duration_score = max(0.0, 1.0 - abs(1.0 - ratio) * 2)
# 无时长的素材得 0 分
else:
duration_score = 0.0
# 分类匹配度
classification_score = 0.0
if target_category is not None:
metadata = getattr(asset, "metadata", {}) or {}
asset_category = metadata.get("category", "")
if asset_category == target_category:
classification_score = 1.0
elif asset_category:
# 部分匹配(同大类)给 0.5
classification_score = 0.3
else:
# 无分类要求,所有素材得满分
classification_score = 1.0
total = (
_WEIGHT_QUALITY * quality_score
+ _WEIGHT_DURATION * duration_score
+ _WEIGHT_CLASSIFICATION * classification_score
)
return total
@staticmethod
def _parse_material_requirements(config: object | None) -> dict:
"""从 TemplateClipConfig 解析素材筛选条件。
将 material_requirements JSON 和 config 自身的时长/类型字段
统一转换为 search_candidates 可用的筛选参数。
"""
result: dict = {}
if config is None:
return result
# 从 material_requirements 提取筛选条件
requirements = getattr(config, "material_requirements", {}) or {}
# 素材类型: material_requirements 中的 "type" 字段
req_type = requirements.get("type")
if req_type and req_type in (AssetType.VIDEO, AssetType.IMAGE, AssetType.AUDIO):
result["file_type"] = req_type
# 最低质量分
min_quality = requirements.get("min_quality_score") or requirements.get("min_quality")
if min_quality is not None:
try:
result["min_quality_score"] = float(min_quality)
except (TypeError, ValueError):
pass
# 分类筛选
category = requirements.get("category") or requirements.get("classification")
if category:
# 验证是否为有效分类
valid_categories = {c.value for c in AssetClassification}
if category in valid_categories:
result["classification_category"] = category
# 标签筛选
tags = requirements.get("tags")
if isinstance(tags, list) and tags:
result["tags"] = tags
# 时长范围:优先使用 config 的 min/max_duration,其次 material_requirements
min_dur = getattr(config, "min_duration", None) or requirements.get("min_duration")
max_dur = getattr(config, "max_duration", None) or requirements.get("max_duration")
if min_dur is not None and min_dur > 0:
result["min_duration"] = float(min_dur)
if max_dur is not None and max_dur > 0:
result["max_duration"] = float(max_dur)
# 目标时长(用于评分)
if min_dur and max_dur:
result["target_duration"] = (float(min_dur) + float(max_dur)) / 2
elif min_dur:
result["target_duration"] = float(min_dur) * 1.2
elif max_dur:
result["target_duration"] = float(max_dur) * 0.8
# 片段类型(用于日志)
clip_type = getattr(config, "clip_type", None)
if clip_type:
result["clip_type"] = clip_type.value if hasattr(clip_type, "value") else str(clip_type)
return result
+473
View File
@@ -0,0 +1,473 @@
"""EditPlanService — 剪辑计划管理业务逻辑.
封装 EditPlan 和 EditPlanClip 的 CRUD 操作、状态机流转、
以及渲染生成流程,提供统一的业务接口供 API 路由层调用。
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanClipRepository,
SQLAlchemyEditPlanRepository,
SQLAlchemyGenerationTaskRepository,
)
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.generation_task import GenerationTaskStatus
logger = logging.getLogger(__name__)
class EditPlanService:
"""剪辑计划管理服务
职责:
- 剪辑计划 CRUD(创建、查询、更新、删除)
- 剪辑片段管理(增删改查、分配素材)
- 状态机流转(draft → editing → rendering → completed/failed
- 渲染生成流程(触发 Celery 任务、查询进度)
"""
def __init__(self, db: Session) -> None:
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
self._generation_task_repo = SQLAlchemyGenerationTaskRepository(db)
# ── 剪辑计划 CRUD ──────────────────────────────────────────────────────
def list_plans(
self,
*,
template_id: Optional[str] = None,
status: Optional[EditPlanStatus] = None,
skip: int = 0,
limit: int = 50,
) -> List[EditPlan]:
"""列出剪辑计划
Args:
template_id: 按模板 ID 筛选
status: 按状态筛选
skip: 分页偏移
limit: 每页数量
"""
if template_id:
return self._plan_repo.list_by_template(
template_id,
status=status,
skip=skip,
limit=limit,
)
return self._plan_repo.list_all(status=status, skip=skip, limit=limit)
def count_plans(
self,
*,
template_id: Optional[str] = None,
status: Optional[EditPlanStatus] = None,
) -> int:
"""统计计划数量
Note:
当指定 template_id 时,通过全量查询计算 total(repo 限制)。
"""
if template_id:
all_matching = self._plan_repo.list_by_template(
template_id,
status=status,
skip=0,
limit=10000,
)
return len(all_matching)
return self._plan_repo.count(status=status)
def get_plan(self, plan_id: str) -> Optional[EditPlan]:
"""获取计划详情"""
return self._plan_repo.get(plan_id)
def get_plan_or_raise(self, plan_id: str) -> EditPlan:
"""获取计划,不存在则抛出 ValueError"""
plan = self._plan_repo.get(plan_id)
if plan is None:
raise ValueError(f"剪辑计划不存在: {plan_id}")
return plan
def create_plan(
self,
template_id: str,
name: str,
*,
config: Optional[dict[str, Any]] = None,
total_duration: float = 0.0,
) -> EditPlan:
"""创建剪辑计划
Raises:
ValueError: 参数校验失败
"""
plan = EditPlan.create(
template_id=template_id,
name=name,
config=config,
total_duration=total_duration,
)
created = self._plan_repo.create(plan)
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
return created
def update_plan(
self,
plan_id: str,
*,
name: Optional[str] = None,
config: Optional[dict[str, Any]] = None,
total_duration: Optional[float] = None,
) -> EditPlan:
"""更新计划基础字段
Raises:
ValueError: 计划不存在
"""
existing = self.get_plan_or_raise(plan_id)
updated = EditPlan(
id=existing.id,
template_id=existing.template_id,
name=name.strip() if name is not None else existing.name,
status=existing.status,
total_duration=total_duration if total_duration is not None else existing.total_duration,
config=config if config is not None else existing.config,
created_at=existing.created_at,
updated_at=existing.updated_at,
)
result = self._plan_repo.update(updated)
logger.info("更新剪辑计划: id=%s", plan_id)
return result
def delete_plan(self, plan_id: str) -> bool:
"""删除剪辑计划及其所有片段
Returns:
bool: 是否删除成功
"""
existing = self._plan_repo.get(plan_id)
if existing is None:
return False
# 先删除所有片段
self._clip_repo.delete_by_plan(plan_id)
# 再删除计划
self._plan_repo.delete(plan_id)
logger.info("删除剪辑计划: id=%s", plan_id)
return True
# ── 状态机流转 ──────────────────────────────────────────────────────────
def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan:
"""流转计划状态
状态流转规则:
- draft → editing (start_editing)
- editing → rendering (start_rendering)
- rendering → completed (mark_completed)
- rendering → failed (mark_failed)
- failed → draft (reset_to_draft)
Raises:
ValueError: 计划不存在或状态流转非法
"""
plan = self.get_plan_or_raise(plan_id)
# 如果已是目标状态,直接返回
if plan.status == target_status:
return plan
# 根据目标状态调用对应的状态机方法
transition_map = {
EditPlanStatus.EDITING: plan.start_editing,
EditPlanStatus.RENDERING: plan.start_rendering,
EditPlanStatus.COMPLETED: plan.mark_completed,
EditPlanStatus.FAILED: plan.mark_failed,
EditPlanStatus.DRAFT: plan.reset_to_draft,
}
transition_fn = transition_map.get(target_status)
if transition_fn is None:
raise ValueError(f"无效的目标状态: {target_status}")
transition_fn()
result = self._plan_repo.update(plan)
logger.info(
"状态流转: plan_id=%s %s%s",
plan_id,
plan.status,
target_status,
)
return result
# ── 剪辑片段管理 ────────────────────────────────────────────────────────
def list_clips(
self,
plan_id: str,
*,
status: Optional[EditPlanClipStatus] = None,
skip: int = 0,
limit: int = 100,
) -> List[EditPlanClip]:
"""列出计划的片段"""
# 确保计划存在
self.get_plan_or_raise(plan_id)
return self._clip_repo.list_by_plan(plan_id, status=status, skip=skip, limit=limit)
def count_clips(
self,
plan_id: str,
*,
status: Optional[EditPlanClipStatus] = None,
) -> int:
"""统计片段数量"""
return self._clip_repo.count(plan_id=plan_id, status=status)
def get_clip(self, clip_id: str) -> Optional[EditPlanClip]:
"""获取片段详情"""
return self._clip_repo.get(clip_id)
def get_clip_or_raise(self, clip_id: str) -> EditPlanClip:
"""获取片段,不存在则抛出 ValueError"""
clip = self._clip_repo.get(clip_id)
if clip is None:
raise ValueError(f"片段不存在: {clip_id}")
return clip
def create_clip(
self,
plan_id: str,
clip_type: str,
order: int,
*,
template_clip_config_id: str = "",
asset_id: str = "",
text_content: str = "",
start_time: float = 0.0,
duration: float = 0.0,
transition_effect: str = "cut",
config: Optional[dict[str, Any]] = None,
) -> EditPlanClip:
"""创建片段
Raises:
ValueError: 计划不存在或参数校验失败
"""
# 确保计划存在
self.get_plan_or_raise(plan_id)
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_type,
order=order,
template_clip_config_id=template_clip_config_id,
asset_id=asset_id,
text_content=text_content,
start_time=start_time,
duration=duration,
transition_effect=transition_effect,
config=config,
)
created = self._clip_repo.create(clip)
logger.info(
"创建片段: id=%s plan_id=%s clip_type=%s order=%d",
created.id,
plan_id,
created.clip_type,
created.order,
)
return created
def update_clip(
self,
clip_id: str,
*,
clip_type: Optional[str] = None,
order: Optional[int] = None,
asset_id: Optional[str] = None,
text_content: Optional[str] = None,
start_time: Optional[float] = None,
duration: Optional[float] = None,
transition_effect: Optional[str] = None,
config: Optional[dict[str, Any]] = None,
) -> EditPlanClip:
"""更新片段
Raises:
ValueError: 片段不存在
"""
existing = self.get_clip_or_raise(clip_id)
updated = EditPlanClip(
id=existing.id,
plan_id=existing.plan_id,
clip_type=clip_type.strip() if clip_type is not None else existing.clip_type,
order=order if order is not None else existing.order,
template_clip_config_id=existing.template_clip_config_id,
asset_id=asset_id.strip() if asset_id is not None else existing.asset_id,
text_content=text_content.strip() if text_content is not None else existing.text_content,
start_time=start_time if start_time is not None else existing.start_time,
duration=duration if duration is not None else existing.duration,
transition_effect=transition_effect.strip() if transition_effect is not None else existing.transition_effect,
status=existing.status,
config=config if config is not None else existing.config,
created_at=existing.created_at,
updated_at=existing.updated_at,
)
result = self._clip_repo.update(updated)
logger.info("更新片段: id=%s", clip_id)
return result
def assign_asset(self, clip_id: str, asset_id: str) -> EditPlanClip:
"""为片段分配素材
Raises:
ValueError: 片段不存在或 asset_id 为空
"""
clip = self.get_clip_or_raise(clip_id)
clip.assign_asset(asset_id)
result = self._clip_repo.update(clip)
logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id)
return result
def delete_clip(self, clip_id: str) -> bool:
"""删除片段
Returns:
bool: 是否删除成功
"""
deleted = self._clip_repo.delete(clip_id)
if deleted:
logger.info("删除片段: id=%s", clip_id)
return deleted
def delete_all_clips(self, plan_id: str) -> int:
"""删除计划下所有片段
Returns:
int: 删除的片段数量
"""
count = self._clip_repo.delete_by_plan(plan_id)
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
return count
# ── 渲染生成流程 ────────────────────────────────────────────────────────
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
"""获取计划及其所有片段
Returns:
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
"""
plan = self.get_plan_or_raise(plan_id)
clips = self._clip_repo.list_by_plan(plan_id)
return {
"plan": plan,
"clips": clips,
}
def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
"""获取渲染进度状态
Returns:
dict: {
"plan": EditPlan,
"clips": List[EditPlanClip],
"generation_task_id": Optional[str],
"generation_task_status": Optional[str],
}
Raises:
ValueError: 计划不存在
"""
plan = self.get_plan_or_raise(plan_id)
clips = self._clip_repo.list_by_plan(plan_id)
# 从 plan.config 中获取 generation_task_id
generation_task_id = plan.config.get("generation_task_id")
generation_task_status = None
if generation_task_id:
task = self._generation_task_repo.get(generation_task_id)
if task:
generation_task_status = task.status.value if hasattr(task.status, "value") else task.status
return {
"plan": plan,
"clips": clips,
"generation_task_id": generation_task_id,
"generation_task_status": generation_task_status,
}
def can_generate(self, plan_id: str) -> tuple[bool, str]:
"""检查是否可以触发渲染
Returns:
tuple: (can_generate, reason)
"""
plan = self.get_plan_or_raise(plan_id)
# 检查状态
if plan.status != EditPlanStatus.EDITING:
return False, f"只有 editing 状态的计划可以触发渲染,当前状态: {plan.status}"
# 检查是否有片段
clips = self._clip_repo.list_by_plan(plan_id)
if not clips:
return False, "计划下没有片段,无法触发渲染"
return True, ""
def mark_clips_ready(self, plan_id: str) -> int:
"""将所有 pending 状态的片段标记为 ready
Returns:
int: 标记的片段数量
"""
clips = self._clip_repo.list_by_plan(
plan_id,
status=EditPlanClipStatus.PENDING,
)
count = 0
for clip in clips:
clip.mark_ready()
self._clip_repo.update(clip)
count += 1
logger.info("标记片段就绪: plan_id=%s count=%d", plan_id, count)
return count
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
"""更新计划配置(合并更新)
Args:
plan_id: 计划 ID
config_updates: 要合并的配置
Returns:
更新后的计划
"""
plan = self.get_plan_or_raise(plan_id)
new_config = {**plan.config, **config_updates}
updated = EditPlan(
id=plan.id,
template_id=plan.template_id,
name=plan.name,
status=plan.status,
total_duration=plan.total_duration,
config=new_config,
created_at=plan.created_at,
updated_at=plan.updated_at,
)
return self._plan_repo.update(updated)
@@ -0,0 +1,396 @@
"""EditTemplateService — 模板管理业务逻辑.
封装 EditTemplate 和 TemplateClipConfig 的 CRUD 操作,
提供统一的业务接口供 API 路由层调用。
"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditTemplateRepository,
SQLAlchemyTemplateClipConfigRepository,
)
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
from packages.domain.template_clip_config import (
ClipType,
TemplateClipConfig,
TransitionEffect,
)
logger = logging.getLogger(__name__)
class EditTemplateService:
"""模板管理服务
职责:
- 模板 CRUD(创建、查询、更新、软删除)
- 模板片段配置管理(增删改查)
- 业务校验(名称去重、状态合法性等)
"""
def __init__(self, db: Session) -> None:
self._template_repo = SQLAlchemyEditTemplateRepository(db)
self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
# ── 模板 CRUD ──────────────────────────────────────────────────────────
def list_templates(
self,
*,
template_type: Optional[str] = None,
status: Optional[EditTemplateStatus] = None,
active_only: bool = False,
skip: int = 0,
limit: int = 50,
) -> List[EditTemplate]:
"""列出模板
Args:
template_type: 按类型筛选
status: 按状态筛选
active_only: 仅返回激活模板
skip: 分页偏移
limit: 每页数量
"""
if active_only:
return self._template_repo.list_active(
template_type=template_type,
skip=skip,
limit=limit,
)
return self._template_repo.list_all(
template_type=template_type,
status=status,
skip=skip,
limit=limit,
)
def count_templates(
self,
*,
template_type: Optional[str] = None,
status: Optional[EditTemplateStatus] = None,
) -> int:
"""统计模板数量"""
return self._template_repo.count(
template_type=template_type,
status=status,
)
def get_template(self, template_id: str) -> Optional[EditTemplate]:
"""获取模板详情"""
return self._template_repo.get(template_id)
def get_template_or_raise(self, template_id: str) -> EditTemplate:
"""获取模板,不存在则抛出 ValueError"""
template = self._template_repo.get(template_id)
if template is None:
raise ValueError(f"模板不存在: {template_id}")
return template
def create_template(
self,
name: str,
*,
description: str = "",
template_type: str = "default",
config: Optional[dict[str, Any]] = None,
preview_url: str = "",
sort_weight: int = 0,
) -> EditTemplate:
"""创建模板
Raises:
ValueError: 名称为空或重复
"""
# 名称校验
clean_name = name.strip()
if not clean_name:
raise ValueError("模板名称不能为空")
# 名称重复检查
existing = self._template_repo.list_all(skip=0, limit=1000)
for t in existing:
if t.name == clean_name and t.status == EditTemplateStatus.ACTIVE:
raise ValueError(f"模板名称已存在: {clean_name}")
template = EditTemplate.create(
name=clean_name,
description=description,
template_type=template_type,
config=config,
preview_url=preview_url,
sort_weight=sort_weight,
)
created = self._template_repo.create(template)
logger.info("创建模板: id=%s name=%s", created.id, created.name)
return created
def update_template(
self,
template_id: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
template_type: Optional[str] = None,
config: Optional[dict[str, Any]] = None,
preview_url: Optional[str] = None,
sort_weight: Optional[int] = None,
status: Optional[EditTemplateStatus] = None,
) -> EditTemplate:
"""更新模板
Raises:
ValueError: 模板不存在或名称重复
"""
existing = self.get_template_or_raise(template_id)
# 名称重复检查(排除自身)
new_name = name.strip() if name is not None else existing.name
if name is not None and new_name != existing.name:
all_templates = self._template_repo.list_all(skip=0, limit=1000)
for t in all_templates:
if (
t.id != template_id
and t.name == new_name
and t.status == EditTemplateStatus.ACTIVE
):
raise ValueError(f"模板名称已存在: {new_name}")
# 构建更新后的实体
updated = EditTemplate(
id=existing.id,
name=new_name,
description=description.strip() if description is not None else existing.description,
template_type=template_type.strip() if template_type is not None else existing.template_type,
config=config if config is not None else existing.config,
preview_url=preview_url.strip() if preview_url is not None else existing.preview_url,
sort_weight=sort_weight if sort_weight is not None else existing.sort_weight,
status=status if status is not None else existing.status,
created_at=existing.created_at,
updated_at=existing.updated_at,
)
result = self._template_repo.update(updated)
logger.info("更新模板: id=%s", template_id)
return result
def deactivate_template(self, template_id: str) -> EditTemplate:
"""软删除模板(设为 inactive
Raises:
ValueError: 模板不存在
"""
existing = self.get_template_or_raise(template_id)
existing.deactivate()
result = self._template_repo.update(existing)
logger.info("停用模板: id=%s", template_id)
return result
# ── 模板片段配置管理 ────────────────────────────────────────────────────
def list_clip_configs(
self,
template_id: str,
*,
clip_type: Optional[ClipType] = None,
skip: int = 0,
limit: int = 100,
) -> List[TemplateClipConfig]:
"""列出模板的片段配置"""
# 确保模板存在
self.get_template_or_raise(template_id)
return self._clip_config_repo.list_by_template(
template_id,
clip_type=clip_type,
skip=skip,
limit=limit,
)
def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]:
"""获取片段配置详情"""
return self._clip_config_repo.get(config_id)
def get_clip_config_or_raise(self, config_id: str) -> TemplateClipConfig:
"""获取片段配置,不存在则抛出 ValueError"""
config = self._clip_config_repo.get(config_id)
if config is None:
raise ValueError(f"片段配置不存在: {config_id}")
return config
def create_clip_config(
self,
template_id: str,
clip_type: ClipType | str,
order: int,
*,
min_duration: float = 0.0,
max_duration: float = 0.0,
text_template: str = "",
material_requirements: Optional[dict[str, Any]] = None,
transition_effect: TransitionEffect | str = TransitionEffect.CUT,
config: Optional[dict[str, Any]] = None,
) -> TemplateClipConfig:
"""创建片段配置
Raises:
ValueError: 模板不存在或参数校验失败
"""
# 确保模板存在
self.get_template_or_raise(template_id)
clip_config = TemplateClipConfig.create(
template_id=template_id,
clip_type=clip_type,
order=order,
min_duration=min_duration,
max_duration=max_duration,
text_template=text_template,
material_requirements=material_requirements,
transition_effect=transition_effect,
config=config,
)
created = self._clip_config_repo.create(clip_config)
logger.info(
"创建片段配置: id=%s template_id=%s clip_type=%s order=%d",
created.id,
template_id,
created.clip_type,
created.order,
)
return created
def update_clip_config(
self,
config_id: str,
*,
clip_type: Optional[ClipType | str] = None,
order: Optional[int] = None,
min_duration: Optional[float] = None,
max_duration: Optional[float] = None,
text_template: Optional[str] = None,
material_requirements: Optional[dict[str, Any]] = None,
transition_effect: Optional[TransitionEffect | str] = None,
config: Optional[dict[str, Any]] = None,
) -> TemplateClipConfig:
"""更新片段配置
Raises:
ValueError: 配置不存在或参数校验失败
"""
existing = self.get_clip_config_or_raise(config_id)
# 解析枚举类型
new_clip_type = ClipType(clip_type) if clip_type is not None else existing.clip_type
new_transition = (
TransitionEffect(transition_effect)
if transition_effect is not None
else existing.transition_effect
)
updated = TemplateClipConfig(
id=existing.id,
template_id=existing.template_id,
clip_type=new_clip_type,
order=order if order is not None else existing.order,
min_duration=min_duration if min_duration is not None else existing.min_duration,
max_duration=max_duration if max_duration is not None else existing.max_duration,
text_template=text_template.strip() if text_template is not None else existing.text_template,
material_requirements=material_requirements if material_requirements is not None else existing.material_requirements,
transition_effect=new_transition,
config=config if config is not None else existing.config,
created_at=existing.created_at,
updated_at=existing.updated_at,
)
result = self._clip_config_repo.update(updated)
logger.info("更新片段配置: id=%s", config_id)
return result
def delete_clip_config(self, config_id: str) -> bool:
"""删除片段配置
Returns:
bool: 是否删除成功
"""
deleted = self._clip_config_repo.delete(config_id)
if deleted:
logger.info("删除片段配置: id=%s", config_id)
return deleted
def reorder_clip_configs(
self,
template_id: str,
config_ids: List[str],
) -> List[TemplateClipConfig]:
"""重新排序片段配置
Args:
template_id: 模板 ID
config_ids: 按新顺序排列的配置 ID 列表
Returns:
更新后的配置列表
Raises:
ValueError: 模板不存在或配置 ID 不匹配
"""
# 确保模板存在
self.get_template_or_raise(template_id)
# 获取当前配置
current_configs = self._clip_config_repo.list_by_template(template_id)
current_ids = {c.id for c in current_configs}
# 校验 ID 列表
if set(config_ids) != current_ids:
raise ValueError("配置 ID 列表与模板下的配置不匹配")
# 更新 order
results = []
for new_order, config_id in enumerate(config_ids):
config = self._clip_config_repo.get(config_id)
if config is None:
continue
updated = TemplateClipConfig(
id=config.id,
template_id=config.template_id,
clip_type=config.clip_type,
order=new_order,
min_duration=config.min_duration,
max_duration=config.max_duration,
text_template=config.text_template,
material_requirements=config.material_requirements,
transition_effect=config.transition_effect,
config=config.config,
created_at=config.created_at,
updated_at=config.updated_at,
)
results.append(self._clip_config_repo.update(updated))
logger.info(
"重排序片段配置: template_id=%s count=%d",
template_id,
len(config_ids),
)
return results
def get_template_with_configs(
self,
template_id: str,
) -> dict:
"""获取模板及其所有片段配置
Returns:
dict: {"template": EditTemplate, "clip_configs": List[TemplateClipConfig]}
"""
template = self.get_template_or_raise(template_id)
clip_configs = self._clip_config_repo.list_by_template(template_id)
return {
"template": template,
"clip_configs": clip_configs,
}
+268
View File
@@ -0,0 +1,268 @@
"""JobService 服务层 — Phase 8 任务 2.10.
将 JobService 与 VideoComposeService 集成,提供视频合成的完整异步工作流:
1. 创建 Job(记录任务元数据)
2. 提交执行(dispatch Celery 任务)
3. Celery 任务中更新进度、处理完成/失败
同时也提供通用的 Job 管理能力,供 ClipPlanService、RenderOrchestrator 等使用。
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy.orm import Session
from packages.application.jobs import (
CancelJobUseCase,
CompleteJobCommand,
CompleteJobUseCase,
CreateJobCommand,
CreateJobUseCase,
FailJobCommand,
FailJobUseCase,
GetJobStatisticsUseCase,
GetJobUseCase,
ListJobsUseCase,
RetryJobUseCase,
SubmitJobUseCase,
UpdateJobProgressCommand,
UpdateJobProgressUseCase,
)
from packages.domain.job import Job, JobStatus, JobType
from packages.ports.job_repository import JobRepository
logger = logging.getLogger(__name__)
class JobService:
"""统一异步任务管理服务。
职责:
- 为视频合成等耗时操作提供统一的异步任务管理
- 封装 Use Case 的调用,提供简洁的服务接口
- 与 VideoComposeService 集成,支持视频合成工作流
用法::
job_service = JobService(db)
job = job_service.create_compose_job(
project_id="xxx",
plan_id="yyy",
user_id="zzz",
)
job_service.submit_job(job.id, celery_task_id="celery-xxx")
"""
def __init__(self, job_repo: JobRepository):
self._job_repo = job_repo
# ── 创建任务 ──────────────────────────────────────────────────────────
def create_compose_job(
self,
project_id: str,
plan_id: str,
user_id: str,
*,
max_retries: int = 3,
) -> Job:
"""创建视频合成任务。
Args:
project_id: 项目 ID
plan_id: EditPlan ID
user_id: 创建人 ID
max_retries: 最大重试次数
Returns:
创建的 Job 实例
"""
use_case = CreateJobUseCase(self._job_repo)
return use_case.execute(
CreateJobCommand(
project_id=project_id,
job_type=JobType.VIDEO_COMPOSE,
payload={"plan_id": plan_id},
source_id=plan_id,
created_by_user_id=user_id,
max_retries=max_retries,
)
)
def create_render_job(
self,
project_id: str,
plan_id: str,
user_id: str,
*,
max_retries: int = 3,
) -> Job:
"""创建剪辑计划渲染任务。"""
use_case = CreateJobUseCase(self._job_repo)
return use_case.execute(
CreateJobCommand(
project_id=project_id,
job_type=JobType.RENDER_EDIT_PLAN,
payload={"plan_id": plan_id},
source_id=plan_id,
created_by_user_id=user_id,
max_retries=max_retries,
)
)
def create_job(
self,
project_id: str,
job_type: JobType | str,
*,
payload: dict | None = None,
source_id: str = "",
user_id: str = "",
max_retries: int = 3,
) -> Job:
"""创建通用任务。"""
use_case = CreateJobUseCase(self._job_repo)
return use_case.execute(
CreateJobCommand(
project_id=project_id,
job_type=job_type,
payload=payload or {},
source_id=source_id,
created_by_user_id=user_id,
max_retries=max_retries,
)
)
# ── 提交执行 ──────────────────────────────────────────────────────────
def submit_job(self, job_id: str, celery_task_id: str = "") -> Job:
"""提交任务执行。"""
use_case = SubmitJobUseCase(self._job_repo)
return use_case.execute(job_id, celery_task_id)
# ── 进度更新 ──────────────────────────────────────────────────────────
def update_progress(self, job_id: str, progress: float, stage: str = "") -> Job:
"""更新任务进度。"""
use_case = UpdateJobProgressUseCase(self._job_repo)
return use_case.execute(
UpdateJobProgressCommand(
job_id=job_id,
progress=progress,
current_stage=stage,
)
)
# ── 完成 / 失败 ────────────────────────────────────────────────────────
def complete_job(self, job_id: str, result: dict | None = None) -> Job:
"""标记任务完成。"""
use_case = CompleteJobUseCase(self._job_repo)
return use_case.execute(CompleteJobCommand(job_id=job_id, result=result or {}))
def fail_job(self, job_id: str, error_message: str) -> Job:
"""标记任务失败。"""
use_case = FailJobUseCase(self._job_repo)
return use_case.execute(FailJobCommand(job_id=job_id, error_message=error_message))
# ── 重试 / 取消 ────────────────────────────────────────────────────────
def retry_job(self, job_id: str) -> Job:
"""重试失败任务。"""
use_case = RetryJobUseCase(self._job_repo)
return use_case.execute(job_id)
def cancel_job(self, job_id: str) -> Job:
"""取消任务。"""
use_case = CancelJobUseCase(self._job_repo)
return use_case.execute(job_id)
# ── 查询 ─────────────────────────────────────────────────────────────
def get_job(self, job_id: str) -> Job | None:
"""获取任务详情。"""
use_case = GetJobUseCase(self._job_repo)
return use_case.execute(job_id)
def list_project_jobs(
self,
project_id: str,
*,
job_type: JobType | str | None = None,
status: JobStatus | str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Job]:
"""获取项目下的任务列表。"""
use_case = ListJobsUseCase(self._job_repo)
return use_case.execute(
project_id=project_id,
job_type=job_type,
status=status,
limit=limit,
offset=offset,
)
def list_user_jobs(
self,
user_id: str,
*,
job_type: JobType | str | None = None,
status: JobStatus | str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Job]:
"""获取用户的任务列表。"""
use_case = ListJobsUseCase(self._job_repo)
return use_case.execute(
user_id=user_id,
job_type=job_type,
status=status,
limit=limit,
offset=offset,
)
def get_statistics(self, project_id: str) -> dict[str, Any]:
"""获取项目任务统计。"""
use_case = GetJobStatisticsUseCase(self._job_repo)
return use_case.execute(project_id)
# ── 防重复检查 ────────────────────────────────────────────────────────
def has_active_job_for_source(self, source_id: str, job_type: JobType | str) -> bool:
"""检查是否已有活跃任务(防止重复提交)。
Args:
source_id: 关联的业务实体 ID
job_type: 任务类型
Returns:
True 如果存在活跃任务
"""
return self._job_repo.find_active_by_source(source_id, job_type) is not None
# ── 便捷方法:带防重的视频合成提交 ──────────────────────────────────────
def submit_compose_if_not_exists(
self,
project_id: str,
plan_id: str,
user_id: str,
celery_task_id: str = "",
) -> tuple[Job, bool]:
"""创建并提交视频合成任务(防重复)。
Returns:
(job, created): job 实例和是否新创建的标志
"""
if self.has_active_job_for_source(plan_id, JobType.VIDEO_COMPOSE):
existing = self._job_repo.find_active_by_source(plan_id, JobType.VIDEO_COMPOSE)
logger.info("已存在活跃的视频合成任务: job_id=%s plan_id=%s", existing.id, plan_id)
return existing, False
job = self.create_compose_job(project_id, plan_id, user_id)
job = self.submit_job(job.id, celery_task_id)
return job, True
@@ -0,0 +1,627 @@
"""VideoComposeService — Phase 8 任务 2.09.
FFmpeg 视频合成编排服务:
1. 根据 EditPlan + EditPlanClips 生成 FFmpeg filter_complex 命令
2. 支持逐片段 scale / crop / trim / setpts 滤镜
3. 支持转场效果(fade / slide / dissolve / wipe
4. 支持音频流合并
5. 提供合成前校验逻辑
设计原则:
- 本服务只负责 **命令生成 + 校验**,不执行 FFmpeg
- Worker 层(Celery task)调用本服务生成命令后执行
- API 层可调用 build_compose_command 做预览 / 调试
"""
from __future__ import annotations
import logging
import shutil
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
SQLAlchemyEditPlanClipRepository,
)
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.template_clip_config import TransitionEffect
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_OUTPUT_WIDTH = 1280
DEFAULT_OUTPUT_HEIGHT = 720
DEFAULT_FPS = 25
DEFAULT_CODEC = "libx264"
DEFAULT_CRF = 23
DEFAULT_PRESET = "medium"
# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称
_XFADE_TRANSITION_MAP: dict[str, str] = {
TransitionEffect.FADE: "fade",
TransitionEffect.SLIDE_LEFT: "slideleft",
TransitionEffect.SLIDE_RIGHT: "slideright",
TransitionEffect.DISSOLVE: "dissolve",
TransitionEffect.WIPE: "wipeleft",
}
# 转场默认时长(秒)
DEFAULT_TRANSITION_DURATION = 0.5
# ── 数据结构 ──────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ClipFilterChain:
"""单个片段的滤镜链描述。"""
clip_id: str
input_index: int
video_label: str
audio_label: str | None
filters: list[str]
duration: float
@dataclass(frozen=True)
class ComposeCommand:
"""完整的 FFmpeg 合成命令描述。"""
command: list[str]
"""可直接传给 subprocess.run 的命令列表。"""
filter_complex: str
"""-filter_complex 参数值(方便调试 / 日志)。"""
input_paths: list[str]
"""输入文件路径列表。"""
output_path: str
"""输出文件路径。"""
estimated_duration: float
"""预估输出时长(秒)。"""
clip_chains: list[ClipFilterChain]
"""每个片段的滤镜链描述。"""
@dataclass(frozen=True)
class ComposeValidation:
"""合成前校验结果。"""
valid: bool
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
ready_clip_count: int = 0
total_clip_count: int = 0
# ── 服务主体 ──────────────────────────────────────────────────────────────────
class VideoComposeService:
"""FFmpeg 视频合成编排服务。
职责:
- 根据 EditPlan 及其 Clips 生成 FFmpeg filter_complex 命令
- 校验合成前置条件
- 提供合成状态查询
用法::
svc = VideoComposeService(db)
validation = svc.validate_compose(plan_id)
if validation.valid:
cmd = svc.build_compose_command(plan_id, output_path="/tmp/out.mp4")
subprocess.run(cmd.command, check=True)
"""
def __init__(self, db: Session) -> None:
self._db = db
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
# ── 公开方法 ──────────────────────────────────────────────────────────
def validate_compose(self, plan_id: str) -> ComposeValidation:
"""校验剪辑计划是否可以合成。
检查项:
1. 计划存在
2. 计划状态为 editing 或 rendering
3. 至少有一个 ready 状态的片段
4. 每个 ready 片段都有 asset_id
5. 每个 ready 片段都有 duration > 0
"""
errors: list[str] = []
warnings: list[str] = []
plan = self._plan_repo.get(plan_id)
if plan is None:
return ComposeValidation(
valid=False,
errors=[f"剪辑计划不存在: {plan_id}"],
)
# 状态检查
if plan.status not in (EditPlanStatus.EDITING, EditPlanStatus.RENDERING):
errors.append(
f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status.value}"
)
# 加载片段
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
if not clips:
errors.append("计划没有任何片段")
return ComposeValidation(
valid=False,
errors=errors,
total_clip_count=0,
)
# 按 order 排序
clips.sort(key=lambda c: c.order)
ready_count = 0
pending_count = 0
no_asset_count = 0
no_duration_count = 0
for clip in clips:
if clip.status == EditPlanClipStatus.READY:
ready_count += 1
if not clip.asset_id:
errors.append(f"片段 {clip.id} (order={clip.order}) 没有分配素材")
no_asset_count += 1
if clip.duration <= 0:
warnings.append(
f"片段 {clip.id} (order={clip.order}) 时长为 0,将使用默认时长"
)
no_duration_count += 1
elif clip.status == EditPlanClipStatus.PENDING:
pending_count += 1
elif clip.status == EditPlanClipStatus.FAILED:
warnings.append(f"片段 {clip.id} (order={clip.order}) 状态为 failed,已跳过")
if ready_count == 0:
errors.append("没有就绪(ready)的片段可以合成")
if pending_count > 0:
warnings.append(f"{pending_count} 个片段仍处于 pending 状态")
return ComposeValidation(
valid=len(errors) == 0,
errors=errors,
warnings=warnings,
ready_clip_count=ready_count,
total_clip_count=len(clips),
)
def build_compose_command(
self,
plan_id: str,
output_path: str,
*,
output_width: int = DEFAULT_OUTPUT_WIDTH,
output_height: int = DEFAULT_OUTPUT_HEIGHT,
fps: int = DEFAULT_FPS,
codec: str = DEFAULT_CODEC,
crf: int = DEFAULT_CRF,
preset: str = DEFAULT_PRESET,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
) -> ComposeCommand:
"""构建 FFmpeg 合成命令。
根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。
滤镜链逻辑:
- 每个片段:scale → crop → setpts → trim → atrim
- 多片段之间:concat 滤镜 或 xfade 转场
- 最终输出:-map '[outv]' -map '[outa]'(如有音频)
"""
plan = self._plan_repo.get(plan_id)
if plan is None:
raise ValueError(f"剪辑计划不存在: {plan_id}")
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
if not clips:
raise ValueError(f"剪辑计划没有片段: {plan_id}")
# 只处理 ready 且有 asset_id 的片段
ready_clips = [
c for c in clips
if c.status == EditPlanClipStatus.READY and c.asset_id
]
ready_clips.sort(key=lambda c: c.order)
if not ready_clips:
raise ValueError(f"剪辑计划没有可合成的片段: {plan_id}")
# 构建每个片段的滤镜链
clip_chains: list[ClipFilterChain] = []
input_paths: list[str] = []
for idx, clip in enumerate(ready_clips):
chain = self._build_clip_filter(
clip=clip,
input_index=idx,
output_width=output_width,
output_height=output_height,
fps=fps,
)
clip_chains.append(chain)
input_paths.append(clip.asset_id) # asset_id 存储的是 storage_key / URL
# 构建 filter_complex
filter_complex, estimated_duration = self._build_filter_complex(
clip_chains=clip_chains,
output_width=output_width,
output_height=output_height,
transition_duration=transition_duration,
transitions=[c.transition_effect for c in ready_clips],
)
# 构建完整命令
command: list[str] = ["ffmpeg", "-y"]
# 输入文件
for path in input_paths:
command.extend(["-i", path])
# filter_complex
command.extend(["-filter_complex", filter_complex])
# 映射输出流
command.extend(["-map", "[outv]"])
if self._has_audio(clip_chains):
command.extend(["-map", "[outa]"])
# 编码参数
command.extend([
"-c:v", codec,
"-crf", str(crf),
"-preset", preset,
"-c:a", "aac",
"-b:a", "192k",
])
# 输出
command.append(output_path)
return ComposeCommand(
command=command,
filter_complex=filter_complex,
input_paths=input_paths,
output_path=output_path,
estimated_duration=estimated_duration,
clip_chains=clip_chains,
)
def build_single_clip_command(
self,
clip_id: str,
output_path: str,
*,
output_width: int = DEFAULT_OUTPUT_WIDTH,
output_height: int = DEFAULT_OUTPUT_HEIGHT,
fps: int = DEFAULT_FPS,
) -> ComposeCommand:
"""为单个片段构建 FFmpeg 命令(预览 / 调试用)。"""
clip = self._clip_repo.get(clip_id)
if clip is None:
raise ValueError(f"片段不存在: {clip_id}")
if not clip.asset_id:
raise ValueError(f"片段没有分配素材: {clip_id}")
chain = self._build_clip_filter(
clip=clip,
input_index=0,
output_width=output_width,
output_height=output_height,
fps=fps,
)
# 简单命令:input → filter → output
filter_str = ",".join(chain.filters)
command = [
"ffmpeg", "-y",
"-i", clip.asset_id,
"-filter_complex", f"{filter_str}[outv]",
"-map", "[outv]",
"-c:v", DEFAULT_CODEC,
"-crf", str(DEFAULT_CRF),
"-preset", DEFAULT_PRESET,
output_path,
]
return ComposeCommand(
command=command,
filter_complex=filter_str,
input_paths=[clip.asset_id],
output_path=output_path,
estimated_duration=clip.duration,
clip_chains=[chain],
)
def get_compose_status(self, plan_id: str) -> dict[str, Any]:
"""获取合成状态摘要。"""
plan = self._plan_repo.get(plan_id)
if plan is None:
raise ValueError(f"剪辑计划不存在: {plan_id}")
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
clips.sort(key=lambda c: c.order)
total_duration = sum(c.duration for c in clips if c.duration > 0)
ready_clips = [c for c in clips if c.status == EditPlanClipStatus.READY]
pending_clips = [c for c in clips if c.status == EditPlanClipStatus.PENDING]
rendered_clips = [c for c in clips if c.status == EditPlanClipStatus.RENDERED]
failed_clips = [c for c in clips if c.status == EditPlanClipStatus.FAILED]
return {
"plan_id": plan_id,
"plan_status": plan.status.value,
"total_clips": len(clips),
"ready_clips": len(ready_clips),
"pending_clips": len(pending_clips),
"rendered_clips": len(rendered_clips),
"failed_clips": len(failed_clips),
"total_duration": total_duration,
"can_compose": len(ready_clips) > 0 and plan.status in (
EditPlanStatus.EDITING,
EditPlanStatus.RENDERING,
),
"rendered_url": plan.config.get("rendered_url", ""),
}
# ── 内部方法 ──────────────────────────────────────────────────────────
@staticmethod
def _build_clip_filter(
clip: EditPlanClip,
input_index: int,
output_width: int,
output_height: int,
fps: int,
) -> ClipFilterChain:
"""为单个片段构建滤镜链。
滤镜顺序:
1. scale — 等比缩放到目标分辨率(保证覆盖)
2. crop — 居中裁剪到目标分辨率
3. setpts — 重置时间戳 + 偏移
4. trim — 视频时长裁剪
5. atrim — 音频时长裁剪(如有音频流)
"""
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
start = clip.start_time
filters: list[str] = []
# 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop
filters.append(
f"scale={output_width}:{output_height}"
f":force_original_aspect_ratio=increase"
)
# 2. crop: 居中裁剪
filters.append(f"crop={output_width}:{output_height}")
# 3. setpts: 重置时间戳
if start > 0:
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
else:
filters.append("setpts=PTS-STARTPTS")
# 4. trim: 视频时长
filters.append(f"trim=0:{duration}")
filters.append(f"setpts=PTS-STARTPTS") # trim 后需要重置 PTS
video_label = f"v{input_index}"
# 5. 音频标签:仅当片段类型可能有音频时才设置
# title/subtitle 是纯文字/图片卡片,没有音频流
clip_type = clip.clip_type.lower() if clip.clip_type else ""
has_audio_stream = clip_type not in ("title", "subtitle")
audio_label = f"a{input_index}" if has_audio_stream else None
return ClipFilterChain(
clip_id=clip.id,
input_index=input_index,
video_label=video_label,
audio_label=audio_label,
filters=filters,
duration=duration,
)
@staticmethod
def _build_filter_complex(
clip_chains: list[ClipFilterChain],
output_width: int,
output_height: int,
transition_duration: float,
transitions: list[str],
) -> tuple[str, float]:
"""构建完整的 filter_complex 字符串。
策略:
- 单片段:直接输出
- 多片段 + 全 cut:使用 concat 滤镜(高效)
- 多片段 + 有转场:使用 xfade 滤镜链
返回 (filter_complex_string, estimated_total_duration)。
"""
n = len(clip_chains)
if n == 0:
return "", 0.0
# ── 单片段 ─────────────────────────────────────────────────────
if n == 1:
chain = clip_chains[0]
filter_str = _chain_filters(chain.filters, chain.video_label)
# 音频
if chain.audio_label:
filter_str += f";[0:a]{chain.audio_label}"
total_duration = chain.duration
return filter_str, total_duration
# ── 检查是否有转场 ─────────────────────────────────────────────
has_transitions = any(
t != TransitionEffect.CUT and t != "cut"
for t in transitions
)
if not has_transitions:
return _build_concat_filter(clip_chains)
# ── 有转场:使用 xfade ─────────────────────────────────────────
return _build_xfade_filter(
clip_chains=clip_chains,
transition_duration=transition_duration,
transitions=transitions,
)
@staticmethod
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
"""是否有任何片段包含音频流。"""
return any(c.audio_label is not None for c in clip_chains)
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
def _chain_filters(filters: list[str], output_label: str) -> str:
"""将滤镜列表串联为 FFmpeg 滤镜字符串。"""
filter_body = ",".join(filters)
return f"[0:v]{filter_body}[{output_label}]"
def _build_concat_filter(
clip_chains: list[ClipFilterChain],
) -> tuple[str, float]:
"""构建 concat 滤镜(无转场,高效拼接)。
格式:
[0:v]filters[v0]; [1:v]filters[v1]; ...
[v0][v1]...[vN]concat=n=N:v=1:a=0[outv]
"""
n = len(clip_chains)
parts: list[str] = []
total_duration = 0.0
# 每个片段的滤镜链
for idx, chain in enumerate(clip_chains):
filter_body = ",".join(chain.filters)
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
total_duration += chain.duration
# concat 滤镜
concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains)
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
parts.append(concat_filter)
# 音频 concat(如果有)
audio_parts: list[str] = []
for idx, chain in enumerate(clip_chains):
if chain.audio_label:
audio_parts.append(
f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]"
)
if audio_parts:
parts.extend(audio_parts)
audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label)
audio_count = sum(1 for c in clip_chains if c.audio_label)
if audio_count > 0:
parts.append(
f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]"
)
return ";".join(parts), total_duration
def _build_xfade_filter(
clip_chains: list[ClipFilterChain],
transition_duration: float,
transitions: list[str],
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链。
每两个相邻片段之间插入 xfade 转场。
offset = 前一个片段的累积时长 - 转场时长。
格式(2 片段):
[0:v]filters[v0]; [1:v]filters[v1];
[v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv]
格式(3+ 片段):
[v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv]
"""
n = len(clip_chains)
parts: list[str] = []
total_duration = 0.0
# 每个片段的滤镜链
for idx, chain in enumerate(clip_chains):
filter_body = ",".join(chain.filters)
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
total_duration += chain.duration
# xfade 链
if n == 1:
# 单片段不需要 xfade
parts.append(f"[{clip_chains[0].video_label}]copy[outv]")
return ";".join(parts), total_duration
# 计算每个转场的 offset
cumulative = 0.0
prev_label = clip_chains[0].video_label
for i in range(1, n):
cumulative += clip_chains[i - 1].duration
offset = max(0.0, cumulative - transition_duration * i)
# 获取转场类型
transition = transitions[i] if i < len(transitions) else "cut"
xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade")
if i == n - 1:
# 最后一个转场,输出到 [outv]
out_label = "outv"
else:
out_label = f"xf{i}"
parts.append(
f"[{prev_label}][{clip_chains[i].video_label}]"
f"xfade=transition={xfade_transition}"
f":duration={transition_duration}"
f":offset={offset:.3f}"
f"[{out_label}]"
)
prev_label = out_label
# 总时长需要减去转场重叠部分
total_duration -= transition_duration * (n - 1)
# 音频 crossfade(简化处理:使用 adelay + amix
audio_labels = [c.audio_label for c in clip_chains if c.audio_label]
if len(audio_labels) >= 2:
# 简单拼接音频(不做 crossfade)
audio_inputs = "".join(f"[{label}]" for label in audio_labels)
parts.append(
f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]"
)
elif len(audio_labels) == 1:
parts.append(f"[{audio_labels[0]}]acopy[outa]")
return ";".join(parts), max(0.0, total_duration)
+161
View File
@@ -0,0 +1,161 @@
/**
* 账号管理 Mock API
*
* 模拟多平台账号绑定/解绑操作
* 支持平台:抖音、快手、小红书、微信视频号
*/
/* ── 类型定义 ───────────────────────────────────────────── */
/** 平台 ID */
export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat";
/** 账号状态 */
export type AccountStatus = "active" | "expired" | "limited";
/** 已绑定的账号 */
export interface Account {
id: string;
platform_id: PlatformId;
name: string;
avatar?: string;
status: AccountStatus;
bound_at: string;
}
/** 平台信息 */
export interface Platform {
id: PlatformId;
name: string;
subName: string;
icon: string;
gradient: string;
}
/** 绑定账号请求 */
export interface BindAccountRequest {
platform_id: PlatformId;
name: string;
}
/* ── 平台配置 ───────────────────────────────────────────── */
export const PLATFORMS: Platform[] = [
{
id: "douyin",
name: "抖音",
subName: "短视频发布平台",
icon: "📱",
gradient: "linear-gradient(135deg, #fe2c55, #25f4ee)",
},
{
id: "kuaishou",
name: "快手",
subName: "短视频发布平台",
icon: "🎬",
gradient: "linear-gradient(135deg, #ff4906, #ffba00)",
},
{
id: "xiaohongshu",
name: "小红书",
subName: "种草笔记发布平台",
icon: "📕",
gradient: "linear-gradient(135deg, #ff2442, #ff6b6b)",
},
{
id: "wechat",
name: "微信视频号",
subName: "视频号发布平台",
icon: "💬",
gradient: "linear-gradient(135deg, #07c160, #4cd964)",
},
];
/* ── Mock 数据 ───────────────────────────────────────────── */
let MOCK_ACCOUNTS: Account[] = [
{
id: "acc-001",
platform_id: "douyin",
name: "小虾官方号",
avatar: "🦐",
status: "active",
bound_at: "2025-12-01T10:00:00Z",
},
{
id: "acc-002",
platform_id: "douyin",
name: "小虾日常",
avatar: "🐟",
status: "active",
bound_at: "2025-12-15T14:30:00Z",
},
{
id: "acc-003",
platform_id: "kuaishou",
name: "小虾剪辑",
avatar: "🎬",
status: "active",
bound_at: "2026-01-05T09:00:00Z",
},
{
id: "acc-004",
platform_id: "xiaohongshu",
name: "小虾种草",
avatar: "📕",
status: "limited",
bound_at: "2026-02-20T16:00:00Z",
},
];
/* ── 模拟延迟 ───────────────────────────────────────────── */
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* ── API 函数 ───────────────────────────────────────────── */
/** 获取指定平台的账号列表 */
export async function getAccountsByPlatform(
platformId: PlatformId,
): Promise<Account[]> {
await delay(300);
return MOCK_ACCOUNTS.filter((a) => a.platform_id === platformId);
}
/** 获取所有平台的账号总数 */
export async function getAllAccounts(): Promise<Account[]> {
await delay(200);
return [...MOCK_ACCOUNTS];
}
/** 绑定新账号 */
export async function bindAccount(data: BindAccountRequest): Promise<Account> {
await delay(500);
const newAccount: Account = {
id: `acc-${Date.now()}`,
platform_id: data.platform_id,
name: data.name,
avatar: undefined,
status: "active",
bound_at: new Date().toISOString(),
};
MOCK_ACCOUNTS = [...MOCK_ACCOUNTS, newAccount];
return newAccount;
}
/** 解绑账号 */
export async function unbindAccount(accountId: string): Promise<void> {
await delay(400);
MOCK_ACCOUNTS = MOCK_ACCOUNTS.filter((a) => a.id !== accountId);
}
/* ── 状态配置 ───────────────────────────────────────────── */
export const ACCOUNT_STATUS_CONFIG: Record<
AccountStatus,
{ label: string; className: string }
> = {
active: { label: "正常", className: "acc-status--active" },
expired: { label: "已过期", className: "acc-status--expired" },
limited: { label: "受限", className: "acc-status--limited" },
};
+289
View File
@@ -0,0 +1,289 @@
/**
* 剪辑计划 API — 对接后端 Edit Plans Schema
* 字段名严格匹配后端 API 响应
*/
import apiClient from "./client";
import type { AssetItem } from "./assets";
/* ============================================================
* 后端 API 类型(严格匹配后端 Schema)
* ============================================================ */
/** 剪辑计划状态枚举 */
export type EditPlanStatus =
| "draft"
| "editing"
| "rendering"
| "completed"
| "failed";
/** 剪辑计划(后端响应) */
export interface EditPlan {
id: string;
template_id: string;
name: string;
status: EditPlanStatus;
total_duration: number;
config: Record<string, unknown>;
created_at: string;
updated_at: string;
}
/** 创建剪辑计划请求(后端要求 template_id + name 必填) */
export interface CreateEditPlanRequest {
template_id: string;
name: string;
config?: Record<string, unknown>;
total_duration?: number;
}
/** 更新剪辑计划请求 */
export interface UpdateEditPlanRequest {
name?: string;
config?: Record<string, unknown>;
total_duration?: number;
status?: EditPlanStatus;
}
/** 生成响应 */
export interface GenerateResponse {
plan_id: string;
plan_status: EditPlanStatus;
generation_task_id: string;
clip_count: number;
}
/** 片段生成状态 */
export interface ClipStatusItem {
clip_id: string;
clip_type: string;
order: number;
status: string;
asset_id?: string;
text_content?: string;
duration?: number;
}
/** 生成状态轮询响应 */
export interface GenerationStatusResponse {
plan_id: string;
plan_status: EditPlanStatus;
generation_task_id?: string;
clips: ClipStatusItem[];
}
/* ============================================================
* 前端 UI 类型(EditingPlanner 组件依赖,保留兼容)
* ============================================================ */
/** 剪辑计划中的片段(UI 层类型) */
export interface EditPlanClip {
id: string;
template_segment_id: string;
/** 素材库中的素材 ID */
media_asset_id?: string;
/** 素材类型 */
material_type: "video" | "image" | "audio" | "voiceover";
/** 片段文案 */
script_text: string;
/** 实际时长(秒) */
duration: number;
/** 转场效果 */
transition?: TransitionEffect;
/** 排序 */
order: number;
}
/** 转场效果 */
export interface TransitionEffect {
type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide";
duration: number; // 转场时长(秒)
}
/** 素材库资产(UI 层类型,映射自后端 AssetResponse */
export interface MediaAsset {
id: string;
name: string;
type: "video" | "image" | "audio";
/** 缩略图 URL */
thumbnail_url?: string;
/** 时长(秒),仅 video/audio */
duration?: number;
/** 文件大小(字节) */
size?: number;
/** 标签 */
tags: string[];
created_at: string;
/** 质量分 0-100 */
quality_score?: number;
/** 分类状态 */
classification_status?: "pending" | "processing" | "completed" | "failed";
}
/* ============================================================
* API 函数 — 严格对接后端
* ============================================================ */
/** 获取剪辑计划列表 */
export async function getEditPlans(params?: {
page?: number;
page_size?: number;
template_id?: string;
status?: string;
}): Promise<EditPlan[]> {
const response = await apiClient.get("/edit-plans", { params });
return response.data.items || [];
}
/** 获取单个剪辑计划 */
export async function getEditPlan(planId: string): Promise<EditPlan> {
const response = await apiClient.get(`/edit-plans/${planId}`);
return response.data;
}
/** 创建剪辑计划 */
export async function createEditPlan(
data: CreateEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.post("/edit-plans", data);
return response.data;
}
/** 更新剪辑计划 */
export async function updateEditPlan(
planId: string,
data: UpdateEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.put(`/edit-plans/${planId}`, data);
return response.data;
}
/** 删除剪辑计划 */
export async function deleteEditPlan(planId: string): Promise<void> {
await apiClient.delete(`/edit-plans/${planId}`);
}
/** 触发剪辑计划生成 */
export async function generateEditPlan(
planId: string,
): Promise<GenerateResponse> {
const response = await apiClient.post(`/edit-plans/${planId}/generate`);
return response.data;
}
/** 获取剪辑计划生成状态(轮询用) */
export async function getGenerationStatus(
planId: string,
): Promise<GenerationStatusResponse> {
const response = await apiClient.get(
`/edit-plans/${planId}/generation-status`,
);
return response.data;
}
/**
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
*/
export async function getMediaAssets(
libraryId?: string,
): Promise<MediaAsset[]> {
const response = await apiClient.get("/assets", {
params: libraryId ? { library_id: libraryId } : undefined,
});
const items: AssetItem[] = response.data.items || [];
return items.map(mapAssetToMediaAsset);
}
/** 获取单个素材 */
export async function getMediaAsset(id: string): Promise<MediaAsset> {
const response = await apiClient.get(`/assets/${id}`);
return mapAssetToMediaAsset(response.data);
}
/* ============================================================
* 映射函数:AssetResponse → MediaAsset
* ============================================================ */
function inferMediaType(mimeType: string): "video" | "image" | "audio" {
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("image/")) return "image";
return "audio";
}
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
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: 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) as MediaAsset["classification_status"],
};
}
/* ============================================================
* 常量
* ============================================================ */
/** 转场效果选项 */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"];
label: string;
}[] = [
{ value: "none", label: "无转场" },
{ value: "fade", label: "淡入淡出" },
{ value: "dissolve", label: "溶解" },
{ value: "wipe", label: "擦除" },
{ value: "zoom", label: "缩放" },
{ value: "slide", label: "滑动" },
];
/** 素材类型标签 */
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
};
/** 素材类型图标 */
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
};
/** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
draft: "草稿",
editing: "编辑中",
rendering: "渲染中",
completed: "已完成",
failed: "失败",
};
/** 质量分筛选选项 */
export const QUALITY_OPTIONS: {
value: string;
label: string;
min?: number;
max?: number;
}[] = [
{ value: "all", label: "全部质量" },
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
{ value: "low", label: "低质量 (0-49)", min: 0, max: 49 },
];
+6
View File
@@ -70,6 +70,12 @@ export const getUserTasks = async (): Promise<TaskItem[]> => {
return data.items || [];
};
/** 获取单个任务详情(用于轮询进度) */
export const getTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.get(`/tasks/${taskId}`);
return data;
};
/** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.post(`/tasks/${taskId}/retry`);
+127
View File
@@ -0,0 +1,127 @@
/**
* TTS 语音合成 API
* 对接后端 /api/v1/tts/* 端点
*
* 任务 3.14 新增
*/
import apiClient from "./client";
/* ── 类型定义 ──────────────────────────────────── */
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string;
voice_id?: string;
output_name?: string;
language?: string;
speed?: number;
voice_model?: string;
voice_clone_profile_id?: string;
format?: string;
metadata?: Record<string, unknown>;
}
/** TTS 合成创建响应 */
export interface TTSSynthesizeResponse {
job_id: string;
status: string;
message: string;
}
/** TTS 任务详情 */
export interface TTSJob {
id: string;
user_id: string;
project_id: string | null;
text: string;
voice_id: string | null;
voice_model: string | null;
voice_clone_profile_id: string | null;
language: string;
speed: number;
output_name: string | null;
output_audio_url: string | null;
output_format: string;
duration_seconds: number | null;
file_size_bytes: number | null;
sample_rate: number | null;
status: string;
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
/** TTS 任务状态(轻量轮询用) */
export interface TTSJobStatus {
id: string;
status: string;
output_audio_url: string | null;
error_message: string | null;
duration_seconds: number | null;
retry_count: number;
}
/** TTS 任务列表响应 */
export interface TTSJobListResponse {
items: TTSJob[];
total: number;
skip: number;
limit: number;
}
/** TTS 任务列表查询参数 */
export interface TTSJobListParams {
status?: string;
skip?: number;
limit?: number;
}
/* ── API 函数 ──────────────────────────────────── */
/** 创建 TTS 合成任务 */
export const synthesizeSpeech = async (
data: TTSSynthesizeRequest,
): Promise<TTSSynthesizeResponse> => {
const response = await apiClient.post<TTSSynthesizeResponse>(
"/tts/synthesize",
data,
);
return response.data;
};
/** 获取 TTS 任务详情 */
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`);
return response.data;
};
/** 获取 TTS 任务状态(轻量轮询) */
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
const response = await apiClient.get<TTSJobStatus>(
`/tts/jobs/${jobId}/status`,
);
return response.data;
};
/** 获取 TTS 任务列表 */
export const getTTSJobs = async (
params?: TTSJobListParams,
): Promise<TTSJobListResponse> => {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set("status", params.status);
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit));
const qs = searchParams.toString();
const response = await apiClient.get<TTSJobListResponse>(
`/tts/jobs${qs ? `?${qs}` : ""}`,
);
return response.data;
};
/** 删除 TTS 任务 */
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`);
};
+211
View File
@@ -0,0 +1,211 @@
/**
* 音色克隆 API
* 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05)
* 任务 3.15:新增 progress 字段用于进度展示
*/
import apiClient from "./client";
/* ── 前端兼容类型 ─────────────────────────────────────── */
/** 克隆音色状态(前端展示用) */
export type VoiceCloneStatus = "ready" | "processing" | "failed";
/** 克隆音色条目(前端展示用) */
export interface VoiceClone {
id: string;
name: string;
description: string;
duration_seconds: number;
status: VoiceCloneStatus;
/** 克隆进度 0-100,仅 processing 状态时有值 */
progress: number;
sample_url?: string;
language: string;
gender: string;
error_message: string | null;
created_at: string;
updated_at: string;
}
/** 创建克隆请求(前端简化版) */
export interface CreateVoiceCloneRequest {
name: string;
audio_url: string;
description?: string;
}
/* ── 后端 API 类型 ────────────────────────────────────── */
/** 后端克隆档案响应 */
export interface VoiceCloneProfile {
id: string;
user_id: string;
name: string;
description: string;
source_audio_url: string;
voice_id: string | null;
voice_model: string;
language: string;
gender: string;
status: "pending" | "processing" | "ready" | "failed";
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
/** 后端克隆列表响应 */
export interface ListVoiceCloneResponse {
items: VoiceCloneProfile[];
total: number;
}
/** 后端克隆状态响应 */
export interface VoiceCloneStatusResponse {
id: string;
status: "pending" | "processing" | "ready" | "failed";
error_message: string | null;
voice_id: string | null;
retry_count: number;
}
/** 后端创建克隆请求(完整版) */
export interface CreateVoiceCloneRequestFull {
name: string;
description?: string;
source_audio_url: string;
voice_model?: string;
language?: string;
gender?: string;
max_retries?: number;
metadata_?: Record<string, unknown>;
}
/* ── 辅助函数 ─────────────────────────────────────────── */
/**
* 将后端 VoiceCloneProfile 转换为前端 VoiceClone
* 后端 status "pending" 映射为前端 "processing"
*/
export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
id: profile.id,
name: profile.name,
description: profile.description || "",
duration_seconds: 0,
status: profile.status === "pending" ? "processing" : profile.status,
progress: 0,
sample_url: profile.source_audio_url || undefined,
language: profile.language || "",
gender: profile.gender || "",
error_message: profile.error_message || null,
created_at: profile.created_at,
updated_at: profile.updated_at,
});
/** 格式化时长 */
export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
};
/* ── 查询参数 ─────────────────────────────────────────── */
export interface VoiceCloneListParams {
status?: string;
skip?: number;
limit?: number;
}
/* ── API 函数 ─────────────────────────────────────────── */
/** 获取克隆音色列表(返回前端兼容数组) */
export const getVoiceClones = async (
params?: VoiceCloneListParams,
): Promise<VoiceClone[]> => {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set("status", params.status);
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit));
const qs = searchParams.toString();
const response = await apiClient.get<ListVoiceCloneResponse>(
`/voice-clones${qs ? `?${qs}` : ""}`,
);
return response.data.items.map(toVoiceClone);
};
/** 获取克隆音色列表(返回完整响应含 total) */
export const getVoiceClonesWithTotal = async (
params?: VoiceCloneListParams,
): Promise<ListVoiceCloneResponse> => {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set("status", params.status);
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit));
const qs = searchParams.toString();
const response = await apiClient.get<ListVoiceCloneResponse>(
`/voice-clones${qs ? `?${qs}` : ""}`,
);
return response.data;
};
/** 获取单个克隆音色详情 */
export const getVoiceCloneDetail = async (
id: string,
): Promise<VoiceCloneProfile> => {
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`);
return response.data;
};
/** 创建克隆音色 */
export const createVoiceClone = async (
data: CreateVoiceCloneRequest,
): Promise<VoiceCloneProfile> => {
const payload: CreateVoiceCloneRequestFull = {
name: data.name,
description: data.description,
source_audio_url: data.audio_url,
};
const response = await apiClient.post<VoiceCloneProfile>(
"/voice-clones",
payload,
);
return response.data;
};
/** 删除克隆音色 */
export const deleteVoiceClone = async (id: string): Promise<void> => {
await apiClient.delete(`/voice-clones/${id}`);
};
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
export const updateVoiceClone = async (
id: string,
data: Partial<Pick<VoiceClone, "name">>,
): Promise<VoiceClone> => {
// 后端暂未提供更新端点,暂用详情接口模拟
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`);
return toVoiceClone({ ...response.data, ...data, updated_at: new Date().toISOString() });
};
/** 获取克隆状态 */
export const getVoiceCloneStatus = async (
id: string,
): Promise<VoiceCloneStatusResponse> => {
const response = await apiClient.get<VoiceCloneStatusResponse>(
`/voice-clones/${id}/status`,
);
return response.data;
};
/** 重试克隆 */
export const retryVoiceClone = async (
id: string,
): Promise<VoiceCloneProfile> => {
const response = await apiClient.post<VoiceCloneProfile>(
`/voice-clones/${id}/retry`,
);
return response.data;
};
+89 -5
View File
@@ -1,10 +1,94 @@
/**
* 配音相关 API
* Phase 1 新增:全局配音库
*
* 任务 3.11:新增统一音色 API(对接后端 3.04),保留旧接口向后兼容
*/
import apiClient from "./client";
/** 配音条目 */
/* ── 统一音色 API(后端 3.04) ─────────────────────────── */
/** 统一音色条目(preset + clone 混合) */
export interface UnifiedVoiceItem {
id: string;
type: "preset" | "clone";
name: string;
description: string;
gender: string;
language: string;
voice_id: string;
voice_provider: string;
audio_url: string | null;
preview_url: string | null;
duration: number | null;
file_size: number | null;
status: string;
tags: string[];
user_id: string | null;
project_id: string | null;
voice_clone_profile_id: string | null;
created_at: string | null;
updated_at: string | null;
}
/** 统一音色列表响应 */
export interface UnifiedVoiceListResponse {
items: UnifiedVoiceItem[];
total: number;
preset_count: number;
clone_count: number;
}
/** 预设音色条目 */
export interface PresetVoiceItem {
voice_id: string;
name: string;
description: string;
gender: string;
language: string;
preview_url: string | null;
tags: string[];
}
/** 预设音色列表响应 */
export interface PresetVoiceListResponse {
items: PresetVoiceItem[];
total: number;
}
/** 统一列表查询参数 */
export interface UnifiedVoiceListParams {
type?: "preset" | "clone";
status?: string;
skip?: number;
limit?: number;
}
/** 获取统一音色列表(推荐) */
export const fetchVoices = async (
params?: UnifiedVoiceListParams,
): Promise<UnifiedVoiceListResponse> => {
const searchParams = new URLSearchParams();
if (params?.type) searchParams.set("type", params.type);
if (params?.status) searchParams.set("status", params.status);
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit));
const qs = searchParams.toString();
const response = await apiClient.get<UnifiedVoiceListResponse>(
`/voices${qs ? `?${qs}` : ""}`,
);
return response.data;
};
/** 获取预设音色列表(无需鉴权) */
export const fetchPresetVoices = async (): Promise<PresetVoiceListResponse> => {
const response = await apiClient.get<PresetVoiceListResponse>("/voices/presets");
return response.data;
};
/* ── 向后兼容(旧接口) ────────────────────────────────── */
/** 配音条目(旧) */
export interface VoiceItem {
id: string;
name: string;
@@ -19,16 +103,16 @@ export interface VoiceItem {
updated_at?: string;
}
/** 创建配音请求 */
/** 创建配音请求(旧) */
export interface CreateVoiceRequest {
name: string;
text: string;
voice_type?: string;
}
/** 获取当前用户的所有配音 */
/** 获取当前用户的所有配音(旧 → /voices/legacy */
export const getVoices = async (): Promise<VoiceItem[]> => {
const response = await apiClient.get("/voices");
const response = await apiClient.get("/voices/legacy");
return response.data.items || response.data || [];
};
@@ -45,7 +129,7 @@ export const updateVoice = async (
voiceId: string,
data: Partial<CreateVoiceRequest>,
): Promise<VoiceItem> => {
const response = await apiClient.patch(`/voices/${voiceId}`, data);
const response = await apiClient.put(`/voices/${voiceId}`, data);
return response.data;
};
@@ -0,0 +1,527 @@
/**
* 素材选择器 - V21 设计系统样式
* 支持批量选择、拖拽排序、预览缩略图、质量分筛选
* 前缀: as- (AssetSelector)
*/
@import "../../styles/global.css";
/* ============================================================
容器
============================================================ */
.as-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ── 工具栏 ─────────────────────────────────────────────────── */
.as-toolbar {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
flex-wrap: wrap;
}
.as-toolbar-left {
display: flex;
align-items: center;
gap: var(--space-sm);
flex: 1;
min-width: 0;
}
.as-toolbar-right {
display: flex;
align-items: center;
gap: var(--space-xs);
}
/* 搜索框 */
.as-search {
flex: 1;
min-width: 120px;
max-width: 200px;
}
/* 视图切换按钮 */
.as-view-btn {
width: 28px;
height: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition-all);
font-size: 14px;
}
.as-view-btn:hover {
color: var(--primary-color);
border-color: var(--primary-color);
}
.as-view-btn.active {
background: var(--primary-soft);
color: var(--primary-color);
border-color: var(--primary-color);
}
/* ── 批量操作栏 ─────────────────────────────────────────────── */
.as-batch-bar {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 6px var(--space-md);
background: var(--primary-soft);
border-bottom: 1px solid var(--color-primary-200);
font-size: var(--font-size-sm);
color: var(--primary-color);
flex-shrink: 0;
}
.as-batch-bar-count {
font-weight: 600;
}
.as-batch-bar-actions {
display: flex;
gap: var(--space-xs);
margin-left: auto;
}
/* ── 素材列表区域 ───────────────────────────────────────────── */
.as-body {
flex: 1;
overflow-y: auto;
padding: var(--space-sm);
}
.as-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-2xl) var(--space-md);
text-align: center;
color: var(--text-secondary);
}
.as-empty-icon {
font-size: 36px;
margin-bottom: var(--space-sm);
opacity: 0.4;
}
.as-empty p {
margin: 0;
font-size: var(--font-size-sm);
}
/* ── 网格视图 ───────────────────────────────────────────────── */
.as-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: var(--space-sm);
}
.as-grid.compact {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 6px;
}
/* ── 列表视图 ───────────────────────────────────────────────── */
.as-list {
display: flex;
flex-direction: column;
gap: 4px;
}
/* ============================================================
素材卡片 — 网格模式
============================================================ */
.as-card {
position: relative;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: var(--transition-all);
background: var(--bg-primary);
user-select: none;
}
.as-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
.as-card.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-soft);
}
.as-card.dragging {
opacity: 0.4;
}
.as-card.drag-over {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color);
}
/* 缩略图区域 */
.as-card-thumb {
position: relative;
aspect-ratio: 9 / 16;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.as-card-thumb-icon {
font-size: 28px;
opacity: 0.4;
}
.as-card-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 类型角标 */
.as-card-type-badge {
position: absolute;
top: 4px;
left: 4px;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 10px;
line-height: 1.4;
backdrop-filter: blur(4px);
}
/* 时长角标 */
.as-card-duration {
position: absolute;
bottom: 4px;
right: 4px;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
}
/* 质量分角标 */
.as-card-quality {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
color: #fff;
}
.as-card-quality.excellent {
background: var(--success-color, #10b981);
}
.as-card-quality.good {
background: var(--primary-color, #6366f1);
}
.as-card-quality.fair {
background: var(--warning-color, #f59e0b);
}
.as-card-quality.poor {
background: var(--error-color, #ef4444);
}
/* 选择 checkbox */
.as-card-checkbox {
position: absolute;
top: 4px;
left: 4px;
z-index: 2;
width: 18px;
height: 18px;
border-radius: var(--radius-sm);
border: 2px solid rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-all);
backdrop-filter: blur(4px);
}
.as-card-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
.as-card-checkbox.checked::after {
content: "✓";
color: #fff;
font-size: 11px;
font-weight: 700;
}
/* 有 checkbox 时,类型角标右移 */
.as-card.has-checkbox .as-card-type-badge {
left: 26px;
}
/* 卡片信息 */
.as-card-info {
padding: 6px 8px;
}
.as-card-name {
font-size: 11px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
line-height: 1.3;
}
.as-card-meta {
font-size: 10px;
color: var(--text-secondary);
margin-top: 2px;
}
/* ============================================================
素材卡片 — 列表模式
============================================================ */
.as-list-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 6px 8px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: var(--transition-all);
user-select: none;
}
.as-list-item:hover {
background: var(--bg-secondary);
border-color: var(--border-color);
}
.as-list-item.selected {
background: var(--primary-soft);
border-color: var(--primary-color);
}
.as-list-item.dragging {
opacity: 0.4;
}
.as-list-item.drag-over {
border-top: 2px solid var(--primary-color);
}
.as-list-item-checkbox {
width: 16px;
height: 16px;
border-radius: 3px;
border: 2px solid var(--border-color);
flex-shrink: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-all);
}
.as-list-item-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
.as-list-item-checkbox.checked::after {
content: "✓";
color: #fff;
font-size: 10px;
font-weight: 700;
}
.as-list-item-drag {
cursor: grab;
color: var(--text-secondary);
font-size: 14px;
opacity: 0.4;
flex-shrink: 0;
}
.as-list-item-drag:hover {
opacity: 1;
}
.as-list-item-drag:active {
cursor: grabbing;
}
.as-list-item-icon {
font-size: 20px;
flex-shrink: 0;
width: 28px;
text-align: center;
}
.as-list-item-info {
flex: 1;
min-width: 0;
}
.as-list-item-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.as-list-item-meta {
font-size: 11px;
color: var(--text-secondary);
}
.as-list-item-quality {
width: 32px;
text-align: center;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
/* ============================================================
悬浮预览
============================================================ */
.as-preview-overlay {
position: fixed;
z-index: 1000;
pointer-events: none;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 8px;
min-width: 180px;
max-width: 260px;
animation: as-preview-in 0.15s ease;
}
.as-preview-overlay-thumb {
width: 100%;
aspect-ratio: 9 / 16;
max-height: 200px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
margin-bottom: 8px;
}
.as-preview-overlay-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.as-preview-overlay-thumb-icon {
font-size: 48px;
opacity: 0.3;
}
.as-preview-overlay-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
word-break: break-all;
}
.as-preview-overlay-meta {
font-size: 11px;
color: var(--text-secondary);
display: flex;
flex-direction: column;
gap: 2px;
}
@keyframes as-preview-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 768px) {
.as-grid {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
.as-toolbar {
padding: var(--space-xs) var(--space-sm);
}
.as-search {
max-width: 140px;
}
}
@media (max-width: 480px) {
.as-grid {
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 4px;
}
.as-card-info {
padding: 4px 6px;
}
.as-card-name {
font-size: 10px;
}
}
@@ -0,0 +1,552 @@
/**
* 素材选择器 — V21 设计系统
* 支持批量选择、拖拽排序、预览缩略图、质量分筛选、视图切换
*
* 功能:
* - 批量选择:checkbox 多选 + 全选/反选 + Shift 连选
* - 拖拽排序:HTML5 DnD,已选素材可拖拽调整顺序
* - 预览缩略图:悬浮放大预览(视频显示时长角标)
* - 筛选增强:类型筛选 + 质量分筛选
* - 视图切换:网格视图 / 列表视图
*/
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
import "./AssetSelector.css";
import { Input, Select, Button } from "@/components/ui";
import type { MediaAsset } from "@/api/editPlans";
import {
MATERIAL_TYPE_LABELS,
MATERIAL_TYPE_ICONS,
QUALITY_OPTIONS,
} from "@/api/editPlans";
/* ──────────── 类型 ──────────── */
export interface AssetSelectorProps {
assets: MediaAsset[];
selectedIds?: string[];
onSelectionChange?: (ids: string[]) => void;
onAssetDragStart?: (asset: MediaAsset) => void;
onReorder?: (fromIdx: number, toIdx: number) => void;
showQualityFilter?: boolean;
showBatchSelect?: boolean;
compact?: boolean;
}
type ViewMode = "grid" | "list";
/* ──────────── 工具函数 ──────────── */
/** 格式化文件大小 */
const formatSize = (bytes?: number): string => {
if (!bytes) return "";
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
};
/** 格式化时长 */
const formatDuration = (seconds?: number): string => {
if (!seconds) return "";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`;
};
/** 获取质量分等级 */
const getQualityLevel = (score?: number): string => {
if (score == null) return "none";
if (score >= 90) return "excellent";
if (score >= 70) return "good";
if (score >= 50) return "fair";
return "poor";
};
/** 质量分颜色 */
const getQualityColor = (score?: number): string => {
if (score == null) return "var(--text-secondary)";
if (score >= 90) return "var(--success-color, #10b981)";
if (score >= 70) return "var(--primary-color, #6366f1)";
if (score >= 50) return "var(--warning-color, #f59e0b)";
return "var(--error-color, #ef4444)";
};
/* ──────────── 类型筛选选项 ──────────── */
const TYPE_OPTIONS = [
{ value: "", label: "全部类型" },
{ value: "video", label: "🎬 视频" },
{ value: "image", label: "🖼️ 图片" },
{ value: "audio", label: "🎵 音频" },
];
/* ──────────── 组件 ──────────── */
const AssetSelector: React.FC<AssetSelectorProps> = ({
assets,
selectedIds = [],
onSelectionChange,
onAssetDragStart,
onReorder,
showQualityFilter = true,
showBatchSelect = true,
compact = false,
}) => {
/* ── 搜索 & 筛选 ── */
const [searchText, setSearchText] = useState("");
const [filterType, setFilterType] = useState("");
const [filterQuality, setFilterQuality] = useState("");
const [viewMode, setViewMode] = useState<ViewMode>("grid");
/* ── 拖拽状态 ── */
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
/* ── 悬浮预览 ── */
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null);
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 });
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
/* ── Shift 连选 ── */
const lastClickedIdx = useRef<number | null>(null);
/* ── 过滤后的素材列表 ── */
const filteredAssets = useMemo(() => {
let list = assets;
if (searchText) {
const q = searchText.toLowerCase();
list = list.filter(
(a) =>
a.name.toLowerCase().includes(q) ||
a.tags.some((t) => t.toLowerCase().includes(q)),
);
}
if (filterType) {
list = list.filter((a) => a.type === filterType);
}
if (filterQuality) {
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality);
if (opt?.min != null && opt?.max != null) {
list = list.filter(
(a) =>
a.quality_score != null &&
a.quality_score >= opt.min! &&
a.quality_score <= opt.max!,
);
}
}
return list;
}, [assets, searchText, filterType, filterQuality]);
/* ── 选中状态 ── */
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
/* ── 选择操作 ── */
const toggleSelect = useCallback(
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
if (!onSelectionChange) return;
if (shiftKey && lastClickedIdx.current !== null) {
// Shift 连选
const start = Math.min(lastClickedIdx.current, idx);
const end = Math.max(lastClickedIdx.current, idx);
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id);
const newSet = new Set(selectedIds);
rangeIds.forEach((id) => newSet.add(id));
onSelectionChange(Array.from(newSet));
} else {
const newSet = new Set(selectedIds);
if (newSet.has(asset.id)) {
newSet.delete(asset.id);
} else {
newSet.add(asset.id);
}
onSelectionChange(Array.from(newSet));
}
lastClickedIdx.current = idx;
},
[onSelectionChange, selectedIds, filteredAssets],
);
const clearSelection = useCallback(() => {
onSelectionChange?.([]);
}, [onSelectionChange]);
/* ── 拖拽排序 ── */
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
setDragIdx(idx);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(idx));
// 设置素材数据,供 TimelinePanel 接收(P1-2 修复)
e.dataTransfer.setData(
"application/x-media-asset",
JSON.stringify(filteredAssets[idx]),
);
// 批量拖拽:如果有多个选中素材,一起携带
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
const batchAssets = filteredAssets.filter((a) =>
selectedIds.includes(a.id),
);
e.dataTransfer.setData(
"application/x-media-assets",
JSON.stringify(batchAssets),
);
}
// 通知父组件素材拖拽开始
if (onAssetDragStart) {
onAssetDragStart(filteredAssets[idx]);
}
},
[filteredAssets, selectedIds, onAssetDragStart],
);
const handleDragOver = useCallback(
(e: React.DragEvent, idx: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragIdx === null || dragIdx === idx) return;
setDragOverIdx(idx);
},
[dragIdx],
);
const handleDrop = useCallback(
(e: React.DragEvent, toIdx: number) => {
e.preventDefault();
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
onReorder(dragIdx, toIdx);
}
setDragIdx(null);
setDragOverIdx(null);
},
[dragIdx, onReorder],
);
const handleDragEnd = useCallback(() => {
setDragIdx(null);
setDragOverIdx(null);
}, []);
/* ── 悬浮预览 ── */
const handleMouseEnter = useCallback(
(asset: MediaAsset, e: React.MouseEvent) => {
if (previewTimer.current) clearTimeout(previewTimer.current);
previewTimer.current = setTimeout(() => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setPreviewAsset(asset);
setPreviewPos({
x: rect.right + 12,
y: Math.max(8, rect.top - 20),
});
}, 400);
},
[],
);
const handleMouseLeave = useCallback(() => {
if (previewTimer.current) {
clearTimeout(previewTimer.current);
previewTimer.current = null;
}
setPreviewAsset(null);
}, []);
// 清理定时器
useEffect(() => {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
};
}, []);
/* ── 点击卡片 ── */
const handleCardClick = useCallback(
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
// 如果点击的是 checkbox 区域,不触发卡片点击
const target = e.target as HTMLElement;
if (target.closest("[data-checkbox]")) return;
toggleSelect(asset, idx, e.shiftKey);
},
[toggleSelect],
);
/* ──────────── 渲染 ──────────── */
const hasSelection = selectedIds.length > 0;
return (
<div className="as-container">
{/* ═══ 工具栏 ═══ */}
<div className="as-toolbar">
<div className="as-toolbar-left">
<div className="as-search">
<Input
placeholder="搜索素材..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
prefix="🔍"
/>
</div>
<Select
value={filterType}
onChange={(v: string) => setFilterType(v)}
options={TYPE_OPTIONS}
/>
{showQualityFilter && (
<Select
value={filterQuality}
onChange={(v: string) => setFilterQuality(v)}
options={QUALITY_OPTIONS}
/>
)}
</div>
<div className="as-toolbar-right">
<button
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
onClick={() => setViewMode("grid")}
title="网格视图"
>
</button>
<button
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
onClick={() => setViewMode("list")}
title="列表视图"
>
</button>
</div>
</div>
{/* ═══ 批量操作栏 ═══ */}
{showBatchSelect && hasSelection && (
<div className="as-batch-bar">
<span className="as-batch-bar-count">
{selectedIds.length}
</span>
<div className="as-batch-bar-actions">
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
</Button>
</div>
</div>
)}
{/* ═══ 素材列表 ═══ */}
<div className="as-body">
{filteredAssets.length === 0 ? (
<div className="as-empty">
<div className="as-empty-icon">📂</div>
<p></p>
</div>
) : viewMode === "grid" ? (
/* ── 网格视图 ── */
<div className={`as-grid${compact ? " compact" : ""}`}>
{filteredAssets.map((asset, idx) => {
const isSelected = selectedSet.has(asset.id);
const isDragging = dragIdx === idx;
const isDragOver = dragOverIdx === idx;
const qualityLevel = getQualityLevel(asset.quality_score);
return (
<div
key={asset.id}
className={[
"as-card",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
showBatchSelect ? "has-checkbox" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onClick={(e) => handleCardClick(asset, idx, e)}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
>
{/* 缩略图 */}
<div className="as-card-thumb">
{asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.name}
loading="lazy"
/>
) : (
<span className="as-card-thumb-icon">
{MATERIAL_TYPE_ICONS[asset.type]}
</span>
)}
{/* Checkbox */}
{showBatchSelect && (
<span
data-checkbox
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation();
toggleSelect(asset, idx, e.shiftKey);
}}
/>
)}
{/* 类型角标 */}
<span className="as-card-type-badge">
{MATERIAL_TYPE_LABELS[asset.type]}
</span>
{/* 时长角标 */}
{asset.duration != null && (
<span className="as-card-duration">
{formatDuration(asset.duration)}
</span>
)}
{/* 质量分角标 */}
{asset.quality_score != null && (
<span
className={`as-card-quality ${qualityLevel}`}
title={`质量分: ${asset.quality_score}`}
>
{asset.quality_score}
</span>
)}
</div>
{/* 信息 */}
<div className="as-card-info">
<p className="as-card-name" title={asset.name}>
{asset.name}
</p>
<div className="as-card-meta">
{formatSize(asset.size)}
</div>
</div>
</div>
);
})}
</div>
) : (
/* ── 列表视图 ── */
<div className="as-list">
{filteredAssets.map((asset, idx) => {
const isSelected = selectedSet.has(asset.id);
const isDragging = dragIdx === idx;
const isDragOver = dragOverIdx === idx;
return (
<div
key={asset.id}
className={[
"as-list-item",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onClick={(e) => handleCardClick(asset, idx, e)}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
>
{/* 拖拽手柄 */}
<span className="as-list-item-drag" title="拖拽排序">
</span>
{/* Checkbox */}
{showBatchSelect && (
<span
data-checkbox
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation();
toggleSelect(asset, idx, e.shiftKey);
}}
/>
)}
{/* 图标 */}
<span className="as-list-item-icon">
{MATERIAL_TYPE_ICONS[asset.type]}
</span>
{/* 信息 */}
<div className="as-list-item-info">
<div className="as-list-item-name">{asset.name}</div>
<div className="as-list-item-meta">
{MATERIAL_TYPE_LABELS[asset.type]}
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
{asset.size != null && ` · ${formatSize(asset.size)}`}
</div>
</div>
{/* 质量分 */}
{asset.quality_score != null && (
<span
className="as-list-item-quality"
style={{ color: getQualityColor(asset.quality_score) }}
>
{asset.quality_score}
</span>
)}
</div>
);
})}
</div>
)}
</div>
{/* ═══ 悬浮预览 ═══ */}
{previewAsset && (
<div
className="as-preview-overlay"
style={{ left: previewPos.x, top: previewPos.y }}
>
<div className="as-preview-overlay-thumb">
{previewAsset.thumbnail_url ? (
<img
src={previewAsset.thumbnail_url}
alt={previewAsset.name}
/>
) : (
<span className="as-preview-overlay-thumb-icon">
{MATERIAL_TYPE_ICONS[previewAsset.type]}
</span>
)}
</div>
<p className="as-preview-overlay-name">{previewAsset.name}</p>
<div className="as-preview-overlay-meta">
<span>: {MATERIAL_TYPE_LABELS[previewAsset.type]}</span>
{previewAsset.duration != null && (
<span>: {formatDuration(previewAsset.duration)}</span>
)}
{previewAsset.size != null && (
<span>: {formatSize(previewAsset.size)}</span>
)}
{previewAsset.quality_score != null && (
<span>
: {previewAsset.quality_score}
</span>
)}
{previewAsset.tags.length > 0 && (
<span>: {previewAsset.tags.join(", ")}</span>
)}
</div>
</div>
)}
</div>
);
};
export default AssetSelector;
@@ -0,0 +1,2 @@
export { default as AssetSelector } from "./AssetSelector";
export type { AssetSelectorProps } from "./AssetSelector";
+123 -119
View File
@@ -2,52 +2,52 @@
/* ==================== 按钮 ==================== */
.xx-primary-btn {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
color: white !important;
background: var(--gradient-primary) !important;
color: var(--text-inverse) !important;
border: none !important;
border-radius: 14px !important;
border-radius: var(--radius-md) !important;
padding: 10px 20px !important;
font-weight: 600 !important;
box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important;
transition: all 0.2s !important;
font-weight: var(--font-weight-bold) !important;
box-shadow: var(--shadow-primary) !important;
transition: var(--transition-all) !important;
cursor: pointer;
height: auto !important;
}
.xx-primary-btn:hover {
box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important;
box-shadow: var(--shadow-hover) !important;
transform: translateY(-1px);
}
.xx-ghost-btn {
background: transparent !important;
color: #4f46e5 !important;
border: 2px solid #4f46e5 !important;
border-radius: 14px !important;
padding: 8px 18px !important;
font-weight: 600 !important;
transition: all 0.2s !important;
color: var(--primary-color) !important;
border: 2px solid var(--primary-color) !important;
border-radius: var(--radius-md) !important;
padding: var(--space-sm) 18px !important;
font-weight: var(--font-weight-bold) !important;
transition: var(--transition-all) !important;
cursor: pointer;
height: auto !important;
}
.xx-ghost-btn:hover {
background: #eef2ff !important;
background: var(--primary-soft) !important;
}
/* ==================== 卡片 ==================== */
.xx-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 22px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 24px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
padding: var(--space-lg);
margin-bottom: 20px;
transition: all 0.25s;
transition: all var(--transition-slow);
}
.xx-card:hover {
box-shadow: 0 26px 64px rgba(15, 23, 42, 0.14);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
@@ -55,7 +55,7 @@
.xx-page {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
padding: var(--space-lg);
}
.xx-page-head {
@@ -68,63 +68,63 @@
.xx-page-head h2 {
font-size: 26px;
font-weight: 850;
color: #0f172a;
margin: 0 0 8px;
font-weight: var(--font-weight-extrabold);
color: var(--text-primary);
margin: 0 0 var(--space-sm);
}
.xx-page-head p {
font-size: 14px;
color: #64748b;
font-size: var(--font-size-base);
color: var(--text-secondary);
margin: 0;
}
/* ==================== 表格样式 ==================== */
.xx-table-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 22px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
padding: 20px;
overflow: hidden;
}
/* 表格包装器 */
.xx-table-wrapper {
border-radius: 16px;
border-radius: var(--radius-lg);
overflow: hidden;
}
/* ==================== 标签/Tag ==================== */
.xx-tag {
padding: 4px 12px;
border-radius: 10px;
padding: var(--space-xs) 12px;
border-radius: var(--radius-xs);
font-size: 13px;
font-weight: 500;
font-weight: var(--font-weight-medium);
}
.xx-tag-indigo {
background: #eef2ff;
color: #4f46e5;
border: 1px solid #c7d2fe;
background: var(--primary-soft);
color: var(--primary-color);
border: 1px solid var(--color-primary-200);
}
.xx-tag-success {
background: #f0fdf4;
color: #16a34a;
border: 1px solid #bbf7d0;
background: var(--success-soft);
color: var(--color-secondary-500);
border: 1px solid var(--success-border);
}
.xx-tag-warning {
background: #fffbeb;
color: #d97706;
border: 1px solid #fde68a;
background: var(--warning-soft);
color: var(--accent-dark);
border: 1px solid var(--color-accent-200);
}
.xx-tag-error {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
background: var(--error-soft);
color: var(--error-color);
border: 1px solid var(--error-border);
}
/* ==================== 搜索栏 ==================== */
@@ -135,52 +135,53 @@
.xx-search-input {
width: 100%;
padding: 12px 18px;
border: 2px solid #e2e8f0;
border-radius: 14px;
font-size: 14px;
background: white;
transition: all 0.2s;
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
background: var(--bg-primary);
transition: var(--transition-all);
outline: none;
}
.xx-search-input:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1);
border-color: var(--primary-color);
box-shadow: 0 0 0 4px
color-mix(in srgb, var(--primary-color) 10%, transparent);
}
/* ==================== Modal ==================== */
.xx-modal .ant-modal-content {
border-radius: 22px;
padding: 24px;
border-radius: var(--radius-xl);
padding: var(--space-lg);
}
.xx-modal .ant-modal-header {
border-radius: 22px 22px 0 0;
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
padding: 20px var(--space-lg);
border-bottom: 1px solid var(--border-color);
}
.xx-modal .ant-modal-title {
font-size: 18px;
font-weight: 700;
color: #0f172a;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-modal .ant-modal-footer {
border-top: 1px solid #e2e8f0;
padding: 16px 24px;
border-top: 1px solid var(--border-color);
padding: var(--space-md) var(--space-lg);
}
/* ==================== 空状态 ==================== */
.xx-empty-state {
text-align: center;
padding: 48px 24px;
color: #64748b;
padding: var(--space-3xl) var(--space-lg);
color: var(--text-secondary);
}
.xx-empty-state-icon {
font-size: 48px;
margin-bottom: 16px;
margin-bottom: var(--space-md);
}
/* ==================== 网格布局 ==================== */
@@ -213,15 +214,16 @@
/* ==================== 配额展示 ==================== */
.xx-quota-item {
padding: 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 16px;
transition: all 0.2s;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
transition: var(--transition-all);
}
.xx-quota-item:hover {
border-color: #4f46e5;
box-shadow: 0 8px 24px rgba(79, 70, 229, 0.1);
border-color: var(--primary-color);
box-shadow: 0 8px 24px
color-mix(in srgb, var(--primary-color) 10%, transparent);
}
/* ==================== 进度条 ==================== */
@@ -232,130 +234,132 @@
/* ==================== Ant Design 覆盖样式 ==================== */
/* Table overrides */
.ant-table-wrapper .ant-table-thead > tr > th {
background: #f8fafc !important;
font-weight: 700 !important;
color: #0f172a !important;
border-bottom: 2px solid #e2e8f0 !important;
padding: 14px 16px !important;
background: var(--bg-secondary) !important;
font-weight: var(--font-weight-bold) !important;
color: var(--text-primary) !important;
border-bottom: 2px solid var(--border-color) !important;
padding: 14px var(--space-md) !important;
}
.ant-table-wrapper .ant-table-tbody > tr > td {
padding: 14px 16px !important;
border-bottom: 1px solid #f1f5f9 !important;
padding: 14px var(--space-md) !important;
border-bottom: 1px solid var(--color-gray-100) !important;
}
.ant-table-wrapper .ant-table-tbody > tr:hover > td {
background: #fafbfc !important;
background: var(--color-gray-50) !important;
}
/* Card overrides */
.ant-card {
border-radius: 22px !important;
border: 1px solid rgba(226, 232, 240, 0.95) !important;
border-radius: var(--radius-xl) !important;
border: 1px solid var(--border-color) !important;
}
.ant-card-head {
border-bottom: 1px solid #e2e8f0 !important;
border-bottom: 1px solid var(--border-color) !important;
min-height: 52px !important;
padding: 0 24px !important;
padding: 0 var(--space-lg) !important;
}
.ant-card-head-title {
font-weight: 700 !important;
font-size: 16px !important;
color: #0f172a !important;
font-weight: var(--font-weight-bold) !important;
font-size: var(--font-size-md) !important;
color: var(--text-primary) !important;
}
.ant-card-body {
padding: 20px 24px !important;
padding: 20px var(--space-lg) !important;
}
/* Modal overrides */
.ant-modal-content {
border-radius: 22px !important;
border-radius: var(--radius-xl) !important;
overflow: hidden;
}
.ant-modal-header {
padding: 20px 24px !important;
background: white !important;
padding: 20px var(--space-lg) !important;
background: var(--bg-primary) !important;
}
.ant-modal-title {
font-weight: 700 !important;
font-size: 18px !important;
color: #0f172a !important;
font-weight: var(--font-weight-bold) !important;
font-size: var(--font-size-lg) !important;
color: var(--text-primary) !important;
}
.ant-modal-body {
padding: 24px !important;
padding: var(--space-lg) !important;
}
.ant-modal-footer {
padding: 16px 24px !important;
padding: var(--space-md) var(--space-lg) !important;
}
/* Button overrides */
.ant-btn-primary {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
background: var(--gradient-primary) !important;
border: none !important;
border-radius: 14px !important;
box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important;
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow-primary) !important;
height: auto !important;
padding: 10px 20px !important;
font-weight: 600 !important;
font-weight: var(--font-weight-bold) !important;
}
.ant-btn-primary:hover {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important;
background: var(--gradient-primary) !important;
box-shadow: var(--shadow-hover) !important;
transform: translateY(-1px);
}
/* Tag overrides */
.ant-tag {
border-radius: 10px !important;
padding: 4px 12px !important;
font-weight: 500 !important;
border-radius: var(--radius-xs) !important;
padding: var(--space-xs) 12px !important;
font-weight: var(--font-weight-medium) !important;
}
/* Select overrides */
.ant-select-selector {
border-radius: 14px !important;
border-color: #e2e8f0 !important;
border-radius: var(--radius-md) !important;
border-color: var(--border-color) !important;
}
.ant-select:not(.ant-select-disabled):hover .ant-select-selector {
border-color: #4f46e5 !important;
border-color: var(--primary-color) !important;
}
.ant-select-focused .ant-select-selector {
border-color: #4f46e5 !important;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
}
/* Input overrides */
.ant-input {
border-radius: 14px !important;
border-color: #e2e8f0 !important;
border-radius: var(--radius-md) !important;
border-color: var(--border-color) !important;
padding: 10px 14px !important;
}
.ant-input:hover {
border-color: #4f46e5 !important;
border-color: var(--primary-color) !important;
}
.ant-input:focus {
border-color: #4f46e5 !important;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
}
/* Progress overrides */
.ant-progress-inner {
background: #f1f5f9 !important;
border-radius: 10px !important;
background: var(--color-gray-100) !important;
border-radius: var(--radius-xs) !important;
}
.ant-progress-bg {
border-radius: 10px !important;
border-radius: var(--radius-xs) !important;
}
+40 -40
View File
@@ -3,17 +3,17 @@
height: 68px;
position: sticky;
top: 0;
z-index: 30;
background: rgba(255, 255, 255, 0.86);
z-index: var(--z-fixed);
background: var(--bg-glass);
backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(226, 232, 240, 0.92);
border-bottom: 1px solid var(--border-color);
}
.xx-top-nav-inner {
max-width: 1440px;
margin: 0 auto;
height: 100%;
padding: 0 24px;
padding: 0 var(--space-lg);
display: flex;
align-items: center;
justify-content: space-between;
@@ -23,12 +23,12 @@
display: flex;
gap: 10px;
align-items: center;
font-size: 20px;
font-weight: 900;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-extrabold);
border: none;
background: none;
cursor: pointer;
color: #0f172a;
color: var(--text-primary);
padding: 0;
flex-shrink: 0;
}
@@ -36,41 +36,41 @@
.xx-logo {
width: 38px;
height: 38px;
border-radius: 14px;
background: linear-gradient(135deg, #818cf8, #4f46e5);
border-radius: var(--radius-md);
background: var(--gradient-primary);
display: grid;
place-items: center;
color: #fff;
box-shadow: 0 12px 26px rgba(79, 70, 229, 0.24);
font-size: 20px;
color: var(--text-inverse);
box-shadow: var(--shadow-primary);
font-size: var(--font-size-xl);
}
/* 桌面端导航链接 */
.xx-nav-links {
display: flex;
gap: 16px;
color: #64748b;
font-weight: 750;
gap: var(--space-md);
color: var(--text-secondary);
font-weight: var(--font-weight-bold);
}
.xx-nav-links button {
border: none;
background: none;
cursor: pointer;
color: #64748b;
color: var(--text-secondary);
padding: 6px 4px;
font-size: 14px;
font-weight: 750;
transition: color 0.2s;
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
transition: color var(--transition-fast);
white-space: nowrap;
}
.xx-nav-links button:hover {
color: #4f46e5;
color: var(--primary-color);
}
.xx-nav-links button.active {
color: #4f46e5;
color: var(--primary-color);
}
/* 右侧区域 */
@@ -84,17 +84,17 @@
.xx-user-menu {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-sm);
cursor: pointer;
color: #0f172a;
font-size: 14px;
color: var(--text-primary);
font-size: var(--font-size-base);
}
.xx-avatar {
width: 34px;
height: 34px;
background: #eef2ff;
color: #4338ca;
background: var(--primary-soft);
color: var(--primary-dark);
}
/* 汉堡菜单按钮(默认隐藏) */
@@ -103,42 +103,42 @@
border: none;
background: none;
cursor: pointer;
font-size: 20px;
color: #64748b;
padding: 8px;
font-size: var(--font-size-xl);
color: var(--text-secondary);
padding: var(--space-sm);
}
/* 手机端导航抽屉内容 */
.xx-mobile-nav {
display: flex;
flex-direction: column;
gap: 4px;
gap: var(--space-xs);
}
.xx-mobile-nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
padding: 12px var(--space-md);
border: none;
background: none;
cursor: pointer;
font-size: 16px;
color: #334155;
border-radius: 8px;
transition: background 0.2s;
font-size: var(--font-size-md);
color: var(--color-gray-700);
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
width: 100%;
text-align: left;
}
.xx-mobile-nav-item:hover {
background: #f1f5f9;
background: var(--bg-tertiary);
}
.xx-mobile-nav-item.active {
background: #eef2ff;
color: #4f46e5;
font-weight: 600;
background: var(--primary-soft);
color: var(--primary-color);
font-weight: var(--font-weight-semibold);
}
.xx-mobile-nav-icon {
@@ -182,7 +182,7 @@
/* 小屏幕平板:导航文字缩小 */
@media (min-width: 769px) and (max-width: 1024px) {
.xx-nav-links {
gap: 8px;
gap: var(--space-sm);
}
.xx-nav-links button {
+1 -79
View File
@@ -9,92 +9,14 @@ import {
SettingOutlined,
UserOutlined,
MenuOutlined,
DashboardOutlined,
FileOutlined,
FileTextOutlined,
AudioOutlined,
AppstoreOutlined,
VideoCameraOutlined,
HistoryOutlined,
TrophyOutlined,
ScanOutlined,
EditOutlined,
FolderOutlined,
} from "@ant-design/icons";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuthStore } from "@/store/authStore";
import { useLogout } from "@/hooks/useAuth";
import type { MenuProps } from "antd";
import { NAV_ITEMS } from "@/config/navigation";
import "./Header.css";
/** 导航项定义 */
interface NavItem {
key: string;
label: string;
path: string;
icon: React.ReactNode;
}
/** 固定导航菜单 */
const NAV_ITEMS: NavItem[] = [
{
key: "dashboard",
label: "概览",
path: "/dashboard",
icon: <DashboardOutlined />,
},
{ key: "assets", label: "素材库", path: "/assets", icon: <FileOutlined /> },
{
key: "titles",
label: "标题库",
path: "/titles",
icon: <FileTextOutlined />,
},
{ key: "voices", label: "配音库", path: "/voices", icon: <AudioOutlined /> },
{
key: "templates",
label: "模板库",
path: "/templates",
icon: <AppstoreOutlined />,
},
{
key: "editing-planner",
label: "剪辑编辑器",
path: "/editing-planner",
icon: <EditOutlined />,
},
{
key: "my-templates",
label: "我的模板",
path: "/my-templates",
icon: <FolderOutlined />,
},
{
key: "generate",
label: "一键生成",
path: "/generate",
icon: <VideoCameraOutlined />,
},
{
key: "history",
label: "任务历史",
path: "/history",
icon: <HistoryOutlined />,
},
{
key: "products",
label: "成品库",
path: "/products",
icon: <TrophyOutlined />,
},
{
key: "duplication",
label: "查重",
path: "/duplication",
icon: <ScanOutlined />,
},
];
const Header: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
@@ -142,7 +142,6 @@
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle-label {
display: inline;
}
}
/* ============================================================
+36 -28
View File
@@ -30,7 +30,7 @@ export interface PageHeadProps {
/** 页面标题 */
title: string;
/** 页面描述(可选,显示在标题下方) */
description?: string;
description?: React.ReactNode;
/** 面包屑项(可选,不传则自动根据路由生成) */
breadcrumb?: BreadcrumbItem[];
/** 右侧操作区内容(按钮等) */
@@ -42,37 +42,39 @@ export interface PageHeadProps {
/* ── 路由 → 标题映射(用于自动生成面包屑) ────────────────── */
const ROUTE_TITLE_MAP: Record<string, string> = {
"/dashboard": "首页",
"/generate": "一键生成",
"/assets": "素材库",
"/voices": "配音库",
"/titles": "标题库",
"/products": "成片库",
"/templates": "模板库",
"/history": "任务历史",
"/admin": "控制台",
"/admin/users": "用户管理",
"/admin/analytics": "数据分析",
"/admin/monitor": "系统监控",
"/admin/logs": "系统日志",
"/subscription": "订阅管理",
"/subscription/upgrade": "升级订阅",
"/subscription/billing": "账单管理",
"/profile": "个人设置",
"/editing-planner": "剪辑规划",
"/my-templates": "我的模板",
"/duplication": "查重",
"/duplication/results": "查重结果",
"/app/dashboard": "首页",
"/app/generate": "一键生成",
"/app/assets": "素材库",
"/app/voices": "配音库",
"/app/titles": "标题库",
"/app/products": "成片库",
"/app/templates": "模板库",
"/app/history": "任务历史",
"/app/admin": "控制台",
"/app/admin/users": "用户管理",
"/app/admin/analytics": "数据分析",
"/app/admin/monitor": "系统监控",
"/app/admin/logs": "系统日志",
"/app/subscription": "订阅管理",
"/app/subscription/upgrade": "升级订阅",
"/app/subscription/billing": "账单管理",
"/app/profile": "个人设置",
"/app/editing-planner": "剪辑规划",
"/app/my-templates": "我的模板",
"/app/voice-clone": "我的音色",
"/app/accounts": "账号管理",
"/app/duplication": "查重",
"/app/duplication/results": "查重结果",
};
/* ── 自动生成面包屑 ─────────────────────────────────────── */
/** 根据当前路径生成面包屑 */
const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => {
const items: BreadcrumbItem[] = [{ label: "首页", path: "/dashboard" }];
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }];
// 首页本身不需要面包屑
if (pathname === "/" || pathname === "/dashboard") {
if (pathname === "/app" || pathname === "/app/dashboard") {
return items;
}
@@ -123,8 +125,8 @@ const PageHead: React.FC<PageHeadProps> = ({
const showBreadcrumb =
!hideBreadcrumb &&
breadcrumbItems.length > 1 &&
location.pathname !== "/" &&
location.pathname !== "/dashboard";
location.pathname !== "/app" &&
location.pathname !== "/app/dashboard";
return (
<header className="xx-page-head">
@@ -136,7 +138,10 @@ const PageHead: React.FC<PageHeadProps> = ({
{breadcrumbItems.map((item, index) => {
const isLast = index === breadcrumbItems.length - 1;
return (
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
<li
key={`${item.label}-${index}`}
className="xx-page-breadcrumb-item"
>
{index > 0 && (
<RightOutlined className="xx-page-breadcrumb-separator" />
)}
@@ -155,7 +160,10 @@ const PageHead: React.FC<PageHeadProps> = ({
<span>{item.label}</span>
</Link>
) : (
<span className="xx-page-breadcrumb-current" aria-current="page">
<span
className="xx-page-breadcrumb-current"
aria-current="page"
>
{index === 0 ? (
<HomeOutlined className="xx-page-breadcrumb-home" />
) : null}
+5 -107
View File
@@ -11,114 +11,10 @@
*/
import React, { useContext } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import {
DashboardOutlined,
VideoCameraOutlined,
FileOutlined,
AudioOutlined,
FileTextOutlined,
TrophyOutlined,
AppstoreOutlined,
HistoryOutlined,
ControlOutlined,
CrownOutlined,
} from "@ant-design/icons";
import { SidebarContext } from "./MainLayout";
import { NAV_GROUPS } from "@/config/navigation";
import "./Sidebar.css";
/** 导航项定义 */
interface SidebarMenuItem {
key: string;
label: string;
path: string;
icon: React.ReactNode;
}
/** 导航分组定义 */
interface SidebarMenuGroup {
title: string;
items: SidebarMenuItem[];
}
/** 侧边栏导航分组 */
const MENU_GROUPS: SidebarMenuGroup[] = [
{
title: "创作工具",
items: [
{
key: "dashboard",
label: "首页",
path: "/dashboard",
icon: <DashboardOutlined />,
},
{
key: "generate",
label: "一键生成",
path: "/generate",
icon: <VideoCameraOutlined />,
},
],
},
{
title: "资源管理",
items: [
{
key: "assets",
label: "素材库",
path: "/assets",
icon: <FileOutlined />,
},
{
key: "voices",
label: "配音库",
path: "/voices",
icon: <AudioOutlined />,
},
{
key: "titles",
label: "标题库",
path: "/titles",
icon: <FileTextOutlined />,
},
{
key: "products",
label: "成片库",
path: "/products",
icon: <TrophyOutlined />,
},
{
key: "templates",
label: "模板库",
path: "/templates",
icon: <AppstoreOutlined />,
},
],
},
{
title: "系统",
items: [
{
key: "history",
label: "任务历史",
path: "/history",
icon: <HistoryOutlined />,
},
{
key: "admin",
label: "控制台",
path: "/admin",
icon: <ControlOutlined />,
},
{
key: "subscription",
label: "订阅管理",
path: "/subscription",
icon: <CrownOutlined />,
},
],
},
];
/** 判断菜单项是否激活 */
const isMenuItemActive = (pathname: string, path: string): boolean => {
if (path === "/dashboard") {
@@ -137,8 +33,10 @@ const Sidebar: React.FC = () => {
};
return (
<div className={`xx-sidebar-nav${collapsed ? " xx-sidebar-nav--collapsed" : ""}`}>
{MENU_GROUPS.map((group) => (
<div
className={`xx-sidebar-nav${collapsed ? " xx-sidebar-nav--collapsed" : ""}`}
>
{NAV_GROUPS.map((group) => (
<div className="xx-sidebar-group" key={group.title}>
{!collapsed && (
<div className="xx-sidebar-group-title">{group.title}</div>
@@ -0,0 +1,277 @@
/**
* CloneVoiceModal — 音色克隆弹窗
*
* 三步骤状态:input → uploading → success
* 支持上传音频文件或直接录制(mock,无真实录音)
*
* V21 Design System — 零 antd 直接导入
*/
import React, { useState, useCallback, useRef } from "react";
import { Modal, Button } from "@/components/ui";
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import "./clone-voice-modal.css";
/* ── 类型定义 ───────────────────────────────────────────── */
type ModalStep = "input" | "uploading" | "success";
export interface CloneVoiceModalProps {
/** 弹窗是否可见 */
open: boolean;
/** 关闭弹窗回调 */
onClose: () => void;
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void;
}
/* ── 默认音色名称计数器 ─────────────────────────────────── */
let cloneCounter = 1;
const getNextDefaultName = (): string => {
const name = `我的声音 ${cloneCounter}`;
cloneCounter += 1;
return name;
};
/* ── 组件 ───────────────────────────────────────────────── */
const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
open,
onClose,
onSuccess,
}) => {
const [step, setStep] = useState<ModalStep>("input");
const [voiceName, setVoiceName] = useState("");
const [isRecording, setIsRecording] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setStep("input");
setVoiceName("");
setSelectedFile(null);
setIsRecording(false);
setDragActive(false);
}, []);
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
/** 上传区域点击 */
const handleUploadClick = () => {
fileInputRef.current?.click();
};
/** 文件选择 */
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
// 清除之前的录制状态
setIsRecording(false);
}
// 清空 input 以允许重复选择同一文件
e.target.value = "";
};
/** 拖拽事件 */
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) {
const ext = file.name.split(".").pop()?.toLowerCase();
if (ext === "mp3" || ext === "wav") {
setSelectedFile(file);
setIsRecording(false);
}
}
};
/** 录制按钮(mock */
const handleRecord = () => {
setIsRecording((prev) => !prev);
if (!isRecording) {
// 开始录制 — 清除已选文件
setSelectedFile(null);
}
};
/** 开始克隆 */
const handleStartClone = async () => {
const name = voiceName.trim() || getNextDefaultName();
setStep("uploading");
try {
// Mock:模拟上传 + 克隆过程
const result = await createVoiceClone({
name,
audio_url: selectedFile
? `mock://${selectedFile.name}`
: "mock://recorded-audio",
});
setStep("success");
// 2秒后自动关闭
setTimeout(() => {
onSuccess?.(toVoiceClone(result));
handleClose();
}, 2000);
} catch {
setStep("input");
}
};
/** 弹窗打开时初始化默认名称 */
const handleAfterOpenChange = (visible: boolean) => {
if (visible) {
setVoiceName(getNextDefaultName());
}
};
const canStart = selectedFile || isRecording;
return (
<Modal
open={open}
onCancel={handleClose}
title="🎤 克隆新音色"
width={520}
footer={null}
destroyOnClose
afterOpenChange={handleAfterOpenChange}
>
{/* ── 输入步骤 ──────────────────────────────────── */}
{step === "input" && (
<div className="cvm-body">
{/* 音色名称 */}
<div className="cvm-field">
<label className="cvm-label"></label>
<input
type="text"
className="cvm-input"
value={voiceName}
onChange={(e) => setVoiceName(e.target.value)}
placeholder="输入音色名称"
/>
</div>
{/* 上传区域 */}
<div className="cvm-field">
<label className="cvm-label"></label>
<div
className={`cvm-upload-zone${dragActive ? " cvm-upload-zone--active" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="cvm-upload-icon">🎵</div>
<p className="cvm-upload-title">
{selectedFile ? selectedFile.name : "拖拽音频文件到此处"}
</p>
<p className="cvm-upload-hint"> MP3WAV </p>
<input
ref={fileInputRef}
type="file"
accept=".mp3,.wav,audio/mpeg,audio/wav"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
{/* 或分隔 */}
<div className="cvm-divider">
<div className="cvm-divider-line" />
<span className="cvm-divider-text"></span>
<div className="cvm-divider-line" />
</div>
{/* 录制区域 */}
<div className="cvm-field">
<label className="cvm-label"></label>
<div className="cvm-record-area">
<p className="cvm-record-hint">
{isRecording
? "录制中…再次点击停止"
: "点击按钮开始录制你的声音"}
</p>
<button
type="button"
className={`cvm-record-btn${isRecording ? " cvm-record-btn--recording" : ""}`}
onClick={handleRecord}
>
🎙
</button>
</div>
</div>
{/* 提示 */}
<div className="cvm-tip">
<span className="cvm-tip-icon">💡</span>
<span>
10~3
</span>
</div>
{/* 底部按钮 */}
<div className="cvm-footer">
<Button buttonType="ghost" onClick={handleClose}>
</Button>
<Button
buttonType="primary"
disabled={!canStart}
onClick={handleStartClone}
>
🎤
</Button>
</div>
</div>
)}
{/* ── 上传中步骤 ────────────────────────────────── */}
{step === "uploading" && (
<div className="cvm-uploading">
<div className="cvm-uploading-spinner" />
<p className="cvm-uploading-text"></p>
<p className="cvm-uploading-sub">AI </p>
</div>
)}
{/* ── 成功步骤 ──────────────────────────────────── */}
{step === "success" && (
<div className="cvm-success">
<div className="cvm-success-icon"></div>
<h3 className="cvm-success-title"></h3>
<p className="cvm-success-desc">
</p>
</div>
)}
</Modal>
);
};
export default CloneVoiceModal;
@@ -0,0 +1,441 @@
/**
* VoiceCloneModal — 音色克隆弹窗
*
* 功能:上传音频文件 / 录制音频、填写音色名称、提交克隆任务
* 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
* 对接 APIMock):POST /api/v1/voice-clones
*
* CSS 变量统一,与配音库 / 我的音色页面风格一致
* V21 Design System — 零 antd 直接导入
*/
import React, { useState, useCallback, useRef, useEffect } from "react";
import { Modal, Button } from "@/components/ui";
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import "./voice-clone-modal.css";
/* ── 类型定义 ───────────────────────────────────────────── */
/** 弹窗阶段:input=输入 | uploading=上传中 | cloning=克隆中 | done=完成 */
type ModalPhase = "input" | "uploading" | "cloning" | "done";
export interface VoiceCloneModalProps {
/** 弹窗是否可见 */
open: boolean;
/** 关闭弹窗回调 */
onClose: () => void;
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void;
}
/* ── 进度阶段配置 ─────────────────────────────────────────── */
const PROGRESS_STEPS: { key: ModalPhase; label: string; icon: string }[] = [
{ key: "uploading", label: "上传中", icon: "📤" },
{ key: "cloning", label: "克隆中", icon: "🧬" },
{ key: "done", label: "完成", icon: "✅" },
];
/* ── 默认音色名称计数器 ─────────────────────────────────── */
let cloneCounter = 1;
const getNextDefaultName = (): string => {
const name = `我的声音 ${cloneCounter}`;
cloneCounter += 1;
return name;
};
/* ── 支持的文件扩展名 ─────────────────────────────────── */
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "aac", "ogg"];
const ACCEPTED_MIME = ".mp3,.wav,.m4a,.aac,.ogg,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg";
/* ── 组件 ───────────────────────────────────────────────── */
const VoiceCloneModal: React.FC<VoiceCloneModalProps> = ({
open,
onClose,
onSuccess,
}) => {
const [phase, setPhase] = useState<ModalPhase>("input");
const [voiceName, setVoiceName] = useState("");
const [isRecording, setIsRecording] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dragActive, setDragActive] = useState(false);
const [recordTime, setRecordTime] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input");
setVoiceName("");
setSelectedFile(null);
setIsRecording(false);
setDragActive(false);
setRecordTime(0);
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current);
recordTimerRef.current = null;
}
}, []);
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
/** 弹窗打开时初始化默认名称 */
useEffect(() => {
if (open) {
setVoiceName(getNextDefaultName());
}
}, [open]);
/** 清理录制计时器 */
useEffect(() => {
return () => {
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current);
}
};
}, []);
/* ── 文件上传 ──────────────────────────────────────── */
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
setIsRecording(false);
setRecordTime(0);
}
e.target.value = "";
};
/* ── 拖拽 ──────────────────────────────────────────── */
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) {
const ext = file.name.split(".").pop()?.toLowerCase();
if (ext && ACCEPTED_EXTENSIONS.includes(ext)) {
setSelectedFile(file);
setIsRecording(false);
setRecordTime(0);
}
}
};
/* ── 录制(mock) ──────────────────────────────────── */
const handleRecord = () => {
if (isRecording) {
// 停止录制
setIsRecording(false);
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current);
recordTimerRef.current = null;
}
} else {
// 开始录制
setIsRecording(true);
setSelectedFile(null);
setRecordTime(0);
recordTimerRef.current = setInterval(() => {
setRecordTime((prev) => prev + 1);
}, 1000);
}
};
/** 格式化录制时间 mm:ss */
const formatRecordTime = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
};
/* ── 提交克隆 ──────────────────────────────────────── */
const handleStartClone = async () => {
const name = voiceName.trim() || getNextDefaultName();
// 阶段 1:上传中
setPhase("uploading");
await new Promise((r) => setTimeout(r, 1200));
// 阶段 2:克隆中
setPhase("cloning");
try {
const result = await createVoiceClone({
name,
audio_url: selectedFile
? `mock://${selectedFile.name}`
: "mock://recorded-audio",
});
// 阶段 3:完成
setPhase("done");
// 2秒后自动关闭
setTimeout(() => {
onSuccess?.(toVoiceClone(result));
handleClose();
}, 2000);
} catch {
setPhase("input");
}
};
/** 当前进度索引(用于进度条展示) */
const getProgressIndex = (): number => {
switch (phase) {
case "uploading":
return 0;
case "cloning":
return 1;
case "done":
return 2;
default:
return -1;
}
};
const canStart = selectedFile || isRecording;
const progressIndex = getProgressIndex();
const isProcessing = phase === "uploading" || phase === "cloning";
return (
<Modal
open={open}
onCancel={handleClose}
title="🎤 克隆新音色"
width={540}
footer={null}
destroyOnClose
maskClosable={!isProcessing}
keyboard={!isProcessing}
>
{/* ── 输入阶段 ──────────────────────────────────── */}
{phase === "input" && (
<div className="xx-vcmodal-body">
{/* 音色名称 */}
<div className="xx-vcmodal-field">
<label className="xx-vcmodal-label"></label>
<input
type="text"
className="xx-vcmodal-input"
value={voiceName}
onChange={(e) => setVoiceName(e.target.value)}
placeholder="输入音色名称"
maxLength={30}
/>
</div>
{/* 上传区域 */}
<div className="xx-vcmodal-field">
<label className="xx-vcmodal-label"></label>
<div
className={`xx-vcmodal-upload-zone${dragActive ? " xx-vcmodal-upload-zone--active" : ""}${selectedFile ? " xx-vcmodal-upload-zone--has-file" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="xx-vcmodal-upload-icon">
{selectedFile ? "📄" : "🎵"}
</div>
<p className="xx-vcmodal-upload-title">
{selectedFile
? selectedFile.name
: "拖拽音频文件到此处,或点击上传"}
</p>
<p className="xx-vcmodal-upload-hint">
MP3WAVM4AAACOGG
</p>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_MIME}
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
{/* 或分隔 */}
<div className="xx-vcmodal-divider">
<div className="xx-vcmodal-divider-line" />
<span className="xx-vcmodal-divider-text"></span>
<div className="xx-vcmodal-divider-line" />
</div>
{/* 录制区域 */}
<div className="xx-vcmodal-field">
<label className="xx-vcmodal-label"></label>
<div className="xx-vcmodal-record-area">
<div className="xx-vcmodal-record-info">
<p className="xx-vcmodal-record-hint">
{isRecording
? `录制中 ${formatRecordTime(recordTime)}`
: "点击按钮开始录制你的声音"}
</p>
{isRecording && (
<div className="xx-vcmodal-record-wave">
<span className="xx-vcmodal-record-wave-bar" />
<span className="xx-vcmodal-record-wave-bar" />
<span className="xx-vcmodal-record-wave-bar" />
<span className="xx-vcmodal-record-wave-bar" />
<span className="xx-vcmodal-record-wave-bar" />
</div>
)}
</div>
<button
type="button"
className={`xx-vcmodal-record-btn${isRecording ? " xx-vcmodal-record-btn--recording" : ""}`}
onClick={handleRecord}
title={isRecording ? "停止录制" : "开始录制"}
>
{isRecording ? "⏹" : "🎙️"}
</button>
</div>
</div>
{/* 提示 */}
<div className="xx-vcmodal-tip">
<span className="xx-vcmodal-tip-icon">💡</span>
<span>
10 ~ 3
</span>
</div>
{/* 底部按钮 */}
<div className="xx-vcmodal-footer">
<Button buttonType="ghost" onClick={handleClose}>
</Button>
<Button
buttonType="primary"
disabled={!canStart}
onClick={handleStartClone}
>
🎤
</Button>
</div>
</div>
)}
{/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────── */}
{isProcessing && (
<div className="xx-vcmodal-progress-body">
{/* 步骤指示器 */}
<div className="xx-vcmodal-steps">
{PROGRESS_STEPS.map((step, idx) => {
const isActive = idx === progressIndex;
const isDone = idx < progressIndex;
const stepClass = [
"xx-vcmodal-step",
isActive ? "xx-vcmodal-step--active" : "",
isDone ? "xx-vcmodal-step--done" : "",
]
.filter(Boolean)
.join(" ");
return (
<React.Fragment key={step.key}>
{idx > 0 && (
<div
className={`xx-vcmodal-step-connector${isDone ? " xx-vcmodal-step-connector--done" : ""}`}
/>
)}
<div className={stepClass}>
<div className="xx-vcmodal-step-icon">
{isDone ? "✓" : step.icon}
</div>
<span className="xx-vcmodal-step-label">
{step.label}
</span>
</div>
</React.Fragment>
);
})}
</div>
{/* 当前阶段描述 */}
<div className="xx-vcmodal-progress-info">
{phase === "uploading" && (
<>
<div className="xx-vcmodal-progress-spinner" />
<p className="xx-vcmodal-progress-text"></p>
<p className="xx-vcmodal-progress-sub">
</p>
</>
)}
{phase === "cloning" && (
<>
<div className="xx-vcmodal-progress-spinner xx-vcmodal-progress-spinner--cloning" />
<p className="xx-vcmodal-progress-text">
AI
</p>
<p className="xx-vcmodal-progress-sub">
</p>
</>
)}
</div>
</div>
)}
{/* ── 完成阶段 ──────────────────────────────────── */}
{phase === "done" && (
<div className="xx-vcmodal-progress-body">
{/* 步骤指示器(全部完成) */}
<div className="xx-vcmodal-steps">
{PROGRESS_STEPS.map((step, idx) => (
<React.Fragment key={step.key}>
{idx > 0 && (
<div className="xx-vcmodal-step-connector xx-vcmodal-step-connector--done" />
)}
<div className="xx-vcmodal-step xx-vcmodal-step--done">
<div className="xx-vcmodal-step-icon"></div>
<span className="xx-vcmodal-step-label">{step.label}</span>
</div>
</React.Fragment>
))}
</div>
<div className="xx-vcmodal-success">
<div className="xx-vcmodal-success-icon">🎉</div>
<h3 className="xx-vcmodal-success-title"></h3>
<p className="xx-vcmodal-success-desc">
</p>
</div>
</div>
)}
</Modal>
);
};
export default VoiceCloneModal;
@@ -0,0 +1,325 @@
/**
* CloneVoiceModal — V21 Design System
*
* 音色克隆弹窗样式
* 三步骤状态:input → uploading → success
*/
/* ── 弹窗内容区 ─────────────────────────────────────────── */
.cvm-body {
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── 表单区 ─────────────────────────────────────────────── */
.cvm-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.cvm-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #475467);
}
.cvm-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--line, #e4e7ec);
border-radius: var(--radius-sm, 8px);
background: var(--bg-surface, #fff);
color: var(--text-primary, #101828);
font-size: 14px;
line-height: 1.5;
transition:
border-color 0.2s,
box-shadow 0.2s;
outline: none;
}
.cvm-input:focus {
border-color: var(--primary, #6366f1);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--primary-color) 12%, transparent);
}
.cvm-input::placeholder {
color: var(--muted, #98a2b3);
}
/* ── 上传区域 ───────────────────────────────────────────── */
.cvm-upload-zone {
border: 2px dashed var(--line, #e4e7ec);
border-radius: var(--radius-md, 12px);
padding: 28px 20px;
text-align: center;
background: var(--bg-subtle, #f8fafc);
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
}
.cvm-upload-zone:hover {
border-color: var(--primary, #6366f1);
background: color-mix(in srgb, var(--primary-color) 4%, transparent);
}
.cvm-upload-zone.cvm-upload-zone--active {
border-color: var(--primary, #6366f1);
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
}
.cvm-upload-icon {
font-size: 36px;
margin-bottom: 8px;
line-height: 1;
}
.cvm-upload-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #101828);
margin: 0 0 4px;
}
.cvm-upload-hint {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 0;
}
/* ── 或分隔线 ───────────────────────────────────────────── */
.cvm-divider {
display: flex;
align-items: center;
gap: 16px;
margin: 4px 0;
}
.cvm-divider-line {
flex: 1;
height: 1px;
background: var(--line, #e4e7ec);
}
.cvm-divider-text {
font-size: 13px;
color: var(--muted, #98a2b3);
flex-shrink: 0;
}
/* ── 录制区域 ───────────────────────────────────────────── */
.cvm-record-area {
border: 1px solid var(--line, #e4e7ec);
border-radius: var(--radius-md, 12px);
padding: 24px;
text-align: center;
}
.cvm-record-hint {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 0 0 14px;
}
.cvm-record-btn {
width: 80px;
height: 80px;
border-radius: 50%;
border: none;
cursor: pointer;
font-size: 32px;
line-height: 1;
padding: 0;
background: linear-gradient(
135deg,
var(--error-color, #ef4444),
var(--error-dark, #dc2626)
);
color: var(--text-inverse);
box-shadow: 0 4px 14px
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
transition:
transform 0.15s,
box-shadow 0.15s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.cvm-record-btn:hover {
transform: scale(1.06);
box-shadow: 0 6px 20px
color-mix(in srgb, var(--error-color, #ef4444) 45%, transparent);
}
.cvm-record-btn:active {
transform: scale(0.96);
}
.cvm-record-btn--recording {
animation: cvm-pulse 1.2s ease-in-out infinite;
}
@keyframes cvm-pulse {
0%,
100% {
box-shadow: 0 4px 14px
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
}
50% {
box-shadow: 0 4px 28px
color-mix(in srgb, var(--error-color, #ef4444) 60%, transparent);
}
}
/* ── 提示条 ─────────────────────────────────────────────── */
.cvm-tip {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 16px;
background: var(--warning-soft, #fef3c7);
border-radius: var(--radius-sm, 8px);
font-size: 13px;
color: var(--warning-color, #92400e);
line-height: 1.5;
}
.cvm-tip-icon {
flex-shrink: 0;
font-size: 14px;
line-height: 1.5;
}
/* ── 底部按钮 ───────────────────────────────────────────── */
.cvm-footer {
display: flex;
gap: 12px;
margin-top: 4px;
}
.cvm-footer .xx-btn {
flex: 1;
}
/* ── 上传中状态 ─────────────────────────────────────────── */
.cvm-uploading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
gap: 16px;
}
.cvm-uploading-spinner {
width: 48px;
height: 48px;
border: 3px solid var(--line, #e4e7ec);
border-top-color: var(--primary, #6366f1);
border-radius: 50%;
animation: cvm-spin 0.8s linear infinite;
}
@keyframes cvm-spin {
to {
transform: rotate(360deg);
}
}
.cvm-uploading-text {
font-size: 15px;
font-weight: 500;
color: var(--text-primary, #101828);
margin: 0;
}
.cvm-uploading-sub {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 0;
}
/* ── 成功状态 ───────────────────────────────────────────── */
.cvm-success {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
gap: 12px;
}
.cvm-success-icon {
font-size: 56px;
line-height: 1;
}
.cvm-success-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #101828);
margin: 0;
}
.cvm-success-desc {
font-size: 14px;
color: var(--muted, #98a2b3);
margin: 0;
}
/* ── 响应式 ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.cvm-overlay {
padding: var(--space-md);
}
.cvm-modal {
width: 100%;
max-width: 100%;
padding: var(--space-lg);
}
}
@media (max-width: 576px) {
.cvm-upload-zone {
padding: 20px 14px;
}
.cvm-record-btn {
width: 64px;
height: 64px;
font-size: 26px;
}
.cvm-footer {
flex-direction: column;
}
}
@media (max-width: 480px) {
.cvm-record-btn {
width: 60px;
height: 60px;
}
.cvm-tip {
font-size: 12px;
padding: var(--space-sm);
}
}
@@ -0,0 +1,471 @@
/**
* VoiceCloneModal 样式 — V21 设计系统
* CSS 变量统一,与配音库 / 我的音色页面风格一致
* 命名规范:xx-vcmodal-*
*/
@import "../../styles/global.css";
/* ── 弹窗内容区 ───────────────────────────────────────────── */
.xx-vcmodal-body {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-sm) 0;
}
/* ── 字段容器 ─────────────────────────────────────────────── */
.xx-vcmodal-field {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.xx-vcmodal-label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
letter-spacing: var(--letter-spacing-wide);
}
/* ── 输入框 ───────────────────────────────────────────────── */
.xx-vcmodal-input {
width: 100%;
height: 40px;
padding: 0 var(--space-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
font-size: var(--font-size-base);
color: var(--text-primary);
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.xx-vcmodal-input::placeholder {
color: var(--text-tertiary);
}
.xx-vcmodal-input:focus {
border-color: var(--color-primary-400);
box-shadow: 0 0 0 3px var(--color-primary-100);
}
/* ── 上传区域 ─────────────────────────────────────────────── */
.xx-vcmodal-upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-lg) var(--space-md);
border: 2px dashed var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.xx-vcmodal-upload-zone:hover {
border-color: var(--color-primary-300);
background: var(--color-primary-50);
}
.xx-vcmodal-upload-zone--active {
border-color: var(--color-primary-500);
background: var(--color-primary-100);
}
.xx-vcmodal-upload-zone--has-file {
border-style: solid;
border-color: var(--color-primary-400);
background: var(--color-primary-50);
}
.xx-vcmodal-upload-icon {
font-size: 32px;
line-height: 1;
}
.xx-vcmodal-upload-title {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
text-align: center;
word-break: break-all;
}
.xx-vcmodal-upload-hint {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
text-align: center;
}
/* ── 分隔线 ───────────────────────────────────────────────── */
.xx-vcmodal-divider {
display: flex;
align-items: center;
gap: var(--space-md);
margin: var(--space-xs) 0;
}
.xx-vcmodal-divider-line {
flex: 1;
height: 1px;
background: var(--border-color);
}
.xx-vcmodal-divider-text {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
font-weight: var(--font-weight-medium);
}
/* ── 录制区域 ─────────────────────────────────────────────── */
.xx-vcmodal-record-area {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
}
.xx-vcmodal-record-info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.xx-vcmodal-record-hint {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* 录制波形动画 */
.xx-vcmodal-record-wave {
display: flex;
align-items: flex-end;
gap: 3px;
height: 20px;
}
.xx-vcmodal-record-wave-bar {
width: 3px;
background: var(--color-primary-500);
border-radius: var(--radius-full);
animation: vcmodal-wave 0.8s ease-in-out infinite alternate;
}
.xx-vcmodal-record-wave-bar:nth-child(1) { height: 40%; animation-delay: 0s; }
.xx-vcmodal-record-wave-bar:nth-child(2) { height: 70%; animation-delay: 0.15s; }
.xx-vcmodal-record-wave-bar:nth-child(3) { height: 100%; animation-delay: 0.3s; }
.xx-vcmodal-record-wave-bar:nth-child(4) { height: 60%; animation-delay: 0.45s; }
.xx-vcmodal-record-wave-bar:nth-child(5) { height: 30%; animation-delay: 0.6s; }
@keyframes vcmodal-wave {
from { transform: scaleY(0.4); }
to { transform: scaleY(1); }
}
/* 录制按钮 */
.xx-vcmodal-record-btn {
width: 48px;
height: 48px;
border: none;
border-radius: var(--radius-full);
background: var(--bg-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
font-size: 20px;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.xx-vcmodal-record-btn:hover {
background: var(--color-primary-50);
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.xx-vcmodal-record-btn--recording {
background: var(--error-color);
color: var(--text-inverse);
animation: vcmodal-pulse 1.2s ease-in-out infinite;
}
.xx-vcmodal-record-btn--recording:hover {
background: var(--error-hover);
}
@keyframes vcmodal-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
}
/* ── 提示 ─────────────────────────────────────────────────── */
.xx-vcmodal-tip {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-xs);
background: var(--info-soft);
border: 1px solid var(--info-border);
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: var(--line-height-base);
}
.xx-vcmodal-tip-icon {
flex-shrink: 0;
font-size: var(--font-size-base);
}
/* ── 底部按钮 ─────────────────────────────────────────────── */
.xx-vcmodal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--border-light);
}
/* ── 进度阶段通用 ─────────────────────────────────────────── */
.xx-vcmodal-progress-body {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xl);
padding: var(--space-lg) 0 var(--space-md);
}
/* ── 步骤指示器 ───────────────────────────────────────────── */
.xx-vcmodal-steps {
display: flex;
align-items: center;
gap: 0;
width: 100%;
max-width: 360px;
}
.xx-vcmodal-step {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
flex: 1;
position: relative;
}
.xx-vcmodal-step-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background: var(--bg-tertiary);
border: 2px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--text-tertiary);
transition: all 0.3s ease;
}
.xx-vcmodal-step--active .xx-vcmodal-step-icon {
background: var(--color-primary-50);
border-color: var(--color-primary-500);
color: var(--color-primary-600);
box-shadow: 0 0 0 4px var(--color-primary-100);
}
.xx-vcmodal-step--done .xx-vcmodal-step-icon {
background: var(--color-secondary-50);
border-color: var(--color-secondary-500);
color: var(--color-secondary-600);
}
.xx-vcmodal-step-label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-tertiary);
transition: color 0.3s ease;
}
.xx-vcmodal-step--active .xx-vcmodal-step-label {
color: var(--color-primary-600);
font-weight: var(--font-weight-semibold);
}
.xx-vcmodal-step--done .xx-vcmodal-step-label {
color: var(--color-secondary-600);
}
/* 步骤连接线 */
.xx-vcmodal-step-connector {
flex: 0 0 40px;
height: 2px;
background: var(--border-color);
margin-bottom: 20px;
transition: background 0.3s ease;
}
.xx-vcmodal-step-connector--done {
background: var(--color-secondary-400);
}
/* ── 进度信息 ─────────────────────────────────────────────── */
.xx-vcmodal-progress-info {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
text-align: center;
}
/* 加载动画 */
.xx-vcmodal-progress-spinner {
width: 48px;
height: 48px;
border: 3px solid var(--color-primary-100);
border-top-color: var(--color-primary-500);
border-radius: var(--radius-full);
animation: vcmodal-spin 0.8s linear infinite;
}
.xx-vcmodal-progress-spinner--cloning {
border-color: var(--color-secondary-100);
border-top-color: var(--color-secondary-500);
}
@keyframes vcmodal-spin {
to { transform: rotate(360deg); }
}
.xx-vcmodal-progress-text {
margin: 0;
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-vcmodal-progress-sub {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* ── 完成阶段 ─────────────────────────────────────────────── */
.xx-vcmodal-success {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
text-align: center;
}
.xx-vcmodal-success-icon {
font-size: 48px;
line-height: 1;
animation: vcmodal-bounce 0.6s ease-out;
}
@keyframes vcmodal-bounce {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(1); }
}
.xx-vcmodal-success-title {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-vcmodal-success-desc {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
max-width: 280px;
line-height: var(--line-height-relaxed);
}
/* ── 响应式 ───────────────────────────────────────────────── */
@media (max-width: 768px) {
.xx-vcmodal-body {
gap: var(--space-sm);
}
.xx-vcmodal-upload-zone {
padding: var(--space-md) var(--space-sm);
}
.xx-vcmodal-record-area {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.xx-vcmodal-record-btn {
align-self: center;
}
.xx-vcmodal-steps {
max-width: 280px;
}
.xx-vcmodal-step-connector {
flex: 0 0 24px;
}
.xx-vcmodal-footer {
flex-direction: column-reverse;
}
.xx-vcmodal-footer > * {
width: 100%;
}
}
@media (max-width: 480px) {
.xx-vcmodal-step-icon {
width: 32px;
height: 32px;
font-size: 13px;
}
.xx-vcmodal-step-connector {
flex: 0 0 16px;
margin-bottom: 16px;
}
.xx-vcmodal-progress-spinner {
width: 36px;
height: 36px;
}
.xx-vcmodal-success-icon {
font-size: 36px;
}
}
+4 -7
View File
@@ -9,12 +9,7 @@ import classNames from "classnames";
import "./ui.css";
export type ButtonType =
| "primary"
| "secondary"
| "ghost"
| "text"
| "danger"
| "link";
"primary" | "secondary" | "ghost" | "text" | "danger" | "link";
export type ButtonSize = "sm" | "md" | "lg";
@@ -70,7 +65,9 @@ const Button: React.FC<ButtonProps> = ({
...rest
}) => {
const antdType = type ?? toAntdType(buttonType);
const antdSize = size ?? (buttonSize === "sm" ? "small" : buttonSize === "lg" ? "large" : "middle");
const antdSize =
size ??
(buttonSize === "sm" ? "small" : buttonSize === "lg" ? "large" : "middle");
const v21Class = classNames(
"xx-btn",
+4 -1
View File
@@ -20,7 +20,10 @@ const Form = ({ className, compact, children, ...rest }: FormProps) => {
className,
);
return (
<AntForm className={v21Class} {...(rest as Omit<FormProps, "className" | "compact" | "children">)}>
<AntForm
className={v21Class}
{...(rest as Omit<FormProps, "className" | "compact" | "children">)}
>
{children as React.ReactNode}
</AntForm>
);
+7 -11
View File
@@ -9,7 +9,11 @@ import type { InputRef } from "antd/es/input";
import classNames from "classnames";
import "./ui.css";
const { Search: AntSearch, TextArea: AntTextArea, Password: AntPassword } = AntInput;
const {
Search: AntSearch,
TextArea: AntTextArea,
Password: AntPassword,
} = AntInput;
export interface InputProps extends AntInputProps {
/** 是否处于错误状态 */
@@ -46,11 +50,7 @@ const Search: React.FC<
const TextArea: React.FC<
React.ComponentProps<typeof AntTextArea> & { error?: boolean }
> = ({ className, error, ...rest }) => {
const v21Class = classNames(
"xx-input",
error && "xx-input-error",
className,
);
const v21Class = classNames("xx-input", error && "xx-input-error", className);
return <AntTextArea className={v21Class} {...rest} />;
};
@@ -59,11 +59,7 @@ const Password = React.forwardRef<
InputRef,
React.ComponentProps<typeof AntPassword> & { error?: boolean }
>(({ className, error, ...rest }, ref) => {
const v21Class = classNames(
"xx-input",
error && "xx-input-error",
className,
);
const v21Class = classNames("xx-input", error && "xx-input-error", className);
return <AntPassword ref={ref} className={v21Class} {...rest} />;
});
Password.displayName = "Password";
+1 -4
View File
@@ -19,10 +19,7 @@ const Select: React.FC<SelectProps> = ({
...rest
}) => {
const v21Class = classNames("xx-select", className);
const v21DropdownClass = classNames(
"xx-select-dropdown",
dropdownClassName,
);
const v21DropdownClass = classNames("xx-select-dropdown", dropdownClassName);
return (
<AntSelect
className={v21Class}
+3 -2
View File
@@ -8,8 +8,9 @@ import type { TableProps as AntTableProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface TableProps<RecordType = unknown>
extends AntTableProps<RecordType> {
export interface TableProps<
RecordType = unknown,
> extends AntTableProps<RecordType> {
/** 使用 V21 样式 */
v21?: boolean;
}
+4 -1
View File
@@ -4,7 +4,10 @@
*/
import React from "react";
import { Tag as AntTag, Badge as AntBadge } from "antd";
import type { TagProps as AntTagProps, BadgeProps as AntBadgeProps } from "antd";
import type {
TagProps as AntTagProps,
BadgeProps as AntBadgeProps,
} from "antd";
import classNames from "classnames";
import "./ui.css";
+1 -4
View File
@@ -3,10 +3,7 @@
* 封装 Ant Design Tooltip 和 Popover,应用 V21 设计系统样式
*/
import React from "react";
import {
Tooltip as AntTooltip,
Popover as AntPopover,
} from "antd";
import { Tooltip as AntTooltip, Popover as AntPopover } from "antd";
import classNames from "classnames";
import "./ui.css";
@@ -0,0 +1,366 @@
/**
* CloneModal — 音色克隆弹窗
* 任务 3.13:实现克隆 Modal,用户点击「克隆音色」按钮后弹出
*
* 功能:
* - 步骤引导:上传音频 → 填写信息 → 提交克隆
* - 表单字段:音频文件上传(wav/mp3/m4a,≤10MB)、音色名称(必填,2-20字符)、音色描述(可选,≤100字符)
* - 提交后调用 POST /api/v1/voice-clones 创建克隆
* - 创建成功后关闭 Modal,刷新配音列表
* - 错误处理:上传失败、格式错误、大小超限等提示
*
* V21 Design System — 零 antd 直接导入
*/
import React, { useState, useCallback, useRef, useEffect } from "react";
import { Modal, Button } from "@/components/ui";
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import { uploadAsset } from "@/api/assets";
import "./clone-modal.css";
/* ── 类型定义 ───────────────────────────────────────────── */
type ModalPhase = "input" | "uploading" | "success";
export interface CloneModalProps {
/** 弹窗是否可见 */
open: boolean;
/** 关闭弹窗回调 */
onClose: () => void;
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void;
}
/* ── 常量 ───────────────────────────────────────────────── */
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"];
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
/* ── 组件 ───────────────────────────────────────────────── */
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
const [phase, setPhase] = useState<ModalPhase>("input");
const [voiceName, setVoiceName] = useState("");
const [voiceDescription, setVoiceDescription] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dragActive, setDragActive] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** 组件卸载时清理定时器(P2-2 修复) */
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input");
setVoiceName("");
setVoiceDescription("");
setSelectedFile(null);
setDragActive(false);
setErrorMessage("");
}, []);
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
/** 弹窗打开时重置状态 */
useEffect(() => {
if (open) {
resetState();
}
}, [open, resetState]);
/* ── 文件验证 ──────────────────────────────────────── */
const validateFile = (file: File): string | null => {
const ext = file.name.split(".").pop()?.toLowerCase();
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件";
}
if (file.size > MAX_FILE_SIZE) {
return "文件大小超过 10MB,请压缩后重试";
}
return null;
};
/* ── 文件上传 ──────────────────────────────────────── */
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const error = validateFile(file);
if (error) {
setErrorMessage(error);
setSelectedFile(null);
} else {
setErrorMessage("");
setSelectedFile(file);
}
}
e.target.value = "";
};
/* ── 拖拽 ──────────────────────────────────────────── */
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) {
const error = validateFile(file);
if (error) {
setErrorMessage(error);
setSelectedFile(null);
} else {
setErrorMessage("");
setSelectedFile(file);
}
}
};
/* ── 表单验证 ──────────────────────────────────────── */
const validateForm = (): string | null => {
const name = voiceName.trim();
if (!name) {
return "请输入音色名称";
}
if (name.length < 2 || name.length > 20) {
return "音色名称需在 2-20 个字符之间";
}
if (!selectedFile) {
return "请上传音频文件";
}
return null;
};
/* ── 提交克隆 ──────────────────────────────────────── */
const handleSubmit = async () => {
const formError = validateForm();
if (formError) {
setErrorMessage(formError);
return;
}
setPhase("uploading");
setErrorMessage("");
try {
// P1 修复:先上传音频文件获取真实 URL,再调用克隆 API
const formData = new FormData();
formData.append("file", selectedFile!);
const uploadResult = await uploadAsset(formData);
const result = await createVoiceClone({
name: voiceName.trim(),
description: voiceDescription.trim() || undefined,
audio_url: uploadResult.url,
});
setPhase("success");
// 2秒后自动关闭(P2-2 修复:使用 timerRef 以便 cleanup
timerRef.current = setTimeout(() => {
onSuccess?.(toVoiceClone(result));
handleClose();
}, 2000);
} catch (err) {
setPhase("input");
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试");
}
};
/* ── 计算属性 ──────────────────────────────────────── */
const canSubmit =
voiceName.trim().length >= 2 &&
voiceName.trim().length <= 20 &&
selectedFile !== null;
const isProcessing = phase === "uploading";
return (
<Modal
open={open}
onCancel={handleClose}
title="克隆我的音色"
width={560}
footer={null}
destroyOnClose
maskClosable={!isProcessing}
keyboard={!isProcessing}
>
{/* ── 输入阶段 ──────────────────────────────────── */}
{phase === "input" && (
<div className="xx-clonemodal-body">
{/* 步骤引导 */}
<div className="xx-clonemodal-steps">
<div className="xx-clonemodal-step xx-clonemodal-step--active">
<div className="xx-clonemodal-step-number">1</div>
<span className="xx-clonemodal-step-label"></span>
</div>
<div className="xx-clonemodal-step-connector" />
<div className="xx-clonemodal-step">
<div className="xx-clonemodal-step-number">2</div>
<span className="xx-clonemodal-step-label"></span>
</div>
<div className="xx-clonemodal-step-connector" />
<div className="xx-clonemodal-step">
<div className="xx-clonemodal-step-number">3</div>
<span className="xx-clonemodal-step-label"></span>
</div>
</div>
{/* 上传区域 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label">
<span className="xx-clonemodal-required">*</span>
</label>
<div
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="xx-clonemodal-upload-icon">
{selectedFile ? "📄" : "🎵"}
</div>
<p className="xx-clonemodal-upload-title">
{selectedFile
? selectedFile.name
: "拖拽音频文件到此处,或点击上传"}
</p>
<p className="xx-clonemodal-upload-hint">
MP3WAVM4A 10MB
</p>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_MIME}
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
{/* 音色名称 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label">
<span className="xx-clonemodal-required">*</span>
</label>
<input
type="text"
className="xx-clonemodal-input"
value={voiceName}
onChange={(e) => setVoiceName(e.target.value)}
placeholder="输入音色名称(2-20字符)"
maxLength={20}
/>
<div className="xx-clonemodal-char-count">
{voiceName.length}/20
</div>
</div>
{/* 音色描述 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<textarea
className="xx-clonemodal-textarea"
value={voiceDescription}
onChange={(e) => setVoiceDescription(e.target.value)}
placeholder="可选,描述这个音色的特点(最多100字符)"
maxLength={100}
rows={3}
/>
<div className="xx-clonemodal-char-count">
{voiceDescription.length}/100
</div>
</div>
{/* 错误提示 */}
{errorMessage && (
<div className="xx-clonemodal-error">
<span className="xx-clonemodal-error-icon"></span>
<span>{errorMessage}</span>
</div>
)}
{/* 提示 */}
<div className="xx-clonemodal-tip">
<span className="xx-clonemodal-tip-icon">💡</span>
<span>
10 ~ 3
</span>
</div>
{/* 底部按钮 */}
<div className="xx-clonemodal-footer">
<Button buttonType="ghost" onClick={handleClose}>
</Button>
<Button
buttonType="primary"
disabled={!canSubmit}
onClick={handleSubmit}
>
🎤
</Button>
</div>
</div>
)}
{/* ── 上传中阶段 ────────────────────────────────── */}
{phase === "uploading" && (
<div className="xx-clonemodal-uploading">
<div className="xx-clonemodal-uploading-spinner" />
<p className="xx-clonemodal-uploading-text"></p>
<p className="xx-clonemodal-uploading-sub">
AI
</p>
</div>
)}
{/* ── 成功阶段 ──────────────────────────────────── */}
{phase === "success" && (
<div className="xx-clonemodal-success">
<div className="xx-clonemodal-success-icon"></div>
<h3 className="xx-clonemodal-success-title"></h3>
<p className="xx-clonemodal-success-desc">
</p>
</div>
)}
</Modal>
);
};
export default CloneModal;
@@ -0,0 +1,340 @@
/**
* CloneModal 样式
* 任务 3.13:音色克隆弹窗
*
* V21 Design System
* CSS 前缀:xx-clonemodal-
*/
/* ── 容器 ───────────────────────────────────────────────── */
.xx-clonemodal-body {
padding: 4px 0;
}
/* ── 步骤引导 ───────────────────────────────────────────── */
.xx-clonemodal-steps {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin-bottom: 24px;
padding: 0 16px;
}
.xx-clonemodal-step {
display: flex;
align-items: center;
gap: 8px;
opacity: 0.5;
transition: opacity 0.2s ease;
}
.xx-clonemodal-step--active {
opacity: 1;
}
.xx-clonemodal-step-number {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--xx-color-primary, #6366f1);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
.xx-clonemodal-step:not(.xx-clonemodal-step--active) .xx-clonemodal-step-number {
background: var(--xx-color-border, #e5e7eb);
color: var(--xx-color-text-secondary, #6b7280);
}
.xx-clonemodal-step-label {
font-size: 13px;
font-weight: 500;
color: var(--xx-color-text, #111827);
white-space: nowrap;
}
.xx-clonemodal-step-connector {
width: 40px;
height: 2px;
background: var(--xx-color-border, #e5e7eb);
margin: 0 8px;
flex-shrink: 0;
}
/* ── 表单字段 ───────────────────────────────────────────── */
.xx-clonemodal-field {
margin-bottom: 18px;
position: relative;
}
.xx-clonemodal-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--xx-color-text, #111827);
margin-bottom: 6px;
}
.xx-clonemodal-required {
color: var(--xx-color-error, #ef4444);
margin-left: 2px;
}
.xx-clonemodal-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--xx-color-border, #e5e7eb);
border-radius: 8px;
font-size: 14px;
color: var(--xx-color-text, #111827);
background: var(--xx-color-bg-secondary, #f9fafb);
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
box-sizing: border-box;
}
.xx-clonemodal-input:focus {
border-color: var(--xx-color-primary, #6366f1);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.xx-clonemodal-input::placeholder {
color: var(--xx-color-text-placeholder, #9ca3af);
}
.xx-clonemodal-textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--xx-color-border, #e5e7eb);
border-radius: 8px;
font-size: 14px;
color: var(--xx-color-text, #111827);
background: var(--xx-color-bg-secondary, #f9fafb);
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
box-sizing: border-box;
}
.xx-clonemodal-textarea:focus {
border-color: var(--xx-color-primary, #6366f1);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.xx-clonemodal-textarea::placeholder {
color: var(--xx-color-text-placeholder, #9ca3af);
}
.xx-clonemodal-char-count {
position: absolute;
right: 0;
top: 0;
font-size: 12px;
color: var(--xx-color-text-secondary, #6b7280);
}
/* ── 上传区域 ───────────────────────────────────────────── */
.xx-clonemodal-upload-zone {
border: 2px dashed var(--xx-color-border, #e5e7eb);
border-radius: 12px;
padding: 28px 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: var(--xx-color-bg-secondary, #f9fafb);
}
.xx-clonemodal-upload-zone:hover {
border-color: var(--xx-color-primary, #6366f1);
background: rgba(99, 102, 241, 0.03);
}
.xx-clonemodal-upload-zone--active {
border-color: var(--xx-color-primary, #6366f1);
background: rgba(99, 102, 241, 0.06);
}
.xx-clonemodal-upload-zone--has-file {
border-style: solid;
border-color: var(--xx-color-primary, #6366f1);
background: rgba(99, 102, 241, 0.04);
}
.xx-clonemodal-upload-icon {
font-size: 32px;
margin-bottom: 8px;
}
.xx-clonemodal-upload-title {
font-size: 14px;
font-weight: 500;
color: var(--xx-color-text, #111827);
margin: 0 0 4px;
word-break: break-all;
}
.xx-clonemodal-upload-hint {
font-size: 12px;
color: var(--xx-color-text-secondary, #6b7280);
margin: 0;
}
/* ── 错误提示 ───────────────────────────────────────────── */
.xx-clonemodal-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--xx-color-error, #ef4444);
font-size: 13px;
margin-bottom: 16px;
}
.xx-clonemodal-error-icon {
flex-shrink: 0;
font-size: 14px;
}
/* ── 提示 ───────────────────────────────────────────────── */
.xx-clonemodal-tip {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
background: rgba(99, 102, 241, 0.06);
font-size: 12px;
color: var(--xx-color-text-secondary, #6b7280);
margin-bottom: 20px;
line-height: 1.5;
}
.xx-clonemodal-tip-icon {
flex-shrink: 0;
font-size: 14px;
}
/* ── 底部按钮 ───────────────────────────────────────────── */
.xx-clonemodal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 4px;
}
/* ── 上传中状态 ─────────────────────────────────────────── */
.xx-clonemodal-uploading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
text-align: center;
}
.xx-clonemodal-uploading-spinner {
width: 48px;
height: 48px;
border: 3px solid var(--xx-color-border, #e5e7eb);
border-top-color: var(--xx-color-primary, #6366f1);
border-radius: 50%;
animation: xx-clonemodal-spin 0.8s linear infinite;
margin-bottom: 20px;
}
@keyframes xx-clonemodal-spin {
to {
transform: rotate(360deg);
}
}
.xx-clonemodal-uploading-text {
font-size: 16px;
font-weight: 500;
color: var(--xx-color-text, #111827);
margin: 0 0 8px;
}
.xx-clonemodal-uploading-sub {
font-size: 13px;
color: var(--xx-color-text-secondary, #6b7280);
margin: 0;
}
/* ── 成功状态 ───────────────────────────────────────────── */
.xx-clonemodal-success {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
text-align: center;
}
.xx-clonemodal-success-icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
margin-bottom: 16px;
}
.xx-clonemodal-success-title {
font-size: 18px;
font-weight: 600;
color: var(--xx-color-text, #111827);
margin: 0 0 8px;
}
.xx-clonemodal-success-desc {
font-size: 13px;
color: var(--xx-color-text-secondary, #6b7280);
margin: 0;
line-height: 1.5;
}
/* ── 响应式 ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.xx-clonemodal-steps {
padding: 0 8px;
}
.xx-clonemodal-step-connector {
width: 24px;
margin: 0 4px;
}
.xx-clonemodal-step-label {
font-size: 12px;
}
.xx-clonemodal-step-number {
width: 24px;
height: 24px;
font-size: 12px;
}
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 统一导航配置
* Header.tsx 和 Sidebar.tsx 共享此数据源,避免路由配置重复
*/
import React from "react";
import {
DashboardOutlined,
FileOutlined,
FileTextOutlined,
AudioOutlined,
AppstoreOutlined,
EditOutlined,
FolderOutlined,
VideoCameraOutlined,
HistoryOutlined,
TrophyOutlined,
ScanOutlined,
ControlOutlined,
CrownOutlined,
} from "@ant-design/icons";
/** 导航项类型 */
export interface NavItem {
key: string;
label: string;
path: string;
icon: React.ReactNode;
}
/** 导航分组类型 */
export interface NavGroup {
title: string;
items: NavItem[];
}
/** 全量导航项(Header 扁平列表使用) */
export const NAV_ITEMS: NavItem[] = [
{ key: "dashboard", label: "概览", path: "/dashboard", icon: React.createElement(DashboardOutlined) },
{ key: "assets", label: "素材库", path: "/assets", icon: React.createElement(FileOutlined) },
{ key: "titles", label: "标题库", path: "/titles", icon: React.createElement(FileTextOutlined) },
{ key: "voices", label: "配音库", path: "/voices", icon: React.createElement(AudioOutlined) },
{ key: "templates", label: "模板库", path: "/templates", icon: React.createElement(AppstoreOutlined) },
{ key: "editing-planner", label: "剪辑编辑器", path: "/editing-planner", icon: React.createElement(EditOutlined) },
{ key: "my-templates", label: "我的模板", path: "/my-templates", icon: React.createElement(FolderOutlined) },
{ key: "generate", label: "一键生成", path: "/generate", icon: React.createElement(VideoCameraOutlined) },
{ key: "history", label: "任务历史", path: "/history", icon: React.createElement(HistoryOutlined) },
{ key: "products", label: "成品库", path: "/products", icon: React.createElement(TrophyOutlined) },
{ key: "duplication", label: "查重", path: "/duplication", icon: React.createElement(ScanOutlined) },
];
/** 侧边栏导航分组(Sidebar 分组列表使用) */
export const NAV_GROUPS: NavGroup[] = [
{
title: "创作工具",
items: [
{ key: "dashboard", label: "概览", path: "/dashboard", icon: React.createElement(DashboardOutlined) },
{ key: "generate", label: "一键生成", path: "/generate", icon: React.createElement(VideoCameraOutlined) },
{ key: "editing-planner", label: "剪辑编辑器", path: "/editing-planner", icon: React.createElement(EditOutlined) },
],
},
{
title: "资源管理",
items: [
{ key: "assets", label: "素材库", path: "/assets", icon: React.createElement(FileOutlined) },
{ key: "voices", label: "配音库", path: "/voices", icon: React.createElement(AudioOutlined) },
{ key: "titles", label: "标题库", path: "/titles", icon: React.createElement(FileTextOutlined) },
{ key: "products", label: "成品库", path: "/products", icon: React.createElement(TrophyOutlined) },
{ key: "templates", label: "模板库", path: "/templates", icon: React.createElement(AppstoreOutlined) },
{ key: "my-templates", label: "我的模板", path: "/my-templates", icon: React.createElement(FolderOutlined) },
],
},
{
title: "系统",
items: [
{ key: "history", label: "任务历史", path: "/history", icon: React.createElement(HistoryOutlined) },
{ key: "duplication", label: "查重", path: "/duplication", icon: React.createElement(ScanOutlined) },
{ key: "admin", label: "控制台", path: "/admin", icon: React.createElement(ControlOutlined) },
{ key: "subscription", label: "订阅管理", path: "/subscription", icon: React.createElement(CrownOutlined) },
],
},
];
+189
View File
@@ -0,0 +1,189 @@
/**
* 统一导航配置
* Header 和 Sidebar 共用此数据源
*/
import React from "react";
import {
DashboardOutlined,
VideoCameraOutlined,
FileOutlined,
AudioOutlined,
FileTextOutlined,
TrophyOutlined,
AppstoreOutlined,
HistoryOutlined,
ControlOutlined,
CrownOutlined,
ScanOutlined,
EditOutlined,
FolderOutlined,
} from "@ant-design/icons";
/** 导航项定义 */
export interface NavItem {
key: string;
label: string;
path: string;
icon: React.ReactNode;
}
/** 导航分组定义 */
export interface NavGroup {
title: string;
items: NavItem[];
}
/**
* 扁平导航列表(Header 使用)
*/
export const NAV_ITEMS: NavItem[] = [
{
key: "dashboard",
label: "概览",
path: "/dashboard",
icon: <DashboardOutlined />,
},
{ key: "assets", label: "素材库", path: "/assets", icon: <FileOutlined /> },
{
key: "titles",
label: "标题库",
path: "/titles",
icon: <FileTextOutlined />,
},
{ key: "voices", label: "配音库", path: "/voices", icon: <AudioOutlined /> },
{
key: "voice-clone",
label: "我的音色",
path: "/voice-clone",
icon: <AudioOutlined />,
},
{
key: "templates",
label: "模板库",
path: "/templates",
icon: <AppstoreOutlined />,
},
{
key: "editing-planner",
label: "剪辑编辑器",
path: "/editing-planner",
icon: <EditOutlined />,
},
{
key: "my-templates",
label: "我的模板",
path: "/my-templates",
icon: <FolderOutlined />,
},
{
key: "generate",
label: "一键生成",
path: "/generate",
icon: <VideoCameraOutlined />,
},
{
key: "history",
label: "任务历史",
path: "/history",
icon: <HistoryOutlined />,
},
{
key: "products",
label: "成品库",
path: "/products",
icon: <TrophyOutlined />,
},
{
key: "duplication",
label: "查重",
path: "/duplication",
icon: <ScanOutlined />,
},
];
/**
* 分组导航列表(Sidebar 使用)
*/
export const NAV_GROUPS: NavGroup[] = [
{
title: "创作工具",
items: [
{
key: "dashboard",
label: "首页",
path: "/dashboard",
icon: <DashboardOutlined />,
},
{
key: "generate",
label: "一键生成",
path: "/generate",
icon: <VideoCameraOutlined />,
},
],
},
{
title: "资源管理",
items: [
{
key: "assets",
label: "素材库",
path: "/assets",
icon: <FileOutlined />,
},
{
key: "voices",
label: "配音库",
path: "/voices",
icon: <AudioOutlined />,
},
{
key: "voice-clone",
label: "我的音色",
path: "/voice-clone",
icon: <AudioOutlined />,
},
{
key: "titles",
label: "标题库",
path: "/titles",
icon: <FileTextOutlined />,
},
{
key: "products",
label: "成片库",
path: "/products",
icon: <TrophyOutlined />,
},
{
key: "templates",
label: "模板库",
path: "/templates",
icon: <AppstoreOutlined />,
},
],
},
{
title: "系统",
items: [
{
key: "history",
label: "任务历史",
path: "/history",
icon: <HistoryOutlined />,
},
{
key: "admin",
label: "控制台",
path: "/admin",
icon: <ControlOutlined />,
},
{
key: "subscription",
label: "订阅管理",
path: "/subscription",
icon: <CrownOutlined />,
},
],
},
];
+90
View File
@@ -0,0 +1,90 @@
/**
* useCloneProgress — 克隆进度轮询 Hook
*
* 当存在 processing 状态的克隆条目时,每 3 秒自动拉取最新列表;
* 全部完成(ready / failed)后停止轮询。
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { getVoiceClones } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
const POLL_INTERVAL = 3000; // 3 秒
export const useCloneProgress = () => {
const [clones, setClones] = useState<VoiceClone[]>([]);
const [loading, setLoading] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
const fetchClones = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
try {
const data = await getVoiceClones();
setClones(data);
} catch {
// 静默失败,下次轮询重试
} finally {
if (!silent) setLoading(false);
}
}, []);
/* 初始加载 */
useEffect(() => {
fetchClones();
}, [fetchClones]);
/* 轮询:有 processing 条目时启动,全部结束时停止 */
useEffect(() => {
const hasProcessing = clones.some((c) => c.status === "processing");
if (hasProcessing) {
if (!timerRef.current) {
timerRef.current = setInterval(() => {
fetchClones(true);
}, POLL_INTERVAL);
}
} else {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = undefined;
}
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = undefined;
}
};
}, [clones, fetchClones]);
/** 手动刷新 */
const refresh = useCallback(() => fetchClones(), [fetchClones]);
/** 克隆成功后追加到列表 */
const addClone = useCallback((voice: VoiceClone) => {
setClones((prev) => [voice, ...prev]);
}, []);
/** 删除后从列表移除 */
const removeClone = useCallback((id: string) => {
setClones((prev) => prev.filter((c) => c.id !== id));
}, []);
/** 更新某条克隆(如改名) */
const updateClone = useCallback((updated: VoiceClone) => {
setClones((prev) =>
prev.map((c) => (c.id === updated.id ? updated : c)),
);
}, []);
return {
clones,
loading,
refresh,
addClone,
removeClone,
updateClone,
/** 是否有正在处理中的克隆 */
hasProcessing: clones.some((c) => c.status === "processing"),
};
};
+238
View File
@@ -0,0 +1,238 @@
/**
* 账号管理页面 — V21 Design System
*
* 展示多平台账号绑定状态(抖音/快手/小红书/微信视频号)
* 支持绑定/解绑操作
*
* 零 antd 直接导入,全部使用 CSS 变量
*/
import React, { useState, useCallback } from "react";
import { useQueries, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import {
PLATFORMS,
getAccountsByPlatform,
unbindAccount,
bindAccount,
ACCOUNT_STATUS_CONFIG,
type Platform,
type Account,
type PlatformId,
} from "@/api/accounts";
import "./accounts.css";
/* ── Toast 系统 ─────────────────────────────────────────── */
interface Toast {
id: number;
message: string;
type: "success" | "error";
}
let toastIdCounter = 0;
/* ── 平台卡片组件 ───────────────────────────────────────── */
interface PlatformCardProps {
platform: Platform;
accounts: Account[];
isLoading: boolean;
onBind: (platformId: PlatformId) => void;
onUnbind: (accountId: string, accountName: string) => void;
}
const PlatformCard: React.FC<PlatformCardProps> = ({
platform,
accounts,
isLoading,
onBind,
onUnbind,
}) => {
return (
<div className="acc-card">
{/* 平台头部 */}
<div className="acc-card-header">
<div
className="acc-card-icon"
style={{ background: platform.gradient }}
>
{platform.icon}
</div>
<div>
<h3 className="acc-card-title">{platform.name}</h3>
<p className="acc-card-subtitle">{platform.subName}</p>
</div>
</div>
{/* 账号列表 */}
<div className="acc-account-list">
{isLoading ? (
<div className="acc-empty">
<p className="acc-empty-text"></p>
</div>
) : accounts.length > 0 ? (
accounts.map((account) => {
const statusCfg = ACCOUNT_STATUS_CONFIG[account.status];
return (
<div key={account.id} className="acc-account-row">
<div
className="acc-account-avatar"
style={{ background: platform.gradient }}
>
{account.avatar || platform.icon}
</div>
<div className="acc-account-info">
<div className="acc-account-name">{account.name}</div>
<span className={`acc-status-pill ${statusCfg.className}`}>
{statusCfg.label}
</span>
</div>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => onUnbind(account.id, account.name)}
>
</Button>
</div>
);
})
) : (
<div className="acc-empty">
<div className="acc-empty-icon">🔓</div>
<p className="acc-empty-text"></p>
</div>
)}
</div>
{/* 绑定按钮 */}
<Button buttonType="ghost" onClick={() => onBind(platform.id)}>
+
</Button>
</div>
);
};
/* ── 主页面 ─────────────────────────────────────────────── */
const Accounts: React.FC = () => {
const queryClient = useQueryClient();
const [toasts, setToasts] = useState<Toast[]>([]);
/** 显示 toast */
const showToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastIdCounter;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
/** 查询所有平台的账号 */
const _queriesResults = useQueries({
queries: PLATFORMS.map((platform) => ({
queryKey: ["accounts", platform.id] as const,
queryFn: () => getAccountsByPlatform(platform.id),
})),
});
const accountQueries = PLATFORMS.map((platform, i) => ({
platform,
..._queriesResults[i],
}));
/** 解绑 mutation */
const unbindMutation = useMutation({
mutationFn: unbindAccount,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
showToast("已解绑账号", "success");
},
onError: () => {
showToast("解绑失败", "error");
},
});
/** 绑定 mutationmock */
const bindMutation = useMutation({
mutationFn: bindAccount,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
showToast("账号绑定成功", "success");
},
onError: () => {
showToast("绑定失败", "error");
},
});
/** 绑定新账号(mock:直接创建) */
const handleBind = (platformId: PlatformId) => {
const platform = PLATFORMS.find((p) => p.id === platformId);
if (!platform) return;
const name = window.prompt(`请输入要绑定的${platform.name}账号名称:`);
if (name && name.trim()) {
bindMutation.mutate({ platform_id: platformId, name: name.trim() });
}
};
/** 解绑账号 */
const handleUnbind = (accountId: string, accountName: string) => {
if (window.confirm(`确定解绑账号「${accountName}」吗?`)) {
unbindMutation.mutate(accountId);
}
};
/** 统计已绑定账号数 */
const totalBound = accountQueries.reduce(
(sum, q) => sum + (q.data?.length ?? 0),
0,
);
const totalPlatforms = PLATFORMS.length;
return (
<div className="acc-page">
<PageHead
title="🔑 账号管理"
description="绑定您的社交平台账号,用于视频一键发布到各平台"
/>
{/* 平台卡片网格 */}
<div className="acc-grid">
{accountQueries.map(({ platform, data, isLoading }) => (
<PlatformCard
key={platform.id}
platform={platform}
accounts={data ?? []}
isLoading={isLoading}
onBind={handleBind}
onUnbind={handleUnbind}
/>
))}
</div>
{/* 底部统计栏 */}
<div className="acc-stats-bar">
<span className="acc-stats-icon">📊</span>
<span className="acc-stats-text">
<span className="acc-stats-highlight">{totalBound}</span>{" "}
/ {" "}
<span className="acc-stats-highlight">{totalPlatforms}</span>
</span>
</div>
{/* Toast 提示 */}
{toasts.length > 0 && (
<div className="vc-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)}
</div>
);
};
export default Accounts;
+251
View File
@@ -0,0 +1,251 @@
/**
* 账号管理页面 — V21 Design System
*
* 多平台账号绑定状态展示,2列网格布局
* 支持绑定/解绑操作
*/
/* ── 页面容器 ───────────────────────────────────────────── */
.acc-page {
display: flex;
flex-direction: column;
gap: 24px;
}
/* ── 平台卡片网格 ───────────────────────────────────────── */
.acc-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
/* ── 平台卡片 ───────────────────────────────────────────── */
.acc-card {
background: var(--bg-surface, #fff);
border: 1px solid var(--line, #e4e7ec);
border-radius: var(--radius-lg, 14px);
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
transition: box-shadow 0.2s;
}
.acc-card:hover {
box-shadow: 0 4px 20px var(--shadow-sm, rgba(0, 0, 0, 0.06));
}
/* ── 卡片头部 ───────────────────────────────────────────── */
.acc-card-header {
display: flex;
align-items: center;
gap: 12px;
}
.acc-card-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-md, 12px);
display: grid;
place-items: center;
font-size: 24px;
line-height: 1;
flex-shrink: 0;
color: var(--text-inverse);
}
.acc-card-title {
font-size: 17px;
font-weight: 700;
color: var(--text-primary, #101828);
margin: 0;
line-height: 1.3;
}
.acc-card-subtitle {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 2px 0 0;
}
/* ── 账号列表 ───────────────────────────────────────────── */
.acc-account-list {
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── 账号行 ─────────────────────────────────────────────── */
.acc-account-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: var(--bg-subtle, #f8fafc);
border-radius: var(--radius-md, 12px);
transition: background 0.15s;
}
.acc-account-row:hover {
background: color-mix(in srgb, var(--primary-color) 4%, transparent);
}
.acc-account-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--text-inverse);
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
}
.acc-account-info {
flex: 1;
min-width: 0;
}
.acc-account-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #101828);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── 状态标签 ───────────────────────────────────────────── */
.acc-status-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
line-height: 1.6;
margin-top: 2px;
}
.acc-status--active {
background: var(--success-soft, #ecfdf5);
color: var(--secondary-color, #059669);
}
.acc-status--expired {
background: var(--error-soft, #fef2f2);
color: var(--error-color, #dc2626);
}
.acc-status--limited {
background: var(--warning-soft, #fffbeb);
color: var(--accent-color, #d97706);
}
/* ── 空状态 ─────────────────────────────────────────────── */
.acc-empty {
padding: 24px 16px;
text-align: center;
color: var(--muted, #98a2b3);
}
.acc-empty-icon {
font-size: 32px;
margin-bottom: 8px;
line-height: 1;
opacity: 0.6;
}
.acc-empty-text {
font-size: 14px;
margin: 0;
}
/* ── 底部统计栏 ─────────────────────────────────────────── */
.acc-stats-bar {
padding: 16px 20px;
background: var(--bg-subtle, #f8fafc);
border-radius: var(--radius-md, 12px);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 14px;
color: var(--text-secondary, #475467);
}
.acc-stats-icon {
font-size: 16px;
}
.acc-stats-text {
font-weight: 600;
}
.acc-stats-highlight {
color: var(--primary, #6366f1);
font-weight: 700;
}
/* ── 响应式:平板 ───────────────────────────────────────── */
@media (max-width: 768px) {
.acc-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.acc-card {
padding: 20px;
}
}
/* ── 响应式:手机 ───────────────────────────────────────── */
@media (max-width: 480px) {
.acc-page {
gap: 16px;
}
.acc-card {
padding: 16px;
gap: 12px;
}
.acc-card-icon {
width: 40px;
height: 40px;
font-size: 20px;
}
.acc-card-title {
font-size: 15px;
}
.acc-account-row {
padding: 10px;
gap: 8px;
}
.acc-account-avatar {
width: 32px;
height: 32px;
font-size: 12px;
}
.acc-stats-bar {
padding: 12px 16px;
font-size: 13px;
}
}
File diff suppressed because it is too large Load Diff
+454
View File
@@ -0,0 +1,454 @@
/**
* 素材库页面 - V21 设计系统样式
* 两栏布局:左侧素材库列表(260px)+ 右侧素材网格
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
/* ============================================================
页面容器
============================================================ */
.xx-assets-page {
min-height: 100%;
padding: var(--space-xl);
}
/* ============================================================
两栏布局
============================================================ */
.xx-assets-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 20px;
align-items: start;
}
/* ============================================================
左侧素材库列表
============================================================ */
.xx-asset-library-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
position: sticky;
top: var(--space-md);
}
.xx-asset-library-item {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius-sm);
padding: 14px;
cursor: pointer;
transition: var(--transition-all);
}
.xx-asset-library-item:hover {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.xx-asset-library-item.active {
border-color: var(--primary-color);
background: var(--primary-soft);
box-shadow: var(--shadow-primary);
}
.xx-asset-library-item h4 {
margin: 0 0 4px;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.xx-asset-library-item span {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.xx-asset-library-delete {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-xs);
font-size: var(--font-size-sm);
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
flex-shrink: 0;
}
.xx-asset-library-item:hover .xx-asset-library-delete {
opacity: 1;
}
.xx-asset-library-delete:hover {
color: var(--error-color);
background: var(--error-soft);
}
.xx-asset-library-add {
border: 1px dashed var(--border-color);
background: transparent;
border-radius: var(--radius-sm);
padding: 14px;
cursor: pointer;
text-align: center;
color: var(--text-secondary);
font-size: var(--font-size-sm);
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
.xx-asset-library-add:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: var(--primary-soft);
}
/* ============================================================
右侧内容区
============================================================ */
.xx-assets-content {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
/* ============================================================
上传区域
============================================================ */
.xx-asset-upload-zone {
border: 2px dashed var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-2xl) var(--space-xl);
text-align: center;
background: var(--bg-secondary);
cursor: pointer;
transition: var(--transition-all);
}
.xx-asset-upload-zone:hover {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.xx-asset-upload-icon {
font-size: 40px;
margin-bottom: var(--space-sm);
color: var(--primary-color);
}
.xx-asset-upload-text {
font-size: var(--font-size-base) !important;
color: var(--text-primary) !important;
margin: 0 0 var(--space-xs) !important;
font-weight: var(--font-weight-medium);
}
.xx-asset-upload-hint {
font-size: var(--font-size-sm) !important;
color: var(--text-tertiary) !important;
margin: 0 !important;
}
/* ============================================================
筛选栏
============================================================ */
.xx-assets-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
}
.xx-assets-filters-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.xx-assets-filters-right {
display: flex;
align-items: center;
gap: var(--space-sm);
}
/* ============================================================
素材网格
============================================================ */
.xx-asset-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
/* ============================================================
素材卡片
============================================================ */
.xx-asset-card {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: var(--transition-all);
}
.xx-asset-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
transform: translateY(-2px);
}
/* 缩略图 */
.xx-asset-thumb {
aspect-ratio: 9 / 16;
position: relative;
overflow: hidden;
display: grid;
place-items: center;
color: #fff;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.xx-asset-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.xx-asset-thumb-placeholder {
font-size: var(--font-size-3xl);
opacity: 0.7;
}
/* 播放按钮 */
.xx-asset-play {
position: absolute;
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
display: grid;
place-items: center;
color: #fff;
font-size: var(--font-size-md);
transition: var(--transition-all);
}
.xx-asset-card:hover .xx-asset-play {
background: rgba(255, 255, 255, 0.4);
transform: scale(1.1);
}
/* 选中态 */
.xx-asset-card-selected {
border-color: var(--primary-color) !important;
box-shadow: var(--shadow-primary) !important;
}
.xx-asset-check {
position: absolute;
top: var(--space-sm);
right: var(--space-sm);
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background: var(--gradient-primary);
color: var(--text-inverse);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
z-index: 2;
}
/* 卡片信息 */
.xx-asset-info {
padding: 12px;
}
.xx-asset-name {
margin: 0 0 6px;
font-size: 13px;
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.xx-asset-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-bottom: var(--space-sm);
}
/* 诊断按钮 */
.xx-asset-diagnose-btn {
width: 100%;
padding: 6px;
font-size: var(--font-size-sm);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xs);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
.xx-asset-diagnose-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: var(--primary-soft);
}
/* ============================================================
状态标签(StatusPill
============================================================ */
.xx-status-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
.xx-status-pill-ok {
background: var(--success-soft);
color: var(--success-color);
}
.xx-status-pill-warn {
background: var(--warning-soft);
color: var(--warning-color);
}
.xx-status-pill-bad {
background: var(--error-soft);
color: var(--error-color);
}
.xx-status-pill-info {
background: var(--info-soft);
color: var(--info-color);
}
/* ============================================================
空状态
============================================================ */
.xx-assets-empty {
text-align: center;
padding: var(--space-3xl) var(--space-xl);
color: var(--text-secondary);
}
.xx-assets-empty-icon {
font-size: 48px;
margin-bottom: var(--space-md);
opacity: 0.5;
}
/* ============================================================
批量操作栏
============================================================ */
.xx-assets-batch-bar {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
background: var(--primary-soft);
border: 1px solid var(--primary-color);
border-radius: var(--radius-md);
}
.xx-assets-batch-count {
font-size: var(--font-size-sm);
color: var(--primary-color);
font-weight: var(--font-weight-semibold);
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1400px) {
.xx-asset-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1200px) {
.xx-assets-layout {
grid-template-columns: 1fr;
}
.xx-asset-library-list {
flex-direction: row;
overflow-x: auto;
position: static;
gap: var(--space-sm);
padding-bottom: var(--space-sm);
}
.xx-asset-library-item {
min-width: 180px;
flex-shrink: 0;
}
.xx-asset-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.xx-assets-page {
padding: var(--space-md);
}
.xx-asset-grid {
grid-template-columns: repeat(2, 1fr);
}
.xx-assets-filters {
flex-direction: column;
align-items: stretch;
}
.xx-assets-filters-left,
.xx-assets-filters-right {
justify-content: flex-start;
}
}
@media (max-width: 480px) {
.xx-assets-page {
padding: var(--space-sm);
}
.xx-asset-grid {
grid-template-columns: 1fr;
}
}
+452 -206
View File
@@ -1,225 +1,471 @@
/**
* 仪表盘页面
* 展示用户用量总览和最近生成记录
* 控制台页面 — V21 设计系统
* KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告
* 使用 mock 数据,CSS 变量,V21 组件
*/
import React from "react";
import { useQuery } from "@tanstack/react-query";
import {
Card,
Col,
Row,
Statistic,
Table,
Tag,
Spin,
Button,
Progress,
Space,
Alert,
} from "antd";
import {
FileOutlined,
VideoCameraOutlined,
AudioOutlined,
FileTextOutlined,
CloudServerOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { getDashboardOverview } from "@/api/dashboard";
import type { ColumnsType } from "antd/es/table";
import PageHead from "@/components/layout/PageHead";
import { Button, Tag } from "@/components/ui";
import "./dashboard.css";
/** 格式化文件大小 */
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
/* ============================================================
* Mock 数据
* ============================================================ */
interface KpiItem {
key: string;
icon: string;
iconGradient: string;
value: string;
label: string;
trend: string;
trendDirection: "up" | "down" | "neutral";
accent: string;
}
const kpiData: KpiItem[] = [
{
key: "projects",
icon: "🎬",
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
value: "12",
label: "项目总数",
trend: "↑ 2 本月新增",
trendDirection: "up",
accent: "#6366f1",
},
{
key: "assets",
icon: "📦",
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
value: "486",
label: "素材总数",
trend: "↑ 38 本月上传",
trendDirection: "up",
accent: "#0ea5e9",
},
{
key: "generations",
icon: "✨",
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
value: "156",
label: "本月生成数",
trend: "↑ 23% 较上月",
trendDirection: "up",
accent: "#10b981",
},
{
key: "storage",
icon: "💾",
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
value: "2.4GB",
label: "存储空间",
trend: "已用 24%",
trendDirection: "neutral",
accent: "#f59e0b",
},
];
interface QuickEntry {
id: string;
icon: string;
iconGradient: string;
title: string;
description: string;
path: string;
}
const quickEntries: QuickEntry[] = [
{
id: "titles",
icon: "📝",
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
title: "标题库",
description: "24条标题 · 5个分类",
path: "/titles",
},
{
id: "assets",
icon: "📦",
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
title: "素材库",
description: "486个素材 · 3个素材库",
path: "/assets",
},
{
id: "generate",
icon: "✨",
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
title: "一键生成",
description: "开始创作新视频",
path: "/generate",
},
{
id: "products",
icon: "🎬",
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
title: "成片库",
description: "89个成片 · 3个待复核",
path: "/products",
},
];
type TaskStatus = "completed" | "processing" | "pending" | "failed";
interface RecentTask {
id: string;
name: string;
type: string;
template: string;
status: TaskStatus;
date: string;
duration?: string;
}
const statusLabel: Record<TaskStatus, string> = {
completed: "已完成",
processing: "进行中",
pending: "排队中",
failed: "失败",
};
/** 任务状态标签 */
const StatusTag: React.FC<{ status: string }> = ({ status }) => {
const config: Record<string, { color: string; icon: React.ReactNode }> = {
completed: { color: "success", icon: <CheckCircleOutlined /> },
processing: { color: "processing", icon: <ClockCircleOutlined /> },
pending: { color: "default", icon: <ClockCircleOutlined /> },
failed: { color: "error", icon: <CloseCircleOutlined /> },
};
const c = config[status] || config.pending;
return (
<Tag color={c.color} icon={c.icon}>
{status}
</Tag>
);
const recentTasks: RecentTask[] = [
{
id: "t-1",
name: "产品介绍视频_春季促销",
type: "视频生成",
template: "商品展示模板",
status: "completed",
date: "2026-07-01 09:30",
duration: "2分18秒",
},
{
id: "t-2",
name: "品牌宣传片_终版",
type: "视频生成",
template: "品牌宣传模板",
status: "processing",
date: "2026-07-01 10:15",
},
{
id: "t-3",
name: "用户评价合集",
type: "视频生成",
template: "评价展示模板",
status: "completed",
date: "2026-06-30 16:42",
duration: "1分45秒",
},
{
id: "t-4",
name: "新品发布预告",
type: "视频生成",
template: "新品预告模板",
status: "pending",
date: "2026-06-30 14:20",
},
{
id: "t-5",
name: "活动回顾_618大促",
type: "视频生成",
template: "活动回顾模板",
status: "failed",
date: "2026-06-29 11:05",
},
];
interface ChartItem {
label: string;
value: number;
}
const weeklyData: ChartItem[] = [
{ label: "周一", value: 18 },
{ label: "周二", value: 25 },
{ label: "周三", value: 32 },
{ label: "周四", value: 28 },
{ label: "周五", value: 42 },
{ label: "周六", value: 15 },
{ label: "周日", value: 8 },
];
interface Announcement {
id: string;
tag: "update" | "notice" | "activity";
tagLabel: string;
title: string;
date: string;
}
const announcements: Announcement[] = [
{
id: "a-1",
tag: "update",
tagLabel: "更新",
title: "系统已升级至 v2.0,新增批量生成功能",
date: "2026-07-01",
},
{
id: "a-2",
tag: "activity",
tagLabel: "活动",
title: "7月创作挑战赛已开启,参与赢积分奖励",
date: "2026-06-28",
},
{
id: "a-3",
tag: "notice",
tagLabel: "公告",
title: "7月3日凌晨 2:00-4:00 系统维护通知",
date: "2026-06-25",
},
];
/* ============================================================
* 工具函数
* ============================================================ */
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 6) return "夜深了";
if (hour < 12) return "早上好";
if (hour < 14) return "中午好";
if (hour < 18) return "下午好";
return "晚上好";
};
const formatDate = () => {
const d = new Date();
const weekDays = ["日", "一", "二", "三", "四", "五", "六"];
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日 星期${weekDays[d.getDay()]}`;
};
/* ============================================================
* 组件
* ============================================================ */
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const { data, isLoading, isError } = useQuery({
queryKey: ["dashboard-overview"],
queryFn: getDashboardOverview,
});
const taskColumns: ColumnsType<
NonNullable<typeof data>["recent_tasks"][number]
> = [
{
title: "任务类型",
dataIndex: "task_type",
key: "task_type",
render: (type: string) => (type === "generation" ? "视频生成" : type),
},
{
title: "状态",
dataIndex: "status",
key: "status",
render: (status: string) => <StatusTag status={status} />,
},
{
title: "进度",
dataIndex: "progress",
key: "progress",
render: (progress: number) => (
<Progress percent={Math.round(progress * 100)} size="small" />
),
},
{
title: "信息",
dataIndex: "user_message",
key: "user_message",
ellipsis: true,
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
render: (t: string) => (t ? new Date(t).toLocaleString("zh-CN") : "-"),
},
];
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (isError) {
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<Alert
type="error"
message="加载数据失败"
description="仪表盘数据获取失败,请刷新页面重试。"
showIcon
/>
</div>
);
}
const maxChart = Math.max(...weeklyData.map((d) => d.value));
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<PageHead title="概览" />
<div className="xx-dashboard-page">
{/* ── 欢迎头部 ─────────────────────────────────────────── */}
<div className="xx-dashboard-welcome">
<h2>{getGreeting()} 👋</h2>
<p>{formatDate()} </p>
</div>
{/* 用量统计卡片 */}
<Row gutter={[16, 16]}>
<Col xs={12} sm={8} md={4}>
<Card hoverable onClick={() => navigate("/assets")}>
<Statistic
title="素材"
value={data?.total_assets ?? 0}
prefix={<FileOutlined />}
suffix="个"
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card hoverable onClick={() => navigate("/assets")}>
<Statistic
title="存储空间"
value={formatFileSize(data?.used_storage_bytes ?? 0)}
prefix={<CloudServerOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card hoverable onClick={() => navigate("/titles")}>
<Statistic
title="标题"
value={data?.total_titles ?? 0}
prefix={<FileTextOutlined />}
suffix="条"
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card hoverable onClick={() => navigate("/voices")}>
<Statistic
title="配音"
value={data?.total_voices ?? 0}
prefix={<AudioOutlined />}
suffix="条"
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card hoverable onClick={() => navigate("/history")}>
<Statistic
title="生成任务"
value={data?.total_tasks ?? 0}
prefix={<VideoCameraOutlined />}
suffix="次"
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card hoverable onClick={() => navigate("/products")}>
<Statistic
title="成品"
value={data?.total_products ?? 0}
prefix={<VideoCameraOutlined />}
suffix="个"
/>
</Card>
</Col>
</Row>
{/* 快捷操作 */}
<Card style={{ marginTop: 24 }}>
<Space wrap>
<Button type="primary" onClick={() => navigate("/generate")}>
</Button>
<Button onClick={() => navigate("/assets")}></Button>
<Button onClick={() => navigate("/templates")}></Button>
<Button onClick={() => navigate("/subscription")}></Button>
</Space>
</Card>
{/* 最近生成任务 */}
<Card title="最近生成" style={{ marginTop: 24 }}>
<Table
columns={taskColumns}
dataSource={data?.recent_tasks || []}
rowKey="id"
pagination={false}
size="small"
locale={{ emptyText: "暂无生成记录" }}
scroll={{ x: 600 }}
/>
{(data?.recent_tasks?.length ?? 0) > 0 && (
<div style={{ textAlign: "center", marginTop: 12 }}>
<Button type="link" onClick={() => navigate("/history")}>
</Button>
{/* ── KPI 卡片网格 ─────────────────────────────────────── */}
<div className="xx-kpi-grid">
{kpiData.map((item) => (
<div
key={item.key}
className="xx-kpi-card"
style={{ "--kpi-accent": item.accent } as React.CSSProperties}
>
<div
className="xx-kpi-icon"
style={{ background: item.iconGradient }}
>
{item.icon}
</div>
<div className="xx-kpi-value">{item.value}</div>
<div className="xx-kpi-label">{item.label}</div>
<span
className={`xx-kpi-trend xx-kpi-trend--${item.trendDirection}`}
>
{item.trend}
</span>
</div>
)}
</Card>
))}
</div>
{/* ── 主内容区:左侧任务+图表 / 右侧公告 ──────────────── */}
<div className="xx-dashboard-main">
{/* 左列 */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "var(--space-md)",
}}
>
{/* 最近任务 */}
<div className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<button onClick={() => navigate("/history")}></button>
</div>
<div className="xx-task-list">
{recentTasks.map((task) => (
<div key={task.id} className="xx-task-item">
<div className="xx-task-info">
<h4>{task.name}</h4>
<span>
{task.type} · {task.template}
</span>
</div>
<Tag
variant={
task.status === "completed"
? "success"
: task.status === "processing"
? "info"
: task.status === "failed"
? "error"
: "warning"
}
>
{statusLabel[task.status]}
</Tag>
<div className="xx-task-time">
<span>{task.date}</span>
{task.status === "completed"
? `耗时 ${task.duration}`
: task.status === "processing"
? "生成中..."
: task.status === "failed"
? "请重试"
: "等待中"}
</div>
<div className="xx-task-action">
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => navigate("/history")}
>
</Button>
</div>
</div>
))}
</div>
</div>
{/* 使用统计图表 */}
<div className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<span
style={{
fontSize: "12px",
color: "var(--text-secondary)",
}}
>
{weeklyData.reduce((s, d) => s + d.value, 0)}
</span>
</div>
<div className="xx-chart-container">
<div className="xx-chart-bars">
{weeklyData.map((d, i) => (
<div key={i} className="xx-chart-bar-wrapper">
<div
className="xx-chart-bar"
style={{
height: `${(d.value / maxChart) * 100}%`,
}}
>
<span className="xx-chart-bar-value">{d.value}</span>
</div>
</div>
))}
</div>
<div className="xx-chart-labels">
{weeklyData.map((d, i) => (
<div key={i} className="xx-chart-label">
{d.label}
</div>
))}
</div>
</div>
</div>
</div>
{/* 右列 — 公告 + 存储用量 */}
<div className="xx-dashboard-section" style={{ alignSelf: "start" }}>
<div className="xx-dashboard-section-header">
<h3></h3>
</div>
<div className="xx-announcement-list">
{announcements.map((a) => (
<div key={a.id} className="xx-announcement-item">
<span
className={`xx-announcement-tag xx-announcement-tag--${a.tag}`}
>
{a.tagLabel}
</span>
<div className="xx-announcement-content">
<h4>{a.title}</h4>
<time>{a.date}</time>
</div>
</div>
))}
</div>
{/* 存储用量 */}
<div style={{ marginTop: "var(--space-md)" }}>
<div
style={{
fontSize: "13px",
fontWeight: 500,
color: "var(--text-primary)",
marginBottom: "4px",
}}
>
</div>
<div className="xx-storage-bar">
<div className="xx-storage-bar-track">
<div className="xx-storage-bar-fill" style={{ width: "24%" }} />
</div>
<div className="xx-storage-bar-label">
<span>2.4 GB </span>
<span>10 GB </span>
</div>
</div>
</div>
</div>
</div>
{/* ── 快速入口 ─────────────────────────────────────────── */}
<div style={{ marginBottom: "var(--space-md)" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "var(--space-md)",
}}
>
<h3
style={{
margin: 0,
fontSize: "var(--font-size-base)",
fontWeight: "var(--font-weight-semibold)",
color: "var(--text-primary)",
}}
>
</h3>
</div>
<div className="xx-quick-grid">
{quickEntries.map((entry) => (
<div
key={entry.id}
className="xx-quick-card"
onClick={() => navigate(entry.path)}
>
<div
className="xx-quick-card-icon"
style={{ background: entry.iconGradient }}
>
{entry.icon}
</div>
<h3>{entry.title}</h3>
<p>{entry.description}</p>
</div>
))}
</div>
</div>
</div>
);
};
+524
View File
@@ -0,0 +1,524 @@
/**
* 控制台页面 - V21 设计系统样式
* KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
/* ============================================================
页面容器
============================================================ */
.xx-dashboard-page {
min-height: 100%;
padding: var(--space-xl);
}
/* ============================================================
欢迎头部
============================================================ */
.xx-dashboard-welcome {
margin-bottom: var(--space-lg);
}
.xx-dashboard-welcome h2 {
margin: 0 0 4px;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-dashboard-welcome p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* ============================================================
KPI 卡片网格
============================================================ */
.xx-kpi-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.xx-kpi-card {
background: linear-gradient(
180deg,
var(--bg-primary),
var(--bg-secondary, #f8fafc)
);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
transition: var(--transition-all);
position: relative;
overflow: hidden;
}
.xx-kpi-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--kpi-accent, var(--primary-color));
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.xx-kpi-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.xx-kpi-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-bottom: 12px;
}
.xx-kpi-value {
font-size: 30px;
font-weight: 800;
color: var(--text-primary);
line-height: 1.2;
margin-bottom: 4px;
}
.xx-kpi-label {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.xx-kpi-trend {
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.xx-kpi-trend--up {
color: var(--success-color);
background: var(--success-soft);
}
.xx-kpi-trend--down {
color: var(--error-color);
background: var(--error-soft);
}
.xx-kpi-trend--neutral {
color: var(--text-secondary);
background: var(--bg-secondary, #f1f5f9);
}
/* ============================================================
主内容区两栏布局
============================================================ */
.xx-dashboard-main {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
/* ============================================================
区块卡片
============================================================ */
.xx-dashboard-section {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-md);
}
.xx-dashboard-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.xx-dashboard-section-header h3 {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-dashboard-section-header a,
.xx-dashboard-section-header button {
font-size: var(--font-size-sm);
color: var(--primary-color);
cursor: pointer;
background: none;
border: none;
padding: 0;
text-decoration: none;
transition: var(--transition-color);
}
.xx-dashboard-section-header a:hover,
.xx-dashboard-section-header button:hover {
color: var(--primary-hover);
text-decoration: underline;
}
/* ============================================================
最近任务卡片列表
============================================================ */
.xx-task-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.xx-task-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 14px 16px;
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 16px;
align-items: center;
transition: var(--transition-all);
}
.xx-task-item:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
.xx-task-info h4 {
margin: 0 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.xx-task-info span {
font-size: 12px;
color: var(--text-secondary);
}
.xx-task-status {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.xx-task-status--completed {
color: var(--success-color);
background: var(--success-soft);
border: 1px solid var(--success-border);
}
.xx-task-status--processing {
color: var(--info-color);
background: var(--primary-soft);
border: 1px solid var(--color-primary-200);
}
.xx-task-status--pending {
color: var(--text-secondary);
background: var(--bg-secondary, #f1f5f9);
border: 1px solid var(--border-color);
}
.xx-task-status--failed {
color: var(--error-color);
background: var(--error-soft);
border: 1px solid var(--error-border);
}
.xx-task-time {
text-align: right;
font-size: 12px;
color: var(--text-secondary);
min-width: 100px;
}
.xx-task-time span {
display: block;
margin-bottom: 2px;
}
.xx-task-action {
font-size: 12px;
}
/* ============================================================
使用统计图表(纯 CSS 柱状图)
============================================================ */
.xx-chart-container {
padding: var(--space-sm) 0;
}
.xx-chart-bars {
display: flex;
align-items: flex-end;
gap: 8px;
height: 160px;
padding-top: var(--space-sm);
}
.xx-chart-bar-wrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
}
.xx-chart-bar {
width: 100%;
max-width: 36px;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: var(--gradient-primary);
transition: height 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
min-height: 4px;
cursor: pointer;
}
.xx-chart-bar:hover {
opacity: 0.85;
}
.xx-chart-bar-value {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
opacity: 0;
transition: var(--transition-opacity);
}
.xx-chart-bar:hover .xx-chart-bar-value {
opacity: 1;
}
.xx-chart-labels {
display: flex;
gap: 8px;
margin-top: 8px;
}
.xx-chart-label {
flex: 1;
text-align: center;
font-size: 11px;
color: var(--text-secondary);
}
/* ============================================================
快速入口卡片网格
============================================================ */
.xx-quick-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.xx-quick-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
cursor: pointer;
transition: var(--transition-all);
text-align: center;
}
.xx-quick-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.xx-quick-card-icon {
width: 52px;
height: 52px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin: 0 auto 14px;
color: #fff;
}
.xx-quick-card h3 {
margin: 0 0 8px;
font-size: 16px;
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-quick-card p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
}
/* ============================================================
公告区域
============================================================ */
.xx-announcement-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-announcement-item {
display: flex;
gap: 12px;
padding: 12px;
border-radius: var(--radius-md);
background: var(--bg-secondary, #f8fafc);
border: 1px solid var(--border-color);
transition: var(--transition-all);
}
.xx-announcement-item:hover {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.xx-announcement-tag {
flex-shrink: 0;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
height: fit-content;
}
.xx-announcement-tag--update {
color: var(--primary-color);
background: var(--primary-soft);
}
.xx-announcement-tag--notice {
color: var(--warning-color);
background: var(--warning-soft);
}
.xx-announcement-tag--activity {
color: var(--success-color);
background: var(--success-soft);
}
.xx-announcement-content {
flex: 1;
min-width: 0;
}
.xx-announcement-content h4 {
margin: 0 0 4px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
}
.xx-announcement-content time {
font-size: 11px;
color: var(--text-secondary);
}
/* ============================================================
存储用量条
============================================================ */
.xx-storage-bar {
margin-top: 12px;
}
.xx-storage-bar-track {
height: 8px;
background: var(--bg-secondary, #e2e8f0);
border-radius: 4px;
overflow: hidden;
}
.xx-storage-bar-fill {
height: 100%;
border-radius: 4px;
background: var(--gradient-primary);
transition: width 0.6s ease;
}
.xx-storage-bar-label {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-secondary);
margin-top: 4px;
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1200px) {
.xx-dashboard-main {
grid-template-columns: 1fr;
}
.xx-kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
.xx-quick-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.xx-dashboard-page {
padding: var(--space-md);
}
.xx-kpi-grid {
grid-template-columns: 1fr;
}
.xx-quick-grid {
grid-template-columns: 1fr;
}
.xx-task-item {
grid-template-columns: 1fr;
gap: 8px;
}
.xx-task-time {
text-align: left;
}
.xx-chart-bars {
height: 120px;
}
}
@@ -1,34 +1,19 @@
/**
* 重复视频对比详情页面
* 展示查重结果中的重复片段详情,支持时间线对比
* 查重详情页面 — V21 设计系统
* 风险评估 + 基本信息 + 检测项列表 + 匹配片段
* 零 antd 依赖
*/
import React from "react";
import { useQuery } from "@tanstack/react-query";
import {
Typography,
Card,
Tag,
Button,
Space,
Descriptions,
Progress,
Spin,
Empty,
Row,
Col,
Tooltip,
Divider,
} from "antd";
import {
ArrowLeftOutlined,
VideoCameraOutlined,
ClockCircleOutlined,
WarningOutlined,
} from "@ant-design/icons";
import React, { useState, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Tag, Tooltip } from "@/components/ui";
import { useParams, useNavigate } from "react-router-dom";
import { getDuplicationDetail, type DuplicateSegment } from "@/api/duplication";
const { Title, Text, Paragraph } = Typography;
import {
getDuplicationDetail,
retryDuplication,
type DuplicateSegment,
} from "@/api/duplication";
import "./duplication.css";
import PageHead from "@/components/layout/PageHead";
/** 格式化时间(秒 → mm:ss */
const formatTime = (seconds: number) => {
@@ -45,20 +30,49 @@ const formatSize = (bytes: number) => {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
};
/** 查重率颜色 */
const getRateColor = (rate: number) => {
if (rate <= 10) return "#52c41a";
if (rate <= 30) return "#faad14";
return "#ff4d4f";
/** 格式化时长 */
const formatDuration = (seconds?: number) => {
if (!seconds) return "-";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return m > 0 ? `${m}${s}` : `${s}`;
};
/** 相似度颜色 */
const getSimilarityColor = (similarity: number) => {
if (similarity >= 90) return "#ff4d4f";
if (similarity >= 70) return "#faad14";
return "#52c41a";
/** 根据查重率获取风险等级 */
const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
if (rate === undefined) return "low";
if (rate <= 10) return "low";
if (rate <= 30) return "medium";
return "high";
};
/** 风险等级描述 */
const RISK_DESC: Record<string, string> = {
low: "查重率较低,内容原创度高",
medium: "存在一定重复,建议修改部分片段",
high: "重复率较高,建议大幅修改或替换",
};
/** 风险等级标签变体 */
const RISK_TAG_VARIANT: Record<string, "success" | "warning" | "error"> = {
low: "success",
medium: "warning",
high: "error",
};
/** 风险等级文字 */
const RISK_LABEL: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
};
/** 简易 toast */
interface ToastState {
message: string;
type: "success" | "error" | "warning";
}
/** 单个重复片段卡片 */
const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
segment,
@@ -66,164 +80,53 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
}) => {
const sourceDuration = segment.source_end - segment.source_start;
const matchedDuration = segment.matched_end - segment.matched_start;
const riskLevel =
segment.similarity >= 90
? "high"
: segment.similarity >= 70
? "medium"
: "low";
return (
<Card
size="small"
title={
<Space>
<Tag color="blue"> {index + 1}</Tag>
<Tag
color={getSimilarityColor(segment.similarity)}
style={{ fontWeight: "bold" }}
>
{segment.similarity.toFixed(1)}%
</Tag>
</Space>
}
style={{ marginBottom: 16 }}
>
<Row gutter={[16, 16]}>
{/* 原始视频片段 */}
<Col xs={24} md={12}>
<Card
size="small"
type="inner"
title={
<Space>
<VideoCameraOutlined />
<Text strong></Text>
</Space>
}
>
<Descriptions column={1} size="small">
<Descriptions.Item label="时间范围">
<Tag color="blue">
{formatTime(segment.source_start)} -{" "}
{formatTime(segment.source_end)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="片段时长">
{sourceDuration.toFixed(1)}
</Descriptions.Item>
</Descriptions>
{/* 时间线可视化 */}
<div
style={{
marginTop: 12,
background: "#f5f5f5",
borderRadius: 4,
height: 24,
position: "relative",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
left: `${(segment.source_start / (segment.source_end + 30)) * 100}%`,
width: `${(sourceDuration / (segment.source_end + 30)) * 100}%`,
height: "100%",
background: "rgba(24, 144, 255, 0.4)",
border: "1px solid #1890ff",
borderRadius: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
fontSize: 10,
color: "#1890ff",
fontWeight: "bold",
}}
>
{sourceDuration.toFixed(0)}s
</Text>
</div>
</div>
</Card>
</Col>
{/* 匹配到的视频片段 */}
<Col xs={24} md={12}>
<Card
size="small"
type="inner"
title={
<Space>
<WarningOutlined style={{ color: "#faad14" }} />
<Text strong></Text>
</Space>
}
>
<Descriptions column={1} size="small">
<Descriptions.Item label="视频名称">
<Tooltip title={segment.matched_video_name}>
<Text ellipsis style={{ maxWidth: 200 }}>
{segment.matched_video_name}
</Text>
</Tooltip>
</Descriptions.Item>
<Descriptions.Item label="时间范围">
<Tag color="orange">
{formatTime(segment.matched_start)} -{" "}
{formatTime(segment.matched_end)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="片段时长">
{matchedDuration.toFixed(1)}
</Descriptions.Item>
</Descriptions>
{/* 时间线可视化 */}
<div
style={{
marginTop: 12,
background: "#f5f5f5",
borderRadius: 4,
height: 24,
position: "relative",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
left: `${(segment.matched_start / (segment.matched_end + 30)) * 100}%`,
width: `${(matchedDuration / (segment.matched_end + 30)) * 100}%`,
height: "100%",
background: "rgba(250, 173, 20, 0.4)",
border: "1px solid #faad14",
borderRadius: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
fontSize: 10,
color: "#faad14",
fontWeight: "bold",
}}
>
{matchedDuration.toFixed(0)}s
</Text>
</div>
</div>
</Card>
</Col>
</Row>
</Card>
<div className="dup-check-item">
<div className="dup-check-icon">🎬</div>
<div className="dup-check-body">
<h4>
{index + 1}{segment.matched_video_name}
</h4>
<p>
{formatTime(segment.source_start)} -{" "}
{formatTime(segment.source_end)}{sourceDuration.toFixed(0)}s {" "}
{formatTime(segment.matched_start)} -{" "}
{formatTime(segment.matched_end)}{matchedDuration.toFixed(0)}s
</p>
</div>
<div className={`dup-check-bar`}>
<div
className={`dup-check-bar-fill ${riskLevel}`}
style={{ width: `${Math.min(segment.similarity, 100)}%` }}
/>
</div>
<span className={`dup-check-value ${riskLevel}`}>
{segment.similarity.toFixed(1)}%
</span>
</div>
);
};
const DuplicationDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [toast, setToast] = useState<ToastState | null>(null);
const showToast = useCallback(
(message: string, type: "success" | "error" | "warning") => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
},
[],
);
const {
data: detail,
@@ -235,149 +138,218 @@ const DuplicationDetail: React.FC = () => {
enabled: !!id,
});
// 重新查重
const retryMutation = useMutation({
mutationFn: retryDuplication,
onSuccess: () => {
showToast("已重新提交查重", "success");
queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] });
},
onError: () => {
showToast("重新查重失败", "error");
},
});
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: 80 }}>
<Spin size="large" />
<div className="dup-page">
<div className="dup-results-empty">
<div className="dup-results-empty-icon"></div>
<p>...</p>
</div>
</div>
);
}
if (isError) {
if (isError || !detail) {
return (
<div style={{ padding: 24 }}>
<Empty description="加载查重记录失败">
<Button onClick={() => navigate("/duplication/results")}>
<div className="dup-page">
<div className="dup-results-empty">
<div className="dup-results-empty-icon">😕</div>
<p>{isError ? "加载查重记录失败" : "未找到查重记录"}</p>
<Button
buttonType="primary"
buttonSize="md"
onClick={() => navigate("/duplication/results")}
style={{ marginTop: 16 }}
>
</Button>
</Empty>
</div>
</div>
);
}
if (!detail) {
return (
<div style={{ padding: 24 }}>
<Empty description="未找到查重记录">
<Button onClick={() => navigate("/duplication/results")}>
</Button>
</Empty>
</div>
);
}
const riskLevel = getRiskLevel(detail.duplicate_rate);
const similarityPercent =
detail.duplicate_rate !== undefined
? detail.duplicate_rate.toFixed(1)
: "—";
return (
<div style={{ padding: "24px", maxWidth: 1000, margin: "0 auto" }}>
{/* 顶部导航 */}
<Space style={{ marginBottom: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/duplication/results")}
>
</Button>
</Space>
<div className="dup-page">
{/* Toast */}
{toast && (
<div className={`dup-toast dup-toast--${toast.type}`}>
{toast.message}
</div>
)}
{/* 基本信息 */}
<Card style={{ marginBottom: 24 }}>
<Title level={4} style={{ marginTop: 0 }}>
<VideoCameraOutlined style={{ marginRight: 8 }} />
{detail.filename}
</Title>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={6}>
<Descriptions column={1} size="small">
<Descriptions.Item label="文件大小">
{formatSize(detail.file_size)}
</Descriptions.Item>
</Descriptions>
</Col>
<Col xs={24} sm={12} md={6}>
<Descriptions column={1} size="small">
<Descriptions.Item label="视频时长">
{detail.duration_seconds
? `${Math.floor(detail.duration_seconds / 60)}${detail.duration_seconds % 60}`
: "-"}
</Descriptions.Item>
</Descriptions>
</Col>
<Col xs={24} sm={12} md={6}>
<Descriptions column={1} size="small">
<Descriptions.Item label="提交时间">
{new Date(detail.created_at).toLocaleString("zh-CN")}
</Descriptions.Item>
</Descriptions>
</Col>
<Col xs={24} sm={12} md={6}>
<Descriptions column={1} size="small">
<Descriptions.Item label="查重率">
{detail.duplicate_rate !== undefined ? (
<Space>
<Text
strong
style={{
color: getRateColor(detail.duplicate_rate),
fontSize: 16,
}}
>
{detail.duplicate_rate.toFixed(1)}%
</Text>
</Space>
) : (
"-"
)}
</Descriptions.Item>
</Descriptions>
</Col>
</Row>
{/* 查重率进度条 */}
{detail.duplicate_rate !== undefined && (
<div style={{ marginTop: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Progress
percent={Math.round(detail.duplicate_rate)}
strokeColor={getRateColor(detail.duplicate_rate)}
status={
detail.duplicate_rate <= 10
? "success"
: detail.duplicate_rate <= 30
? "normal"
: "exception"
}
/>
<PageHead
title={`🎬 ${detail.filename}`}
description={
<div
className="dup-detail-header-meta"
style={{
display: "flex",
gap: 12,
alignItems: "center",
marginTop: 8,
}}
>
<Tag variant={RISK_TAG_VARIANT[riskLevel]}>
{RISK_LABEL[riskLevel]}
</Tag>
<span>{formatSize(detail.file_size)}</span>
<span>{formatDuration(detail.duration_seconds)}</span>
<span>
{new Date(detail.created_at).toLocaleString("zh-CN")}
</span>
</div>
)}
</Card>
{/* 重复片段列表 */}
<Card
title={
<Space>
<ClockCircleOutlined />
<span></span>
<Tag color="blue">{detail.segments?.length ?? 0} </Tag>
</Space>
}
>
{detail.segments && detail.segments.length > 0 ? (
<>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
</Paragraph>
<Divider style={{ margin: "0 0 16px 0" }} />
{detail.segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} />
))}
</>
) : (
<Empty description="未发现重复片段" />
)}
</Card>
actions={
<div
className="dup-detail-actions"
style={{ display: "flex", gap: 8 }}
>
<Button
buttonType="secondary"
buttonSize="md"
onClick={() => {
showToast("报告下载功能开发中", "warning");
}}
>
📥
</Button>
{detail.status === "failed" && (
<Button
buttonType="primary"
buttonSize="md"
onClick={() => retryMutation.mutate(detail.id)}
>
🔄
</Button>
)}
</div>
}
/>
{/* 内容网格 */}
<div className="dup-detail-grid">
{/* 风险评估卡片 */}
<div className="dup-risk-card">
<h3>📊 </h3>
<div className={`dup-risk-circle ${riskLevel}`}>
<span className="dup-risk-value">{similarityPercent}%</span>
<span className="dup-risk-label"></span>
</div>
<p className="dup-risk-desc">{RISK_DESC[riskLevel]}</p>
</div>
{/* 基本信息卡片 */}
<div className="dup-info-detail-card">
<h3>📋 </h3>
<div className="dup-info-rows">
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<Tooltip title={detail.filename}>
<span
className="dup-info-row-value"
style={{
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{detail.filename}
</span>
</Tooltip>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
{formatSize(detail.file_size)}
</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
{formatDuration(detail.duration_seconds)}
</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
<Tag
variant={
detail.status === "completed"
? "success"
: detail.status === "failed"
? "error"
: detail.status === "processing"
? "warning"
: "info"
}
>
{detail.status === "completed"
? "✅ 已完成"
: detail.status === "failed"
? "❌ 失败"
: detail.status === "processing"
? "🔄 查重中"
: "⏳ 等待中"}
</Tag>
</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
{detail.duplicate_count ?? 0}
</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
{new Date(detail.created_at).toLocaleString("zh-CN")}
</span>
</div>
</div>
</div>
{/* 检测项列表 */}
<div className="dup-checks-section">
<h3>
🔍
<Tag variant="primary" style={{ marginLeft: 8 }}>
{detail.segments?.length ?? 0}
</Tag>
</h3>
{detail.segments && detail.segments.length > 0 ? (
<div className="dup-checks-list">
{detail.segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} />
))}
</div>
) : (
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
<div className="dup-results-empty-icon">🎉</div>
<p></p>
</div>
)}
</div>
</div>
</div>
);
};
@@ -1,74 +1,51 @@
/**
* 查重结果列表页面
* 展示所有查重记录,支持查看详情、删除、重新查重
* 查重结果列表页面 — V21 设计系统
* 胶囊筛选 + 卡片列表,零 antd 依赖
*/
import React, { useState } from "react";
import React, { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Typography,
Table,
Tag,
Button,
Space,
Popconfirm,
message,
Progress,
Tooltip,
} from "antd";
import type { ColumnsType } from "antd/es/table";
import {
EyeOutlined,
DeleteOutlined,
ReloadOutlined,
UploadOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
SyncOutlined,
} from "@ant-design/icons";
import { Button, Tag, Tooltip } from "@/components/ui";
import { useNavigate } from "react-router-dom";
import {
getDuplicationRecords,
deleteDuplicationRecord,
retryDuplication,
type DuplicationRecord,
type DuplicationStatus,
} from "@/api/duplication";
import "./duplication.css";
import PageHead from "@/components/layout/PageHead";
const { Title, Text } = Typography;
/** 风险等级分类 */
type RiskFilter = "all" | "high" | "medium" | "low";
/** 状态配置 */
const STATUS_CONFIG: Record<
DuplicationStatus,
{ color: string; text: string; icon: React.ReactNode }
{
variant: "primary" | "warning" | "success" | "error";
text: string;
icon: string;
}
> = {
pending: {
color: "default",
text: "等待中",
icon: <ClockCircleOutlined />,
},
processing: {
color: "processing",
text: "查重中",
icon: <SyncOutlined spin />,
},
completed: {
color: "success",
text: "已完成",
icon: <CheckCircleOutlined />,
},
failed: {
color: "error",
text: "失败",
icon: <CloseCircleOutlined />,
},
pending: { variant: "primary", text: "等待中", icon: "⏳" },
processing: { variant: "warning", text: "查重中", icon: "🔄" },
completed: { variant: "success", text: "已完成", icon: "✅" },
failed: { variant: "error", text: "失败", icon: "❌" },
};
/** 查重率颜色 */
const getRateColor = (rate: number) => {
if (rate <= 10) return "#52c41a";
if (rate <= 30) return "#faad14";
return "#ff4d4f";
/** 根据查重率获取风险等级 */
const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
if (rate === undefined) return "low";
if (rate <= 10) return "low";
if (rate <= 30) return "medium";
return "high";
};
/** 风险等级标签 */
const RISK_LABELS: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
};
/** 格式化文件大小 */
@@ -87,10 +64,32 @@ const formatDuration = (seconds?: number) => {
return m > 0 ? `${m}${s}` : `${s}`;
};
/** 简易 toast */
interface ToastState {
message: string;
type: "success" | "error" | "warning";
}
const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "low", label: "低风险" },
{ key: "medium", label: "中风险" },
{ key: "high", label: "高风险" },
];
const DuplicationResults: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [riskFilter, setRiskFilter] = useState<RiskFilter>("all");
const [toast, setToast] = useState<ToastState | null>(null);
const showToast = (
message: string,
type: "success" | "error" | "warning",
) => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
// 获取查重记录
const { data: records = [], isLoading } = useQuery({
@@ -102,12 +101,11 @@ const DuplicationResults: React.FC = () => {
const deleteMutation = useMutation({
mutationFn: deleteDuplicationRecord,
onSuccess: () => {
message.success("已删除");
showToast("已删除", "success");
queryClient.invalidateQueries({ queryKey: ["duplication-records"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("删除失败");
onError: () => {
showToast("删除失败", "error");
},
});
@@ -115,203 +113,173 @@ const DuplicationResults: React.FC = () => {
const retryMutation = useMutation({
mutationFn: retryDuplication,
onSuccess: () => {
message.success("已重新提交查重");
showToast("已重新提交查重", "success");
queryClient.invalidateQueries({ queryKey: ["duplication-records"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("重新查重失败");
onError: () => {
showToast("重新查重失败", "error");
},
});
/** 批量删除 */
const handleBatchDelete = async () => {
const results = await Promise.allSettled(
selectedRowKeys.map((key) => deleteDuplicationRecord(String(key))),
);
const succeeded = results.filter((r) => r.status === "fulfilled").length;
const failed = results.length - succeeded;
if (failed === 0) {
message.success(`已删除 ${succeeded} 条记录`);
} else {
message.warning(`删除完成:${succeeded} 条成功,${failed} 条失败`);
}
setSelectedRowKeys([]);
queryClient.invalidateQueries({ queryKey: ["duplication-records"] });
};
const columns: ColumnsType<DuplicationRecord> = [
{
title: "文件名",
dataIndex: "filename",
key: "filename",
ellipsis: true,
width: 200,
render: (text: string) => (
<Tooltip title={text}>
<Text strong>{text}</Text>
</Tooltip>
),
},
{
title: "文件大小",
dataIndex: "file_size",
key: "file_size",
width: 100,
render: (size: number) => formatSize(size),
},
{
title: "时长",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 80,
render: (seconds?: number) => formatDuration(seconds),
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
render: (status: DuplicationStatus) => {
const cfg = STATUS_CONFIG[status];
return (
<Tag color={cfg.color} icon={cfg.icon}>
{cfg.text}
</Tag>
);
},
},
{
title: "查重率",
dataIndex: "duplicate_rate",
key: "duplicate_rate",
width: 140,
render: (rate?: number, record?: DuplicationRecord) => {
if (record?.status !== "completed" || rate === undefined) return "-";
return (
<Space>
<Progress
percent={Math.round(rate)}
size="small"
strokeColor={getRateColor(rate)}
style={{ width: 60 }}
/>
<Text style={{ color: getRateColor(rate), fontSize: 12 }}>
{rate.toFixed(1)}%
</Text>
</Space>
);
},
},
{
title: "重复片段",
dataIndex: "duplicate_count",
key: "duplicate_count",
width: 80,
align: "center",
render: (count?: number, record?: DuplicationRecord) => {
if (record?.status !== "completed") return "-";
return <Text>{count ?? 0}</Text>;
},
},
{
title: "提交时间",
dataIndex: "created_at",
key: "created_at",
width: 160,
render: (time: string) => new Date(time).toLocaleString("zh-CN"),
},
{
title: "操作",
key: "action",
width: 160,
fixed: "right",
render: (_: unknown, record: DuplicationRecord) => (
<Space size="small">
{record.status === "completed" && (
<Tooltip title="查看详情">
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => navigate(`/duplication/${record.id}`)}
/>
</Tooltip>
)}
{record.status === "failed" && (
<Tooltip title="重新查重">
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={() => retryMutation.mutate(record.id)}
/>
</Tooltip>
)}
<Popconfirm
title="确定删除此记录?"
onConfirm={() => deleteMutation.mutate(record.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
/** 按风险等级筛选 */
const filteredRecords = useMemo(() => {
if (riskFilter === "all") return records;
return records.filter((r) => {
if (r.status !== "completed") return riskFilter === "low";
return getRiskLevel(r.duplicate_rate) === riskFilter;
});
}, [records, riskFilter]);
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
flexWrap: "wrap",
gap: 12,
}}
>
<Title level={3} style={{ margin: 0 }}>
</Title>
<Space>
{selectedRowKeys.length > 0 && (
<Popconfirm
title={`确定删除选中的 ${selectedRowKeys.length} 条记录?`}
onConfirm={handleBatchDelete}
>
<Button danger icon={<DeleteOutlined />}>
({selectedRowKeys.length})
</Button>
</Popconfirm>
)}
<Button
type="primary"
icon={<UploadOutlined />}
onClick={() => navigate("/duplication")}
>
</Button>
</Space>
</div>
<div className="dup-page">
{/* Toast */}
{toast && (
<div className={`dup-toast dup-toast--${toast.type}`}>
{toast.message}
</div>
)}
<Table
columns={columns}
dataSource={records}
rowKey="id"
loading={isLoading}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
scroll={{ x: 900 }}
size="middle"
<PageHead
title="查重记录"
actions={
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{/* 筛选胶囊 */}
<div className="dup-filter">
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.key}
className={`dup-filter-btn ${riskFilter === opt.key ? "active" : ""}`}
onClick={() => setRiskFilter(opt.key)}
>
{opt.label}
</button>
))}
</div>
<Button
buttonType="primary"
buttonSize="md"
onClick={() => navigate("/duplication")}
>
📤
</Button>
</div>
}
/>
{/* 加载中 */}
{isLoading && (
<div className="dup-results-empty">
<div className="dup-results-empty-icon"></div>
<p>...</p>
</div>
)}
{/* 空状态 */}
{!isLoading && filteredRecords.length === 0 && (
<div className="dup-results-empty">
<div className="dup-results-empty-icon">📭</div>
<p>
{riskFilter === "all"
? "暂无查重记录,上传视频开始查重吧"
: `没有${RISK_LABELS[riskFilter]}的记录`}
</p>
</div>
)}
{/* 结果卡片列表 */}
{!isLoading && filteredRecords.length > 0 && (
<div className="dup-results-list">
{filteredRecords.map((record) => {
const statusCfg = STATUS_CONFIG[record.status];
const riskLevel = getRiskLevel(record.duplicate_rate);
const rateValue = record.duplicate_rate;
return (
<div
key={record.id}
className="dup-result-card"
onClick={() => {
if (record.status === "completed") {
navigate(`/duplication/${record.id}`);
}
}}
>
{/* 缩略图 */}
<div className="dup-result-card-thumb">🎬</div>
{/* 信息区 */}
<div className="dup-result-card-body">
<h4>{record.filename}</h4>
<div className="dup-result-card-meta">
<Tag variant={statusCfg.variant}>
{statusCfg.icon} {statusCfg.text}
</Tag>
<span>{formatSize(record.file_size)}</span>
<span>{formatDuration(record.duration_seconds)}</span>
<span>
{new Date(record.created_at).toLocaleDateString("zh-CN")}
</span>
{record.status === "completed" &&
record.duplicate_count !== undefined && (
<span>{record.duplicate_count} </span>
)}
</div>
</div>
{/* 查重率 */}
<div className="dup-result-card-score">
{record.status === "completed" && rateValue !== undefined ? (
<>
<div className="dup-score-bar">
<div
className={`dup-score-bar-fill ${riskLevel}`}
style={{ width: `${Math.min(rateValue, 100)}%` }}
/>
</div>
<span className={`dup-score-value ${riskLevel}`}>
{rateValue.toFixed(1)}%
</span>
</>
) : record.status === "failed" ? (
<Tooltip title="重新查重">
<Button
buttonType="ghost"
buttonSize="sm"
onClick={(e) => {
e.stopPropagation();
retryMutation.mutate(record.id);
}}
>
🔄
</Button>
</Tooltip>
) : (
<span
style={{ color: "var(--text-secondary)", fontSize: 12 }}
>
{record.status === "processing" ? "分析中..." : "—"}
</span>
)}
</div>
{/* 删除按钮 */}
<Button
buttonType="ghost"
buttonSize="sm"
onClick={(e) => {
e.stopPropagation();
if (window.confirm("确定删除此记录?")) {
deleteMutation.mutate(record.id);
}
}}
>
🗑
</Button>
</div>
);
})}
</div>
)}
</div>
);
};
@@ -1,186 +1,244 @@
/**
* 上传查重页面
* 用户上传视频文件,系统进行查重检测
* 查重上传页面 — V21 设计系统
* 左右分栏:拖拽上传区 + 格式说明
* 零 antd 依赖
*/
import React, { useState } from "react";
import React, { useState, useRef, useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import {
Button,
Typography,
Upload,
message,
Card,
Progress,
Alert,
Space,
Result,
} from "antd";
import {
InboxOutlined,
VideoCameraOutlined,
CheckCircleOutlined,
LoadingOutlined,
} from "@ant-design/icons";
import { Button, Card, Tag } from "@/components/ui";
import { uploadForDuplication } from "@/api/duplication";
import { useNavigate } from "react-router-dom";
import type { UploadFile } from "antd/es/upload";
const { Title, Text, Paragraph } = Typography;
const { Dragger } = Upload;
import "./duplication.css";
import PageHead from "@/components/layout/PageHead";
/** 支持的视频格式 */
const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm";
const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"];
/** 最大文件大小:2GB */
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024;
/** 简易 toast */
interface ToastState {
message: string;
type: "success" | "error" | "warning";
}
const DuplicationUpload: React.FC = () => {
const navigate = useNavigate();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<{
id: string;
message: string;
} | null>(null);
const [uploading, setUploading] = useState(false);
const [toast, setToast] = useState<ToastState | null>(null);
/** 显示 toast */
const showToast = useCallback(
(message: string, type: "success" | "error" | "warning") => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
},
[],
);
// 上传查重 mutation
const uploadMutation = useMutation({
mutationFn: (file: File) => uploadForDuplication(file),
onSuccess: (data) => {
setUploading(false);
setUploadResult({
id: data.id,
message: data.message,
});
message.success("查重任务已提交");
setUploadResult({ id: data.id, message: data.message });
showToast("查重任务已提交", "success");
},
onError: (err: unknown) => {
onError: () => {
setUploading(false);
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("上传失败,请重试");
showToast("上传失败,请重试", "error");
},
});
/** 处理文件上传 */
const handleUpload = (file: File) => {
// 校验文件大小
if (file.size > MAX_FILE_SIZE) {
message.error("文件大小不能超过 2GB");
return false;
}
/** 校验并上传文件 */
const handleFile = useCallback(
(file: File) => {
if (file.size > MAX_FILE_SIZE) {
showToast("文件大小不能超过 2GB", "error");
return;
}
const ext = file.name.toLowerCase().split(".").pop();
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",");
if (!allowedExts.includes(ext || "")) {
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error");
return;
}
setUploading(true);
setUploadResult(null);
uploadMutation.mutate(file);
},
[uploadMutation, showToast],
);
// 校验文件类型
const ext = file.name.toLowerCase().split(".").pop();
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",");
if (!allowedExts.includes(ext || "")) {
message.error(`不支持的文件格式,支持:${ACCEPT_FORMATS}`);
return false;
}
/** 拖拽事件 */
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragging(true);
}, []);
setUploading(true);
setUploadResult(null);
uploadMutation.mutate(file);
return false; // 阻止自动上传
const handleDragLeave = useCallback(() => {
setDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
},
[handleFile],
);
/** 点击选择文件 */
const handleSelectFile = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
// 重置 input 以便重复选择同一文件
e.target.value = "";
};
/** 重置状态 */
const handleReset = () => {
setFileList([]);
setUploadResult(null);
setUploading(false);
};
return (
<div style={{ padding: "24px", maxWidth: 800, margin: "0 auto" }}>
<Title level={3} style={{ marginBottom: 8 }}>
<VideoCameraOutlined style={{ marginRight: 8 }} />
</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
</Paragraph>
<div className="dup-page">
{/* Toast */}
{toast && (
<div className={`dup-toast dup-toast--${toast.type}`}>
{toast.message}
</div>
)}
{/* 上传区域 */}
<Card>
<Dragger
accept={ACCEPT_FORMATS}
multiple={false}
fileList={fileList}
beforeUpload={handleUpload}
onChange={({ fileList: newFileList }) => setFileList(newFileList)}
disabled={uploading}
style={{ padding: "20px 0" }}
>
<p className="ant-upload-drag-icon">
{uploading ? (
<LoadingOutlined style={{ fontSize: 48, color: "#1890ff" }} />
) : (
<InboxOutlined style={{ fontSize: 48 }} />
)}
</p>
<p className="ant-upload-text">
{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}
</p>
<p className="ant-upload-hint">
MP4AVIMOVMKV 2GB
</p>
</Dragger>
<PageHead
title="视频查重"
description="上传视频文件,系统将自动检测与已有素材的重复片段,帮助您避免重复内容。"
/>
{/* 上传进度 */}
{uploading && (
<div style={{ marginTop: 24, textAlign: "center" }}>
<Progress
type="circle"
percent={99}
status="active"
format={() => "查重中..."}
size={120}
/>
<Paragraph type="secondary" style={{ marginTop: 16 }}>
...
</Paragraph>
<div className="dup-upload-grid">
{/* 左侧:上传区域 */}
<Card>
{/* 拖拽上传区 */}
<div
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={uploading ? undefined : handleSelectFile}
>
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
<h3>
{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}
</h3>
<p> MP4AVIMOVMKV 2GB</p>
<div className="dup-upload-formats">
{FORMAT_LIST.map((fmt) => (
<Tag key={fmt} variant="info">
{fmt}
</Tag>
))}
</div>
</div>
)}
{/* 上传结果 */}
{uploadResult && !uploading && (
<div style={{ marginTop: 24 }}>
<Result
status="success"
title="查重任务已提交"
subTitle={uploadResult.message}
extra={[
{/* 隐藏的文件 input */}
<input
ref={fileInputRef}
type="file"
accept={ACCEPT_FORMATS}
style={{ display: "none" }}
onChange={handleFileChange}
/>
{/* 上传按钮 */}
<div className="dup-upload-actions">
<Button
buttonType="primary"
buttonSize="md"
onClick={handleSelectFile}
disabled={uploading}
>
📂
</Button>
</div>
{/* 上传进度 */}
{uploading && (
<div className="dup-progress">
<div className="dup-progress-circle">
<span className="dup-progress-icon"></span>
<span className="dup-progress-text">...</span>
</div>
<p>...</p>
</div>
)}
{/* 上传结果 */}
{uploadResult && !uploading && (
<div className="dup-result">
<div className="dup-result-icon"></div>
<h3></h3>
<p>{uploadResult.message}</p>
<div className="dup-result-actions">
<Button
type="primary"
key="view"
icon={<CheckCircleOutlined />}
buttonType="primary"
buttonSize="md"
onClick={() => navigate("/duplication/results")}
>
</Button>,
<Button key="continue" onClick={handleReset}>
</Button>
<Button
buttonType="secondary"
buttonSize="md"
onClick={handleReset}
>
</Button>,
]}
/>
</div>
)}
</Card>
</Button>
</div>
</div>
)}
</Card>
{/* 提示信息 */}
<Alert
type="info"
showIcon
style={{ marginTop: 24 }}
message="查重说明"
description={
<Space direction="vertical" size={4}>
<Text> </Text>
<Text> </Text>
<Text> </Text>
<Text> MP4AVIMOVMKVWMVFLVWebM</Text>
</Space>
}
/>
{/* 右侧:格式说明 + 提示 */}
<div className="dup-info-card">
<h3>📋 </h3>
<ul className="dup-info-list">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<h3 style={{ marginTop: 24 }}>🎬 </h3>
<div className="dup-format-tags">
{FORMAT_LIST.map((fmt) => (
<Tag key={fmt} variant="info">
{fmt}
</Tag>
))}
</div>
<h3 style={{ marginTop: 24 }}>💡 </h3>
<ul className="dup-info-list">
<li> 2GB</li>
<li> 60 </li>
<li></li>
</ul>
</div>
</div>
</div>
);
};
@@ -0,0 +1,833 @@
/**
* 查重检测页面组 — V21 设计系统样式
* 上传页 / 结果列表 / 详情报告
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
/* ============================================================
通用页面容器
============================================================ */
.dup-page {
padding: var(--space-lg);
max-width: 1100px;
margin: 0 auto;
}
.dup-page-header {
margin-bottom: var(--space-lg);
}
.dup-page-header h2 {
margin: 0 0 6px;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.dup-page-header p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* ============================================================
上传页面 — 左右分栏
============================================================ */
.dup-upload-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-lg);
}
/* 上传区域 */
.dup-upload-zone {
border: 2px dashed var(--border-color);
border-radius: var(--radius-lg);
padding: 48px 24px;
text-align: center;
background: var(--bg-primary);
transition: var(--transition-all);
cursor: pointer;
}
.dup-upload-zone:hover,
.dup-upload-zone.dragging {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.dup-upload-zone.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.dup-upload-icon {
width: 64px;
height: 64px;
border-radius: 20px;
background: var(--primary-soft);
margin: 0 auto var(--space-md);
display: grid;
place-items: center;
font-size: 28px;
}
.dup-upload-zone h3 {
margin: 0 0 8px;
font-size: 18px;
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.dup-upload-zone p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.dup-upload-formats {
margin-top: var(--space-sm);
font-size: 12px;
color: var(--text-secondary);
}
.dup-upload-actions {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-md);
}
.dup-upload-actions .xx-btn-primary {
flex: 1;
}
/* 上传进度 */
.dup-progress {
margin-top: var(--space-lg);
text-align: center;
}
.dup-progress-circle {
width: 100px;
height: 100px;
border-radius: 50%;
background: var(--primary-soft);
margin: 0 auto var(--space-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.dup-progress-circle .dup-progress-icon {
font-size: 28px;
}
.dup-progress-circle .dup-progress-text {
font-size: 12px;
font-weight: 600;
color: var(--primary-color);
}
.dup-progress p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* 上传结果 */
.dup-result {
margin-top: var(--space-lg);
text-align: center;
}
.dup-result-icon {
font-size: 48px;
margin-bottom: var(--space-sm);
}
.dup-result h3 {
margin: 0 0 8px;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.dup-result p {
margin: 0 0 var(--space-md);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.dup-result-actions {
display: flex;
gap: var(--space-sm);
justify-content: center;
}
/* 右侧:格式说明 + 提示 */
.dup-info-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.dup-info-card h3 {
margin: 0 0 var(--space-md);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
display: flex;
align-items: center;
gap: 6px;
}
.dup-info-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.dup-info-list li {
font-size: var(--font-size-sm);
color: var(--text-secondary);
display: flex;
align-items: flex-start;
gap: 8px;
line-height: 1.5;
}
.dup-info-list li::before {
content: "•";
color: var(--primary-color);
font-weight: bold;
flex-shrink: 0;
}
.dup-format-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: var(--space-md);
}
/* ============================================================
结果列表页面
============================================================ */
.dup-results-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-lg);
flex-wrap: wrap;
gap: var(--space-md);
}
.dup-results-header h2 {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
/* 筛选胶囊 */
.dup-filter {
display: flex;
gap: 6px;
padding: 4px;
background: var(--bg-secondary);
border-radius: 999px;
border: 1px solid var(--border-color);
}
.dup-filter-btn {
padding: 5px 14px;
border-radius: 999px;
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: var(--transition-all);
white-space: nowrap;
user-select: none;
}
.dup-filter-btn:hover {
color: var(--primary-color);
background: var(--primary-soft);
}
.dup-filter-btn.active {
background: var(--primary-soft);
border-color: var(--primary-color);
color: var(--primary-color);
font-weight: 600;
}
/* 结果卡片列表 */
.dup-results-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.dup-results-empty {
text-align: center;
padding: var(--space-2xl);
color: var(--text-secondary);
}
.dup-results-empty-icon {
font-size: 48px;
margin-bottom: var(--space-sm);
opacity: 0.5;
}
.dup-results-empty p {
margin: 0;
font-size: var(--font-size-sm);
}
/* 结果卡片 */
.dup-result-card {
display: flex;
align-items: center;
gap: var(--space-md);
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 16px 20px;
cursor: pointer;
transition: var(--transition-all);
}
.dup-result-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.dup-result-card-thumb {
width: 72px;
height: 72px;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
display: grid;
place-items: center;
font-size: 28px;
flex-shrink: 0;
}
.dup-result-card-body {
flex: 1;
min-width: 0;
}
.dup-result-card-body h4 {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dup-result-card-meta {
display: flex;
align-items: center;
gap: var(--space-md);
font-size: 12px;
color: var(--text-secondary);
}
.dup-result-card-score {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.dup-score-bar {
width: 60px;
height: 6px;
border-radius: 3px;
background: var(--bg-tertiary);
overflow: hidden;
}
.dup-score-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.dup-score-bar-fill.low {
background: var(--success-color);
}
.dup-score-bar-fill.medium {
background: var(--warning-color);
}
.dup-score-bar-fill.high {
background: var(--error-color);
}
.dup-score-value {
font-size: 14px;
font-weight: 700;
min-width: 36px;
text-align: right;
}
.dup-score-value.low {
color: var(--success-color);
}
.dup-score-value.medium {
color: var(--warning-color);
}
.dup-score-value.high {
color: var(--error-color);
}
/* ============================================================
详情/报告页面
============================================================ */
.dup-detail-back {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: var(--space-md);
font-size: var(--font-size-sm);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition-all);
background: none;
border: none;
padding: 0;
}
.dup-detail-back:hover {
color: var(--primary-color);
}
.dup-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-lg);
gap: var(--space-md);
flex-wrap: wrap;
}
.dup-detail-header h2 {
margin: 0 0 6px;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.dup-detail-header-meta {
display: flex;
align-items: center;
gap: var(--space-md);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.dup-detail-actions {
display: flex;
gap: var(--space-sm);
flex-shrink: 0;
}
/* 详情内容网格 */
.dup-detail-grid {
display: grid;
gridtemplatecolumns: 1fr 1fr;
gap: var(--space-lg);
}
/* 风险评估卡片 */
.dup-risk-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-lg);
text-align: center;
}
.dup-risk-card h3 {
margin: 0 0 var(--space-md);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.dup-risk-circle {
width: 140px;
height: 140px;
border-radius: 50%;
margin: 0 auto var(--space-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
}
.dup-risk-circle.low {
background: var(--success-soft);
color: var(--success-color);
}
.dup-risk-circle.medium {
background: var(--warning-soft);
color: var(--warning-color);
}
.dup-risk-circle.high {
background: var(--error-soft);
color: var(--error-color);
}
.dup-risk-circle .dup-risk-value {
font-size: 36px;
font-weight: 800;
line-height: 1;
}
.dup-risk-circle .dup-risk-label {
font-size: 14px;
opacity: 0.8;
}
.dup-risk-desc {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* 基本信息卡片 */
.dup-info-detail-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.dup-info-detail-card h3 {
margin: 0 0 var(--space-md);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.dup-info-rows {
display: flex;
flex-direction: column;
gap: 12px;
}
.dup-info-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--font-size-sm);
}
.dup-info-row-label {
color: var(--text-secondary);
}
.dup-info-row-value {
color: var(--text-primary);
font-weight: 500;
}
/* 检测项列表 */
.dup-checks-section {
grid-column: 1 / -1;
}
.dup-checks-section h3 {
margin: 0 0 var(--space-md);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.dup-checks-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.dup-check-item {
display: flex;
align-items: center;
gap: var(--space-md);
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 14px 16px;
transition: var(--transition-all);
}
.dup-check-item:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
.dup-check-icon {
font-size: 24px;
flex-shrink: 0;
}
.dup-check-body {
flex: 1;
min-width: 0;
}
.dup-check-body h4 {
margin: 0 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.dup-check-body p {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.dup-check-bar {
width: 100px;
height: 6px;
border-radius: 3px;
background: var(--bg-tertiary);
overflow: hidden;
flex-shrink: 0;
}
.dup-check-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.dup-check-bar-fill.low {
background: var(--success-color);
}
.dup-check-bar-fill.medium {
background: var(--warning-color);
}
.dup-check-bar-fill.high {
background: var(--error-color);
}
.dup-check-value {
font-size: 14px;
font-weight: 700;
min-width: 40px;
text-align: right;
flex-shrink: 0;
}
.dup-check-value.low {
color: var(--success-color);
}
.dup-check-value.medium {
color: var(--warning-color);
}
.dup-check-value.high {
color: var(--error-color);
}
/* 匹配片段列表 */
.dup-matches-section {
grid-column: 1 / -1;
}
.dup-matches-section h3 {
margin: 0 0 var(--space-md);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.dup-match-item {
display: flex;
align-items: center;
gap: var(--space-md);
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 12px 16px;
margin-bottom: var(--space-sm);
}
.dup-match-item:last-child {
margin-bottom: 0;
}
.dup-match-thumb {
width: 48px;
height: 48px;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
display: grid;
place-items: center;
font-size: 20px;
flex-shrink: 0;
}
.dup-match-body {
flex: 1;
min-width: 0;
}
.dup-match-body h4 {
margin: 0 0 4px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.dup-match-body p {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
/* ============================================================
简易 Toast 提示
============================================================ */
.dup-toast {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%);
padding: 10px 24px;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: 500;
z-index: 1000;
animation: dup-toast-in 0.3s ease;
pointer-events: none;
box-shadow: var(--shadow-md);
}
.dup-toast--success {
background: var(--success-soft);
color: var(--success-color);
border: 1px solid var(--success-border);
}
.dup-toast--error {
background: var(--error-soft);
color: var(--error-color);
border: 1px solid var(--error-border);
}
.dup-toast--warning {
background: var(--warning-soft);
color: var(--warning-color);
border: 1px solid var(--warning-border);
}
@keyframes dup-toast-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(-12px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 768px) {
.dup-upload-grid {
grid-template-columns: 1fr;
}
.dup-detail-grid {
grid-template-columns: 1fr;
}
.dup-checks-section,
.dup-matches-section {
grid-column: auto;
}
.dup-results-header {
flex-direction: column;
align-items: flex-start;
}
.dup-detail-header {
flex-direction: column;
}
.dup-upload-zone {
padding: 32px 16px;
}
.dup-result-card {
flex-direction: column;
text-align: center;
}
.dup-result-card-meta {
justify-content: center;
}
.dup-check-item {
flex-wrap: wrap;
}
.dup-check-bar {
width: 100%;
}
}
@media (max-width: 480px) {
.dup-upload-zone {
padding: 24px 12px;
}
.dup-match-item {
flex-direction: column;
align-items: flex-start;
gap: var(--space-sm);
}
.dup-match-thumb {
width: 36px;
height: 36px;
font-size: 16px;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,106 +1,92 @@
/**
* 剪辑计划编辑器
* 三栏布局:左侧模板面板 / 中间预览+时间线 / 右侧设置面板
* 支持 4 种模式切换(画中画 / 人物口播 / 一镜到底 / 口播+混剪)
*
* P0-2: 读取 URL 参数 ?template=xxx&generate=1
* P1-3: 拆分为子组件
* P1-4: voiceover_id → voiceover_duration
* P1-5: 分类 Input → Select(在 SaveModal 中实现)
* P1-6: SaveTemplatePayload 补充 estimated_duration
* 剪辑计划编辑器 — V21 设计系统(完整版)
* 任务 2.14:三栏布局 — 素材面板 / 时间线 / 片段属性
* 支持拖拽排序、素材关联、转场编辑、剪辑计划 CRUD
*/
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback } from "react";
import "./EditingPlanner.css";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Space, message } from "antd";
import {
SaveOutlined,
VideoCameraOutlined,
AppstoreOutlined,
UserOutlined,
DashboardOutlined,
} from "@ant-design/icons";
import { Button } from "@/components/ui";
import { useSearchParams } from "react-router-dom";
import {
getEditingTemplates,
getTemplateCategories,
createEditingTemplate,
updateEditingTemplate,
generateFromTemplate,
MODE_LABELS,
type EditingTemplate,
type TemplateSegment,
type TemplateMode,
type TitleConfig,
type SubtitleConfig,
type BgmConfig,
type SaveTemplatePayload,
} from "@/api/editingPlanner";
import {
createEditPlan,
updateEditPlan,
type EditPlanClip,
type MediaAsset,
} from "@/api/editPlans";
import {
createGenerationTask,
getTask,
retryTask,
type TaskItem,
} from "@/api/tasks";
/* ── 子组件 ── */
import TemplatePanel from "./components/TemplatePanel";
import MediaPanel from "./components/MediaPanel";
import TimelinePanel from "./components/TimelinePanel";
import SettingsPanel from "./components/SettingsPanel";
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
import PreviewPlayer from "./components/PreviewPlayer";
import SaveModal from "./components/SaveModal";
import GenerateModal from "./components/GenerateModal";
import GenerationProgressModal, {
type GenPhase,
} from "./components/GenerationProgressModal";
/* ──────────── 常量 ──────────── */
const MODES: { key: TemplateMode; icon: React.ReactNode; desc: string }[] = [
{ key: "pip", icon: <AppstoreOutlined />, desc: "多画面叠加" },
{ key: "voice_over", icon: <UserOutlined />, desc: "人物讲解为主" },
{ key: "one_take", icon: <VideoCameraOutlined />, desc: "连续不中断" },
{ key: "voice_pip", icon: <DashboardOutlined />, desc: "口播搭配混剪素材" },
];
const DEFAULT_TITLE: TitleConfig = {
ai_auto_select: true,
content: "",
font_preset: "思源黑体",
font_color: "#ffffff",
font_size: 32,
position: "top",
};
const DEFAULT_SUBTITLE: SubtitleConfig = {
enabled: true,
position: "bottom",
font: "思源黑体",
color: "#ffffff",
size: 24,
animation: "fade",
};
const DEFAULT_BGM: BgmConfig = { enabled: false, music_id: "" };
/** 计算预估时长 = Σ 片段时长范围中值 */
const calcEstimatedDuration = (segs: TemplateSegment[]) =>
Math.round(
segs.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
);
let _clipId = 0;
const newClipId = () => `clip-new-${++_clipId}`;
let _segId = 0;
const newSegId = () => `seg-new-${++_segId}`;
/* ──────────── 简易 Toast ──────────── */
type ToastType = "success" | "error" | "warning";
const useToast = () => {
const [toast, setToast] = useState<{
message: string;
type: ToastType;
} | null>(null);
const show = useCallback((message: string, type: ToastType = "success") => {
setToast({ message, type });
setTimeout(() => setToast(null), 2500);
}, []);
const ToastNode = toast ? (
<div className={`ep-toast ep-toast--${toast.type}`}>{toast.message}</div>
) : null;
return { show, ToastNode };
};
/* ──────────── 组件 ──────────── */
const EditingPlanner: React.FC = () => {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const { show: showToast, ToastNode } = useToast();
/* ── P0-2: URL 参数 ── */
/* ── URL 参数 ── */
const urlTemplateId = searchParams.get("template");
const urlGenerate = searchParams.get("generate");
/* ── 数据查询 ── */
const [searchText, setSearchText] = useState("");
const [filterCategory, setFilterCategory] = useState("");
const { data: templates = [], isLoading: tplLoading } = useQuery({
queryKey: ["editing-templates", filterCategory, searchText],
queryFn: () =>
getEditingTemplates({
category: filterCategory || undefined,
tag: searchText || undefined,
}),
/* ── 数据查询:模板 ── */
const { data: templates = [], isLoading: tplLoading } = useQuery<
EditingTemplate[]
>({
queryKey: ["editing-templates"],
queryFn: () => getEditingTemplates(),
});
const { data: categories = [] } = useQuery({
@@ -110,43 +96,39 @@ const EditingPlanner: React.FC = () => {
/* ── 编辑器状态 ── */
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip");
const [segments, setSegments] = useState<TemplateSegment[]>([
const [clips, setClips] = useState<EditPlanClip[]>([
{
id: newSegId(),
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: null,
id: newClipId(),
template_segment_id: newSegId(),
material_type: "video",
script_text: "",
duration: 10,
transition: { type: "none", duration: 0 },
order: 0,
},
]);
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(null);
const [titleConfig, setTitleConfig] = useState<TitleConfig>({
...DEFAULT_TITLE,
});
const [subtitleConfig, setSubtitleConfig] = useState<SubtitleConfig>({
...DEFAULT_SUBTITLE,
});
const [bgmConfig, setBgmConfig] = useState<BgmConfig>({ ...DEFAULT_BGM });
const [editPlanId, setEditPlanId] = useState<string | null>(null);
/* ── UI 状态 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [generateModalOpen, setGenerateModalOpen] = useState(false);
const [genPhase, setGenPhase] = useState<GenPhase>("setup");
const [taskId, setTaskId] = useState<string | null>(null);
const [draftName, setDraftName] = useState("");
const [draftCategory, setDraftCategory] = useState("");
const [draftTags, setDraftTags] = useState("");
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(
null,
);
const [dragIdx, setDragIdx] = useState<number | null>(null);
/* ── P0-2: 自动加载 URL 指定的模板 ── */
/* ── 自动加载 URL 指定的模板 ── */
useEffect(() => {
if (urlTemplateId && templates.length > 0 && !loadedTemplateId) {
const tpl = templates.find((t) => t.id === urlTemplateId);
if (tpl) {
loadTemplate(tpl);
// 如果 URL 有 generate=1,自动打开发成弹窗
if (urlGenerate === "1") {
setGenerateModalOpen(true);
}
@@ -154,17 +136,17 @@ const EditingPlanner: React.FC = () => {
}
}, [urlTemplateId, templates, loadedTemplateId, urlGenerate]);
/* ── Mutations ── */
/* ── Mutations:模板 ── */
const createMutation = useMutation({
mutationFn: createEditingTemplate,
onSuccess: () => {
message.success("模板已保存");
showToast("模板已保存", "success");
queryClient.invalidateQueries({ queryKey: ["editing-templates"] });
setSaveModalOpen(false);
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("保存失败");
showToast("保存失败", "error");
},
});
@@ -172,111 +154,214 @@ const EditingPlanner: React.FC = () => {
mutationFn: ({ id, data }: { id: string; data: SaveTemplatePayload }) =>
updateEditingTemplate(id, data),
onSuccess: () => {
message.success("模板已更新");
showToast("模板已更新", "success");
queryClient.invalidateQueries({ queryKey: ["editing-templates"] });
setSaveModalOpen(false);
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("保存失败");
showToast("保存失败", "error");
},
});
/* ── Mutations:剪辑计划 ── */
const savePlanMutation = useMutation({
mutationFn: createEditPlan,
onSuccess: (plan) => {
showToast("剪辑计划已保存", "success");
setEditPlanId(plan.id);
setSaveModalOpen(false);
},
onError: () => showToast("保存失败", "error"),
});
const updatePlanMutation = useMutation({
mutationFn: ({
id,
data,
}: {
id: string;
data: { name?: string; config?: Record<string, unknown>; total_duration?: number };
}) => updateEditPlan(id, data),
onSuccess: () => {
showToast("剪辑计划已更新", "success");
},
onError: () => showToast("更新失败", "error"),
});
/* ── 任务轮询 ── */
const { data: taskData } = useQuery<TaskItem>({
queryKey: ["task", taskId],
queryFn: () => getTask(taskId!),
enabled: !!taskId && (genPhase === "progress"),
refetchInterval: (query) => {
const data = query.state.data;
if (!data) return 2000;
// 终态停止轮询
if (data.status === "completed" || data.status === "failed") return false;
return 2000;
},
});
/* 监听任务状态变化,自动切换阶段 */
useEffect(() => {
if (!taskData) return;
if (taskData.status === "completed") {
setGenPhase("completed");
} else if (taskData.status === "failed") {
setGenPhase("failed");
}
}, [taskData]);
const generateMutation = useMutation({
mutationFn: ({
templateId,
duration,
}: {
templateId: string;
duration: number;
}) => generateFromTemplate(templateId, { voiceover_duration: duration }),
}) =>
createGenerationTask({
template_id: templateId,
asset_ids: clips
.filter((c) => c.media_asset_id)
.map((c) => c.media_asset_id!),
title_ids: [],
voice_ids: clips
.filter((c) => c.material_type === "voiceover" && c.media_asset_id)
.map((c) => c.media_asset_id!),
}),
onSuccess: (data) => {
const msg =
data.warnings && data.warnings.length > 0
? `生成任务已提交(${data.warnings.map((w) => w.message).join("; ")}`
: "生成任务已提交";
message.success(msg);
setGenerateModalOpen(false);
setTaskId(data.id);
setGenPhase("progress");
showToast("生成任务已提交", "success");
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("生成失败");
showToast("生成失败", "error");
setGenPhase("setup");
},
});
const saving = createMutation.isPending || updateMutation.isPending;
const saving =
createMutation.isPending ||
updateMutation.isPending ||
savePlanMutation.isPending ||
updatePlanMutation.isPending;
/* ──────────── 片段操作 ──────────── */
const addSegment = () => {
if (currentMode === "one_take") return;
setSegments((prev) => [
...prev,
{
id: newSegId(),
segment_order: prev.length + 1,
duration_min: 5,
duration_max: 15,
material_type: currentMode === "voice_pip" ? "人物" : null,
},
]);
};
const handleSelectClip = useCallback((clipId: string | null) => {
setSelectedClipId(clipId);
}, []);
const removeSegment = (id: string) => {
if (currentMode === "one_take") return;
setSegments((prev) =>
prev
.filter((s) => s.id !== id)
.map((s, i) => ({ ...s, segment_order: i + 1 })),
);
};
const handleUpdateClip = useCallback(
(clipId: string, updates: Partial<EditPlanClip>) => {
setClips((prev) =>
prev.map((c) => (c.id === clipId ? { ...c, ...updates } : c)),
);
},
[],
);
const updateSegment = (id: string, patch: Partial<TemplateSegment>) => {
setSegments((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...patch } : s)),
);
};
const handleRemoveClip = useCallback(
(clipId: string) => {
setClips((prev) => {
const next = prev
.filter((c) => c.id !== clipId)
.map((c, i) => ({ ...c, order: i }));
return next;
});
if (selectedClipId === clipId) {
setSelectedClipId(null);
}
},
[selectedClipId],
);
const handleDragStart = (idx: number) => setDragIdx(idx);
const handleReorderClips = useCallback(
(fromIdx: number, toIdx: number) => {
setClips((prev) => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, moved);
return next.map((c, i) => ({ ...c, order: i }));
});
},
[],
);
const handleDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault();
if (dragIdx === null || dragIdx === idx) return;
setSegments((prev) => {
const next = [...prev];
const [moved] = next.splice(dragIdx, 1);
next.splice(idx, 0, moved);
return next.map((s, i) => ({ ...s, segment_order: i + 1 }));
});
setDragIdx(idx);
};
const handleAssetDrop = useCallback(
(asset: MediaAsset, insertIdx: number) => {
const newClip: EditPlanClip = {
id: newClipId(),
template_segment_id: newSegId(),
media_asset_id: asset.id,
material_type: asset.type as EditPlanClip["material_type"],
script_text: "",
duration: asset.duration || 10,
transition: { type: "none", duration: 0 },
order: insertIdx,
};
setClips((prev) => {
const next = [...prev];
next.splice(insertIdx, 0, newClip);
return next.map((c, i) => ({ ...c, order: i }));
});
setSelectedClipId(newClip.id);
showToast(`已添加素材: ${asset.name}`, "success");
},
[showToast],
);
const handleDragEnd = () => setDragIdx(null);
const handleAddClip = useCallback(() => {
const newClip: EditPlanClip = {
id: newClipId(),
template_segment_id: newSegId(),
material_type: "video",
script_text: "",
duration: 10,
transition: { type: "none", duration: 0 },
order: clips.length,
};
setClips((prev) => [...prev, newClip]);
setSelectedClipId(newClip.id);
}, [clips.length]);
const handleAssetDragStart = useCallback((_asset: MediaAsset) => {
// 素材拖拽开始时的回调(可用于高亮时间线等)
}, []);
/* ── 批量添加素材到时间线 ── */
const handleBatchAddAssets = useCallback(
(assets: MediaAsset[], insertIdx?: number) => {
const at = insertIdx ?? clips.length;
const newClips: EditPlanClip[] = assets.map((asset, i) => ({
id: newClipId(),
template_segment_id: newSegId(),
media_asset_id: asset.id,
material_type: asset.type as EditPlanClip["material_type"],
script_text: "",
duration: asset.duration || 10,
transition: { type: "none", duration: 0 },
order: at + i,
}));
setClips((prev) => {
const next = [...prev];
next.splice(at, 0, ...newClips);
return next.map((c, i) => ({ ...c, order: i }));
});
if (newClips.length > 0) {
setSelectedClipId(newClips[0].id);
}
showToast(`已批量添加 ${assets.length} 个素材`, "success");
},
[clips.length, showToast],
);
/* ──────────── 模式切换 ──────────── */
const handleModeChange = (mode: TemplateMode) => {
setCurrentMode(mode);
if (mode === "one_take") {
// 锁定为 1 个片段
setSegments([
{
id: newSegId(),
segment_order: 1,
duration_min: 10,
duration_max: 20,
material_type: null,
},
]);
} else if (mode === "voice_pip") {
// 确保每个片段有 material_type
setSegments((prev) =>
prev.map((s) => ({
...s,
material_type: s.material_type || "人物",
})),
);
}
};
/* ──────────── 模板操作 ──────────── */
@@ -284,32 +369,43 @@ const EditingPlanner: React.FC = () => {
const loadTemplate = (tpl: EditingTemplate) => {
setLoadedTemplateId(tpl.id);
setCurrentMode(tpl.mode);
setSegments(tpl.segments.map((s) => ({ ...s })));
setTitleConfig({ ...tpl.title_config });
setSubtitleConfig({ ...tpl.subtitle_config });
setBgmConfig({ ...tpl.bgm_config });
// 将模板片段转换为剪辑片段
const newClips: EditPlanClip[] = tpl.segments.map((seg, i) => ({
id: newClipId(),
template_segment_id: seg.id || `seg-${i}`,
material_type: (seg.material_type as EditPlanClip["material_type"]) || "video",
script_text: "",
duration: Math.round((seg.duration_min + seg.duration_max) / 2),
transition: { type: "none", duration: 0 },
order: i,
}));
setClips(newClips);
setSelectedClipId(null);
};
const resetEditor = () => {
setLoadedTemplateId(null);
setEditPlanId(null);
setCurrentMode("pip");
setSegments([
setClips([
{
id: newSegId(),
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: null,
id: newClipId(),
template_segment_id: newSegId(),
material_type: "video",
script_text: "",
duration: 10,
transition: { type: "none", duration: 0 },
order: 0,
},
]);
setTitleConfig({ ...DEFAULT_TITLE });
setSubtitleConfig({ ...DEFAULT_SUBTITLE });
setBgmConfig({ ...DEFAULT_BGM });
setSelectedClipId(null);
};
/* ──────────── 保存/生成 ──────────── */
const openSaveModal = () => {
if (segments.length === 0) {
message.warning("请至少添加一个片段");
if (clips.length === 0) {
showToast("请至少添加一个片段", "warning");
return;
}
setDraftName(
@@ -333,43 +429,86 @@ const EditingPlanner: React.FC = () => {
const handleSave = () => {
if (!draftName.trim()) {
message.warning("请输入模板名称");
showToast("请输入名称", "warning");
return;
}
const estimatedDuration = calcEstimatedDuration(segments);
const payload = {
name: draftName.trim(),
mode: currentMode,
category: draftCategory,
tags: draftTags
.split(/[,]/)
.map((t) => t.trim())
.filter(Boolean),
title_config: titleConfig,
subtitle_config: subtitleConfig,
bgm_config: bgmConfig,
estimated_duration: estimatedDuration,
segments: segments.map(({ id: _id, ...rest }) => rest),
};
if (loadedTemplateId) {
updateMutation.mutate({ id: loadedTemplateId, data: payload });
// 保存剪辑计划 — 字段严格匹配后端 Schema
if (editPlanId) {
updatePlanMutation.mutate({
id: editPlanId,
data: {
name: draftName.trim(),
config: { clips },
},
});
} else {
createMutation.mutate(payload);
const totalDuration = clips.reduce((s, c) => s + c.duration, 0);
savePlanMutation.mutate({
template_id: loadedTemplateId || "default",
name: draftName.trim(),
config: {
mode: currentMode,
clips: clips.map(({ id: _id, ...rest }) => rest),
},
total_duration: totalDuration,
});
}
// 同时保存模板(如果有 loadedTemplateId
if (loadedTemplateId) {
const estimatedDuration = clips.reduce((s, c) => s + c.duration, 0);
const payload: SaveTemplatePayload = {
name: draftName.trim(),
mode: currentMode,
category: draftCategory,
tags: draftTags
.split(/[,]/)
.map((t) => t.trim())
.filter(Boolean),
title_config: {
ai_auto_select: true,
content: "",
font_preset: "思源黑体",
font_color: "#ffffff",
font_size: 32,
position: "top",
},
subtitle_config: {
enabled: true,
position: "bottom",
font: "思源黑体",
color: "#ffffff",
size: 24,
animation: "fade",
},
bgm_config: { enabled: false, music_id: "" },
estimated_duration: estimatedDuration,
segments: clips.map((c) => ({
id: c.template_segment_id,
segment_order: c.order + 1,
duration_min: Math.max(1, c.duration - 3),
duration_max: c.duration + 3,
material_type: c.material_type === "voiceover" ? null : c.material_type,
})),
};
updateMutation.mutate({ id: loadedTemplateId, data: payload });
}
};
const handleGenerate = () => {
if (!loadedTemplateId) {
message.warning("请先保存模板");
showToast("请先选择模板", "warning");
return;
}
setGenPhase("setup");
setTaskId(null);
setGenerateModalOpen(true);
};
const doGenerate = () => {
if (!voiceoverDuration || voiceoverDuration <= 0) {
message.warning("请输入配音时长");
showToast("请输入配音时长", "warning");
return;
}
generateMutation.mutate({
@@ -378,78 +517,116 @@ const EditingPlanner: React.FC = () => {
});
};
const estimatedDuration = calcEstimatedDuration(segments);
const handleRetry = () => {
if (!taskId) return;
retryTask(taskId).then(() => {
setGenPhase("progress");
showToast("任务已重新提交", "success");
}).catch(() => {
showToast("重试失败", "error");
});
};
const handleCloseProgressModal = () => {
setGenerateModalOpen(false);
setGenPhase("setup");
setTaskId(null);
};
const totalDuration = clips.reduce((s, c) => s + c.duration, 0);
const selectedClip = clips.find((c) => c.id === selectedClipId) || null;
/* ──────────── 渲染 ──────────── */
return (
<div className="ep-editor">
<div className="ep-page">
{ToastNode}
{/* ═══ 顶部工具栏 ═══ */}
<div className="ep-toolbar">
<Space wrap>
{MODES.map((m) => (
<Button
key={m.key}
type={currentMode === m.key ? "primary" : "default"}
icon={m.icon}
onClick={() => handleModeChange(m.key)}
>
{MODE_LABELS[m.key]}
</Button>
))}
</Space>
<Space>
<Button icon={<SaveOutlined />} onClick={openSaveModal}>
<div className="ep-toolbar-left">
<div className="ep-mode-switch">
{(["pip", "voice_over", "one_take", "voice_pip"] as TemplateMode[]).map(
(mode) => (
<button
key={mode}
className={`ep-mode-btn${currentMode === mode ? " active" : ""}`}
onClick={() => handleModeChange(mode)}
title={MODE_LABELS[mode]}
>
{MODE_LABELS[mode]}
</button>
),
)}
</div>
</div>
<div className="ep-toolbar-center">
{loadedTemplateId && (
<span className="ep-toolbar-template-name">
📋{" "}
{templates.find((t) => t.id === loadedTemplateId)?.name ||
"未命名模板"}
</span>
)}
{editPlanId && (
<span className="ep-toolbar-plan-badge"></span>
)}
</div>
<div className="ep-toolbar-right">
<Button buttonType="ghost" buttonSize="sm" onClick={openSaveModal}>
💾
</Button>
<Button
type="primary"
icon={<VideoCameraOutlined />}
buttonType="primary"
buttonSize="sm"
onClick={handleGenerate}
disabled={!loadedTemplateId}
>
使
🎬
</Button>
</Space>
</div>
</div>
{/* ═══ 三栏主体 ═══ */}
<div className="ep-body">
{/* 左侧:模板面板 */}
<TemplatePanel
{/* 左侧:素材面板(模板+素材 Tab */}
<MediaPanel
templates={templates}
categories={categories}
isLoading={tplLoading}
searchText={searchText}
filterCategory={filterCategory}
isLoadingTemplates={tplLoading}
loadedTemplateId={loadedTemplateId}
onSearchChange={setSearchText}
onCategoryChange={setFilterCategory}
onTemplateSelect={loadTemplate}
onNewTemplate={resetEditor}
onAssetDragStart={handleAssetDragStart}
onBatchAddAssets={handleBatchAddAssets}
/>
{/* 中间:预览 + 时间线 */}
<TimelinePanel
segments={segments}
currentMode={currentMode}
estimatedDuration={estimatedDuration}
onAddSegment={addSegment}
onRemoveSegment={removeSegment}
onUpdateSegment={updateSegment}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
/>
{/* 中间:预览播放器 + 时间线 */}
<div className="ep-center">
<PreviewPlayer
clips={clips}
totalDuration={totalDuration}
selectedClipId={selectedClipId}
onSelectClip={handleSelectClip}
/>
<TimelinePanel
clips={clips}
selectedClipId={selectedClipId}
onSelectClip={handleSelectClip}
onRemoveClip={handleRemoveClip}
onReorderClips={handleReorderClips}
onAssetDrop={handleAssetDrop}
onBatchAssetDrop={handleBatchAddAssets}
onAddClip={handleAddClip}
totalDuration={totalDuration}
/>
</div>
{/* 右侧:设置面板 */}
<SettingsPanel
titleConfig={titleConfig}
subtitleConfig={subtitleConfig}
bgmConfig={bgmConfig}
onTitleChange={setTitleConfig}
onSubtitleChange={setSubtitleConfig}
onBgmChange={setBgmConfig}
{/* 右侧:片段属性 */}
<ClipPropertiesPanel
selectedClip={selectedClip}
onUpdateClip={handleUpdateClip}
clips={clips}
/>
</div>
@@ -457,12 +634,12 @@ const EditingPlanner: React.FC = () => {
<SaveModal
open={saveModalOpen}
loading={saving}
isUpdate={!!loadedTemplateId}
isUpdate={!!editPlanId}
draftName={draftName}
draftCategory={draftCategory}
draftTags={draftTags}
categories={categories}
estimatedDuration={estimatedDuration}
estimatedDuration={totalDuration}
onNameChange={setDraftName}
onCategoryChange={setDraftCategory}
onTagsChange={setDraftTags}
@@ -470,15 +647,19 @@ const EditingPlanner: React.FC = () => {
onCancel={() => setSaveModalOpen(false)}
/>
{/* 使用模板生成弹窗 */}
<GenerateModal
{/* 生成进度弹窗 */}
<GenerationProgressModal
open={generateModalOpen}
loading={generateMutation.isPending}
phase={genPhase}
voiceoverDuration={voiceoverDuration}
estimatedDuration={estimatedDuration}
estimatedDuration={totalDuration}
onDurationChange={setVoiceoverDuration}
onGenerate={doGenerate}
onCancel={() => setGenerateModalOpen(false)}
task={taskData || null}
submitting={generateMutation.isPending}
onCancel={handleCloseProgressModal}
onRetry={handleRetry}
onClose={handleCloseProgressModal}
/>
</div>
);
@@ -0,0 +1,204 @@
/**
* 右侧片段属性面板 — V21 设计系统
* 选中片段后编辑:文案、时长、转场效果
* 未选中时显示全局设置(标题/字幕/BGM)
*/
import React from "react";
import type { EditPlanClip, TransitionEffect } from "@/api/editPlans";
import { TRANSITION_OPTIONS, MATERIAL_TYPE_ICONS } from "@/api/editPlans";
interface ClipPropertiesPanelProps {
/** 当前选中的片段 */
selectedClip: EditPlanClip | null;
/** 更新片段属性 */
onUpdateClip: (clipId: string, updates: Partial<EditPlanClip>) => void;
/** 所有片段列表(用于显示上下文) */
clips: EditPlanClip[];
}
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
selectedClip,
onUpdateClip,
clips,
}) => {
if (!selectedClip) {
return (
<div className="ep-right">
<div className="ep-clip-props-empty">
<div className="ep-clip-props-empty-icon">👆</div>
<h3></h3>
<p>线</p>
<div className="ep-clip-props-summary">
<div className="ep-clip-props-summary-item">
<span className="ep-clip-props-summary-label"></span>
<span className="ep-clip-props-summary-value">{clips.length}</span>
</div>
<div className="ep-clip-props-summary-item">
<span className="ep-clip-props-summary-label"></span>
<span className="ep-clip-props-summary-value">
{clips.reduce((s, c) => s + c.duration, 0)}s
</span>
</div>
</div>
</div>
</div>
);
}
const transition = selectedClip.transition ?? {
type: "none" as const,
duration: 0,
};
const handleTransitionTypeChange = (type: TransitionEffect["type"]) => {
const duration = type === "none" ? 0 : transition.duration || 0.5;
onUpdateClip(selectedClip.id, {
transition: { type, duration },
});
};
const handleTransitionDurationChange = (duration: number) => {
onUpdateClip(selectedClip.id, {
transition: { ...transition, duration },
});
};
return (
<div className="ep-right">
{/* 片段信息头 */}
<div className="ep-right-section">
<div className="ep-clip-props-header">
<span className="ep-clip-props-header-icon">
{MATERIAL_TYPE_ICONS[selectedClip.material_type] || "📄"}
</span>
<div>
<h3> {selectedClip.order + 1}</h3>
<span className="ep-clip-props-header-type">
{selectedClip.material_type} · {selectedClip.duration}s
</span>
</div>
</div>
</div>
{/* 文案编辑 */}
<div className="ep-right-section">
<h3>📝 </h3>
<div className="ep-clip-props-field">
<textarea
className="ep-clip-props-textarea"
placeholder="输入片段文案..."
value={selectedClip.script_text}
onChange={(e) =>
onUpdateClip(selectedClip.id, {
script_text: e.target.value,
})
}
rows={4}
/>
<div className="ep-clip-props-field-hint">
{selectedClip.script_text.length}
</div>
</div>
</div>
{/* 时长调整 */}
<div className="ep-right-section">
<h3> </h3>
<div className="ep-clip-props-field">
<div className="ep-clip-props-duration-control">
<input
type="range"
className="ep-clip-props-range"
min={1}
max={60}
step={1}
value={selectedClip.duration}
onChange={(e) =>
onUpdateClip(selectedClip.id, {
duration: Number(e.target.value),
})
}
/>
<span className="ep-clip-props-duration-value">
{selectedClip.duration}s
</span>
</div>
</div>
</div>
{/* 转场效果 */}
<div className="ep-right-section">
<h3> </h3>
<div className="ep-clip-props-field">
<label className="ep-clip-props-label"></label>
<div className="ep-clip-props-transition-grid">
{TRANSITION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`ep-clip-props-transition-btn${transition.type === opt.value ? " active" : ""}`}
onClick={() => handleTransitionTypeChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
{transition.type !== "none" && (
<div className="ep-clip-props-field">
<label className="ep-clip-props-label"></label>
<div className="ep-clip-props-duration-control">
<input
type="range"
className="ep-clip-props-range"
min={0.1}
max={2}
step={0.1}
value={transition.duration}
onChange={(e) =>
handleTransitionDurationChange(Number(e.target.value))
}
/>
<span className="ep-clip-props-duration-value">
{transition.duration.toFixed(1)}s
</span>
</div>
</div>
)}
</div>
{/* 素材关联 */}
<div className="ep-right-section">
<h3>🔗 </h3>
<div className="ep-clip-props-field">
{selectedClip.media_asset_id ? (
<div className="ep-clip-props-asset-linked">
<span className="ep-clip-props-asset-icon">
{MATERIAL_TYPE_ICONS[selectedClip.material_type]}
</span>
<span className="ep-clip-props-asset-name">
{selectedClip.media_asset_id}
</span>
<button
className="ep-clip-props-asset-unlink"
onClick={() =>
onUpdateClip(selectedClip.id, {
media_asset_id: undefined,
})
}
title="取消关联"
>
</button>
</div>
) : (
<div className="ep-clip-props-asset-empty">
<p></p>
</div>
)}
</div>
</div>
</div>
);
};
export default ClipPropertiesPanel;
@@ -1,11 +1,9 @@
/**
* 使用模板生成视频弹窗
* 使用模板生成视频弹窗 — V21 设计系统
* P1-4: voiceover_id → voiceover_duration (number)
*/
import React from "react";
import { Modal, InputNumber, Space, Typography } from "antd";
const { Text } = Typography;
import { Modal, Input } from "@/components/ui";
interface GenerateModalProps {
open: boolean;
@@ -35,22 +33,24 @@ const GenerateModal: React.FC<GenerateModalProps> = ({
confirmLoading={loading}
okText="开始生成"
>
<Space direction="vertical" style={{ width: "100%" }} size={12}>
<div>
<Text style={{ fontSize: 13 }}>*</Text>
<InputNumber
placeholder="输入配音时长"
value={voiceoverDuration}
onChange={onDurationChange}
min={1}
max={600}
style={{ width: "100%" }}
/>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
~{estimatedDuration}s ±30%
</Text>
</Space>
<div className="ep-modal-field">
<label>*</label>
<Input
type="number"
placeholder="输入配音时长"
value={voiceoverDuration ?? ""}
onChange={(e) => {
const v = e.target.value ? Number(e.target.value) : null;
onDurationChange(v);
}}
min={1}
max={600}
/>
</div>
<div className="ep-modal-info">
~{estimatedDuration}s ±30%
</div>
</Modal>
);
};
@@ -0,0 +1,251 @@
/**
* 生成进度弹窗 — 任务 2.17
* 三阶段 UIsetup(配置)→ progress(进度轮询)→ completed / failed(结果)
* V21 设计系统,CSS 类名前缀 ep-gen-
*/
import React, { useEffect, useRef } from "react";
import { Modal, Button } from "@/components/ui";
import type { TaskItem } from "@/api/tasks";
/* ──────────── 类型 ──────────── */
export type GenPhase = "setup" | "progress" | "completed" | "failed";
export interface GenerationProgressModalProps {
open: boolean;
phase: GenPhase;
/* setup 阶段 */
voiceoverDuration: number | null;
estimatedDuration: number;
onDurationChange: (v: number | null) => void;
onGenerate: () => void;
/* progress / 结果阶段 */
task: TaskItem | null;
/* 通用 */
submitting: boolean;
onCancel: () => void;
onRetry?: () => void;
onClose?: () => void;
}
/* ──────────── 步骤文案映射 ──────────── */
const STEP_LABELS: Record<string, string> = {
queued: "排队中…",
preparing: "准备素材…",
generating_video: "渲染视频中…",
adding_effects: "添加特效…",
composing: "合成中…",
encoding: "编码输出中…",
completed: "生成完成!",
failed: "生成失败",
};
const getStepLabel = (step: string) =>
STEP_LABELS[step] || step.replace(/_/g, " ");
/* ──────────── 状态徽标颜色 ──────────── */
const STATUS_COLOR: Record<string, string> = {
queued: "#6b7280",
pending: "#6b7280",
preparing: "#f59e0b",
generating_video: "#4f46e5",
adding_effects: "#7c3aed",
composing: "#2563eb",
encoding: "#0891b2",
completed: "#10b981",
failed: "#ef4444",
};
/* ──────────── 组件 ──────────── */
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
open,
phase,
voiceoverDuration,
estimatedDuration,
onDurationChange,
onGenerate,
task,
submitting,
onCancel,
onRetry,
onClose,
}) => {
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
const prevOpen = useRef(false);
useEffect(() => {
if (prevOpen.current && !open) {
/* modal just closed — parent handles reset */
}
prevOpen.current = open;
}, [open]);
const progress = task?.progress ?? 0;
const status = task?.status ?? "";
const currentStep = task?.current_step ?? "";
const userMessage = task?.user_message ?? "";
const errorMessage = task?.error_message ?? "";
const retryable = task?.retryable ?? false;
/* ── setup 阶段 ── */
if (phase === "setup") {
return (
<Modal
open={open}
title="使用模板生成视频"
confirmLoading={submitting}
onOk={onGenerate}
onCancel={onCancel}
okText="开始生成"
cancelText="取消"
width={440}
>
<div className="ep-gen-setup">
<label className="ep-gen-field-label"></label>
<input
type="number"
className="ep-gen-duration-input"
placeholder="请输入配音时长"
value={voiceoverDuration ?? ""}
onChange={(e) => {
const v = e.target.value ? Number(e.target.value) : null;
onDurationChange(v);
}}
min={1}
max={600}
/>
<div className="ep-gen-estimate">
<strong>{estimatedDuration}s</strong>
</div>
</div>
</Modal>
);
}
/* ── progress 阶段 ── */
if (phase === "progress") {
const stepColor = STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5";
return (
<Modal
open={open}
title="视频生成中"
footer={null}
onCancel={onCancel}
closable
width={480}
>
<div className="ep-gen-progress">
{/* 进度环 */}
<div className="ep-gen-progress-ring-wrap">
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
<circle
className="ep-gen-progress-ring-bg"
cx="60" cy="60" r="52"
/>
<circle
className="ep-gen-progress-ring-fill"
cx="60" cy="60" r="52"
style={{
strokeDasharray: `${2 * Math.PI * 52}`,
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
stroke: stepColor,
}}
/>
</svg>
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
{progress}%
</span>
</div>
{/* 当前步骤 */}
<div className="ep-gen-step-text">
{userMessage || getStepLabel(currentStep) || "处理中…"}
</div>
{/* 进度条 */}
<div className="ep-gen-progress-bar">
<div
className="ep-gen-progress-bar-fill"
style={{
width: `${progress}%`,
backgroundColor: stepColor,
}}
/>
</div>
{/* 任务 ID */}
{task?.id && (
<div className="ep-gen-task-id"> ID: {task.id}</div>
)}
</div>
</Modal>
);
}
/* ── completed 阶段 ── */
if (phase === "completed") {
return (
<Modal
open={open}
title="✅ 生成完成"
footer={null}
onCancel={onClose || onCancel}
closable
width={440}
>
<div className="ep-gen-result">
<div className="ep-gen-result-icon">🎉</div>
<div className="ep-gen-result-title"></div>
{userMessage && (
<div className="ep-gen-result-msg">{userMessage}</div>
)}
<div className="ep-gen-result-actions">
<Button buttonType="primary" onClick={onClose || onCancel}>
</Button>
</div>
</div>
</Modal>
);
}
/* ── failed 阶段 ── */
return (
<Modal
open={open}
title="❌ 生成失败"
footer={null}
onCancel={onClose || onCancel}
closable
width={440}
>
<div className="ep-gen-result ep-gen-result--error">
<div className="ep-gen-result-icon">😥</div>
<div className="ep-gen-result-title"></div>
{(errorMessage || userMessage) && (
<div className="ep-gen-result-msg ep-gen-result-msg--error">
{errorMessage || userMessage}
</div>
)}
<div className="ep-gen-result-actions">
{retryable && onRetry && (
<Button buttonType="primary" onClick={onRetry}>
🔄
</Button>
)}
<Button buttonType="secondary" onClick={onClose || onCancel}>
</Button>
</div>
</div>
</Modal>
);
};
export default GenerationProgressModal;
@@ -0,0 +1,224 @@
/**
* 左侧素材面板 — V21 设计系统
* Tab 切换:模板列表 / 素材库
* 素材 Tab 集成 AssetSelector 组件
*/
import React, { useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Input, Select, Tag, Button } from "@/components/ui";
import {
MODE_LABELS,
MODE_COLORS,
type EditingTemplate,
type TemplateCategory,
type TemplateMode,
} from "@/api/editingPlanner";
import {
getMediaAssets,
type MediaAsset,
} from "@/api/editPlans";
import { getAssetLibraries } from "@/api/assets";
import AssetSelector from "@/components/AssetSelector/AssetSelector";
/** antd Tag color → V21 Tag variant */
const modeVariantMap: Record<
string,
"primary" | "success" | "warning" | "info"
> = {
blue: "primary",
green: "success",
orange: "warning",
purple: "info",
};
type LeftTab = "templates" | "assets";
interface MediaPanelProps {
/* 模板相关 */
templates: EditingTemplate[];
categories: TemplateCategory[];
isLoadingTemplates: boolean;
loadedTemplateId: string | null;
onTemplateSelect: (tpl: EditingTemplate) => void;
onNewTemplate: () => void;
/* 素材相关 */
onAssetDragStart?: (asset: MediaAsset) => void;
onBatchAddAssets?: (assets: MediaAsset[]) => void;
}
const MediaPanel: React.FC<MediaPanelProps> = ({
templates,
categories,
isLoadingTemplates,
loadedTemplateId,
onTemplateSelect,
onNewTemplate,
onAssetDragStart,
onBatchAddAssets,
}) => {
const [activeTab, setActiveTab] = useState<LeftTab>("templates");
const [searchText, setSearchText] = useState("");
const [filterCategory, setFilterCategory] = useState("");
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
/* 先获取素材库列表,再用第一个 library_id 获取素材 */
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
});
const libraryId = libraries.length > 0 ? libraries[0].id : undefined;
/* 素材数据查询 */
const { data: assets = [] } = useQuery({
queryKey: ["media-assets", libraryId],
queryFn: () => getMediaAssets(libraryId),
enabled: libraryId !== undefined,
});
/* 过滤模板 */
const filteredTemplates = templates.filter((tpl) => {
if (searchText && !tpl.name.toLowerCase().includes(searchText.toLowerCase()))
return false;
if (filterCategory && tpl.category !== filterCategory) return false;
return true;
});
/* 获取选中的素材 */
const selectedAssets = assets.filter((a) => selectedAssetIds.includes(a.id));
/* 批量添加到时间线 */
const handleBatchAdd = useCallback(() => {
if (selectedAssets.length > 0 && onBatchAddAssets) {
onBatchAddAssets(selectedAssets);
setSelectedAssetIds([]);
}
}, [selectedAssets, onBatchAddAssets]);
return (
<div className="ep-left">
{/* Tab 切换 */}
<div className="ep-left-tabs">
<button
className={`ep-left-tab${activeTab === "templates" ? " active" : ""}`}
onClick={() => setActiveTab("templates")}
>
📂
</button>
<button
className={`ep-left-tab${activeTab === "assets" ? " active" : ""}`}
onClick={() => setActiveTab("assets")}
>
🎬
</button>
</div>
{/* 内容区 */}
{activeTab === "templates" ? (
<>
{/* 搜索栏 */}
<div className="ep-left-header">
<Input.Search
placeholder="搜索模板..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
/>
<Select
placeholder="按分类筛选"
value={filterCategory || undefined}
onChange={(v: string) => setFilterCategory(v || "")}
allowClear
options={categories.map((c) => ({ value: c.name, label: c.name }))}
/>
</div>
{/* 模板列表 */}
<div className="ep-left-list">
{isLoadingTemplates ? (
<div className="ep-left-empty">
<div className="ep-left-empty-icon"></div>
<p>...</p>
</div>
) : filteredTemplates.length === 0 ? (
<div className="ep-left-empty">
<div className="ep-left-empty-icon">📭</div>
<p></p>
</div>
) : (
filteredTemplates.map((tpl) => {
const modeColor =
MODE_COLORS[tpl.mode as TemplateMode] || "blue";
const variant = modeVariantMap[modeColor] || "primary";
return (
<div
key={tpl.id}
className={`ep-template-card${loadedTemplateId === tpl.id ? " selected" : ""}`}
onClick={() => onTemplateSelect(tpl)}
>
<div className="ep-template-card-name">{tpl.name}</div>
<div className="ep-template-card-tags">
<Tag variant={variant}>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
</Tag>
{tpl.tags.slice(0, 2).map((tag) => (
<Tag key={tag} variant="info">
{tag}
</Tag>
))}
</div>
<div className="ep-template-card-meta">
{tpl.segments.length} · ~{tpl.estimated_duration}s
</div>
</div>
);
})
)}
</div>
{/* 底部操作 */}
{loadedTemplateId && (
<div className="ep-left-footer">
<button
className="ep-new-template-btn"
onClick={onNewTemplate}
>
</button>
</div>
)}
</>
) : (
<>
{/* 素材 Tab — 使用 AssetSelector */}
<div className="ep-left-media">
<AssetSelector
assets={assets}
selectedIds={selectedAssetIds}
onSelectionChange={setSelectedAssetIds}
onAssetDragStart={onAssetDragStart}
showQualityFilter
showBatchSelect
compact
/>
</div>
{/* 批量添加按钮 */}
{selectedAssetIds.length > 0 && onBatchAddAssets && (
<div className="ep-left-media-footer">
<Button
buttonType="primary"
buttonSize="sm"
onClick={handleBatchAdd}
block
>
📦 线 ({selectedAssetIds.length})
</Button>
</div>
)}
</>
)}
</div>
);
};
export default MediaPanel;
@@ -0,0 +1,426 @@
/**
* 预览播放器样式 — V21 设计系统
* 任务 2.16
*/
/* ============================================================
预览播放器容器
============================================================ */
.ep-preview {
flex-shrink: 0;
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
/* ── 预览画面 ── */
.ep-preview-screen {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
max-height: 280px;
overflow: hidden;
background: #0f0f14;
border-radius: 0;
}
.ep-preview-visual {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
transition: background 0.4s ease;
}
/* 片段类型大图标 */
.ep-preview-type-icon {
font-size: 56px;
opacity: 0.7;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
animation: ep-preview-float 3s ease-in-out infinite;
}
@keyframes ep-preview-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
/* 文案字幕 */
.ep-preview-subtitle {
max-width: 80%;
padding: 8px 20px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(8px);
border-radius: var(--radius-md);
color: #fff;
font-size: 14px;
line-height: 1.6;
text-align: center;
letter-spacing: 0.02em;
}
/* 片段序号角标 */
.ep-preview-clip-badge {
position: absolute;
top: 12px;
left: 12px;
padding: 3px 10px;
border-radius: var(--radius-sm);
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
}
/* 素材类型标签 */
.ep-preview-material-tag {
position: absolute;
top: 12px;
right: 12px;
padding: 3px 10px;
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(6px);
color: rgba(255, 255, 255, 0.85);
font-size: 11px;
text-transform: capitalize;
}
/* 空状态 */
.ep-preview-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.4);
}
.ep-preview-empty-icon {
font-size: 48px;
margin-bottom: var(--space-sm);
opacity: 0.5;
}
.ep-preview-empty p {
margin: 0;
font-size: var(--font-size-sm);
}
/* ============================================================
控制栏
============================================================ */
.ep-preview-controls {
display: flex;
align-items: center;
gap: var(--space-md);
padding: 8px var(--space-lg);
border-top: 1px solid rgba(255, 255, 255, 0.06);
background: #16161e;
}
/* 时间显示 */
.ep-preview-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-variant-numeric: tabular-nums;
min-width: 90px;
}
.ep-preview-time-current {
color: #fff;
font-weight: 600;
}
.ep-preview-time-sep {
color: rgba(255, 255, 255, 0.3);
}
.ep-preview-time-total {
color: rgba(255, 255, 255, 0.5);
}
/* 播放按钮组 */
.ep-preview-buttons {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
justify-content: center;
}
.ep-preview-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
cursor: pointer;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-all);
}
.ep-preview-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.ep-preview-btn-play {
width: 40px;
height: 40px;
background: var(--primary-color);
color: #fff;
font-size: 16px;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.4);
}
.ep-preview-btn-play:hover {
background: var(--primary-hover);
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.5);
}
/* 片段信息 */
.ep-preview-clip-info {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 12px;
min-width: 90px;
justify-content: flex-end;
}
.ep-preview-clip-idx {
color: rgba(255, 255, 255, 0.6);
}
.ep-preview-clip-dur {
color: var(--primary-color);
font-weight: 600;
background: rgba(79, 70, 229, 0.15);
padding: 1px 8px;
border-radius: 999px;
}
/* ============================================================
进度条(可拖拽)
============================================================ */
.ep-preview-progress {
position: relative;
height: 20px;
padding: 7px var(--space-lg);
cursor: pointer;
user-select: none;
background: #16161e;
}
.ep-preview-progress-track {
position: relative;
height: 6px;
display: flex;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
overflow: hidden;
}
.ep-preview-progress-segment {
height: 100%;
transition: opacity 0.2s ease;
}
.ep-preview-progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: rgba(255, 255, 255, 0.25);
border-radius: 3px;
pointer-events: none;
}
.ep-preview-progress-handle {
position: absolute;
top: 50%;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
transition: transform 0.1s ease;
pointer-events: none;
z-index: 2;
}
.ep-preview-progress:hover .ep-preview-progress-handle {
transform: translate(-50%, -50%) scale(1.2);
}
/* ============================================================
迷你时间线
============================================================ */
.ep-preview-timeline {
position: relative;
display: flex;
height: 28px;
gap: 2px;
padding: 0 var(--space-lg);
background: #12121a;
overflow: hidden;
}
.ep-preview-timeline-seg {
height: 100%;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--transition-all);
min-width: 8px;
position: relative;
}
.ep-preview-timeline-seg:hover {
opacity: 0.85;
transform: scaleY(1.08);
}
.ep-preview-timeline-seg.active {
opacity: 1;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
}
.ep-preview-timeline-seg.selected {
box-shadow: 0 0 0 2px var(--primary-color);
}
.ep-preview-timeline-seg-label {
font-size: 10px;
opacity: 0.8;
pointer-events: none;
}
/* 播放头 */
.ep-preview-playhead {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: #fff;
transform: translateX(-50%);
pointer-events: none;
z-index: 3;
box-shadow: 0 0 4px rgba(255, 255, 255, 0.5);
}
/* ============================================================
片段内进度条
============================================================ */
.ep-preview-clip-progress {
height: 3px;
background: rgba(255, 255, 255, 0.06);
}
.ep-preview-clip-progress-fill {
height: 100%;
transition: width 0.1s linear;
border-radius: 0 2px 2px 0;
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1200px) {
.ep-preview-screen {
max-height: 240px;
}
}
@media (max-width: 900px) {
.ep-preview-screen {
max-height: 200px;
}
.ep-preview-type-icon {
font-size: 40px;
}
.ep-preview-subtitle {
font-size: 13px;
padding: 6px 14px;
}
.ep-preview-time {
min-width: 70px;
font-size: 11px;
}
.ep-preview-clip-info {
min-width: 70px;
font-size: 11px;
}
}
@media (max-width: 768px) {
.ep-preview-screen {
max-height: 180px;
}
.ep-preview-controls {
padding: 6px var(--space-md);
gap: var(--space-sm);
}
.ep-preview-btn-play {
width: 36px;
height: 36px;
font-size: 14px;
}
.ep-preview-timeline {
height: 22px;
padding: 0 var(--space-md);
}
}
@media (max-width: 480px) {
.ep-preview-screen {
max-height: 140px;
}
.ep-preview-type-icon {
font-size: 32px;
}
.ep-preview-subtitle {
font-size: 12px;
max-width: 90%;
}
.ep-preview-clip-badge,
.ep-preview-material-tag {
font-size: 10px;
padding: 2px 6px;
}
.ep-preview-clip-info {
display: none;
}
.ep-preview-time {
min-width: auto;
}
}
@@ -0,0 +1,415 @@
/**
* 预览播放器 — V21 设计系统
* 模拟播放 EditPlan 片段序列,支持播放/暂停、进度条拖拽、时间线点击跳转
* 任务 2.16
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import "./PreviewPlayer.css";
import type { EditPlanClip } from "@/api/editPlans";
import { MATERIAL_TYPE_ICONS } from "@/api/editPlans";
interface PreviewPlayerProps {
clips: EditPlanClip[];
totalDuration: number;
selectedClipId: string | null;
onSelectClip: (clipId: string | null) => void;
}
/* ── 片段颜色(与 TimelinePanel 保持一致) ── */
const CLIP_COLORS = [
"#4f46e5",
"#7c3aed",
"#2563eb",
"#0891b2",
"#059669",
"#d97706",
];
const getClipColor = (idx: number) => CLIP_COLORS[idx % CLIP_COLORS.length];
/* ── 格式化时间 mm:ss ── */
const formatTime = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
};
/* ── 根据播放进度计算当前片段索引 ── */
const getClipIndexAtTime = (
clips: EditPlanClip[],
time: number,
): number => {
let elapsed = 0;
for (let i = 0; i < clips.length; i++) {
elapsed += clips[i].duration;
if (time < elapsed) return i;
}
return Math.max(0, clips.length - 1);
};
/* ── 根据片段索引计算起始时间 ── */
const getClipStartTime = (
clips: EditPlanClip[],
clipIndex: number,
): number => {
let time = 0;
for (let i = 0; i < clipIndex; i++) {
time += clips[i].duration;
}
return time;
};
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
clips,
totalDuration,
selectedClipId,
onSelectClip,
}) => {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const progressRef = useRef<HTMLDivElement>(null);
const wasPlayingRef = useRef(false);
const currentClipIndex = clips.length > 0 ? getClipIndexAtTime(clips, currentTime) : -1;
const currentClip = currentClipIndex >= 0 ? clips[currentClipIndex] : null;
const clipStartTime =
currentClipIndex >= 0 ? getClipStartTime(clips, currentClipIndex) : 0;
const clipProgress =
currentClip && currentClip.duration > 0
? ((currentTime - clipStartTime) / currentClip.duration) * 100
: 0;
const overallProgress =
totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0;
/* ── 播放控制 ── */
const stopPlayback = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setIsPlaying(false);
}, []);
const startPlayback = useCallback(() => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
setCurrentTime((prev) => {
const next = prev + 0.1;
if (next >= totalDuration) {
// 播放结束
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
setIsPlaying(false);
return 0; // 回到起点
}
return next;
});
}, 100);
setIsPlaying(true);
}, [totalDuration]);
const togglePlay = useCallback(() => {
if (clips.length === 0) return;
if (isPlaying) {
stopPlayback();
} else {
// 如果在末尾,从头开始
if (currentTime >= totalDuration - 0.05) {
setCurrentTime(0);
}
startPlayback();
}
}, [isPlaying, currentTime, totalDuration, clips.length, startPlayback, stopPlayback]);
/* ── 停止/重置 ── */
const handleStop = useCallback(() => {
stopPlayback();
setCurrentTime(0);
}, [stopPlayback]);
/* ── 上一段/下一段 ── */
const handlePrevClip = useCallback(() => {
if (currentClipIndex <= 0) {
setCurrentTime(0);
} else {
setCurrentTime(getClipStartTime(clips, currentClipIndex - 1));
}
}, [currentClipIndex, clips]);
const handleNextClip = useCallback(() => {
if (currentClipIndex < clips.length - 1) {
setCurrentTime(getClipStartTime(clips, currentClipIndex + 1));
} else {
setCurrentTime(totalDuration);
}
}, [currentClipIndex, clips, totalDuration]);
/* ── 进度条拖拽 ── */
const updateTimeFromMouse = useCallback(
(clientX: number) => {
if (!progressRef.current || totalDuration === 0) return;
const rect = progressRef.current.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
setCurrentTime(ratio * totalDuration);
},
[totalDuration],
);
const handleProgressMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
wasPlayingRef.current = isPlaying;
if (isPlaying) stopPlayback();
updateTimeFromMouse(e.clientX);
},
[isPlaying, stopPlayback, updateTimeFromMouse],
);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
updateTimeFromMouse(e.clientX);
};
const handleMouseUp = () => {
setIsDragging(false);
if (wasPlayingRef.current) {
startPlayback();
}
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging, updateTimeFromMouse, startPlayback]);
/* ── 点击时间线片段跳转 ── */
const handleTimelineSegmentClick = useCallback(
(idx: number) => {
setCurrentTime(getClipStartTime(clips, idx));
onSelectClip(clips[idx].id);
},
[clips, onSelectClip],
);
/* ── 组件卸载时清理定时器 ── */
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
/* ── 片段变化时同步播放位置(外部选中片段跳转) ── */
useEffect(() => {
if (selectedClipId && !isPlaying) {
const idx = clips.findIndex((c) => c.id === selectedClipId);
if (idx >= 0) {
const startTime = getClipStartTime(clips, idx);
// 只在当前不在该片段范围内时跳转
const endTime = startTime + clips[idx].duration;
if (currentTime < startTime || currentTime >= endTime) {
setCurrentTime(startTime);
}
}
}
}, [selectedClipId, clips, isPlaying]); // eslint-disable-line react-hooks/exhaustive-deps
/* ── 空状态 ── */
if (clips.length === 0) {
return (
<div className="ep-preview">
<div className="ep-preview-screen">
<div className="ep-preview-empty">
<div className="ep-preview-empty-icon">🎬</div>
<p></p>
</div>
</div>
</div>
);
}
return (
<div className="ep-preview">
{/* ── 预览画面 ── */}
<div className="ep-preview-screen">
{/* 背景渐变(模拟视频画面) */}
<div
className="ep-preview-visual"
style={{
background: `linear-gradient(135deg, ${getClipColor(currentClipIndex)}33, ${getClipColor(currentClipIndex)}11)`,
}}
>
{/* 片段类型图标 */}
<div className="ep-preview-type-icon">
{currentClip ? MATERIAL_TYPE_ICONS[currentClip.material_type] || "📄" : "🎬"}
</div>
{/* 文案字幕 */}
{currentClip?.script_text && (
<div className="ep-preview-subtitle">{currentClip.script_text}</div>
)}
{/* 片段序号角标 */}
<div
className="ep-preview-clip-badge"
style={{ backgroundColor: getClipColor(currentClipIndex) }}
>
#{currentClipIndex + 1}
</div>
{/* 素材类型标签 */}
{currentClip && (
<div className="ep-preview-material-tag">
{MATERIAL_TYPE_ICONS[currentClip.material_type]}{" "}
{currentClip.material_type}
</div>
)}
</div>
</div>
{/* ── 控制栏 ── */}
<div className="ep-preview-controls">
{/* 左侧:时间 */}
<div className="ep-preview-time">
<span className="ep-preview-time-current">
{formatTime(currentTime)}
</span>
<span className="ep-preview-time-sep">/</span>
<span className="ep-preview-time-total">
{formatTime(totalDuration)}
</span>
</div>
{/* 中间:播放控制按钮 */}
<div className="ep-preview-buttons">
<button
className="ep-preview-btn"
onClick={handlePrevClip}
title="上一段"
>
</button>
<button
className="ep-preview-btn ep-preview-btn-play"
onClick={togglePlay}
title={isPlaying ? "暂停" : "播放"}
>
{isPlaying ? "⏸" : "▶"}
</button>
<button
className="ep-preview-btn"
onClick={handleStop}
title="停止"
>
</button>
<button
className="ep-preview-btn"
onClick={handleNextClip}
title="下一段"
>
</button>
</div>
{/* 右侧:片段信息 */}
<div className="ep-preview-clip-info">
{currentClip && (
<>
<span className="ep-preview-clip-idx">
{currentClipIndex + 1}/{clips.length}
</span>
<span className="ep-preview-clip-dur">
{currentClip.duration}s
</span>
</>
)}
</div>
</div>
{/* ── 进度条(可拖拽) ── */}
<div
className="ep-preview-progress"
ref={progressRef}
onMouseDown={handleProgressMouseDown}
>
<div className="ep-preview-progress-track">
{/* 片段色块背景 */}
{clips.map((clip, idx) => (
<div
key={clip.id}
className="ep-preview-progress-segment"
style={{
width: `${(clip.duration / totalDuration) * 100}%`,
backgroundColor: getClipColor(idx),
opacity: idx === currentClipIndex ? 0.6 : 0.25,
}}
/>
))}
{/* 已播放覆盖层 */}
<div
className="ep-preview-progress-fill"
style={{ width: `${overallProgress}%` }}
/>
</div>
{/* 拖拽手柄 */}
<div
className="ep-preview-progress-handle"
style={{ left: `${overallProgress}%` }}
/>
</div>
{/* ── 迷你时间线(可点击跳转) ── */}
<div className="ep-preview-timeline">
{clips.map((clip, idx) => {
const isActive = idx === currentClipIndex;
const isSelected = clip.id === selectedClipId;
return (
<div
key={clip.id}
className={`ep-preview-timeline-seg${isActive ? " active" : ""}${isSelected ? " selected" : ""}`}
style={{
width: `${(clip.duration / totalDuration) * 100}%`,
backgroundColor: isActive
? getClipColor(idx)
: `${getClipColor(idx)}55`,
}}
onClick={() => handleTimelineSegmentClick(idx)}
title={`片段 ${idx + 1}: ${clip.duration}s`}
>
<span className="ep-preview-timeline-seg-label">
{MATERIAL_TYPE_ICONS[clip.material_type]}
</span>
</div>
);
})}
{/* 播放头指示器 */}
<div
className="ep-preview-playhead"
style={{ left: `${overallProgress}%` }}
/>
</div>
{/* ── 片段内进度 ── */}
{currentClip && (
<div className="ep-preview-clip-progress">
<div
className="ep-preview-clip-progress-fill"
style={{
width: `${clipProgress}%`,
backgroundColor: getClipColor(currentClipIndex),
}}
/>
</div>
)}
</div>
);
};
export default PreviewPlayer;
@@ -1,13 +1,11 @@
/**
* 保存/更新模板弹窗
* 保存/更新模板弹窗 — V21 设计系统
* 分类使用 Select 关联后端分类 APIP1-5
*/
import React from "react";
import { Modal, Input, Select, Space, Typography } from "antd";
import { Modal, Input, Select } from "@/components/ui";
import type { TemplateCategory } from "@/api/editingPlanner";
const { Text } = Typography;
interface SaveModalProps {
open: boolean;
loading: boolean;
@@ -48,39 +46,39 @@ const SaveModal: React.FC<SaveModalProps> = ({
confirmLoading={loading}
okText="保存"
>
<Space direction="vertical" style={{ width: "100%" }} size={12}>
<div>
<Text style={{ fontSize: 13 }}> *</Text>
<Input
placeholder="输入模板名称"
value={draftName}
onChange={(e) => onNameChange(e.target.value)}
/>
</div>
<div>
<Text style={{ fontSize: 13 }}></Text>
<Select
placeholder="选择分类"
value={draftCategory || undefined}
onChange={(v) => onCategoryChange(v || "")}
allowClear
showSearch
style={{ width: "100%" }}
options={categories.map((c) => ({ value: c.name, label: c.name }))}
/>
</div>
<div>
<Text style={{ fontSize: 13 }}></Text>
<Input
placeholder="例如:vlog, 日常"
value={draftTags}
onChange={(e) => onTagsChange(e.target.value)}
/>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
~{estimatedDuration}s
</Text>
</Space>
<div className="ep-modal-field">
<label> *</label>
<Input
placeholder="输入模板名称"
value={draftName}
onChange={(e) => onNameChange(e.target.value)}
/>
</div>
<div className="ep-modal-field">
<label></label>
<Select
placeholder="选择分类"
value={draftCategory || undefined}
onChange={(v: string) => onCategoryChange(v || "")}
allowClear
showSearch
options={categories.map((c) => ({ value: c.name, label: c.name }))}
/>
</div>
<div className="ep-modal-field">
<label></label>
<Input
placeholder="例如:vlog, 日常"
value={draftTags}
onChange={(e) => onTagsChange(e.target.value)}
/>
</div>
<div className="ep-modal-info">
<strong>~{estimatedDuration}s</strong>
</div>
</Modal>
);
};
@@ -1,18 +1,15 @@
/**
* 右侧设置面板
* 右侧设置面板 — V21 设计系统
* 标题设置 / 字幕设置 / BGM 设置
*/
import React from "react";
import { Typography, Input, Switch, Select, Slider, Tag } from "antd";
import { SoundOutlined, FontSizeOutlined } from "@ant-design/icons";
import { Input, Select, Tag } from "@/components/ui";
import type {
TitleConfig,
SubtitleConfig,
BgmConfig,
} from "@/api/editingPlanner";
const { Text } = Typography;
/* ── 常量 ── */
const FONT_PRESETS = ["思源黑体", "站酷快乐体", "方正兰亭", "汉仪旗黑"];
const POSITIONS = [
@@ -47,56 +44,55 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
}) => {
return (
<div className="ep-right">
{/* 标题设置 */}
<div className="ep-settings-group">
<Text strong style={{ display: "block", marginBottom: 12 }}>
<FontSizeOutlined style={{ marginRight: 6 }} />
</Text>
{/* ── 标题设置 ── */}
<div className="ep-right-section">
<h3>🔤 </h3>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<Text style={{ fontSize: 13 }}>AI </Text>
<Switch
size="small"
checked={titleConfig.ai_auto_select}
onChange={(checked) =>
onTitleChange({ ...titleConfig, ai_auto_select: checked })
}
checkedChildren="ON"
unCheckedChildren="OFF"
/>
{/* AI 自动选择开关 */}
<div className="ep-setting-item">
<div className="ep-setting-label">
<span className="ep-setting-label-text">AI </span>
<label className="ep-switch">
<input
type="checkbox"
checked={titleConfig.ai_auto_select}
onChange={(e) =>
onTitleChange({
...titleConfig,
ai_auto_select: e.target.checked,
})
}
/>
<span className="ep-switch-slider" />
</label>
</div>
</div>
{/* 手动输入标题 */}
{!titleConfig.ai_auto_select && (
<Input.TextArea
placeholder="手动输入标题内容"
value={titleConfig.content}
onChange={(e) =>
onTitleChange({ ...titleConfig, content: e.target.value })
}
rows={2}
size="small"
style={{ marginBottom: 12 }}
/>
<div className="ep-setting-item">
<Input.TextArea
placeholder="手动输入标题内容"
value={titleConfig.content}
onChange={(e) =>
onTitleChange({ ...titleConfig, content: e.target.value })
}
rows={2}
/>
</div>
)}
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}></Text>
<div
style={{ display: "flex", gap: 4, marginTop: 4, flexWrap: "wrap" }}
>
{/* 字体预设 */}
<div className="ep-setting-item">
<div className="ep-setting-label">
<span className="ep-setting-label-text"></span>
</div>
<div className="ep-font-presets">
{FONT_PRESETS.map((font) => (
<Tag
key={font}
color={titleConfig.font_preset === font ? "blue" : "default"}
style={{ cursor: "pointer" }}
variant={titleConfig.font_preset === font ? "primary" : "info"}
className="ep-font-preset-tag"
onClick={() =>
onTitleChange({ ...titleConfig, font_preset: font })
}
@@ -107,168 +103,220 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
</div>
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}></Text>
<Input
size="small"
value={titleConfig.font_color}
onChange={(e) =>
onTitleChange({ ...titleConfig, font_color: e.target.value })
}
style={{ marginTop: 4 }}
/>
</div>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}></Text>
<Select
size="small"
value={titleConfig.position}
onChange={(v) => onTitleChange({ ...titleConfig, position: v })}
options={POSITIONS}
style={{ width: "100%", marginTop: 4 }}
/>
{/* 颜色 + 位置 */}
<div className="ep-setting-item">
<div className="ep-setting-row">
<div style={{ flex: 1 }}>
<div className="ep-setting-label">
<span className="ep-setting-label-text"></span>
</div>
<div className="ep-color-input">
<span
className="ep-color-swatch"
style={{ background: titleConfig.font_color }}
/>
<Input
value={titleConfig.font_color}
onChange={(e) =>
onTitleChange({
...titleConfig,
font_color: e.target.value,
})
}
/>
</div>
</div>
<div style={{ flex: 1 }}>
<div className="ep-setting-label">
<span className="ep-setting-label-text"></span>
</div>
<Select
value={titleConfig.position}
onChange={(v: string) =>
onTitleChange({ ...titleConfig, position: v })
}
options={POSITIONS}
/>
</div>
</div>
</div>
<div>
<Text style={{ fontSize: 12 }}>{titleConfig.font_size}</Text>
<Slider
{/* 字号 */}
<div className="ep-setting-item">
<div className="ep-setting-label">
<span className="ep-setting-label-text">
{titleConfig.font_size}
</span>
</div>
<input
type="range"
min={16}
max={72}
value={titleConfig.font_size}
onChange={(v) => onTitleChange({ ...titleConfig, font_size: v })}
onChange={(e) =>
onTitleChange({
...titleConfig,
font_size: Number(e.target.value),
})
}
style={{ width: "100%" }}
/>
</div>
</div>
{/* 字幕设置 */}
<div className="ep-settings-group">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<Text strong>
<FontSizeOutlined style={{ marginRight: 6 }} />
</Text>
<Switch
size="small"
checked={subtitleConfig.enabled}
onChange={(checked) =>
onSubtitleChange({ ...subtitleConfig, enabled: checked })
}
/>
{/* ── 字幕设置 ── */}
<div className="ep-right-section">
<h3>📝 </h3>
{/* 启用开关 */}
<div className="ep-setting-item">
<div className="ep-setting-label">
<span className="ep-setting-label-text"></span>
<label className="ep-switch">
<input
type="checkbox"
checked={subtitleConfig.enabled}
onChange={(e) =>
onSubtitleChange({
...subtitleConfig,
enabled: e.target.checked,
})
}
/>
<span className="ep-switch-slider" />
</label>
</div>
</div>
{subtitleConfig.enabled && (
<>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}></Text>
{/* 位置 */}
<div className="ep-setting-item">
<div className="ep-setting-label">
<span className="ep-setting-label-text"></span>
</div>
<Select
size="small"
value={subtitleConfig.position}
onChange={(v) =>
onChange={(v: string) =>
onSubtitleChange({ ...subtitleConfig, position: v })
}
options={POSITIONS}
style={{ width: "100%", marginTop: 4 }}
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}></Text>
{/* 字体 */}
<div className="ep-setting-item">
<div className="ep-setting-label">
<span className="ep-setting-label-text"></span>
</div>
<Select
size="small"
value={subtitleConfig.font}
onChange={(v) =>
onChange={(v: string) =>
onSubtitleChange({ ...subtitleConfig, font: v })
}
options={SUBTITLE_FONTS.map((f) => ({ value: f, label: f }))}
style={{ width: "100%", marginTop: 4 }}
/>
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}></Text>
<Input
size="small"
value={subtitleConfig.color}
onChange={(e) =>
onSubtitleChange({
...subtitleConfig,
color: e.target.value,
})
}
style={{ marginTop: 4 }}
/>
</div>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}></Text>
<Select
size="small"
value={subtitleConfig.animation}
onChange={(v) =>
onSubtitleChange({ ...subtitleConfig, animation: v })
}
options={SUBTITLE_ANIMATIONS}
style={{ width: "100%", marginTop: 4 }}
/>
{/* 颜色 + 动画 */}
<div className="ep-setting-item">
<div className="ep-setting-row">
<div style={{ flex: 1 }}>
<div className="ep-setting-label">
<span className="ep-setting-label-text"></span>
</div>
<div className="ep-color-input">
<span
className="ep-color-swatch"
style={{ background: subtitleConfig.color }}
/>
<Input
value={subtitleConfig.color}
onChange={(e) =>
onSubtitleChange({
...subtitleConfig,
color: e.target.value,
})
}
/>
</div>
</div>
<div style={{ flex: 1 }}>
<div className="ep-setting-label">
<span className="ep-setting-label-text"></span>
</div>
<Select
value={subtitleConfig.animation}
onChange={(v: string) =>
onSubtitleChange({ ...subtitleConfig, animation: v })
}
options={SUBTITLE_ANIMATIONS}
/>
</div>
</div>
</div>
<div>
<Text style={{ fontSize: 12 }}>{subtitleConfig.size}</Text>
<Slider
{/* 字号 */}
<div className="ep-setting-item">
<div className="ep-setting-label">
<span className="ep-setting-label-text">
{subtitleConfig.size}
</span>
</div>
<input
type="range"
min={12}
max={48}
value={subtitleConfig.size}
onChange={(v) =>
onSubtitleChange({ ...subtitleConfig, size: v })
onChange={(e) =>
onSubtitleChange({
...subtitleConfig,
size: Number(e.target.value),
})
}
style={{ width: "100%" }}
/>
</div>
</>
)}
</div>
{/* BGM 设置 */}
<div className="ep-settings-group">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<Text strong>
<SoundOutlined style={{ marginRight: 6 }} />
BGM
</Text>
<Switch
size="small"
checked={bgmConfig.enabled}
onChange={(checked) =>
onBgmChange({ ...bgmConfig, enabled: checked })
}
/>
{/* ── BGM 设置 ── */}
<div className="ep-right-section">
<h3>🎵 BGM </h3>
{/* 启用开关 */}
<div className="ep-setting-item">
<div className="ep-setting-label">
<span className="ep-setting-label-text"></span>
<label className="ep-switch">
<input
type="checkbox"
checked={bgmConfig.enabled}
onChange={(e) =>
onBgmChange({ ...bgmConfig, enabled: e.target.checked })
}
/>
<span className="ep-switch-slider" />
</label>
</div>
</div>
{bgmConfig.enabled && (
<div>
<Text style={{ fontSize: 12 }}></Text>
<div className="ep-setting-item">
<div className="ep-setting-label">
<span className="ep-setting-label-text"></span>
</div>
<Select
size="small"
placeholder="选择背景音乐"
value={bgmConfig.music_id || undefined}
onChange={(v) => onBgmChange({ ...bgmConfig, music_id: v })}
style={{ width: "100%", marginTop: 4 }}
onChange={(v: string) =>
onBgmChange({ ...bgmConfig, music_id: v })
}
options={[
{ value: "bgm-1", label: "轻快节奏" },
{ value: "bgm-2", label: "舒缓氛围" },
{ value: "bgm-3", label: "动感活力" },
{ value: "bgm-1", label: "🎶 轻快节奏" },
{ value: "bgm-2", label: "🎹 舒缓氛围" },
{ value: "bgm-3", label: "🥁 动感活力" },
]}
/>
</div>
@@ -1,19 +1,9 @@
/**
* 左侧模板面板
* 左侧模板面板 — V21 设计系统
* 搜索、分类筛选、模板卡片列表
*/
import React from "react";
import {
Input,
Select,
Card,
Tag,
Empty,
Spin,
Button,
Typography,
} from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { Input, Select, Button, Tag } from "@/components/ui";
import {
MODE_LABELS,
MODE_COLORS,
@@ -22,7 +12,16 @@ import {
type TemplateMode,
} from "@/api/editingPlanner";
const { Text } = Typography;
/** antd Tag color → V21 Tag variant */
const modeVariantMap: Record<
string,
"primary" | "success" | "warning" | "info"
> = {
blue: "primary",
green: "success",
orange: "warning",
purple: "info",
};
interface TemplatePanelProps {
templates: EditingTemplate[];
@@ -51,94 +50,78 @@ const TemplatePanel: React.FC<TemplatePanelProps> = ({
}) => {
return (
<div className="ep-left">
<div style={{ padding: "0 12px", marginBottom: 12 }}>
<Text
strong
style={{ fontSize: 14, display: "block", marginBottom: 8 }}
>
</Text>
<Input
prefix={<SearchOutlined />}
{/* 头部:搜索 + 筛选 */}
<div className="ep-left-header">
<h3>📂 </h3>
<Input.Search
placeholder="搜索模板..."
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
size="small"
style={{ marginBottom: 8 }}
/>
<Select
placeholder="按分类筛选"
value={filterCategory || undefined}
onChange={(v) => onCategoryChange(v || "")}
onChange={(v: string) => onCategoryChange(v || "")}
allowClear
size="small"
style={{ width: "100%" }}
options={categories.map((c) => ({ value: c.name, label: c.name }))}
/>
</div>
<div style={{ padding: "0 12px", flex: 1, overflowY: "auto" }}>
{/* 模板卡片列表 */}
<div className="ep-left-list">
{isLoading ? (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin />
<div className="ep-left-empty">
<div className="ep-left-empty-icon"></div>
<p>...</p>
</div>
) : templates.length === 0 ? (
<Empty
description="暂无已保存的模板,请先编辑并保存模板"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: 20 }}
/>
<div className="ep-left-empty">
<div className="ep-left-empty-icon">📭</div>
<p></p>
<p></p>
</div>
) : (
templates.map((tpl) => (
<Card
key={tpl.id}
size="small"
hoverable
className={`ep-tpl-card ${loadedTemplateId === tpl.id ? "ep-tpl-card-active" : ""}`}
onClick={() => onTemplateSelect(tpl)}
style={{ marginBottom: 8 }}
>
templates.map((tpl) => {
const modeColor = MODE_COLORS[tpl.mode as TemplateMode] || "blue";
const variant = modeVariantMap[modeColor] || "primary";
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
key={tpl.id}
className={`ep-template-card${loadedTemplateId === tpl.id ? " selected" : ""}`}
onClick={() => onTemplateSelect(tpl)}
>
<Text strong ellipsis style={{ maxWidth: 140 }}>
{tpl.name}
</Text>
<Tag
color={MODE_COLORS[tpl.mode as TemplateMode] || "blue"}
style={{ marginRight: 0 }}
>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
</Tag>
</div>
<div style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
<div className="ep-template-card-name">{tpl.name}</div>
<div className="ep-template-card-tags">
<Tag variant={variant}>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
</Tag>
{tpl.tags.slice(0, 3).map((tag) => (
<Tag key={tag} variant="info">
{tag}
</Tag>
))}
</div>
<div className="ep-template-card-meta">
{tpl.segments.length} · ~{tpl.estimated_duration}s
</Text>
{tpl.tags.length > 0 && (
<div style={{ marginTop: 4 }}>
{tpl.tags.slice(0, 3).map((tag) => (
<Tag key={tag} style={{ fontSize: 11, marginRight: 4 }}>
{tag}
</Tag>
))}
</div>
)}
</div>
</div>
</Card>
))
);
})
)}
</div>
{/* 底部:新建空白模板 */}
{loadedTemplateId && (
<div style={{ padding: 12, borderTop: "1px solid #f0f0f0" }}>
<Button size="small" block onClick={onNewTemplate}>
<div className="ep-left-footer">
<Button
buttonType="ghost"
buttonSize="sm"
block
onClick={onNewTemplate}
>
</Button>
</div>
)}
@@ -1,225 +1,308 @@
/**
* 中间预览 + 时间线面板
* 视频/封面预览区 + 片段卡片时间线
* 中间时间线面板 — V21 设计系统
* 可视化时长条 + 片段卡片 + 拖拽排序 + 素材拖入
*/
import React from "react";
import { Card, Button, Tag, Typography, Select, Slider } from "antd";
import {
PlusOutlined,
DeleteOutlined,
DragOutlined,
VideoCameraOutlined,
PictureOutlined,
} from "@ant-design/icons";
import type { TemplateSegment, TemplateMode } from "@/api/editingPlanner";
const { Text } = Typography;
import React, { useState, useRef } from "react";
import { Button } from "@/components/ui";
import type { EditPlanClip, MediaAsset } from "@/api/editPlans";
import { MATERIAL_TYPE_ICONS, TRANSITION_OPTIONS } from "@/api/editPlans";
interface TimelinePanelProps {
segments: TemplateSegment[];
currentMode: TemplateMode;
estimatedDuration: number;
onAddSegment: () => void;
onRemoveSegment: (id: string) => void;
onUpdateSegment: (id: string, patch: Partial<TemplateSegment>) => void;
onDragStart: (idx: number) => void;
onDragOver: (e: React.DragEvent, idx: number) => void;
onDragEnd: () => void;
clips: EditPlanClip[];
selectedClipId: string | null;
onSelectClip: (clipId: string | null) => void;
onRemoveClip: (clipId: string) => void;
onReorderClips: (fromIdx: number, toIdx: number) => void;
onAssetDrop: (asset: MediaAsset, insertIdx: number) => void;
onBatchAssetDrop?: (assets: MediaAsset[], insertIdx: number) => void;
onAddClip: () => void;
totalDuration: number;
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
segments,
currentMode,
estimatedDuration,
onAddSegment,
onRemoveSegment,
onUpdateSegment,
onDragStart,
onDragOver,
onDragEnd,
clips,
selectedClipId,
onSelectClip,
onRemoveClip,
onReorderClips,
onAssetDrop,
onBatchAssetDrop,
onAddClip,
totalDuration,
}) => {
const isOneShot = currentMode === "one_take";
const isMixedCut = currentMode === "voice_pip";
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const [isDragOverEmpty, setIsDragOverEmpty] = useState(false);
const dragIdxRef = useRef<number | null>(null);
const handleDragOver = (e: React.DragEvent, idx: number) => {
onDragOver(e, idx);
/* ── 内部片段拖拽排序 ── */
const handleClipDragStart = (e: React.DragEvent, idx: number) => {
dragIdxRef.current = idx;
e.dataTransfer.setData("application/x-clip-index", String(idx));
e.dataTransfer.effectAllowed = "move";
};
return (
<div className="ep-center">
{/* 预览区 */}
<div className="ep-preview-row">
{/* 视频预览 */}
<div className="ep-preview-box">
<div className="ep-preview-frame">
<VideoCameraOutlined style={{ fontSize: 40, color: "#bbb" }} />
<Text type="secondary" style={{ marginTop: 8 }}>
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12, marginTop: 4 }}>
9:16
</Text>
</div>
const handleClipDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverIdx(idx);
};
{/* 封面预览 + 方案按钮 */}
<div style={{ display: "flex", gap: 12, flex: "0 0 auto" }}>
<div className="ep-preview-box">
<div className="ep-preview-frame">
<PictureOutlined style={{ fontSize: 40, color: "#bbb" }} />
<Text type="secondary" style={{ marginTop: 8 }}>
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12, marginTop: 4 }}>
9:16
</Text>
</div>
<div className="ep-cover-btns">
<Button size="small" block>
AI
</Button>
<Button size="small" block>
</Button>
<Button size="small" block>
</Button>
<Button size="small" block>
AI
</Button>
</div>
const handleClipDragEnd = () => {
dragIdxRef.current = null;
setDragOverIdx(null);
};
/* ── 外部素材拖入 ── */
const isAssetDrag = (e: React.DragEvent) =>
e.dataTransfer.types.includes("application/x-media-asset") ||
e.dataTransfer.types.includes("application/x-media-assets");
const handleAssetDragOver = (e: React.DragEvent) => {
if (isAssetDrag(e)) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
};
const handleDropOnClip = (e: React.DragEvent, insertIdx: number) => {
e.preventDefault();
setDragOverIdx(null);
// 内部片段排序
const clipIdx = e.dataTransfer.getData("application/x-clip-index");
if (clipIdx !== "") {
const fromIdx = Number(clipIdx);
if (fromIdx !== insertIdx && fromIdx !== insertIdx - 1) {
const adjustedTo = fromIdx < insertIdx ? insertIdx - 1 : insertIdx;
onReorderClips(fromIdx, adjustedTo);
}
return;
}
// 批量素材拖入
const assetsJson = e.dataTransfer.getData("application/x-media-assets");
if (assetsJson) {
try {
const assets: MediaAsset[] = JSON.parse(assetsJson);
if (onBatchAssetDrop) {
onBatchAssetDrop(assets, insertIdx);
} else {
assets.forEach((asset, i) => onAssetDrop(asset, insertIdx + i));
}
} catch {
// ignore
}
return;
}
// 单个素材拖入
const assetJson = e.dataTransfer.getData("application/x-media-asset");
if (assetJson) {
try {
const asset: MediaAsset = JSON.parse(assetJson);
onAssetDrop(asset, insertIdx);
} catch {
// ignore
}
}
};
const handleDropOnEmpty = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOverEmpty(false);
// 批量素材拖入
const assetsJson = e.dataTransfer.getData("application/x-media-assets");
if (assetsJson) {
try {
const assets: MediaAsset[] = JSON.parse(assetsJson);
if (onBatchAssetDrop) {
onBatchAssetDrop(assets, clips.length);
} else {
assets.forEach((asset, i) => onAssetDrop(asset, clips.length + i));
}
} catch {
// ignore
}
return;
}
// 单个素材拖入
const assetJson = e.dataTransfer.getData("application/x-media-asset");
if (assetJson) {
try {
const asset: MediaAsset = JSON.parse(assetJson);
onAssetDrop(asset, clips.length);
} catch {
// ignore
}
}
};
const handleEmptyDragOver = (e: React.DragEvent) => {
if (isAssetDrag(e)) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverEmpty(true);
}
};
/* ── 转场标签 ── */
const getTransitionLabel = (clip: EditPlanClip) => {
if (!clip.transition || clip.transition.type === "none") return null;
const opt = TRANSITION_OPTIONS.find((o) => o.value === clip.transition?.type);
return opt ? opt.label : clip.transition.type;
};
/* ── 时长条宽度百分比 ── */
const getClipWidth = (clip: EditPlanClip) => {
if (totalDuration === 0) return 100 / Math.max(clips.length, 1);
return (clip.duration / totalDuration) * 100;
};
/* ── 片段颜色 ── */
const clipColors = ["#4f46e5", "#7c3aed", "#2563eb", "#0891b2", "#059669", "#d97706"];
const getClipColor = (idx: number) => clipColors[idx % clipColors.length];
return (
<div>
{/* 可视化时长条 */}
<div className="ep-timeline-bar">
<div className="ep-timeline-bar-label">
线 <span className="ep-timeline-bar-duration">{totalDuration}s</span>
</div>
<div className="ep-timeline-bar-track">
{clips.map((clip, idx) => (
<div
key={clip.id}
className="ep-timeline-bar-segment"
style={{
width: `${getClipWidth(clip)}%`,
backgroundColor: getClipColor(idx),
}}
title={`片段 ${idx + 1}: ${clip.duration}s`}
/>
))}
{clips.length === 0 && (
<div className="ep-timeline-bar-empty"></div>
)}
</div>
</div>
{/* 时间线 */}
<div className="ep-timeline">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<Text strong>
线{" "}
<Text
type="secondary"
style={{ fontWeight: "normal", fontSize: 12 }}
>
~{estimatedDuration}s
</Text>
</Text>
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={onAddSegment}
disabled={isOneShot}
{/* 片段列表 */}
<div className="ep-timeline-header">
<h3> ({clips.length})</h3>
<Button buttonType="secondary" buttonSize="sm" onClick={onAddClip}>
</Button>
</div>
<div className="ep-timeline-list">
{clips.length === 0 ? (
<div
className={`ep-timeline-empty-drop${isDragOverEmpty ? " active" : ""}`}
onDragOver={handleEmptyDragOver}
onDragLeave={() => setIsDragOverEmpty(false)}
onDrop={handleDropOnEmpty}
>
</Button>
</div>
<div className="ep-timeline-empty-icon">🎬</div>
<p></p>
<span></span>
</div>
) : (
clips.map((clip, idx) => {
const isSelected = clip.id === selectedClipId;
const transitionLabel = getTransitionLabel(clip);
<div
style={{
display: "flex",
gap: 12,
overflowX: "auto",
paddingBottom: 8,
}}
>
{segments.map((seg, idx) => (
<Card
key={seg.id}
size="small"
className="ep-seg-card"
draggable={!isOneShot}
onDragStart={() => onDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={onDragEnd}
style={{ minWidth: 180, maxWidth: 220, flexShrink: 0 }}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
}}
>
<span
style={{
cursor: isOneShot ? "default" : "grab",
color: "#999",
return (
<React.Fragment key={clip.id}>
{/* 拖放插入指示器 */}
{dragOverIdx === idx && (
<div className="ep-timeline-drop-indicator" />
)}
<div
className={`ep-clip-card${isSelected ? " selected" : ""}${dragOverIdx === idx ? " drag-over" : ""}`}
draggable
onDragStart={(e) => handleClipDragStart(e, idx)}
onDragOver={(e) => {
handleClipDragOver(e, idx);
handleAssetDragOver(e);
}}
onDragEnd={handleClipDragEnd}
onDrop={(e) => handleDropOnClip(e, idx)}
onClick={() => onSelectClip(clip.id)}
>
<DragOutlined />
</span>
<Tag color="blue">#{seg.segment_order}</Tag>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => onRemoveSegment(seg.id!)}
disabled={isOneShot}
style={{ marginLeft: "auto" }}
/>
</div>
{/* 拖拽手柄 */}
<span className="ep-clip-drag"></span>
{isOneShot ? (
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
) : (
<>
<div style={{ marginBottom: 4 }}>
<Text style={{ fontSize: 12 }}> ()</Text>
<Slider
min={1}
max={seg.duration_max}
value={seg.duration_min}
onChange={(v) =>
onUpdateSegment(seg.id!, { duration_min: v })
}
/>
</div>
<div>
<Text style={{ fontSize: 12 }}> ()</Text>
<Slider
min={seg.duration_min}
max={60}
value={seg.duration_max}
onChange={(v) =>
onUpdateSegment(seg.id!, { duration_max: v })
}
/>
</div>
</>
)}
{/* 序号徽标 */}
<span
className="ep-clip-index"
style={{ backgroundColor: getClipColor(idx) }}
>
#{idx + 1}
</span>
{isMixedCut && (
<div style={{ marginTop: 8 }}>
<Text style={{ fontSize: 12 }}></Text>
<Select
size="small"
value={seg.material_type || "人物"}
onChange={(v) =>
onUpdateSegment(seg.id!, { material_type: v })
}
style={{ width: "100%", marginTop: 4 }}
options={[
{ value: "人物", label: "人物" },
{ value: "场景", label: "场景" },
]}
/>
{/* 片段信息 */}
<div className="ep-clip-info">
<div className="ep-clip-info-top">
<span className="ep-clip-type-icon">
{MATERIAL_TYPE_ICONS[clip.material_type] || "📄"}
</span>
<span className="ep-clip-script">
{clip.script_text || (
<em className="ep-clip-script-empty"></em>
)}
</span>
</div>
<div className="ep-clip-info-bottom">
<div className="ep-clip-duration-bar">
<div
className="ep-clip-duration-fill"
style={{
width: `${Math.min(100, (clip.duration / 60) * 100)}%`,
backgroundColor: getClipColor(idx),
}}
/>
</div>
<span className="ep-clip-duration-text">{clip.duration}s</span>
{clip.media_asset_id && (
<span className="ep-clip-asset-badge" title="已关联素材">
🔗
</span>
)}
{transitionLabel && (
<span className="ep-clip-transition-badge">
{transitionLabel}
</span>
)}
</div>
</div>
{/* 操作按钮 */}
<div className="ep-clip-actions">
<button
className="ep-clip-action-btn"
onClick={(e) => {
e.stopPropagation();
onRemoveClip(clip.id);
}}
title="删除片段"
>
</button>
</div>
</div>
)}
</Card>
))}
</div>
</React.Fragment>
);
})
)}
{/* 末尾插入指示器 */}
{clips.length > 0 && dragOverIdx === clips.length && (
<div className="ep-timeline-drop-indicator" />
)}
</div>
</div>
);
File diff suppressed because it is too large Load Diff
+157 -1
View File
@@ -209,6 +209,7 @@
}
.xx-voice-card {
position: relative;
border-radius: var(--radius-md);
border: 2px solid var(--border-color);
padding: var(--space-md);
@@ -226,6 +227,23 @@
background: var(--primary-soft) !important;
}
.xx-voice-card-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.xx-voice-card-disabled:hover {
border-color: var(--border-color);
}
.xx-voice-check {
position: absolute;
top: 8px;
right: 8px;
color: var(--primary-color);
font-size: 16px;
}
.xx-voice-header {
display: flex;
align-items: center;
@@ -383,7 +401,11 @@
.xx-video-preview-overlay {
position: absolute;
inset: 0;
background: radial-gradient(circle at 72% 28%, rgba(255, 255, 255, 0.2), transparent 40%);
background: radial-gradient(
circle at 72% 28%,
rgba(255, 255, 255, 0.2),
transparent 40%
);
pointer-events: none;
}
@@ -610,6 +632,140 @@
margin: 0 0 var(--space-sm) !important;
}
/* ── 克隆进度展示(任务 3.15) ─────────────────────── */
/* 轮询提示 */
.xx-clone-polling-hint {
display: flex;
align-items: center;
gap: 8px;
margin-top: var(--space-sm);
padding: 6px 12px;
font-size: var(--font-size-xs);
color: var(--accent-color, #f59e0b);
background: color-mix(in srgb, var(--accent-color, #f59e0b) 8%, transparent);
border-radius: var(--radius-sm, 8px);
animation: xx-clone-polling-fade 2s ease-in-out infinite;
}
.xx-clone-polling-dot {
width: 6px;
height: 6px;
border-radius: var(--radius-full);
background: var(--accent-color, #f59e0b);
animation: xx-clone-blink 1.5s ease-in-out infinite;
}
@keyframes xx-clone-polling-fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes xx-clone-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* 卡片状态变体 */
.xx-voice-card--processing {
border-color: color-mix(in srgb, var(--accent-color, #f59e0b) 40%, transparent);
background: color-mix(in srgb, var(--accent-color, #f59e0b) 4%, var(--bg-primary));
}
.xx-voice-card--failed {
border-color: color-mix(in srgb, var(--error-color, #ef4444) 30%, transparent);
opacity: 0.75;
}
/* 头像状态变体 */
.xx-voice-avatar--processing {
background: linear-gradient(135deg, var(--accent-color, #f59e0b), var(--accent-dark, #d97706));
animation: xx-clone-pulse 2s ease-in-out infinite;
}
.xx-voice-avatar--failed {
background: linear-gradient(135deg, var(--color-gray-400, #94a3b8), var(--color-gray-500, #64748b));
}
@keyframes xx-clone-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* 状态行 */
.xx-voice-status-row {
display: flex;
align-items: center;
gap: 5px;
margin-top: 4px;
}
.xx-voice-status-dot {
width: 6px;
height: 6px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
/* 进度条 */
.xx-clone-progress {
position: relative;
height: 5px;
background: var(--bg-tertiary, #f1f5f9);
border-radius: 3px;
margin-top: var(--space-sm);
overflow: hidden;
}
.xx-clone-progress-bar {
height: 100%;
background: linear-gradient(
90deg,
var(--accent-color, #f59e0b),
var(--secondary-color, #10b981)
);
border-radius: 3px;
transition: width 0.5s ease;
}
/* Indeterminate 态:条纹流动动画(progress=0 时后端无进度数据) */
.xx-clone-progress--indeterminate .xx-clone-progress-bar {
width: 100%;
background: repeating-linear-gradient(
90deg,
var(--accent-color, #f59e0b) 0%,
var(--accent-color, #f59e0b) 25%,
var(--secondary-color, #10b981) 25%,
var(--secondary-color, #10b981) 50%,
var(--accent-color, #f59e0b) 50%
);
background-size: 60px 100%;
animation: xx-clone-progress-flow 1.2s linear infinite;
}
@keyframes xx-clone-progress-flow {
from { background-position: 0 0; }
to { background-position: 60px 0; }
}
.xx-clone-progress--indeterminate .xx-clone-progress-text {
animation: xx-clone-progress-pulse 1.5s ease-in-out infinite;
}
@keyframes xx-clone-progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.xx-clone-progress-text {
position: absolute;
right: 0;
top: -16px;
font-size: 11px;
color: var(--accent-color, #f59e0b);
font-weight: 600;
}
/* ============================================================
结果区域
============================================================ */
+339 -170
View File
@@ -1,189 +1,358 @@
/**
* 任务历史页面
* 展示用户所有生成任务,支持筛选和重试
* 任务历史页面 — V21 设计系统
* 页面头部 + 圆角胶囊 Tab 筛选(含计数)+ 卡片式任务列表 + 分页 + 空状态
* 使用 mock 数据,CSS 变量,V21 组件
*/
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Button,
Table,
Tag,
Empty,
Spin,
Select,
Progress,
Popconfirm,
message,
} from "antd";
import {
ReloadOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
SyncOutlined,
} from "@ant-design/icons";
import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks";
import type { ColumnsType } from "antd/es/table";
import PageHead from "@/components/layout/PageHead";
import { Button } from "@/components/ui";
import "./history.css";
/** 任务状态标签 */
const StatusTag: React.FC<{ status: string }> = ({ status }) => {
const config: Record<
string,
{ color: string; icon: React.ReactNode; text: string }
> = {
completed: {
color: "success",
icon: <CheckCircleOutlined />,
text: "已完成",
},
processing: {
color: "processing",
icon: <SyncOutlined spin />,
text: "处理中",
},
pending: {
color: "default",
icon: <ClockCircleOutlined />,
text: "等待中",
},
failed: { color: "error", icon: <CloseCircleOutlined />, text: "失败" },
};
const c = config[status] || config.pending;
return (
<Tag color={c.color} icon={c.icon}>
{c.text}
</Tag>
);
/* ============================================================
* Mock 数据
* ============================================================ */
type TaskStatus = "completed" | "processing" | "pending" | "failed";
interface TaskItem {
id: string;
name: string;
type: string;
template: string;
status: TaskStatus;
date: string;
duration?: string;
}
const statusLabel: Record<TaskStatus, string> = {
completed: "已完成",
processing: "进行中",
pending: "排队中",
failed: "失败",
};
const mockTasks: TaskItem[] = [
{
id: "task-001",
name: "产品介绍视频_春季促销",
type: "视频生成",
template: "商品展示模板",
status: "completed",
date: "2026-07-01 09:30",
duration: "2分18秒",
},
{
id: "task-002",
name: "品牌宣传片_终版",
type: "视频生成",
template: "品牌宣传模板",
status: "processing",
date: "2026-07-01 10:15",
},
{
id: "task-003",
name: "用户评价合集",
type: "视频生成",
template: "评价展示模板",
status: "completed",
date: "2026-06-30 16:42",
duration: "1分45秒",
},
{
id: "task-004",
name: "新品发布预告",
type: "视频生成",
template: "新品预告模板",
status: "pending",
date: "2026-06-30 14:20",
},
{
id: "task-005",
name: "活动回顾_618大促",
type: "视频生成",
template: "活动回顾模板",
status: "failed",
date: "2026-06-29 11:05",
},
{
id: "task-006",
name: "商品口播_夏季新品",
type: "视频生成",
template: "口播模板",
status: "completed",
date: "2026-06-28 15:30",
duration: "1分52秒",
},
{
id: "task-007",
name: "种草视频_护肤推荐",
type: "视频生成",
template: "种草模板",
status: "completed",
date: "2026-06-28 10:20",
duration: "2分05秒",
},
{
id: "task-008",
name: "产品对比评测",
type: "视频生成",
template: "评测模板",
status: "completed",
date: "2026-06-27 14:15",
duration: "3分12秒",
},
{
id: "task-009",
name: "品牌故事_创业历程",
type: "视频生成",
template: "品牌故事模板",
status: "failed",
date: "2026-06-27 09:40",
},
{
id: "task-010",
name: "知识分享_行业趋势",
type: "视频生成",
template: "知识分享模板",
status: "completed",
date: "2026-06-26 16:50",
duration: "2分38秒",
},
{
id: "task-011",
name: "好物推荐_家居用品",
type: "视频生成",
template: "种草模板",
status: "completed",
date: "2026-06-26 11:25",
duration: "1分58秒",
},
{
id: "task-012",
name: "活动预热_双11倒计时",
type: "视频生成",
template: "活动预热模板",
status: "completed",
date: "2026-06-25 13:10",
duration: "1分30秒",
},
{
id: "task-013",
name: "产品演示_新功能介绍",
type: "视频生成",
template: "产品演示模板",
status: "completed",
date: "2026-06-25 09:55",
duration: "2分22秒",
},
{
id: "task-014",
name: "用户访谈_使用体验",
type: "视频生成",
template: "访谈模板",
status: "failed",
date: "2026-06-24 15:40",
},
{
id: "task-015",
name: "品牌活动_周年庆典",
type: "视频生成",
template: "活动模板",
status: "completed",
date: "2026-06-24 10:30",
duration: "2分45秒",
},
];
/* ============================================================
* Tab 配置
* ============================================================ */
interface TabConfig {
key: string;
label: string;
statusFilter?: TaskStatus;
}
const tabs: TabConfig[] = [
{ key: "all", label: "全部" },
{ key: "processing", label: "进行中", statusFilter: "processing" },
{ key: "completed", label: "已完成", statusFilter: "completed" },
{ key: "failed", label: "失败", statusFilter: "failed" },
];
/* ============================================================
* 分页配置
* ============================================================ */
const PAGE_SIZE = 10;
/* ============================================================
* 组件
* ============================================================ */
const TaskHistory: React.FC = () => {
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState<string>("");
const [activeTab, setActiveTab] = useState("all");
const [currentPage, setCurrentPage] = useState(1);
// 获取任务列表
const { data: tasks = [], isLoading } = useQuery({
queryKey: ["user-tasks"],
queryFn: getUserTasks,
});
// 获取当前 Tab 的筛选状态
const currentTab = tabs.find((t) => t.key === activeTab);
const statusFilter = currentTab?.statusFilter;
// 重试任务
const retryMutation = useMutation({
mutationFn: retryTask,
onSuccess: () => {
message.success("任务已重新提交");
queryClient.invalidateQueries({ queryKey: ["user-tasks"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("重试失败");
},
});
/** 过滤后的任务 */
// 过滤任务
const filteredTasks = statusFilter
? tasks.filter((t) => t.status === statusFilter)
: tasks;
? mockTasks.filter((t) => t.status === statusFilter)
: mockTasks;
const columns: ColumnsType<TaskItem> = [
{
title: "任务类型",
dataIndex: "task_type",
key: "task_type",
width: 120,
render: (type: string) => {
const map: Record<string, string> = {
generation: "视频生成",
ingest: "素材入库",
classification: "素材分类",
voice_generate: "配音生成",
};
return map[type] || type;
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
render: (status: string) => <StatusTag status={status} />,
},
{
title: "进度",
dataIndex: "progress",
key: "progress",
width: 120,
render: (progress: number) => (
<Progress percent={Math.round((progress || 0) * 100)} size="small" />
),
},
{
title: "信息",
dataIndex: "user_message",
key: "user_message",
ellipsis: true,
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 180,
render: (t: string) => (t ? new Date(t).toLocaleString("zh-CN") : "-"),
},
{
title: "操作",
key: "actions",
width: 80,
render: (_, record) =>
record.status === "failed" ? (
<Popconfirm
title="确定重试此任务?"
onConfirm={() => retryMutation.mutate(record.id)}
>
<Button type="text" size="small" icon={<ReloadOutlined />}>
</Button>
</Popconfirm>
) : null,
},
];
// 计算各 Tab 的数量
const tabCounts: Record<string, number> = {
all: mockTasks.length,
processing: mockTasks.filter((t) => t.status === "processing").length,
completed: mockTasks.filter((t) => t.status === "completed").length,
failed: mockTasks.filter((t) => t.status === "failed").length,
};
// 分页
const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE);
const paginatedTasks = filteredTasks.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE,
);
// 切换 Tab 时重置页码
const handleTabChange = (key: string) => {
setActiveTab(key);
setCurrentPage(1);
};
// 重试任务(mock
const handleRetry = (taskId: string) => {
console.log("重试任务:", taskId);
// TODO: 调用重试 API
};
// 查看任务详情(mock
const handleView = (taskId: string) => {
console.log("查看任务:", taskId);
// TODO: 跳转到任务详情页
};
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<PageHead
title="任务历史"
actions={
<Select
placeholder="按状态筛选"
value={statusFilter || undefined}
onChange={setStatusFilter}
allowClear
style={{ width: 150 }}
options={[
{ value: "pending", label: "等待中" },
{ value: "processing", label: "处理中" },
{ value: "completed", label: "已完成" },
{ value: "failed", label: "失败" },
]}
/>
}
/>
<div className="xx-history-page">
{/* ── 页面头部 ──────────────────────────────────────────── */}
<div className="xx-history-header">
<h2></h2>
<p></p>
</div>
{isLoading ? (
<div style={{ textAlign: "center", padding: 60 }}>
<Spin size="large" />
{/* ── Tab 切换 ──────────────────────────────────────────── */}
<div className="xx-history-tabs">
{tabs.map((tab) => (
<button
key={tab.key}
className={`xx-history-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => handleTabChange(tab.key)}
>
{tab.label}
<span className="xx-history-tab-count">{tabCounts[tab.key]}</span>
</button>
))}
</div>
{/* ── 任务列表 ──────────────────────────────────────────── */}
{paginatedTasks.length === 0 ? (
<div className="xx-history-empty">
<div className="xx-history-empty-icon">📭</div>
<h3></h3>
<p>
{activeTab === "all"
? "点击上方按钮开始创建任务"
: "当前分类下没有任务"}
</p>
</div>
) : filteredTasks.length === 0 ? (
<Empty description={statusFilter ? "没有匹配的任务" : "暂无任务记录"} />
) : (
<Table
columns={columns}
dataSource={filteredTasks}
rowKey="id"
pagination={{ pageSize: 20, showSizeChanger: true }}
size="small"
scroll={{ x: 700 }}
/>
<div className="xx-history-task-list">
{paginatedTasks.map((task) => (
<div key={task.id} className="xx-history-task-item">
{/* 任务信息 */}
<div className="xx-history-task-info">
<h4>{task.name}</h4>
<span>
{task.type} · {task.template}
</span>
</div>
{/* 状态标签 */}
<span
className={`xx-history-status xx-history-status--${task.status}`}
>
{statusLabel[task.status]}
</span>
{/* 时间区 */}
<div className="xx-history-task-time">
<span>{task.date}</span>
{task.status === "completed" && task.duration ? (
<span> {task.duration}</span>
) : task.status === "processing" ? (
<span>...</span>
) : task.status === "failed" ? (
<span></span>
) : (
<span></span>
)}
</div>
{/* 操作按钮 */}
<div className="xx-history-task-action">
{task.status === "failed" ? (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => handleRetry(task.id)}
>
</Button>
) : (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => handleView(task.id)}
>
</Button>
)}
</div>
</div>
))}
</div>
)}
{/* ── 分页 ──────────────────────────────────────────────── */}
{totalPages > 1 && (
<div className="xx-history-pagination">
<button
className="xx-history-page-btn"
disabled={currentPage === 1}
onClick={() => setCurrentPage(currentPage - 1)}
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
className={`xx-history-page-btn${currentPage === page ? " active" : ""}`}
onClick={() => setCurrentPage(page)}
>
{page}
</button>
))}
<button
className="xx-history-page-btn"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage(currentPage + 1)}
>
</button>
<span className="xx-history-page-info">
{filteredTasks.length}
</span>
</div>
)}
</div>
);
+335
View File
@@ -0,0 +1,335 @@
/**
* 任务历史页面 - V21 设计系统样式
* 页面头部 + 圆角胶囊 Tab 筛选 + 卡片式任务列表 + 分页 + 空状态
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
/* ============================================================
页面容器
============================================================ */
.xx-history-page {
min-height: 100%;
padding: var(--space-xl);
}
/* ============================================================
页面头部
============================================================ */
.xx-history-header {
margin-bottom: var(--space-lg);
}
.xx-history-header h2 {
margin: 0 0 4px;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-history-header p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* ============================================================
Tab 切换 — 圆角胶囊样式
============================================================ */
.xx-history-tabs {
display: flex;
gap: 6px;
margin-bottom: var(--space-lg);
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
}
.xx-history-tab {
padding: 8px 18px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: var(--transition-all);
white-space: nowrap;
user-select: none;
}
.xx-history-tab:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.xx-history-tab.active {
background: var(--primary-soft);
border-color: var(--primary-color);
color: var(--primary-color);
font-weight: 600;
}
.xx-history-tab-count {
margin-left: 4px;
font-size: 12px;
opacity: 0.75;
}
/* ============================================================
任务卡片列表
============================================================ */
.xx-history-task-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.xx-history-task-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 16px;
align-items: center;
transition: var(--transition-all);
}
.xx-history-task-item:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
/* 任务信息区 */
.xx-history-task-info h4 {
margin: 0 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.xx-history-task-info span {
font-size: 12px;
color: var(--text-secondary);
}
/* 状态标签 */
.xx-history-status {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 12px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.xx-history-status--processing {
color: var(--info-color, #0ea5e9);
background: var(--primary-soft);
border: 1px solid var(--color-primary-200, #c7d2fe);
}
.xx-history-status--completed {
color: var(--success-color);
background: var(--success-soft);
border: 1px solid var(--success-border);
}
.xx-history-status--failed {
color: var(--error-color);
background: var(--error-soft);
border: 1px solid var(--error-border);
}
.xx-history-status--pending {
color: var(--text-secondary);
background: var(--bg-secondary, #f1f5f9);
border: 1px solid var(--border-color);
}
/* 时间区 */
.xx-history-task-time {
text-align: right;
font-size: 12px;
color: var(--text-secondary);
min-width: 100px;
}
.xx-history-task-time span {
display: block;
margin-bottom: 2px;
}
/* 操作按钮 */
.xx-history-task-action {
min-width: 60px;
text-align: center;
}
/* ============================================================
分页
============================================================ */
.xx-history-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: var(--space-lg);
padding-top: var(--space-md);
}
.xx-history-page-btn {
min-width: 36px;
height: 36px;
padding: 0 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: var(--transition-all);
display: inline-flex;
align-items: center;
justify-content: center;
}
.xx-history-page-btn:hover:not(:disabled) {
border-color: var(--primary-color);
color: var(--primary-color);
}
.xx-history-page-btn.active {
background: var(--gradient-primary);
border-color: transparent;
color: var(--text-inverse);
font-weight: 600;
}
.xx-history-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.xx-history-page-info {
font-size: 13px;
color: var(--text-secondary);
margin: 0 8px;
}
/* ============================================================
空状态
============================================================ */
.xx-history-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.xx-history-empty-icon {
font-size: 56px;
margin-bottom: 16px;
opacity: 0.5;
}
.xx-history-empty h3 {
margin: 0 0 8px;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-history-empty p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1024px) {
.xx-history-page {
padding: var(--space-lg);
}
.xx-history-task-item {
grid-template-columns: 1fr auto auto;
gap: 12px;
}
.xx-history-task-time {
min-width: 80px;
}
}
@media (max-width: 768px) {
.xx-history-page {
padding: var(--space-md);
}
.xx-history-task-item {
grid-template-columns: 1fr;
gap: 10px;
}
.xx-history-task-time {
text-align: left;
}
.xx-history-task-action {
text-align: left;
}
}
@media (max-width: 480px) {
.xx-history-page {
padding: var(--space-sm);
}
.xx-history-header h2 {
font-size: var(--font-size-lg);
}
.xx-history-tabs {
gap: 4px;
padding-bottom: 8px;
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
}
.xx-history-tab {
padding: 6px 14px;
font-size: var(--font-size-xs);
flex-shrink: 0;
}
.xx-history-task-item {
padding: 12px;
gap: 8px;
}
.xx-history-task-info h4 {
font-size: 13px;
}
.xx-history-status {
font-size: 11px;
padding: 2px 10px;
}
.xx-history-page-btn {
min-width: 32px;
height: 32px;
font-size: 12px;
}
}
+341
View File
@@ -0,0 +1,341 @@
/**
* 首页落地页 — V21 Design System
*
* 5 个区域:HeroSection / FeatureSection / WorkflowSection
* / PricingSection / CTASection
*
* 零 antd 直接导入,全部使用 CSS 变量
*/
import React from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui";
import { useAuthStore } from "@/store/authStore";
import "./home-page.css";
/* ── HeroSection ─────────────────────────────────────────── */
const HeroSection: React.FC = () => {
const navigate = useNavigate();
return (
<section className="hp-hero">
<div className="hp-hero-inner">
{/* 左侧文案 */}
<div className="hp-hero-content">
<span className="hp-hero-tag">🦐 · AI智能视频创作平台</span>
<h1 className="hp-hero-title">
AI自动剪辑
<br />
</h1>
<p className="hp-hero-desc">
AI 30
</p>
<div className="hp-hero-actions">
<Button
buttonType="primary"
buttonSize="lg"
onClick={() => navigate("/register")}
>
</Button>
<Button
buttonType="secondary"
buttonSize="lg"
onClick={() => navigate("/pricing")}
>
</Button>
</div>
</div>
{/* 右侧视觉 */}
<div className="hp-hero-visual">
<div className="hp-hero-video">
<div className="hp-hero-video-inner">
<span className="hp-hero-video-placeholder">🎬</span>
</div>
<button
className="hp-hero-play"
type="button"
aria-label="播放演示视频"
>
</button>
</div>
<div className="hp-hero-info">
<div className="hp-hero-badges">
<span className="hp-hero-badge"> AI智能剪辑</span>
<span className="hp-hero-badge"> 30</span>
</div>
<span className="hp-hero-pill"></span>
</div>
</div>
</div>
</section>
);
};
/* ── FeatureSection ──────────────────────────────────────── */
const FEATURES = [
{
icon: "🤖",
title: "AI 智能剪辑",
desc: "自动识别视频高光片段,智能去除冗余内容,一键生成精彩短视频。",
},
{
icon: "🎙️",
title: "AI 配音克隆",
desc: "克隆您的声音,支持多种音色风格,自动生成自然流畅的配音。",
},
{
icon: "📝",
title: "智能字幕标题",
desc: "自动语音识别生成精准字幕,AI 创作吸睛标题,提升内容传播力。",
},
{
icon: "📱",
title: "多平台一键发布",
desc: "支持抖音、快手、小红书、微信视频号等主流平台,一键同步发布。",
},
];
const FeatureSection: React.FC = () => {
return (
<section className="hp-features">
<div className="hp-section-inner">
<h2 className="hp-section-title"></h2>
<p className="hp-section-desc">
AI
</p>
<div className="hp-feature-grid">
{FEATURES.map((f) => (
<div key={f.title} className="hp-feature-card">
<div className="hp-feature-icon">{f.icon}</div>
<h3 className="hp-feature-title">{f.title}</h3>
<p className="hp-feature-desc">{f.desc}</p>
</div>
))}
</div>
</div>
</section>
);
};
/* ── WorkflowSection ─────────────────────────────────────── */
const STEPS = [
{ icon: "📤", title: "上传素材", desc: "拖拽或选择视频素材,支持批量上传" },
{ icon: "🧠", title: "AI 处理", desc: "AI 自动分析、剪辑、配音、加字幕" },
{ icon: "👀", title: "预览调整", desc: "在线预览生成结果,支持微调编辑" },
{ icon: "🚀", title: "一键发布", desc: "多平台同步发布,追踪数据表现" },
];
const WorkflowSection: React.FC = () => {
return (
<section className="hp-workflow">
<div className="hp-section-inner">
<h2 className="hp-section-title"></h2>
<p className="hp-section-desc">
30
</p>
<div className="hp-step-grid">
{STEPS.map((step, idx) => (
<div key={step.title} className="hp-step-card">
<div className="hp-step-number">{idx + 1}</div>
<div className="hp-step-icon">{step.icon}</div>
<h3 className="hp-step-title">{step.title}</h3>
<p className="hp-step-desc">{step.desc}</p>
{idx < STEPS.length - 1 && <div className="hp-step-arrow"></div>}
</div>
))}
</div>
</div>
</section>
);
};
/* ── PricingSection ──────────────────────────────────────── */
const PLANS = [
{
name: "基础版",
price: "免费",
period: "",
desc: "适合个人体验,快速上手",
features: [
"每月 5 次 AI 生成",
"720p 视频导出",
"基础模板库",
"1 个平台账号绑定",
],
highlighted: false,
cta: "免费开始",
},
{
name: "专业版",
price: "¥99",
period: "/月",
desc: "适合内容创作者,高效产出",
features: [
"每月 100 次 AI 生成",
"1080p 视频导出",
"全部模板库",
"4 个平台账号绑定",
"AI 配音克隆",
"优先客服支持",
],
highlighted: true,
cta: "立即订阅",
},
{
name: "企业版",
price: "¥399",
period: "/月",
desc: "适合团队与企业,规模化运营",
features: [
"无限次 AI 生成",
"4K 视频导出",
"全部模板 + 定制模板",
"无限平台账号绑定",
"团队协作管理",
"API 接入支持",
"专属客户经理",
],
highlighted: false,
cta: "联系销售",
},
];
const PricingSection: React.FC = () => {
const navigate = useNavigate();
return (
<section className="hp-pricing">
<div className="hp-section-inner">
<h2 className="hp-section-title"></h2>
<p className="hp-section-desc"></p>
<div className="hp-pricing-grid">
{PLANS.map((plan) => (
<div
key={plan.name}
className={`hp-pricing-card${plan.highlighted ? " hp-pricing-card--highlight" : ""}`}
>
{plan.highlighted && <div className="hp-pricing-badge"></div>}
<h3 className="hp-pricing-name">{plan.name}</h3>
<div className="hp-pricing-price">
{plan.price}
{plan.period && (
<span className="hp-pricing-period">{plan.period}</span>
)}
</div>
<p className="hp-pricing-desc">{plan.desc}</p>
<ul className="hp-pricing-features">
{plan.features.map((f) => (
<li key={f}>
<span className="hp-pricing-check"></span>
{f}
</li>
))}
</ul>
<Button
buttonType={plan.highlighted ? "primary" : "secondary"}
onClick={() => navigate("/register")}
>
{plan.cta}
</Button>
</div>
))}
</div>
</div>
</section>
);
};
/* ── CTASection ──────────────────────────────────────────── */
const CTASection: React.FC = () => {
const navigate = useNavigate();
return (
<section className="hp-cta">
<div className="hp-cta-inner">
<h2 className="hp-cta-title"> AI </h2>
<p className="hp-cta-desc">
AI
</p>
<Button
buttonType="primary"
buttonSize="lg"
onClick={() => navigate("/register")}
>
</Button>
</div>
</section>
);
};
/* ── 主页面 ─────────────────────────────────────────────── */
const HomePage: React.FC = () => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const navigate = useNavigate();
// 已登录用户自动跳转到 dashboard
React.useEffect(() => {
if (isAuthenticated) {
navigate("/app/dashboard", { replace: true });
}
}, [isAuthenticated, navigate]);
return (
<div className="hp-page">
{/* 顶部导航栏 */}
<header className="hp-nav">
<div className="hp-nav-inner">
<button
className="hp-nav-brand"
type="button"
onClick={() => navigate("/")}
>
<span className="hp-nav-logo">🦐</span>
<span className="hp-nav-brand-text"></span>
</button>
<div className="hp-nav-actions">
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => navigate("/login")}
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
onClick={() => navigate("/register")}
>
</Button>
</div>
</div>
</header>
{/* 5 个区域 */}
<HeroSection />
<FeatureSection />
<WorkflowSection />
<PricingSection />
<CTASection />
{/* 底部 */}
<footer className="hp-footer">
<p>© 2026 · AI智能视频创作平台</p>
</footer>
</div>
);
};
export default HomePage;
+609
View File
@@ -0,0 +1,609 @@
/**
* 首页落地页 — V21 Design System
*
* 5 个区域:HeroSection / FeatureSection / WorkflowSection
* / PricingSection / CTASection
* 全部使用 CSS 变量,零 antd 直接导入
*/
/* ── 页面容器 ────────────────────────────────────────────── */
.hp-page {
min-height: 100vh;
background: var(--bg-base, #fafafa);
color: var(--text-primary, #1a1a2e);
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow-x: hidden;
}
/* ── 顶部导航 ────────────────────────────────────────────── */
.hp-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: var(--bg-glass, rgba(255, 255, 255, 0.72));
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--line, rgba(0, 0, 0, 0.06));
}
.hp-nav-inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.hp-nav-brand {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.hp-nav-logo {
font-size: 24px;
}
.hp-nav-brand-text {
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #1a1a2e);
}
.hp-nav-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* ── HeroSection ─────────────────────────────────────────── */
.hp-hero {
padding: 120px 24px 60px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--primary-color) 4%, transparent) 0%,
transparent 60%
);
}
.hp-hero-inner {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 420px;
gap: 48px;
align-items: center;
}
.hp-hero-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.hp-hero-tag {
display: inline-flex;
align-self: flex-start;
padding: 6px 16px;
border-radius: var(--radius-sm, 8px);
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
color: var(--primary, var(--primary-color));
font-size: 14px;
font-weight: 500;
}
.hp-hero-title {
font-size: 44px;
font-weight: 800;
line-height: 1.2;
color: var(--text-primary, #1a1a2e);
margin: 0;
letter-spacing: -0.02em;
}
.hp-hero-desc {
font-size: 17px;
line-height: 1.7;
color: var(--text-secondary, #6b7280);
margin: 0;
max-width: 520px;
}
.hp-hero-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
/* Hero 右侧视觉 */
.hp-hero-visual {
display: flex;
flex-direction: column;
gap: 16px;
}
.hp-hero-video {
position: relative;
border-radius: var(--radius-lg, 16px);
overflow: hidden;
background: linear-gradient(
135deg,
var(--primary-color) 0%,
var(--color-primary-400, #8b5cf6) 100%
);
aspect-ratio: 16 / 10;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 20px 60px
color-mix(in srgb, var(--primary-color) 18%, transparent);
}
.hp-hero-video-inner {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.hp-hero-video-placeholder {
font-size: 64px;
opacity: 0.6;
}
.hp-hero-play {
position: absolute;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--bg-elevated, rgba(255, 255, 255, 0.92));
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--primary, var(--primary-color));
box-shadow: 0 4px 20px var(--shadow-lg, rgba(0, 0, 0, 0.15));
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.hp-hero-play:hover {
transform: scale(1.08);
box-shadow: 0 6px 28px var(--shadow-xl, rgba(0, 0, 0, 0.2));
}
.hp-hero-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.hp-hero-badges {
display: flex;
gap: 8px;
}
.hp-hero-badge {
padding: 6px 14px;
border-radius: var(--radius-sm, 8px);
background: var(--bg-surface, #fff);
border: 1px solid var(--line, rgba(0, 0, 0, 0.06));
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1a1a2e);
white-space: nowrap;
}
.hp-hero-pill {
padding: 5px 14px;
border-radius: 20px;
background: color-mix(in srgb, var(--secondary-color) 10%, transparent);
color: var(--secondary-color, #059669);
font-size: 13px;
font-weight: 600;
}
/* ── 通用 Section 样式 ──────────────────────────────────── */
.hp-section-inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
.hp-section-title {
font-size: 32px;
font-weight: 700;
text-align: center;
margin: 0 0 12px;
color: var(--text-primary, #1a1a2e);
}
.hp-section-desc {
font-size: 16px;
text-align: center;
color: var(--text-secondary, #6b7280);
margin: 0 0 48px;
}
/* ── FeatureSection ──────────────────────────────────────── */
.hp-features {
padding: 80px 0;
}
.hp-feature-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.hp-feature-card {
padding: 32px 24px;
border-radius: var(--radius-lg, 16px);
background: var(--bg-surface, #fff);
border: 1px solid var(--line, rgba(0, 0, 0, 0.06));
text-align: center;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.hp-feature-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px var(--shadow-md, rgba(0, 0, 0, 0.08));
}
.hp-feature-icon {
width: 56px;
height: 56px;
margin: 0 auto 16px;
border-radius: var(--radius-md, 12px);
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
}
.hp-feature-title {
font-size: 17px;
font-weight: 600;
margin: 0 0 8px;
color: var(--text-primary, #1a1a2e);
}
.hp-feature-desc {
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary, #6b7280);
margin: 0;
}
/* ── WorkflowSection ─────────────────────────────────────── */
.hp-workflow {
padding: 80px 0;
background: linear-gradient(
180deg,
transparent 0%,
rgba(99, 102, 241, 0.03) 50%,
transparent 100%
);
}
.hp-step-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
position: relative;
}
.hp-step-card {
position: relative;
padding: 32px 24px;
border-radius: var(--radius-lg, 16px);
background: var(--bg-surface, #fff);
border: 1px solid var(--line, rgba(0, 0, 0, 0.06));
text-align: center;
}
.hp-step-number {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--primary, var(--primary-color));
color: var(--text-inverse);
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.hp-step-icon {
font-size: 36px;
margin: 8px 0 12px;
}
.hp-step-title {
font-size: 17px;
font-weight: 600;
margin: 0 0 8px;
color: var(--text-primary, #1a1a2e);
}
.hp-step-desc {
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary, #6b7280);
margin: 0;
}
.hp-step-arrow {
position: absolute;
right: -18px;
top: 50%;
transform: translateY(-50%);
font-size: 20px;
color: var(--text-secondary, #6b7280);
opacity: 0.4;
z-index: 1;
}
/* ── PricingSection ──────────────────────────────────────── */
.hp-pricing {
padding: 80px 0;
}
.hp-pricing-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
align-items: start;
}
.hp-pricing-card {
position: relative;
padding: 36px 28px;
border-radius: var(--radius-lg, 16px);
background: var(--bg-surface, #fff);
border: 1px solid var(--line, rgba(0, 0, 0, 0.06));
display: flex;
flex-direction: column;
gap: 16px;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.hp-pricing-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px var(--shadow-md, rgba(0, 0, 0, 0.08));
}
.hp-pricing-card--highlight {
border-color: var(--primary, var(--primary-color));
box-shadow: 0 8px 32px
color-mix(in srgb, var(--primary-color) 12%, transparent);
}
.hp-pricing-badge {
position: absolute;
top: -12px;
right: 20px;
padding: 4px 14px;
border-radius: 20px;
background: var(--primary, var(--primary-color));
color: var(--text-inverse);
font-size: 12px;
font-weight: 600;
}
.hp-pricing-name {
font-size: 20px;
font-weight: 600;
margin: 0;
color: var(--text-primary, #1a1a2e);
}
.hp-pricing-price {
font-size: 36px;
font-weight: 800;
color: var(--text-primary, #1a1a2e);
line-height: 1;
}
.hp-pricing-period {
font-size: 15px;
font-weight: 400;
color: var(--text-secondary, #6b7280);
}
.hp-pricing-desc {
font-size: 14px;
color: var(--text-secondary, #6b7280);
margin: 0;
}
.hp-pricing-features {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.hp-pricing-features li {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-primary, #1a1a2e);
}
.hp-pricing-check {
color: var(--secondary-color, #10b981);
font-weight: 700;
font-size: 14px;
}
/* ── CTASection ──────────────────────────────────────────── */
.hp-cta {
padding: 80px 24px;
}
.hp-cta-inner {
max-width: 680px;
margin: 0 auto;
text-align: center;
padding: 60px 40px;
border-radius: var(--radius-lg, 16px);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--primary-color) 6%, transparent) 0%,
color-mix(in srgb, var(--color-primary-400, #8b5cf6) 6%, transparent) 100%
);
border: 1px solid var(--line, rgba(0, 0, 0, 0.06));
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.hp-cta-title {
font-size: 30px;
font-weight: 700;
margin: 0;
color: var(--text-primary, #1a1a2e);
}
.hp-cta-desc {
font-size: 16px;
line-height: 1.6;
color: var(--text-secondary, #6b7280);
margin: 0;
}
/* ── 底部 ────────────────────────────────────────────────── */
.hp-footer {
padding: 32px 24px;
text-align: center;
border-top: 1px solid var(--line, rgba(0, 0, 0, 0.06));
}
.hp-footer p {
margin: 0;
font-size: 13px;
color: var(--text-secondary, #6b7280);
}
/* ── 响应式 768px ────────────────────────────────────────── */
@media (max-width: 768px) {
.hp-hero-inner {
grid-template-columns: 1fr;
gap: 32px;
}
.hp-hero-visual {
max-width: 400px;
margin: 0 auto;
}
.hp-hero-title {
font-size: 32px;
}
.hp-hero {
padding: 100px 24px 48px;
}
.hp-feature-grid {
grid-template-columns: repeat(2, 1fr);
}
.hp-step-grid {
grid-template-columns: repeat(2, 1fr);
}
.hp-step-arrow {
display: none;
}
.hp-pricing-grid {
grid-template-columns: 1fr;
max-width: 400px;
margin: 0 auto;
}
.hp-section-title {
font-size: 26px;
}
.hp-cta-inner {
padding: 40px 24px;
}
.hp-cta-title {
font-size: 24px;
}
}
/* ── 响应式 480px ────────────────────────────────────────── */
@media (max-width: 480px) {
.hp-hero-title {
font-size: 26px;
}
.hp-hero-desc {
font-size: 15px;
}
.hp-hero-actions {
flex-direction: column;
}
.hp-hero-badges {
flex-direction: column;
gap: 6px;
}
.hp-feature-grid {
grid-template-columns: 1fr;
}
.hp-step-grid {
grid-template-columns: 1fr;
}
.hp-nav-actions {
gap: 8px;
}
}
+372
View File
@@ -0,0 +1,372 @@
/**
* 我的音色页面 — V21 设计系统(任务 3.12 升级 / 3.15 进度轮询)
*
* 功能:克隆音色卡片列表、试听播放、状态标签、删除、编辑名称、空状态引导
* 使用 useCloneProgress hook 实现 processing 状态自动轮询
*/
import React, { useState, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
PlayCircleOutlined,
PauseCircleOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
SoundOutlined,
ClockCircleOutlined,
} from "@ant-design/icons";
import { Button, Modal, Input, Tooltip } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import { useCloneProgress } from "@/hooks/useCloneProgress";
import {
deleteVoiceClone,
updateVoiceClone,
formatDuration,
} from "@/api/voiceClone";
import type { VoiceClone, VoiceCloneStatus } from "@/api/voiceClone";
import "./my-voices.css";
/* ============================================================
* 工具函数
* ============================================================ */
function formatDate(isoStr: string): string {
const d = new Date(isoStr);
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
}
/* ============================================================
* 状态配置
* ============================================================ */
const STATUS_CONFIG: Record<VoiceCloneStatus, { label: string; dotClass: string }> = {
ready: { label: "就绪", dotClass: "xx-mv-status-dot--ready" },
processing: { label: "克隆中", dotClass: "xx-mv-status-dot--processing" },
failed: { label: "失败", dotClass: "xx-mv-status-dot--failed" },
};
/* ============================================================
* Toast 组件
* ============================================================ */
interface ToastItem {
id: number;
message: string;
type: "success" | "error";
}
let _toastId = 0;
/* ============================================================
* 音色卡片组件
* ============================================================ */
interface VoiceCardProps {
voice: VoiceClone;
isPlaying: boolean;
onTogglePlay: (voice: VoiceClone) => void;
onEdit: (voice: VoiceClone) => void;
onDelete: (voice: VoiceClone) => void;
}
const VoiceCard: React.FC<VoiceCardProps> = ({
voice,
isPlaying,
onTogglePlay,
onEdit,
onDelete,
}) => {
const statusCfg = STATUS_CONFIG[voice.status];
const isReady = voice.status === "ready";
return (
<div className={`xx-mv-card xx-mv-card--${voice.status}`}>
{/* 头部:头像 + 名称 + 状态 */}
<div className="xx-mv-card-header">
<div className={`xx-mv-card-avatar xx-mv-card-avatar--${voice.status}`}>
<SoundOutlined />
</div>
<div className="xx-mv-card-info">
<h4 className="xx-mv-card-name">{voice.name}</h4>
<span className="xx-mv-status">
<span className={`xx-mv-status-dot ${statusCfg.dotClass}`} />
{statusCfg.label}
</span>
</div>
</div>
{/* 元信息 */}
<div className="xx-mv-card-meta">
<span className="xx-mv-card-meta-item">
<ClockCircleOutlined /> {formatDate(voice.created_at)}
</span>
{voice.duration_seconds > 0 && (
<span className="xx-mv-card-meta-item">
{formatDuration(voice.duration_seconds)}
</span>
)}
</div>
{/* 进度条(克隆中 — indeterminate 条纹流动动画) */}
{voice.status === "processing" && (
<div className="xx-mv-progress xx-mv-progress--indeterminate">
<div className="xx-mv-progress-bar" />
<span className="xx-mv-progress-text"></span>
</div>
)}
{/* 操作区 */}
<div className="xx-mv-card-actions">
{isReady ? (
<Button
buttonType={isPlaying ? "secondary" : "ghost"}
buttonSize="sm"
onClick={() => onTogglePlay(voice)}
>
{isPlaying ? <><PauseCircleOutlined /> </> : <><PlayCircleOutlined /> </>}
</Button>
) : voice.status === "failed" ? (
<Button buttonType="ghost" buttonSize="sm" disabled>
</Button>
) : (
<Button buttonType="ghost" buttonSize="sm" disabled>
...
</Button>
)}
<div className="xx-mv-card-icon-actions">
<Tooltip title="编辑名称">
<button
type="button"
className="xx-mv-icon-btn"
onClick={() => onEdit(voice)}
disabled={!isReady}
>
<EditOutlined />
</button>
</Tooltip>
<Tooltip title="删除">
<button
type="button"
className="xx-mv-icon-btn xx-mv-icon-btn--danger"
onClick={() => onDelete(voice)}
>
<DeleteOutlined />
</button>
</Tooltip>
</div>
</div>
</div>
);
};
/* ============================================================
* 主页面组件
* ============================================================ */
const MyVoices: React.FC = () => {
const navigate = useNavigate();
const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress();
const [playingId, setPlayingId] = useState<string | null>(null);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(null);
const [editName, setEditName] = useState("");
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Toast
const showToast = useCallback((message: string, type: ToastItem["type"]) => {
const id = ++_toastId;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000);
}, []);
// 试听播放
const handleTogglePlay = useCallback((voice: VoiceClone) => {
if (playingId === voice.id) {
audioRef.current?.pause();
setPlayingId(null);
return;
}
if (audioRef.current) {
audioRef.current.pause();
}
// Mock: 使用 sample_url 或占位 URL
const url = voice.sample_url || `/mock/audio/clone-${voice.id}.mp3`;
const audio = new Audio(url);
audioRef.current = audio;
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"));
audio.onended = () => setPlayingId(null);
setPlayingId(voice.id);
}, [playingId, showToast]);
// 编辑
const handleEdit = (voice: VoiceClone) => {
setEditingVoice(voice);
setEditName(voice.name);
setEditModalOpen(true);
};
const handleEditConfirm = async () => {
if (!editingVoice || !editName.trim()) return;
try {
const updated = await updateVoiceClone(editingVoice.id, { name: editName.trim() });
updateClone(updated);
setEditModalOpen(false);
setEditingVoice(null);
showToast("名称已更新", "success");
} catch {
showToast("更新失败,请重试", "error");
}
};
// 删除
const handleDelete = (voice: VoiceClone) => {
setDeleteConfirmId(voice.id);
};
const handleDeleteConfirm = async () => {
if (!deleteConfirmId) return;
try {
await deleteVoiceClone(deleteConfirmId);
removeClone(deleteConfirmId);
setDeleteConfirmId(null);
showToast("音色已删除", "success");
} catch {
showToast("删除失败,请重试", "error");
}
};
// 克隆新音色
const handleCloneNew = () => {
navigate("/app/voices");
};
// 统计
const readyCount = clones.filter((v) => v.status === "ready").length;
const processingCount = clones.filter((v) => v.status === "processing").length;
return (
<div className="xx-mv-page">
<PageHead
title="我的音色"
description="管理你的克隆音色,试听并使用在视频配音中"
breadcrumb={[
{ label: "配音中心", path: "/app/voices" },
{ label: "我的音色" },
]}
actions={
<Button buttonType="primary" onClick={handleCloneNew}>
<PlusOutlined />
</Button>
}
/>
{/* 轮询提示 */}
{hasProcessing && (
<div className="xx-mv-polling-hint">
<span className="xx-mv-polling-dot" />
...
</div>
)}
{/* 统计栏 */}
{!loading && clones.length > 0 && (
<div className="xx-mv-stats">
<span className="xx-mv-stat">
<strong>{clones.length}</strong>
</span>
<span className="xx-mv-stat xx-mv-stat--ready">
<span className="xx-mv-stat-dot xx-mv-stat-dot--ready" />
{readyCount}
</span>
{processingCount > 0 && (
<span className="xx-mv-stat xx-mv-stat--processing">
<span className="xx-mv-stat-dot xx-mv-stat-dot--processing" />
{processingCount}
</span>
)}
</div>
)}
{/* 加载状态 */}
{loading && (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon"></div>
<p className="xx-mv-empty-desc">...</p>
</div>
)}
{/* 卡片网格 */}
{!loading && clones.length > 0 && (
<div className="xx-mv-grid">
{clones.map((voice) => (
<VoiceCard
key={voice.id}
voice={voice}
isPlaying={playingId === voice.id}
onTogglePlay={handleTogglePlay}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* 空状态 */}
{!loading && clones.length === 0 && (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon">🎤</div>
<h3 className="xx-mv-empty-title"></h3>
<p className="xx-mv-empty-desc">
AI
</p>
<Button buttonType="primary" onClick={handleCloneNew}>
<PlusOutlined />
</Button>
</div>
)}
{/* 编辑弹窗 */}
<Modal
open={editModalOpen}
title="编辑音色名称"
onCancel={() => setEditModalOpen(false)}
onOk={handleEditConfirm}
okText="保存"
cancelText="取消"
>
<Input
value={editName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEditName(e.target.value)}
placeholder="输入音色名称"
autoFocus
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Enter") handleEditConfirm();
}}
/>
</Modal>
{/* 删除确认弹窗 */}
<Modal
open={!!deleteConfirmId}
title="确认删除"
onCancel={() => setDeleteConfirmId(null)}
onOk={handleDeleteConfirm}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true } as any}
>
<p></p>
</Modal>
{/* Toast */}
{toasts.length > 0 && (
<div className="xx-mv-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`xx-mv-toast xx-mv-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)}
</div>
);
};
export default MyVoices;
+453
View File
@@ -0,0 +1,453 @@
/* ═══════════════════════════════════════════════════════════
我的音色页面 — V21 Design System(任务 3.12
命名前缀:xx-mv-
═══════════════════════════════════════════════════════════ */
/* ── 页面容器 ──────────────────────────────────────────── */
.xx-mv-page {
display: flex;
flex-direction: column;
gap: var(--space-md, 20px);
}
/* ── 轮询提示 ──────────────────────────────────────────── */
.xx-mv-polling-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 13px;
color: var(--accent-color, #f59e0b);
background: color-mix(in srgb, var(--accent-color, #f59e0b) 8%, transparent);
border-radius: var(--radius-md, 10px);
animation: xx-mv-polling-fade 2s ease-in-out infinite;
}
.xx-mv-polling-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-color, #f59e0b);
animation: xx-mv-blink 1.5s ease-in-out infinite;
}
@keyframes xx-mv-polling-fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ── 统计栏 ────────────────────────────────────────────── */
.xx-mv-stats {
display: flex;
align-items: center;
gap: var(--space-md, 16px);
padding: var(--space-sm, 12px) var(--space-md, 16px);
background: var(--bg-secondary, #f8fafc);
border-radius: var(--radius-md, 10px);
font-size: 13px;
color: var(--text-secondary, #64748b);
}
.xx-mv-stat {
display: inline-flex;
align-items: center;
gap: 6px;
}
.xx-mv-stat strong {
color: var(--text-primary, #1e293b);
font-weight: 600;
}
.xx-mv-stat-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.xx-mv-stat--ready {
color: var(--secondary-color, #10b981);
}
.xx-mv-stat--processing {
color: var(--accent-color, #f59e0b);
}
/* ── 卡片网格 ──────────────────────────────────────────── */
.xx-mv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: var(--space-md, 16px);
}
/* ── 卡片 ──────────────────────────────────────────────── */
.xx-mv-card {
position: relative;
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e2e8f0);
border-radius: var(--radius-lg, 14px);
padding: var(--space-md, 20px);
transition: border-color 0.2s, box-shadow 0.2s;
}
.xx-mv-card:hover {
border-color: var(--color-primary-400, #818cf8);
box-shadow: 0 4px 20px color-mix(in srgb, var(--primary-color, #4f46e5) 8%, transparent);
}
.xx-mv-card--processing {
border-color: color-mix(in srgb, var(--accent-color, #f59e0b) 30%, transparent);
}
.xx-mv-card--failed {
border-color: color-mix(in srgb, var(--error-color, #ef4444) 25%, transparent);
opacity: 0.85;
}
/* ── 卡片头部 ──────────────────────────────────────────── */
.xx-mv-card-header {
display: flex;
align-items: center;
gap: var(--space-sm, 14px);
margin-bottom: var(--space-sm, 14px);
}
.xx-mv-card-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 20px;
color: var(--text-inverse, #fff);
flex-shrink: 0;
background: linear-gradient(
135deg,
var(--secondary-color, #10b981),
var(--color-secondary-700, #059669)
);
}
.xx-mv-card-avatar--processing {
background: linear-gradient(
135deg,
var(--accent-color, #f59e0b),
var(--accent-dark, #d97706)
);
animation: xx-mv-pulse 2s ease-in-out infinite;
}
.xx-mv-card-avatar--failed {
background: linear-gradient(
135deg,
var(--color-gray-400, #94a3b8),
var(--color-gray-500, #64748b)
);
}
@keyframes xx-mv-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.xx-mv-card-info {
flex: 1;
min-width: 0;
}
.xx-mv-card-name {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
color: var(--text-primary, #1e293b);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── 状态标签 ──────────────────────────────────────────── */
.xx-mv-status {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #64748b);
}
.xx-mv-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.xx-mv-status-dot--ready {
color: var(--secondary-color, #10b981);
}
.xx-mv-status-dot--processing {
color: var(--accent-color, #f59e0b);
animation: xx-mv-blink 1.5s ease-in-out infinite;
}
.xx-mv-status-dot--failed {
color: var(--error-color, #ef4444);
}
@keyframes xx-mv-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── 元信息 ────────────────────────────────────────────── */
.xx-mv-card-meta {
display: flex;
align-items: center;
gap: var(--space-sm, 12px);
margin-bottom: var(--space-sm, 14px);
font-size: 13px;
color: var(--text-secondary, #64748b);
}
.xx-mv-card-meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* ── 进度条 ────────────────────────────────────────────── */
.xx-mv-progress {
position: relative;
height: 6px;
background: var(--bg-tertiary, #f1f5f9);
border-radius: 3px;
margin-bottom: var(--space-sm, 14px);
overflow: hidden;
}
.xx-mv-progress-bar {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
/* Indeterminate 态:条纹流动动画(后端无 progress 字段) */
.xx-mv-progress--indeterminate {
background: var(--bg-tertiary, #f1f5f9);
}
.xx-mv-progress--indeterminate .xx-mv-progress-bar {
width: 100%;
background: repeating-linear-gradient(
90deg,
var(--accent-color, #f59e0b) 0%,
var(--accent-color, #f59e0b) 25%,
var(--secondary-color, #10b981) 25%,
var(--secondary-color, #10b981) 50%,
var(--accent-color, #f59e0b) 50%
);
background-size: 60px 100%;
animation: xx-mv-progress-flow 1.2s linear infinite;
}
@keyframes xx-mv-progress-flow {
from { background-position: 0 0; }
to { background-position: 60px 0; }
}
.xx-mv-progress--indeterminate .xx-mv-progress-text {
animation: xx-mv-progress-pulse 1.5s ease-in-out infinite;
}
@keyframes xx-mv-progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.xx-mv-progress-text {
position: absolute;
right: 0;
top: -18px;
font-size: 11px;
color: var(--accent-color, #f59e0b);
font-weight: 600;
}
/* ── 操作区 ────────────────────────────────────────────── */
.xx-mv-card-actions {
display: flex;
align-items: center;
gap: var(--space-sm, 10px);
padding-top: var(--space-sm, 12px);
border-top: 1px solid var(--border-color, #e2e8f0);
}
.xx-mv-card-actions .xx-btn {
flex: 1;
}
.xx-mv-card-icon-actions {
display: flex;
gap: 4px;
}
.xx-mv-icon-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: none;
border-radius: var(--radius-sm, 8px);
background: transparent;
color: var(--text-secondary, #64748b);
font-size: 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.xx-mv-icon-btn:hover {
background: var(--bg-secondary, #f8fafc);
color: var(--text-primary, #1e293b);
}
.xx-mv-icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.xx-mv-icon-btn--danger:hover {
background: var(--error-soft, #fef2f2);
color: var(--error-color, #ef4444);
}
/* ── 空状态 ────────────────────────────────────────────── */
.xx-mv-empty {
margin-top: var(--space-lg, 40px);
text-align: center;
padding: 60px var(--space-md, 24px);
background: var(--bg-card, #fff);
border: 1px dashed var(--border-color, #e2e8f0);
border-radius: var(--radius-lg, 14px);
}
.xx-mv-empty-icon {
font-size: 64px;
margin-bottom: var(--space-md, 16px);
line-height: 1;
}
.xx-mv-empty-title {
margin: 0 0 var(--space-sm, 8px);
font-size: 18px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.xx-mv-empty-desc {
margin: 0 0 var(--space-md, 24px);
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── Toast ─────────────────────────────────────────────── */
.xx-mv-toast-container {
position: fixed;
top: 80px;
right: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-mv-toast {
padding: 10px 16px;
border-radius: var(--radius-md, 10px);
font-size: 13px;
font-weight: 500;
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e2e8f0);
box-shadow: 0 4px 12px var(--shadow-sm, rgba(0, 0, 0, 0.08));
animation: xx-mv-toast-in 0.25s ease-out;
display: flex;
align-items: center;
gap: 8px;
}
.xx-mv-toast--success {
border-color: var(--secondary-color, #10b981);
color: var(--secondary-color, #10b981);
}
.xx-mv-toast--error {
border-color: var(--error-color, #ef4444);
color: var(--error-color, #ef4444);
}
@keyframes xx-mv-toast-in {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* ── 响应式 ────────────────────────────────────────────── */
@media (max-width: 1200px) {
.xx-mv-grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
}
@media (max-width: 768px) {
.xx-mv-grid {
grid-template-columns: 1fr;
}
.xx-mv-stats {
flex-wrap: wrap;
gap: var(--space-sm, 8px);
}
.xx-mv-empty {
padding: 40px var(--space-md, 16px);
}
.xx-mv-empty-icon {
font-size: 48px;
}
}
@media (max-width: 480px) {
.xx-mv-card {
padding: var(--space-sm, 14px);
}
.xx-mv-card-avatar {
width: 40px;
height: 40px;
font-size: 17px;
}
.xx-mv-card-actions {
flex-wrap: wrap;
}
}
File diff suppressed because it is too large Load Diff
+705
View File
@@ -0,0 +1,705 @@
/**
* 成片库页面 - V21 设计系统样式
* 卡片网格布局,支持批量操作、视频播放弹窗
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
/* ============================================================
页面容器
============================================================ */
.xx-products-page {
min-height: 100%;
padding: var(--space-xl);
}
/* ============================================================
页面头部
============================================================ */
.xx-products-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-lg);
}
.xx-products-header h2 {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
/* ============================================================
批量操作栏
============================================================ */
.xx-products-batch-bar {
display: flex;
align-items: center;
gap: var(--space-md);
padding: 12px 16px;
background: var(--primary-soft);
border: 1px solid var(--primary-color);
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
animation: batch-bar-in 0.2s ease-out;
}
@keyframes batch-bar-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.xx-products-batch-bar-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.xx-products-batch-bar-right {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-left: auto;
}
.xx-products-batch-count {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--primary-color);
}
/* 全选复选框 */
.xx-products-select-all {
display: flex;
align-items: center;
gap: var(--space-xs);
cursor: pointer;
font-size: var(--font-size-sm);
color: var(--text-secondary);
user-select: none;
}
.xx-products-checkbox {
width: 18px;
height: 18px;
border: 2px solid var(--border-color);
border-radius: var(--radius-xs);
display: grid;
place-items: center;
transition: var(--transition-all);
background: var(--bg-primary);
flex-shrink: 0;
}
.xx-products-checkbox:hover {
border-color: var(--primary-color);
}
.xx-products-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
/* ============================================================
筛选栏
============================================================ */
.xx-products-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
margin-bottom: var(--space-lg);
}
.xx-products-filters-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.xx-products-filters-right {
display: flex;
align-items: center;
gap: var(--space-sm);
}
/* ============================================================
卡片网格(4列)
============================================================ */
.xx-products-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
/* ============================================================
成片卡片
============================================================ */
.xx-product-card {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius-md);
overflow: hidden;
transition: var(--transition-all);
position: relative;
cursor: pointer;
}
.xx-product-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
transform: translateY(-2px);
}
/* 选中状态 */
.xx-product-card.selected {
border-color: var(--primary-color);
box-shadow:
0 0 0 2px var(--primary-soft),
var(--shadow-sm);
}
/* 已发布状态 - 降低透明度 */
.xx-product-card.published {
opacity: 0.65;
}
.xx-product-card.published:hover {
opacity: 0.85;
}
/* 卡片复选框(左上角) */
.xx-product-card-checkbox {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
width: 22px;
height: 22px;
border: 2px solid rgba(255, 255, 255, 0.8);
border-radius: var(--radius-xs);
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
cursor: pointer;
transition: var(--transition-all);
color: #fff;
font-size: 12px;
}
.xx-product-card-checkbox:hover {
border-color: var(--primary-color);
background: rgba(0, 0, 0, 0.5);
}
.xx-product-card-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
/* 已发布徽章(右上角) */
.xx-product-badge {
position: absolute;
top: 10px;
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);
background: rgba(16, 185, 129, 0.9);
color: #fff;
backdrop-filter: blur(4px);
}
/* 缩略图区域 */
.xx-product-thumb {
aspect-ratio: 9 / 16;
max-height: 220px;
position: relative;
overflow: hidden;
display: grid;
place-items: center;
color: #fff;
}
.xx-product-thumb-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
}
.xx-product-play {
position: relative;
z-index: 1;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
display: grid;
place-items: center;
font-size: 18px;
transition: var(--transition-all);
}
.xx-product-card:hover .xx-product-play {
background: var(--primary-color);
transform: scale(1.1);
}
/* 时长标签 */
.xx-product-duration {
position: absolute;
bottom: 8px;
right: 8px;
z-index: 1;
padding: 2px 8px;
border-radius: var(--radius-xs);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
background: rgba(0, 0, 0, 0.65);
color: #fff;
backdrop-filter: blur(4px);
}
/* 卡片信息区 */
.xx-product-info {
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-product-title {
margin: 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.xx-product-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
}
/* 状态标签 */
.xx-product-status {
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.xx-product-status.completed {
background: #ecfdf5;
color: #059669;
}
.xx-product-status.processing {
background: var(--primary-soft);
color: var(--primary-color);
}
.xx-product-status.review {
background: #fefce8;
color: #ca8a04;
}
.xx-product-status.failed {
background: #fef2f2;
color: #dc2626;
}
/* 深色模式状态标签 */
.dark .xx-product-status.completed,
[data-theme="dark"] .xx-product-status.completed {
background: rgba(5, 150, 105, 0.15);
color: #34d399;
}
.dark .xx-product-status.processing,
[data-theme="dark"] .xx-product-status.processing {
background: var(--primary-soft);
color: var(--primary-color);
}
.dark .xx-product-status.review,
[data-theme="dark"] .xx-product-status.review {
background: rgba(202, 138, 4, 0.15);
color: #facc15;
}
.dark .xx-product-status.failed,
[data-theme="dark"] .xx-product-status.failed {
background: rgba(220, 38, 38, 0.15);
color: #f87171;
}
/* 日期 */
.xx-product-date {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
/* 查重率 */
.xx-product-dup-rate {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.xx-product-dup-rate.good {
color: #059669;
}
.xx-product-dup-rate.warn {
color: #ca8a04;
}
.xx-product-dup-rate.bad {
color: #dc2626;
}
/* 操作按钮区 */
.xx-product-actions {
display: flex;
gap: 6px;
padding: 0 14px 14px;
}
.xx-product-action-btn {
flex: 1;
padding: 6px 4px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
}
.xx-product-action-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: var(--primary-soft);
}
.xx-product-action-btn.primary {
background: var(--gradient-primary);
color: #fff;
border: none;
}
.xx-product-action-btn.primary:hover {
box-shadow: var(--shadow-primary);
transform: translateY(-1px);
}
.xx-product-action-btn.success {
background: #059669;
color: #fff;
border: none;
}
.xx-product-action-btn.success:hover {
background: #047857;
}
.xx-product-action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* ============================================================
视频播放弹窗
============================================================ */
.xx-player-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
display: grid;
place-items: center;
animation: player-fade-in 0.25s ease-out;
}
@keyframes player-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.xx-player-container {
width: 90%;
max-width: 540px;
background: var(--bg-primary);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.3);
animation: player-scale-in 0.25s ease-out;
}
@keyframes player-scale-in {
from {
opacity: 0;
transform: scale(0.92);
}
to {
opacity: 1;
transform: scale(1);
}
}
.xx-player-video-wrap {
position: relative;
background: #000;
aspect-ratio: 9 / 16;
max-height: 60vh;
display: grid;
place-items: center;
}
.xx-player-video-wrap video {
width: 100%;
height: 100%;
object-fit: contain;
}
/* 播放/暂停大按钮 */
.xx-player-play-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 64px;
height: 64px;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
color: #fff;
font-size: 28px;
display: grid;
place-items: center;
cursor: pointer;
border: none;
transition: var(--transition-all);
z-index: 2;
}
.xx-player-play-btn:hover {
background: var(--primary-color);
transform: translate(-50%, -50%) scale(1.1);
}
/* 进度条 */
.xx-player-progress-wrap {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px 16px 12px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
z-index: 2;
}
.xx-player-progress {
height: 4px;
background: rgba(255, 255, 255, 0.25);
border-radius: var(--radius-full);
overflow: hidden;
cursor: pointer;
}
.xx-player-progress-bar {
height: 100%;
background: var(--gradient-primary);
border-radius: var(--radius-full);
transition: width 0.1s linear;
}
.xx-player-time {
display: flex;
justify-content: space-between;
font-size: var(--font-size-xs);
color: rgba(255, 255, 255, 0.8);
margin-top: 4px;
}
/* 弹窗信息区 */
.xx-player-info {
padding: 16px;
}
.xx-player-title {
margin: 0 0 10px;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-player-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.xx-player-detail-item {
font-size: var(--font-size-sm);
}
.xx-player-detail-label {
color: var(--text-tertiary);
}
.xx-player-detail-value {
color: var(--text-primary);
font-weight: var(--font-weight-medium);
}
/* 弹窗底部操作 */
.xx-player-footer {
display: flex;
gap: var(--space-sm);
padding: 0 16px 16px;
}
/* 关闭按钮 */
.xx-player-close {
position: absolute;
top: 12px;
right: 12px;
z-index: 3;
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
color: #fff;
font-size: 16px;
display: grid;
place-items: center;
cursor: pointer;
border: none;
transition: var(--transition-all);
}
.xx-player-close:hover {
background: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
/* ============================================================
空状态
============================================================ */
.xx-products-empty {
text-align: center;
padding: var(--space-3xl) var(--space-xl);
color: var(--text-secondary);
}
.xx-products-empty-icon {
font-size: 48px;
margin-bottom: var(--space-md);
opacity: 0.5;
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1400px) {
.xx-products-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 992px) {
.xx-products-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.xx-products-page {
padding: var(--space-md);
}
.xx-products-filters {
flex-direction: column;
align-items: stretch;
}
.xx-products-filters-left,
.xx-products-filters-right {
justify-content: flex-start;
}
.xx-products-batch-bar {
flex-direction: column;
align-items: flex-start;
}
.xx-products-batch-bar-right {
margin-left: 0;
width: 100%;
}
}
@media (max-width: 576px) {
.xx-products-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.xx-product-info {
padding: 10px;
}
.xx-product-actions {
padding: 0 10px 10px;
flex-direction: column;
}
.xx-player-container {
width: 95%;
}
}
@media (max-width: 480px) {
.xx-products-page {
padding: var(--space-sm);
}
.xx-products-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-sm);
}
}
+48 -35
View File
@@ -1,61 +1,74 @@
/**
* 个人设置页面
* P1-2: 添加 PageHead
* P1-3: antd Form/Input/Button/Alert → 自定义 UI 组件
*/
import React from "react";
import { Form, Input, Button, Alert } from "antd";
import React, { useState } from "react";
import { Button, Input, Modal } from "@/components/ui";
import { useAuthStore } from "@/store/authStore";
import PageHead from "@/components/layout/PageHead";
import "./ProfileSettings.css";
const Settings: React.FC = () => {
const user = useAuthStore((state) => state.user);
const [form] = Form.useForm();
const [displayName, setDisplayName] = useState(user?.display_name || "");
const onFinish = () => undefined;
const handleSave = () => {
Modal.info({
title: "提示",
content: "个人资料修改接口暂未开放,保存功能即将上线。",
});
};
return (
<div className="xx-settings-page">
<PageHead title="个人设置" description="管理您的账户信息" />
<div className="xx-settings-card">
<h3></h3>
<Alert
type="info"
showIcon
style={{ marginBottom: 20 }}
message="个人资料编辑暂未开放"
description="当前仅展示登录用户信息,资料修改接口接入后再开放保存。"
/>
<Form
form={form}
layout="vertical"
onFinish={onFinish}
initialValues={{
username: user?.username,
email: user?.email,
display_name: user?.display_name,
}}
>
<Form.Item label="用户名" name="username">
<Input disabled />
</Form.Item>
<div className="xx-settings-notice">
<span className="xx-settings-notice-icon"></span>
<div>
<strong></strong>
<p></p>
</div>
</div>
<Form.Item label="邮箱" name="email">
<Input disabled />
</Form.Item>
<div className="xx-settings-form">
<div className="xx-settings-field">
<label className="xx-settings-label"></label>
<Input value={user?.username || ""} disabled placeholder="用户名" />
</div>
<Form.Item label="显示名称" name="display_name">
<Input />
</Form.Item>
<div className="xx-settings-field">
<label className="xx-settings-label"></label>
<Input value={user?.email || ""} disabled placeholder="邮箱" />
</div>
<Form.Item>
<Button type="primary" htmlType="submit" disabled>
<div className="xx-settings-field">
<label className="xx-settings-label"></label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="请输入显示名称"
/>
</div>
<div className="xx-settings-field">
<Button
buttonType="primary"
buttonSize="md"
onClick={handleSave}
disabled
>
</Button>
</Form.Item>
</Form>
</div>
</div>
</div>
</div>
);
};
export default Settings;
export const Component = Settings;
+174 -72
View File
@@ -1,128 +1,230 @@
/* 账单管理页面 */
/**
* 账单管理页面样式
* P1-3: 全部使用 CSS 变量 + 新增 ToggleSwitch/Spinner 样式
*/
.xx-billing-page {
max-width: 960px;
max-width: 800px;
margin: 0 auto;
padding: 40px 24px;
}
/* 订阅概览 */
.xx-billing-overview {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 20px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
padding: 32px;
margin-bottom: 40px;
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
}
.xx-billing-overview h2 {
font-size: 24px;
font-weight: 800;
color: var(--slate, #0f172a);
margin: 0 0 24px;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 20px;
}
.xx-overview-details {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 24px;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
@media (max-width: 768px) {
@media (max-width: 640px) {
.xx-overview-details {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: 1fr;
}
}
.xx-overview-item {
display: flex;
flex-direction: column;
gap: 6px;
gap: 4px;
}
.xx-label {
font-size: 13px;
color: var(--muted, #64748b);
color: var(--text-secondary);
font-weight: 500;
}
.xx-value {
font-size: 16px;
font-weight: 700;
color: var(--slate, #0f172a);
color: var(--text-primary);
font-weight: 600;
}
/* 账单记录 */
.xx-billing-history {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 20px;
/* 自动续费 */
.xx-billing-auto-renew {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
padding: 32px;
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
box-shadow: var(--shadow-sm);
}
.xx-billing-history h2 {
font-size: 24px;
font-weight: 800;
color: var(--slate, #0f172a);
margin: 0 0 24px;
}
/* 表格样式 */
.xx-billing-table {
width: 100%;
}
.xx-table-header {
display: grid;
grid-template-columns: 1.2fr 1fr 0.8fr 1fr 0.8fr 1fr;
gap: 12px;
padding: 12px 16px;
background: rgba(241, 245, 249, 0.8);
border-radius: 10px;
margin-bottom: 8px;
font-size: 13px;
.xx-billing-auto-renew h2 {
font-size: 20px;
font-weight: 700;
color: var(--muted, #64748b);
color: var(--text-primary);
margin: 0 0 20px;
}
.xx-table-row {
display: grid;
grid-template-columns: 1.2fr 1fr 0.8fr 1fr 0.8fr 1fr;
gap: 12px;
padding: 14px 16px;
.xx-auto-renew-row {
display: flex;
align-items: center;
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
font-size: 14px;
color: var(--slate, #0f172a);
transition: background 0.15s;
justify-content: space-between;
gap: 24px;
}
.xx-table-row:hover {
background: rgba(241, 245, 249, 0.4);
.xx-auto-renew-info {
flex: 1;
}
.xx-table-row:last-child {
border-bottom: none;
.xx-auto-renew-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.xx-amount {
font-weight: 700;
color: var(--indigo, #4f46e5);
.xx-auto-renew-desc {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
/* ── ToggleSwitch 组件 ───────────────────────────────── */
.xx-toggle-switch {
position: relative;
display: inline-flex;
align-items: center;
width: 44px;
height: 24px;
padding: 0;
border: none;
border-radius: 12px;
background: var(--color-gray-300);
cursor: pointer;
transition: background 0.2s ease;
flex-shrink: 0;
}
.xx-toggle-switch.checked {
background: var(--primary-color);
}
.xx-toggle-switch.loading {
opacity: 0.65;
cursor: not-allowed;
}
.xx-toggle-switch-handle {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bg-primary);
box-shadow: var(--shadow-xs);
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.xx-toggle-switch.checked .xx-toggle-switch-handle {
transform: translateX(20px);
}
.xx-toggle-switch-inner {
position: absolute;
width: 100%;
text-align: center;
font-size: 0;
/* 文字不显示,仅保留 aria 语义 */
}
.xx-toggle-switch-spinner {
width: 12px;
height: 12px;
border: 2px solid var(--primary-color);
border-top-color: transparent;
border-radius: 50%;
animation: xx-toggle-spin 0.6s linear infinite;
}
@keyframes xx-toggle-spin {
to {
transform: rotate(360deg);
}
}
/* ── Spinner 组件 ───────────────────────────────── */
.xx-spinner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px 0;
}
.xx-spinner-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--primary-color);
animation: xx-spinner-bounce 1.2s ease-in-out infinite;
}
.xx-spinner-dot:nth-child(2) {
animation-delay: 0.15s;
}
.xx-spinner-dot:nth-child(3) {
animation-delay: 0.3s;
}
.xx-spinner--small .xx-spinner-dot {
width: 6px;
height: 6px;
}
@keyframes xx-spinner-bounce {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@media (max-width: 768px) {
.xx-table-header,
.xx-table-row {
grid-template-columns: 1fr 1fr 1fr;
.billing-table {
font-size: var(--font-size-sm);
}
.billing-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-sm);
}
}
@media (max-width: 480px) {
.billing-table {
font-size: 12px;
}
.xx-table-header span:nth-child(4),
.xx-table-header span:nth-child(6),
.xx-table-row span:nth-child(4),
.xx-table-row span:nth-child(6) {
display: none;
.billing-row {
padding: var(--space-sm) var(--space-xs);
}
}
+43 -3
View File
@@ -1,11 +1,13 @@
/**
* 账单管理页面
* 展示当前订阅信息 + 自动续费开关
* P1-3: antd Switch→自定义ToggleSwitch, antd Spin→自定义Spinner
*/
import React, { useState, useEffect } from "react";
import { Switch, message, Spin } from "antd";
import { message } from "antd";
import { getCurrentSubscription, toggleAutoRenew } from "@/api/subscription";
import type { SubscriptionInfo } from "@/api/subscription";
import PageHead from "@/components/layout/PageHead";
import "./Billing.css";
const formatDate = (iso: string): string => {
@@ -17,6 +19,42 @@ const formatDate = (iso: string): string => {
});
};
/** 自定义 ToggleSwitch 组件 */
const ToggleSwitch: React.FC<{
checked: boolean;
onChange: (checked: boolean) => void;
loading?: boolean;
checkedChildren?: string;
unCheckedChildren?: string;
}> = ({ checked, onChange, loading, checkedChildren, unCheckedChildren }) => (
<button
type="button"
className={`xx-toggle-switch ${checked ? "checked" : ""} ${loading ? "loading" : ""}`}
onClick={() => !loading && onChange(!checked)}
disabled={loading}
role="switch"
aria-checked={checked}
>
<span className="xx-toggle-switch-handle">
{loading && <span className="xx-toggle-switch-spinner" />}
</span>
<span className="xx-toggle-switch-inner">
{checked ? checkedChildren : unCheckedChildren}
</span>
</button>
);
/** 自定义 Spinner 组件 */
const Spinner: React.FC<{ size?: "small" | "large" }> = ({
size = "large",
}) => (
<div className={`xx-spinner xx-spinner--${size}`}>
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
</div>
);
const Billing: React.FC = () => {
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(
null,
@@ -62,13 +100,15 @@ const Billing: React.FC = () => {
if (loading) {
return (
<div className="xx-billing-page">
<Spin size="large" />
<Spinner size="large" />
</div>
);
}
return (
<div className="xx-billing-page">
<PageHead title="账单管理" description="管理您的订阅和账单信息" />
{subscription && (
<>
{/* 当前订阅概览 */}
@@ -104,7 +144,7 @@ const Billing: React.FC = () => {
</p>
</div>
<Switch
<ToggleSwitch
checked={autoRenewChecked}
onChange={handleToggleAutoRenew}
loading={autoRenewLoading}
+66 -35
View File
@@ -1,4 +1,4 @@
/* V21 定价页面 */
/* V21 定价页面 - P1-2/1-5: CSS 变量替换 */
.xx-plans-page {
max-width: 1200px;
margin: 0 auto;
@@ -14,14 +14,14 @@
.xx-page-head h2 {
font-size: 42px;
font-weight: 900;
color: var(--slate, #0f172a);
color: var(--text-primary);
margin: 0 0 16px;
letter-spacing: -0.02em;
}
.xx-page-head p {
font-size: 18px;
color: var(--muted, #64748b);
color: var(--text-secondary);
margin: 0;
max-width: 560px;
margin-left: auto;
@@ -53,10 +53,10 @@
/* V21 定价卡片 */
.xx-plan-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 28px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
box-shadow: var(--shadow-card);
padding: 32px 28px;
position: relative;
transition: all 0.3s;
@@ -65,16 +65,17 @@
}
.xx-plan-card:hover {
border-color: var(--indigo, #4f46e5);
box-shadow: 0 32px 90px rgba(79, 70, 229, 0.15);
border-color: var(--primary-color);
box-shadow: 0 32px 90px
color-mix(in srgb, var(--primary-color) 15%, transparent);
transform: translateY(-4px);
}
.xx-plan-card.featured {
border: 2px solid var(--indigo, #4f46e5);
border: 2px solid var(--primary-color);
box-shadow:
0 0 0 4px rgba(79, 70, 229, 0.1),
0 32px 90px rgba(79, 70, 229, 0.15);
0 0 0 4px color-mix(in srgb, var(--primary-color) 10%, transparent),
0 32px 90px color-mix(in srgb, var(--primary-color) 15%, transparent);
transform: scale(1.02);
}
@@ -88,8 +89,12 @@
top: -12px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
background: linear-gradient(
135deg,
var(--color-primary-500),
var(--primary-color)
);
color: var(--text-inverse);
padding: 6px 16px;
border-radius: 999px;
font-size: 12px;
@@ -99,14 +104,14 @@
/* 企业版定制标签 */
.xx-badge.enterprise {
background: linear-gradient(135deg, #f59e0b, #d97706);
background: linear-gradient(135deg, var(--accent-color), var(--accent-dark));
}
/* 套餐名称 */
.xx-plan-card h3 {
font-size: 22px;
font-weight: 900;
color: var(--slate, #0f172a);
color: var(--text-primary);
margin: 0 0 20px;
text-align: center;
}
@@ -120,20 +125,20 @@
.xx-plan-price .currency {
font-size: 18px;
font-weight: 700;
color: var(--muted, #64748b);
color: var(--text-secondary);
vertical-align: top;
}
.xx-plan-price .amount {
font-size: 48px;
font-weight: 900;
color: var(--indigo, #4f46e5);
color: var(--primary-color);
line-height: 1;
}
.xx-plan-price .period {
font-size: 16px;
color: var(--muted, #64748b);
color: var(--text-secondary);
font-weight: 500;
}
@@ -144,12 +149,12 @@
/* 描述 */
.xx-plan-description {
color: var(--muted, #64748b);
color: var(--text-secondary);
font-size: 14px;
text-align: center;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--line, #e2e8f0);
border-bottom: 1px solid var(--border-color);
}
/* 功能列表 */
@@ -163,14 +168,14 @@
display: flex;
gap: 12px;
align-items: flex-start;
color: var(--muted, #64748b);
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 16px;
line-height: 1.5;
}
.xx-feature span:first-child {
color: var(--green, #10b981);
color: var(--secondary-color);
font-weight: 900;
flex-shrink: 0;
}
@@ -182,37 +187,63 @@
padding: 14px 24px !important;
font-size: 15px !important;
font-weight: 850 !important;
border-radius: 14px !important;
border-radius: var(--radius-md) !important;
transition: all 0.2s !important;
cursor: pointer;
border: none;
}
.xx-subscribe-btn.primary {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
color: white !important;
background: linear-gradient(
135deg,
var(--color-primary-500),
var(--primary-color)
) !important;
color: var(--text-inverse) !important;
border: none !important;
box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important;
box-shadow: var(--shadow-primary) !important;
}
.xx-subscribe-btn.primary:hover {
box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important;
box-shadow: var(--shadow-hover) !important;
transform: translateY(-1px);
}
.xx-subscribe-btn.ghost {
background: white !important;
border: 1px solid var(--line, #e2e8f0) !important;
color: var(--slate, #0f172a) !important;
background: var(--bg-primary) !important;
border: 1px solid var(--border-color) !important;
color: var(--text-primary) !important;
}
.xx-subscribe-btn.ghost:hover {
border-color: var(--indigo, #4f46e5) !important;
color: var(--indigo, #4f46e5) !important;
border-color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
/* 企业版按钮 */
.xx-subscribe-btn.enterprise {
background: linear-gradient(135deg, #f59e0b, #d97706) !important;
color: white !important;
background: linear-gradient(
135deg,
var(--accent-color),
var(--accent-dark)
) !important;
color: var(--text-inverse) !important;
border: none !important;
box-shadow: 0 14px 26px rgba(245, 158, 11, 0.22) !important;
box-shadow: 0 14px 26px
color-mix(in srgb, var(--accent-color) 22%, transparent) !important;
}
@media (max-width: 480px) {
.plans-grid {
grid-template-columns: 1fr;
gap: var(--space-md);
}
.plan-card {
padding: var(--space-lg);
}
.plan-price {
font-size: 1.75rem;
}
}
@@ -1,28 +1,10 @@
/* 升级/降级页面 */
/* 升级/降级页面 - P1-3: 全部使用 CSS 变量 */
.xx-upgrade-page {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px;
}
.xx-upgrade-header {
text-align: center;
margin-bottom: 40px;
}
.xx-upgrade-header h2 {
font-size: 32px;
font-weight: 900;
color: var(--slate, #0f172a);
margin: 0 0 8px;
}
.xx-upgrade-header p {
font-size: 16px;
color: var(--muted, #64748b);
margin: 0;
}
/* 套餐卡片 */
.xx-upgrade-plans {
display: grid;
@@ -38,8 +20,8 @@
}
.xx-upgrade-card {
background: rgba(255, 255, 255, 0.94);
border: 2px solid rgba(226, 232, 240, 0.95);
background: var(--bg-elevated);
border: 2px solid var(--border-color);
border-radius: 20px;
padding: 28px 24px;
text-align: center;
@@ -49,27 +31,28 @@
}
.xx-upgrade-card:hover {
border-color: var(--indigo, #4f46e5);
box-shadow: 0 8px 30px rgba(79, 70, 229, 0.1);
border-color: var(--primary-color);
box-shadow: 0 8px 30px
color-mix(in srgb, var(--primary-color) 10%, transparent);
}
.xx-upgrade-card.selected {
border-color: var(--indigo, #4f46e5);
border-color: var(--primary-color);
box-shadow:
0 0 0 3px rgba(79, 70, 229, 0.12),
0 8px 30px rgba(79, 70, 229, 0.1);
0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent),
0 8px 30px color-mix(in srgb, var(--primary-color) 10%, transparent);
}
.xx-upgrade-card.current {
border-color: var(--green, #10b981);
border-color: var(--secondary-color);
}
.xx-current-badge {
position: absolute;
top: -10px;
right: 16px;
background: var(--green, #10b981);
color: white;
background: var(--secondary-color);
color: var(--text-inverse);
padding: 3px 12px;
border-radius: 999px;
font-size: 12px;
@@ -79,7 +62,7 @@
.xx-upgrade-card h3 {
font-size: 20px;
font-weight: 800;
color: var(--slate, #0f172a);
color: var(--text-primary);
margin: 0 0 16px;
}
@@ -87,14 +70,48 @@
margin-bottom: 8px;
}
/* ── BillingCycleSwitch 组件 ──────────────────────── */
.xx-billing-cycle-switch {
display: inline-flex;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
}
.xx-billing-cycle-btn {
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
border: none;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.xx-billing-cycle-btn:first-child {
border-right: 1px solid var(--border-color);
}
.xx-billing-cycle-btn.active {
background: var(--primary-color);
color: var(--text-inverse);
}
.xx-save {
display: inline-block;
margin-left: 4px;
font-size: 11px;
color: var(--green, #10b981);
color: var(--secondary-color);
font-weight: 600;
}
.xx-billing-cycle-btn.active .xx-save {
color: var(--text-inverse);
opacity: 0.85;
}
/* 操作区 */
.xx-upgrade-actions {
text-align: center;
@@ -108,10 +125,67 @@
display: flex;
align-items: center;
gap: 8px;
color: var(--muted, #64748b);
color: var(--text-secondary);
font-size: 14px;
}
.xx-cancel-btn {
margin-top: 8px;
}
/* ── Spinner 组件 ───────────────────────────────── */
.xx-spinner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px 0;
}
.xx-spinner-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--primary-color);
animation: xx-spinner-bounce 1.2s ease-in-out infinite;
}
.xx-spinner-dot:nth-child(2) {
animation-delay: 0.15s;
}
.xx-spinner-dot:nth-child(3) {
animation-delay: 0.3s;
}
.xx-spinner--small .xx-spinner-dot {
width: 6px;
height: 6px;
}
@keyframes xx-spinner-bounce {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@media (max-width: 480px) {
.upgrade-container {
padding: var(--space-md);
}
.upgrade-actions {
flex-direction: column;
}
.upgrade-actions button {
width: 100%;
}
}
@@ -1,8 +1,10 @@
/**
* 升级/降级/续费页面
* P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件
*/
import React, { useState, useEffect } from "react";
import { Button, Radio, message, Spin, Modal } from "antd";
import { message } from "antd";
import { Button, Modal } from "@/components/ui";
import { useNavigate } from "react-router-dom";
import {
getCurrentSubscription,
@@ -15,6 +17,7 @@ import type {
PlanType,
BillingCycle,
} from "@/api/subscription";
import PageHead from "@/components/layout/PageHead";
import "./UpgradeSubscription.css";
const PLANS_META: Record<
@@ -27,6 +30,45 @@ const PLANS_META: Record<
enterprise: { name: "企业版", price: 0, yearlyPrice: 0 },
};
/** 自定义计费周期切换组件 */
const BillingCycleSwitch: React.FC<{
value: BillingCycle;
onChange: (cycle: BillingCycle) => void;
monthlyPrice: number;
yearlyPrice: number;
}> = ({ value, onChange, monthlyPrice, yearlyPrice }) => (
<div className="xx-billing-cycle-switch">
<button
type="button"
className={`xx-billing-cycle-btn ${value === "monthly" ? "active" : ""}`}
onClick={() => onChange("monthly")}
>
¥{monthlyPrice}/
</button>
<button
type="button"
className={`xx-billing-cycle-btn ${value === "yearly" ? "active" : ""}`}
onClick={() => onChange("yearly")}
>
¥{yearlyPrice}/
{yearlyPrice > 0 && monthlyPrice > 0 && (
<span className="xx-save"> ¥{monthlyPrice * 12 - yearlyPrice}</span>
)}
</button>
</div>
);
/** 自定义 Spinner 组件 */
const Spinner: React.FC<{ size?: "small" | "large" }> = ({
size = "large",
}) => (
<div className={`xx-spinner xx-spinner--${size}`}>
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
</div>
);
const UpgradeSubscription: React.FC = () => {
const navigate = useNavigate();
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(
@@ -113,7 +155,6 @@ const UpgradeSubscription: React.FC = () => {
title: "确认取消订阅",
content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。",
okText: "确认取消",
okType: "danger",
cancelText: "再想想",
onOk: async () => {
try {
@@ -131,7 +172,7 @@ const UpgradeSubscription: React.FC = () => {
if (loading) {
return (
<div className="xx-upgrade-page">
<Spin size="large" />
<Spinner size="large" />
</div>
);
}
@@ -140,10 +181,10 @@ const UpgradeSubscription: React.FC = () => {
return (
<div className="xx-upgrade-page">
<div className="xx-upgrade-header">
<h2></h2>
<p>{PLANS_META[currentPlan]?.name ?? "体验版"}</p>
</div>
<PageHead
title="变更订阅方案"
description={`当前套餐:${PLANS_META[currentPlan]?.name ?? "体验版"}`}
/>
<div className="xx-upgrade-plans">
{(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => {
@@ -158,21 +199,12 @@ const UpgradeSubscription: React.FC = () => {
{isCurrent && <div className="xx-current-badge"></div>}
<h3>{plan.name}</h3>
<div className="xx-price">
<Radio.Group
<BillingCycleSwitch
value={billingCycle}
onChange={(e) => setBillingCycle(e.target.value)}
size="small"
>
<Radio.Button value="monthly">¥{plan.price}/</Radio.Button>
<Radio.Button value="yearly">
¥{plan.yearlyPrice}/
{plan.yearlyPrice > 0 && plan.price > 0 && (
<span className="xx-save">
¥{plan.price * 12 - plan.yearlyPrice}
</span>
)}
</Radio.Button>
</Radio.Group>
onChange={setBillingCycle}
monthlyPrice={plan.price}
yearlyPrice={plan.yearlyPrice}
/>
</div>
</div>
);
@@ -181,13 +213,12 @@ const UpgradeSubscription: React.FC = () => {
<div className="xx-upgrade-actions">
<Button
type="primary"
size="large"
loading={submitting}
buttonType="primary"
buttonSize="lg"
disabled={submitting || selectedPlan === currentPlan}
onClick={handleUpgrade}
disabled={selectedPlan === currentPlan}
>
{submitting ? "处理中..." : "确认变更"}
</Button>
{subscription && subscription.status === "active" && (
@@ -196,7 +227,8 @@ const UpgradeSubscription: React.FC = () => {
{subscription.auto_renew ? "已开启" : "已关闭"}
</span>
<Button
type="link"
buttonType="text"
buttonSize="sm"
onClick={() => handleToggleAutoRenew(!subscription.auto_renew)}
>
{subscription.auto_renew ? "关闭" : "开启"}
@@ -208,8 +240,8 @@ const UpgradeSubscription: React.FC = () => {
subscription.status === "active" &&
currentPlan !== "free" && (
<Button
type="link"
danger
buttonType="danger"
buttonSize="sm"
onClick={handleCancel}
className="xx-cancel-btn"
>
+755 -225
View File
@@ -1,244 +1,774 @@
/**
* 模板库页面
* 展示系统模板,支持预览和筛选
* 模板库页面(升级版)— V21 设计系统
* 任务 2.13:模板类型分类展示、模板预览功能、创建 EditPlan 入口
*
* - 按 EditTemplate 类型分组展示
* - 缩略图 + 预览弹窗
* - 创建 EditPlan 入口 UI
* - Mock 数据 + 预留 API 对接接口
*/
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Card,
Button,
Typography,
Space,
Row,
Col,
Tag,
Empty,
Spin,
Input,
Select,
Modal,
Image,
message,
} from "antd";
import {
PlayCircleOutlined,
StarOutlined,
StarFilled,
SearchOutlined,
} from "@ant-design/icons";
import {
getTemplates,
toggleFavoriteTemplate,
type TemplateItem,
} from "@/api/templates";
import PageHead from "@/components/layout/PageHead";
import React, { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui";
import "./templates.css";
const { Paragraph } = Typography;
/* ============================================================
* 类型定义(对齐后端 EditTemplate / EditPlan / TemplateClipConfig
* ============================================================ */
const TemplateLibrary: React.FC = () => {
const queryClient = useQueryClient();
const [searchText, setSearchText] = useState("");
const [categoryFilter, setCategoryFilter] = useState("");
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(
null,
/** 模板片段配置 */
interface TemplateClipConfig {
id: string;
order: number;
clipType: string;
description: string;
duration: number; // 秒
}
/** 模板类型 */
type EditTemplateType =
| "口播"
| "种草"
| "产品"
| "品牌"
| "混剪"
| "Vlog";
/** 模板数据 */
interface EditTemplate {
id: string;
name: string;
type: EditTemplateType;
description: string;
usageCount: number;
isFavorite: boolean;
thumbnailGradient: string;
scriptContent: string;
clipConfigs: TemplateClipConfig[];
recommendedDuration: number; // 秒
tags: string[];
}
/** 创建 EditPlan 参数 */
interface CreateEditPlanParams {
templateId: string;
planName: string;
}
/* ============================================================
* API 占位函数(预留后端对接)
* ============================================================ */
/** 获取模板列表 */
export async function fetchTemplates(
type?: EditTemplateType,
): Promise<EditTemplate[]> {
// TODO: 对接后端 GET /api/templates?type=xxx
void type;
return mockTemplates;
}
/** 获取模板详情 */
export async function fetchTemplateById(
id: string,
): Promise<EditTemplate | null> {
// TODO: 对接后端 GET /api/templates/:id
return mockTemplates.find((t) => t.id === id) ?? null;
}
/** 创建 EditPlan */
export async function createEditPlan(
params: CreateEditPlanParams,
): Promise<{ planId: string }> {
// TODO: 对接后端 POST /api/edit-plans
console.log("[Mock] createEditPlan", params);
return { planId: `plan-${Date.now()}` };
}
/* ============================================================
* 模板类型配置
* ============================================================ */
const TEMPLATE_TYPES: Array<{
type: EditTemplateType | "全部";
label: string;
icon: string;
color: string;
}> = [
{ type: "全部", label: "全部", icon: "📋", color: "#6366f1" },
{ type: "口播", label: "口播", icon: "🎙️", color: "#6366f1" },
{ type: "种草", label: "种草", icon: "🌱", color: "#10b981" },
{ type: "产品", label: "产品", icon: "📦", color: "#0ea5e9" },
{ type: "品牌", label: "品牌", icon: "🏷️", color: "#f59e0b" },
{ type: "混剪", label: "混剪", icon: "🎬", color: "#8b5cf6" },
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
];
/* ============================================================
* Mock 数据
* ============================================================ */
const mockTemplates: EditTemplate[] = [
{
id: "tpl-1",
name: "商品口播模板",
type: "口播",
description: "适用于电商商品介绍的口播视频模板,包含开场、产品介绍、卖点展示、结尾引导等完整结构。",
usageCount: 128,
isFavorite: true,
thumbnailGradient: "linear-gradient(135deg, #6366f1, #8b5cf6)",
scriptContent: "# 商品口播脚本\n## 开场白\n大家好,今天给大家推荐...\n## 产品介绍\n这款产品的特点是...\n## 使用演示\n我们来看实际效果...\n## 总结\n赶紧下单吧!",
clipConfigs: [
{ id: "c1", order: 1, clipType: "开场", description: "吸引注意力的开场白", duration: 5 },
{ id: "c2", order: 2, clipType: "产品展示", description: "产品外观和功能展示", duration: 15 },
{ id: "c3", order: 3, clipType: "卖点讲解", description: "核心卖点详细说明", duration: 20 },
{ id: "c4", order: 4, clipType: "结尾", description: "引导下单的结尾", duration: 5 },
],
recommendedDuration: 45,
tags: ["电商", "口播", "商品介绍"],
},
{
id: "tpl-2",
name: "种草笔记视频模板",
type: "种草",
description: "适合美妆、护肤、生活方式等种草类内容,真实体验分享风格。",
usageCount: 96,
isFavorite: false,
thumbnailGradient: "linear-gradient(135deg, #10b981, #059669)",
scriptContent: "# 种草视频\n## 使用场景\n早上出门前的护肤步骤...\n## 产品亮点\n温和不刺激,适合敏感肌...\n## 使用心得\n用了一个月后的感受...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "场景引入", description: "日常场景带入", duration: 8 },
{ id: "c2", order: 2, clipType: "产品体验", description: "使用过程展示", duration: 20 },
{ id: "c3", order: 3, clipType: "效果对比", description: "使用前后对比", duration: 10 },
{ id: "c4", order: 4, clipType: "总结推荐", description: "使用心得总结", duration: 7 },
],
recommendedDuration: 45,
tags: ["种草", "美妆", "体验分享"],
},
{
id: "tpl-3",
name: "新品发布展示模板",
type: "产品",
description: "适合新品发布、产品升级等场景,突出产品亮点和技术优势。",
usageCount: 74,
isFavorite: false,
thumbnailGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
scriptContent: "# 新品发布\n## 产品概览\n全新升级,性能提升50%...\n## 核心功能\nAI智能识别,一键优化...\n## 技术参数\n详细技术规格展示...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "悬念开场", description: "产品悬念引入", duration: 5 },
{ id: "c2", order: 2, clipType: "产品全景", description: "产品360度展示", duration: 10 },
{ id: "c3", order: 3, clipType: "功能演示", description: "核心功能实操", duration: 25 },
{ id: "c4", order: 4, clipType: "技术规格", description: "参数对比展示", duration: 10 },
{ id: "c5", order: 5, clipType: "结尾", description: "购买引导", duration: 5 },
],
recommendedDuration: 55,
tags: ["产品", "发布", "科技"],
},
{
id: "tpl-4",
name: "品牌故事宣传片",
type: "品牌",
description: "讲述品牌故事,传递品牌理念,适合品牌形象建设和宣传。",
usageCount: 52,
isFavorite: true,
thumbnailGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
scriptContent: "# 品牌故事\n## 品牌起源\n2020年,我们从一个想法开始...\n## 品牌理念\n让创作更简单...\n## 团队风采\n我们的团队...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "品牌起源", description: "创业故事引入", duration: 15 },
{ id: "c2", order: 2, clipType: "发展历程", description: "里程碑事件", duration: 15 },
{ id: "c3", order: 3, clipType: "核心理念", description: "品牌价值主张", duration: 10 },
{ id: "c4", order: 4, clipType: "未来展望", description: "品牌愿景", duration: 10 },
],
recommendedDuration: 50,
tags: ["品牌", "宣传", "故事"],
},
{
id: "tpl-5",
name: "知识分享口播模板",
type: "口播",
description: "适合知识博主、教程类内容,结构清晰,信息密度高。",
usageCount: 215,
isFavorite: false,
thumbnailGradient: "linear-gradient(135deg, #8b5cf6, #7c3aed)",
scriptContent: "# 知识分享\n## 主题引入\n今天我们来聊一个很多人问的问题...\n## 核心内容\n第一点,第二点,第三点...\n## 总结\n希望对你有帮助...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "开场", description: "话题引入", duration: 5 },
{ id: "c2", order: 2, clipType: "知识点1", description: "第一个要点", duration: 15 },
{ id: "c3", order: 3, clipType: "知识点2", description: "第二个要点", duration: 15 },
{ id: "c4", order: 4, clipType: "总结", description: "内容回顾", duration: 5 },
],
recommendedDuration: 40,
tags: ["知识", "教程", "口播"],
},
{
id: "tpl-6",
name: "好物推荐种草模板",
type: "种草",
description: "以痛点引入,展示解决方案,适合日常好物推荐。",
usageCount: 183,
isFavorite: true,
thumbnailGradient: "linear-gradient(135deg, #059669, #047857)",
scriptContent: "# 好物推荐\n## 痛点引入\n你是不是也有这样的困扰...\n## 解决方案\n直到我发现了这个神器...\n## 使用效果\n一个月后的变化...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "痛点", description: "用户痛点共鸣", duration: 8 },
{ id: "c2", order: 2, clipType: "产品引入", description: "产品出场", duration: 10 },
{ id: "c3", order: 3, clipType: "使用展示", description: "实际使用过程", duration: 15 },
{ id: "c4", order: 4, clipType: "效果", description: "使用效果展示", duration: 10 },
],
recommendedDuration: 43,
tags: ["种草", "好物", "推荐"],
},
{
id: "tpl-7",
name: "产品对比评测模板",
type: "产品",
description: "多维度对比评测,客观公正,适合数码、家电等产品。",
usageCount: 67,
isFavorite: false,
thumbnailGradient: "linear-gradient(135deg, #0284c7, #0369a1)",
scriptContent: "# 产品对比\n## 对比维度\n外观、性能、价格、体验...\n## 详细对比\n逐项分析...\n## 总结推荐\n综合来看,A适合...B适合...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "产品亮相", description: "两款产品展示", duration: 8 },
{ id: "c2", order: 2, clipType: "外观对比", description: "外观设计对比", duration: 12 },
{ id: "c3", order: 3, clipType: "性能测试", description: "性能数据对比", duration: 15 },
{ id: "c4", order: 4, clipType: "总结", description: "购买建议", duration: 10 },
],
recommendedDuration: 45,
tags: ["评测", "对比", "产品"],
},
{
id: "tpl-8",
name: "品牌活动预热模板",
type: "品牌",
description: "适合品牌活动、促销预热,制造期待感。",
usageCount: 41,
isFavorite: false,
thumbnailGradient: "linear-gradient(135deg, #d97706, #b45309)",
scriptContent: "# 活动预热\n## 悬念引入\n倒计时3天,惊喜即将揭晓...\n## 活动亮点\n福利一、福利二、福利三...\n## 参与方式\n如何参与活动...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "悬念", description: "倒计时悬念", duration: 5 },
{ id: "c2", order: 2, clipType: "亮点", description: "活动亮点展示", duration: 15 },
{ id: "c3", order: 3, clipType: "福利", description: "优惠福利说明", duration: 10 },
{ id: "c4", order: 4, clipType: "引导", description: "参与方式引导", duration: 5 },
],
recommendedDuration: 35,
tags: ["品牌", "活动", "预热"],
},
{
id: "tpl-9",
name: "多素材混剪模板",
type: "混剪",
description: "适合多段素材拼接,自动匹配节奏,适合混剪类视频。",
usageCount: 89,
isFavorite: false,
thumbnailGradient: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
scriptContent: "# 混剪模板\n## 素材1\n开场高能片段...\n## 素材2\n过渡衔接...\n## 素材3\n高潮部分...\n## 结尾\n精彩回顾...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "高能开场", description: "吸引眼球的开场", duration: 5 },
{ id: "c2", order: 2, clipType: "素材A", description: "第一段素材", duration: 10 },
{ id: "c3", order: 3, clipType: "过渡", description: "转场衔接", duration: 3 },
{ id: "c4", order: 4, clipType: "素材B", description: "第二段素材", duration: 10 },
{ id: "c5", order: 5, clipType: "高潮", description: "高潮片段", duration: 8 },
{ id: "c6", order: 6, clipType: "结尾", description: "精彩回顾", duration: 4 },
],
recommendedDuration: 40,
tags: ["混剪", "多素材", "节奏"],
},
{
id: "tpl-10",
name: "日常Vlog模板",
type: "Vlog",
description: "记录日常生活,轻松自然的Vlog风格模板。",
usageCount: 156,
isFavorite: true,
thumbnailGradient: "linear-gradient(135deg, #ec4899, #db2777)",
scriptContent: "# 日常Vlog\n## 早安\n今天又是美好的一天...\n## 出门\n准备出门啦...\n## 日常\n记录精彩瞬间...\n## 晚安\n今天也很充实...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "早安", description: "起床日常", duration: 8 },
{ id: "c2", order: 2, clipType: "出门", description: "准备出门", duration: 10 },
{ id: "c3", order: 3, clipType: "日常", description: "日常活动记录", duration: 20 },
{ id: "c4", order: 4, clipType: "晚安", description: "一天总结", duration: 7 },
],
recommendedDuration: 45,
tags: ["Vlog", "日常", "生活"],
},
];
/* ============================================================
* 辅助函数
* ============================================================ */
/** 获取类型对应颜色 */
const getTypeColor = (type: EditTemplateType): string => {
const found = TEMPLATE_TYPES.find((t) => t.type === type);
return found?.color ?? "#6366f1";
};
/** 获取片段类型标签 */
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 colorMap[clipType] ?? "#6366f1";
};
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m === 0) return `${s}`;
return `${m}${s > 0 ? `${s}` : ""}`;
};
/* ============================================================
* 预览弹窗组件
* ============================================================ */
interface TemplatePreviewModalProps {
template: EditTemplate;
isFavorite: boolean;
onClose: () => void;
onToggleFavorite: (id: string) => void;
onUse: (template: EditTemplate) => void;
}
const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
template,
isFavorite,
onClose,
onToggleFavorite,
onUse,
}) => {
const totalDuration = template.clipConfigs.reduce(
(sum, c) => sum + c.duration,
0,
);
// 获取模板列表
const { data: templates = [], isLoading } = useQuery({
queryKey: ["templates"],
queryFn: getTemplates,
});
// 收藏/取消收藏
const favMutation = useMutation({
mutationFn: toggleFavoriteTemplate,
onSuccess: (data) => {
message.success(data.is_favorite ? "已收藏" : "已取消收藏");
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("操作失败");
},
});
/** 提取所有分类 */
const categories = Array.from(
new Set(templates.map((t) => t.category).filter(Boolean)),
);
/** 过滤后的模板 */
const filteredTemplates = templates.filter((t) => {
const matchSearch =
!searchText ||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
(t.description || "").toLowerCase().includes(searchText.toLowerCase());
const matchCategory = !categoryFilter || t.category === categoryFilter;
return matchSearch && matchCategory;
});
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<PageHead title="模板库" />
<div className="xx-template-modal-overlay" onClick={onClose}>
<div
className="xx-template-modal"
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button
className="xx-template-modal-close"
onClick={onClose}
title="关闭"
>
</button>
{/* 搜索和筛选 */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={16} md={18}>
<Input
prefix={<SearchOutlined />}
placeholder="搜索模板..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
/>
</Col>
<Col xs={24} sm={8} md={6}>
<Select
placeholder="按分类筛选"
value={categoryFilter || undefined}
onChange={setCategoryFilter}
allowClear
style={{ width: "100%" }}
options={categories.map((c) => ({ value: c, label: c }))}
/>
</Col>
</Row>
{isLoading ? (
<div style={{ textAlign: "center", padding: 60 }}>
<Spin size="large" />
{/* 预览区域 */}
<div
className="xx-template-modal-preview"
style={{ background: template.thumbnailGradient }}
>
<div className="xx-template-modal-preview-content">
<span style={{ fontSize: 48 }}>
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon ?? "📋"}
</span>
<span style={{ fontSize: 18, fontWeight: 600, color: "#fff", marginTop: 8 }}>
{template.name}
</span>
</div>
</div>
) : filteredTemplates.length === 0 ? (
<Empty
description={
searchText || categoryFilter ? "未找到匹配的模板" : "暂无模板"
}
/>
) : (
<Row gutter={[16, 16]}>
{filteredTemplates.map((template) => (
<Col xs={24} sm={12} md={8} lg={6} key={template.id}>
<Card
hoverable
size="small"
cover={
template.thumbnail_url ? (
<Image
src={template.thumbnail_url}
alt={template.name}
style={{ height: 180, objectFit: "cover" }}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lt6XlooPlvIDova7kuK3lm77niYw8L3RleHQ+PC9zdmc+"
/>
) : (
<div
{/* 内容区域 */}
<div className="xx-template-modal-content">
{/* 标题行 */}
<div className="xx-template-modal-title-row">
<h3>{template.name}</h3>
<span
className="xx-template-modal-type-badge"
style={{
color: getTypeColor(template.type),
background: `${getTypeColor(template.type)}18`,
}}
>
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon}{" "}
{template.type}
</span>
</div>
{/* 描述 */}
<p className="xx-template-modal-desc">{template.description}</p>
{/* 标签 */}
<div className="xx-template-modal-tags">
{template.tags.map((tag) => (
<span key={tag} className="xx-template-modal-tag">
#{tag}
</span>
))}
</div>
{/* 脚本内容 */}
<div className="xx-template-modal-section">
<h4>📝 </h4>
<pre className="xx-template-modal-script">
{template.scriptContent}
</pre>
</div>
{/* 视频结构 */}
<div className="xx-template-modal-section">
<h4>🎬 </h4>
<div className="xx-template-modal-clip-list">
{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={{
height: 180,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f5f5f5",
color: getClipTypeColor(clip.clipType),
background: `${getClipTypeColor(clip.clipType)}18`,
}}
>
<PlayCircleOutlined
style={{ fontSize: 48, color: "#bbb" }}
/>
</div>
)
}
actions={[
<Button
key="preview"
type="text"
size="small"
icon={<PlayCircleOutlined />}
onClick={() => setPreviewTemplate(template)}
>
</Button>,
<Button
key="fav"
type="text"
size="small"
icon={
template.is_favorite ? (
<StarFilled style={{ color: "#faad14" }} />
) : (
<StarOutlined />
)
}
disabled={favMutation.isPending}
onClick={() => favMutation.mutate(template.id)}
/>,
]}
>
<Card.Meta
title={template.name}
description={
<Space direction="vertical" size={4}>
<Paragraph
ellipsis={{ rows: 2 }}
style={{ marginBottom: 0, fontSize: 12 }}
>
{template.description || "暂无描述"}
</Paragraph>
<Space>
{template.category && (
<Tag color="blue">{template.category}</Tag>
)}
{template.target_duration && (
<Tag> {template.target_duration}s</Tag>
)}
</Space>
</Space>
}
/>
</Card>
</Col>
))}
</Row>
)}
{getClipTypeLabel(clip.clipType)}
</span>
<span className="xx-template-modal-clip-desc">
{clip.description}
</span>
<span className="xx-template-modal-clip-duration">
{clip.duration}
</span>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(totalDuration)}
{template.recommendedDuration !== totalDuration && (
<span>
{" "}
· {formatDuration(template.recommendedDuration)}
</span>
)}
</div>
</div>
{/* 预览弹窗 */}
<Modal
title={previewTemplate?.name}
open={!!previewTemplate}
onCancel={() => setPreviewTemplate(null)}
footer={
<Button
type="primary"
onClick={() => {
// TODO: 跳转到生成页面,携带模板参数
setPreviewTemplate(null);
{/* 统计信息 */}
<div className="xx-template-modal-stats">
<span>使 {template.usageCount} </span>
<button
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={() => onToggleFavorite(template.id)}
>
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
</button>
</div>
{/* 操作按钮 */}
<div className="xx-template-modal-actions">
<Button buttonType="ghost" buttonSize="md" onClick={onClose}>
</Button>
<Button
buttonType="primary"
buttonSize="md"
onClick={() => onUse(template)}
>
使
</Button>
</div>
</div>
</div>
</div>
);
};
/* ============================================================
* 模板卡片组件
* ============================================================ */
interface TemplateCardProps {
template: EditTemplate;
isFavorite: boolean;
onPreview: (template: EditTemplate) => void;
onToggleFavorite: (id: string, e: React.MouseEvent) => void;
onUse: (template: EditTemplate) => void;
}
const TemplateCard: React.FC<TemplateCardProps> = ({
template,
isFavorite,
onPreview,
onToggleFavorite,
onUse,
}) => {
return (
<div
className="xx-template-card"
onClick={() => onPreview(template)}
>
{/* 缩略图 */}
<div className="xx-template-thumb">
<div
className="xx-template-thumb-bg"
style={{ background: template.thumbnailGradient }}
>
{template.scriptContent.slice(0, 80)}...
</div>
<div className="xx-template-thumb-overlay" />
<div className="xx-template-preview-hint"></div>
<button
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={(e) => onToggleFavorite(template.id, e)}
title={isFavorite ? "取消收藏" : "收藏"}
>
{isFavorite ? "★" : "☆"}
</button>
</div>
{/* 信息区 */}
<div className="xx-template-info">
<div className="xx-template-info-top">
<h4 className="xx-template-name">{template.name}</h4>
<span
className="xx-template-category-pill"
style={{
color: getTypeColor(template.type),
background: `${getTypeColor(template.type)}18`,
}}
>
{template.type}
</span>
</div>
<p className="xx-template-desc">{template.description}</p>
<div className="xx-template-meta">
<span className="xx-template-usage">
使 {template.usageCount}
</span>
<button
className="xx-template-use-btn"
onClick={(e) => {
e.stopPropagation();
onUse(template);
}}
>
使
</Button>
}
>
{previewTemplate && (
<Space direction="vertical" style={{ width: "100%" }}>
{previewTemplate.preview_url && (
<video
src={previewTemplate.preview_url}
controls
style={{ width: "100%", maxHeight: 400 }}
/>
)}
<Paragraph>{previewTemplate.description}</Paragraph>
<Space wrap>
{previewTemplate.category && (
<Tag color="blue">{previewTemplate.category}</Tag>
)}
{previewTemplate.target_duration && (
<Tag> {previewTemplate.target_duration}s</Tag>
)}
{previewTemplate.clip_count && (
<Tag> {previewTemplate.clip_count}</Tag>
)}
</Space>
</Space>
)}
</Modal>
</button>
</div>
</div>
</div>
);
};
/* ============================================================
* 主组件
* ============================================================ */
const TemplateLibrary: React.FC = () => {
const navigate = useNavigate();
const [searchText, setSearchText] = useState("");
const [activeType, setActiveType] = useState<EditTemplateType | "全部">("全部");
const [previewTemplate, setPreviewTemplate] = useState<EditTemplate | null>(null);
const [favorites, setFavorites] = useState<Set<string>>(
() => new Set(mockTemplates.filter((t) => t.isFavorite).map((t) => t.id)),
);
/** 切换收藏 */
const toggleFavorite = (id: string, e?: React.MouseEvent) => {
e?.stopPropagation();
setFavorites((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
/** 过滤模板 */
const filtered = useMemo(() => {
return mockTemplates.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]);
/** 按类型分组 */
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]);
/** 使用模板 → 创建 EditPlan */
const handleUseTemplate = async (template: EditTemplate) => {
try {
const result = await createEditPlan({
templateId: template.id,
planName: `基于「${template.name}」的剪辑计划`,
});
console.log("[TemplateLibrary] EditPlan created:", result.planId);
navigate("/app/editing-planner");
} catch (err) {
console.error("[TemplateLibrary] createEditPlan failed:", err);
}
};
return (
<div className="xx-templates-page">
{/* ── 页面头部 ──────────────────────────────────────────── */}
<div className="xx-templates-header">
<div className="xx-templates-header-text">
<h2></h2>
<p></p>
</div>
<Button buttonType="primary" buttonSize="md">
+
</Button>
</div>
{/* ── 工具栏:搜索 + 类型按钮组 ─────────────────────────── */}
<div className="xx-templates-toolbar">
<div className="xx-templates-search">
<span className="xx-templates-search-icon">🔍</span>
<input
className="xx-templates-search-input"
type="text"
placeholder="搜索模板名称、描述或标签..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
<div className="xx-templates-categories">
{TEMPLATE_TYPES.map((cat) => (
<button
key={cat.type}
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
onClick={() => setActiveType(cat.type)}
>
<span className="xx-templates-cat-icon">{cat.icon}</span>
{cat.label}
</button>
))}
</div>
</div>
{/* ── 模板展示区 ────────────────────────────────────────── */}
{filtered.length === 0 ? (
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon">📭</div>
<h3>
{searchText || activeType !== "全部"
? "未找到匹配的模板"
: "暂无模板"}
</h3>
<p>
{searchText || activeType !== "全部"
? "试试调整搜索条件或切换类型"
: "点击上方「创建模板」开始创作"}
</p>
</div>
) : activeType === "全部" ? (
/* 全部类型 → 按类型分组展示 */
<div className="xx-templates-grouped">
{Object.entries(groupedTemplates).map(([type, templates]) => {
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">
{templates.length}
</span>
</div>
<div className="xx-templates-grid">
{templates.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={favorites.has(tpl.id)}
onPreview={setPreviewTemplate}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
/>
))}
</div>
</div>
);
})}
</div>
) : (
/* 单类型 → 平铺网格 */
<div className="xx-templates-grid">
{filtered.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={favorites.has(tpl.id)}
onPreview={setPreviewTemplate}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
/>
))}
</div>
)}
{/* ── 预览弹窗 ──────────────────────────────────────────── */}
{previewTemplate && (
<TemplatePreviewModal
template={previewTemplate}
isFavorite={favorites.has(previewTemplate.id)}
onClose={() => setPreviewTemplate(null)}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
/>
)}
</div>
);
};
+690
View File
@@ -0,0 +1,690 @@
/**
* 模板库页面 - V21 设计系统样式(升级版)
* 任务 2.13:新增分组展示、预览弹窗、卡片描述等样式
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
/* ============================================================
页面容器
============================================================ */
.xx-templates-page {
min-height: 100%;
padding: var(--space-xl);
}
/* ============================================================
页面头部
============================================================ */
.xx-templates-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-lg);
}
.xx-templates-header-text h2 {
margin: 0 0 4px;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-templates-header-text p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* ============================================================
工具栏:搜索框 + 分类按钮组
============================================================ */
.xx-templates-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-lg);
flex-wrap: wrap;
}
.xx-templates-search {
position: relative;
max-width: 400px;
flex: 1;
min-width: 200px;
}
.xx-templates-search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
color: var(--text-secondary);
pointer-events: none;
}
.xx-templates-search-input {
width: 100%;
height: 40px;
padding: 0 14px 0 40px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
font-size: var(--font-size-sm);
transition: var(--transition-all);
outline: none;
}
.xx-templates-search-input::placeholder {
color: var(--text-secondary);
}
.xx-templates-search-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px var(--primary-soft);
}
/* 分类按钮组 */
.xx-templates-categories {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.xx-templates-cat-btn {
padding: 6px 18px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: var(--transition-all);
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.xx-templates-cat-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.xx-templates-cat-btn.active {
background: var(--primary-soft);
border-color: var(--primary-color);
color: var(--primary-color);
font-weight: 600;
}
.xx-templates-cat-icon {
font-size: 14px;
}
/* ============================================================
分组展示
============================================================ */
.xx-templates-grouped {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.xx-templates-group-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: var(--space-md);
}
.xx-templates-group-icon {
font-size: 20px;
}
.xx-templates-group-header h3 {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-templates-group-count {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-left: 4px;
}
/* ============================================================
模板卡片网格
============================================================ */
.xx-templates-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
}
/* ============================================================
模板卡片
============================================================ */
.xx-template-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
transition: var(--transition-all);
cursor: pointer;
}
.xx-template-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
transform: translateY(-3px);
}
/* 缩略图区域 — 16:9 */
.xx-template-thumb {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.xx-template-thumb-bg {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
line-height: 1.6;
padding: 16px;
white-space: pre-wrap;
word-break: break-all;
}
/* 底部渐变遮罩 */
.xx-template-thumb-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.3), transparent);
pointer-events: none;
}
/* 预览提示(hover 显示) */
.xx-template-preview-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
color: #fff;
font-size: 14px;
font-weight: 500;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 1;
}
.xx-template-card:hover .xx-template-preview-hint {
opacity: 1;
}
/* 收藏按钮 */
.xx-template-fav-btn {
position: absolute;
top: 10px;
right: 10px;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
color: #fff;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-all);
z-index: 2;
}
.xx-template-fav-btn:hover {
background: rgba(0, 0, 0, 0.55);
transform: scale(1.1);
}
.xx-template-fav-btn.is-favorite {
color: #fbbf24;
background: rgba(0, 0, 0, 0.5);
}
/* 卡片信息区 */
.xx-template-info {
padding: 14px 16px;
}
.xx-template-info-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.xx-template-name {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
/* 卡片描述 */
.xx-template-desc {
margin: 0 0 10px;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 分类药丸 */
.xx-template-category-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
/* 使用次数 */
.xx-template-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.xx-template-usage {
font-size: 12px;
color: var(--text-secondary);
}
.xx-template-use-btn {
padding: 5px 14px;
border-radius: var(--radius-md);
border: none;
background: var(--gradient-primary);
color: #fff;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: var(--transition-all);
white-space: nowrap;
}
.xx-template-use-btn:hover {
opacity: 0.9;
box-shadow: var(--shadow-sm);
}
/* ============================================================
空状态
============================================================ */
.xx-templates-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.xx-templates-empty-icon {
font-size: 56px;
margin-bottom: 16px;
opacity: 0.5;
}
.xx-templates-empty h3 {
margin: 0 0 8px;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-templates-empty p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* ============================================================
预览弹窗
============================================================ */
.xx-template-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.xx-template-modal {
background: var(--bg-primary);
border-radius: var(--radius-lg);
max-width: 720px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.xx-template-modal-close {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
color: #fff;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: var(--transition-all);
}
.xx-template-modal-close:hover {
background: rgba(0, 0, 0, 0.5);
transform: scale(1.1);
}
/* 预览区域 */
.xx-template-modal-preview {
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
overflow: hidden;
}
.xx-template-modal-preview-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
}
/* 内容区域 */
.xx-template-modal-content {
padding: 24px;
}
.xx-template-modal-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.xx-template-modal-title-row h3 {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-template-modal-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.xx-template-modal-desc {
margin: 0 0 12px;
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.6;
}
.xx-template-modal-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.xx-template-modal-tag {
padding: 2px 8px;
border-radius: 4px;
background: var(--bg-surface, var(--bg-secondary));
color: var(--text-secondary);
font-size: 11px;
}
/* 脚本内容 */
.xx-template-modal-section {
margin-bottom: 16px;
}
.xx-template-modal-section h4 {
margin: 0 0 8px;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-template-modal-script {
margin: 0;
padding: 12px;
background: var(--bg-surface, var(--bg-secondary));
border-radius: var(--radius-sm);
font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
max-height: 160px;
overflow-y: auto;
}
/* 片段列表 */
.xx-template-modal-clip-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-template-modal-clip-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-surface, var(--bg-secondary));
border-radius: var(--radius-sm);
}
.xx-template-modal-clip-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.xx-template-modal-clip-desc {
flex: 1;
font-size: 12px;
color: var(--text-primary);
}
.xx-template-modal-clip-duration {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
}
.xx-template-modal-total-duration {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
font-size: 12px;
color: var(--text-secondary);
text-align: right;
}
/* 统计信息 */
.xx-template-modal-stats {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
margin-bottom: 16px;
font-size: 13px;
color: var(--text-secondary);
}
.xx-template-modal-fav-btn {
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: var(--transition-all);
}
.xx-template-modal-fav-btn:hover {
border-color: #fbbf24;
color: #fbbf24;
}
.xx-template-modal-fav-btn.is-favorite {
border-color: #fbbf24;
color: #fbbf24;
background: #fbbf2418;
}
/* 操作按钮 */
.xx-template-modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1200px) {
.xx-templates-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.xx-templates-page {
padding: var(--space-md);
}
.xx-templates-grid {
grid-template-columns: repeat(2, 1fr);
}
.xx-templates-header {
flex-direction: column;
gap: var(--space-sm);
}
.xx-templates-toolbar {
flex-direction: column;
align-items: stretch;
}
.xx-templates-search {
max-width: none;
}
.xx-templates-categories {
justify-content: flex-start;
}
/* 弹窗移动端适配 */
.xx-template-modal-content {
padding: 16px;
}
.xx-template-modal-actions {
flex-direction: column-reverse;
}
.xx-template-modal-actions button {
width: 100%;
}
}
@media (max-width: 480px) {
.xx-templates-grid {
grid-template-columns: 1fr;
}
}
File diff suppressed because it is too large Load Diff
+486
View File
@@ -0,0 +1,486 @@
/**
* 标题库页面 - V21 设计系统样式
* 两栏布局:左侧分类列表(220px)+ 右侧标题卡片网格(3列)
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
/* ============================================================
页面容器
============================================================ */
.xx-titles-page {
min-height: 100%;
padding: var(--space-xl);
}
/* ============================================================
两栏布局
============================================================ */
.xx-titles-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 20px;
align-items: start;
}
/* ============================================================
左侧分类列表
============================================================ */
.xx-title-category-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
position: sticky;
top: var(--space-md);
}
.xx-title-category-item {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius-sm);
padding: 14px;
cursor: pointer;
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: space-between;
}
.xx-title-category-item:hover {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.xx-title-category-item.active {
border-color: var(--primary-color);
background: var(--primary-soft);
box-shadow: var(--shadow-primary);
}
.xx-title-category-item h4 {
margin: 0 0 4px;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.xx-title-category-item span {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.xx-title-category-delete {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-xs);
font-size: var(--font-size-sm);
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
flex-shrink: 0;
}
.xx-title-category-item:hover .xx-title-category-delete {
opacity: 1;
}
.xx-title-category-delete:hover {
color: var(--error-color);
background: var(--error-soft);
}
.xx-title-category-add {
border: 1px dashed var(--border-color);
background: transparent;
border-radius: var(--radius-sm);
padding: 14px;
cursor: pointer;
text-align: center;
color: var(--text-secondary);
font-size: var(--font-size-sm);
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
.xx-title-category-add:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: var(--primary-soft);
}
/* ============================================================
右侧内容区
============================================================ */
.xx-titles-content {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
/* ============================================================
筛选栏
============================================================ */
.xx-titles-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
}
.xx-titles-filters-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.xx-titles-filters-right {
display: flex;
align-items: center;
gap: var(--space-sm);
}
/* ============================================================
标题卡片网格(3列)
============================================================ */
.xx-title-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
/* ============================================================
标题卡片
============================================================ */
.xx-title-card {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius-md);
padding: 16px;
transition: var(--transition-all);
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
}
.xx-title-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
/* 标题文本 */
.xx-title-card-text {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
line-height: 1.5;
word-break: break-word;
/* 最多3行,超出省略 */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 编辑态 */
.xx-title-card-edit {
width: 100%;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
line-height: 1.5;
border: 1px solid var(--primary-color);
border-radius: var(--radius-sm);
padding: 6px 10px;
background: var(--bg-primary);
outline: none;
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.xx-title-card-edit:focus {
box-shadow: 0 0 0 2px var(--primary-soft);
}
/* 底部元信息 */
.xx-title-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
flex-wrap: wrap;
}
.xx-title-card-meta-left {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-wrap: wrap;
}
/* 类型标签 */
.xx-title-type-tag {
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.xx-title-type-tag.hot {
background: #fef2f2;
color: #dc2626;
}
.xx-title-type-tag.normal {
background: var(--primary-soft);
color: var(--primary-color);
}
.xx-title-type-tag.creative {
background: #fefce8;
color: #ca8a04;
}
/* 深色模式 */
.dark .xx-title-type-tag.hot,
[data-theme="dark"] .xx-title-type-tag.hot {
background: rgba(220, 38, 38, 0.15);
color: #f87171;
}
.dark .xx-title-type-tag.normal,
[data-theme="dark"] .xx-title-type-tag.normal {
background: var(--primary-soft);
color: var(--primary-color);
}
.dark .xx-title-type-tag.creative,
[data-theme="dark"] .xx-title-type-tag.creative {
background: rgba(202, 138, 4, 0.15);
color: #facc15;
}
/* 使用次数 & 时间 */
.xx-title-card-stat {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
white-space: nowrap;
}
/* 操作按钮区 */
.xx-title-card-actions {
display: flex;
align-items: center;
gap: 4px;
}
.xx-title-card-action-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px 6px;
border-radius: var(--radius-xs);
font-size: var(--font-size-sm);
transition: var(--transition-all);
display: flex;
align-items: center;
gap: 2px;
}
.xx-title-card-action-btn:hover {
color: var(--primary-color);
background: var(--primary-soft);
}
.xx-title-card-action-btn.danger:hover {
color: var(--error-color);
background: var(--error-soft);
}
/* 收藏按钮 */
.xx-title-fav-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 2px;
line-height: 1;
transition: var(--transition-all);
}
.xx-title-fav-btn:hover {
transform: scale(1.2);
}
/* ============================================================
空状态
============================================================ */
.xx-titles-empty {
text-align: center;
padding: var(--space-3xl) var(--space-xl);
color: var(--text-secondary);
}
.xx-titles-empty-icon {
font-size: 48px;
margin-bottom: var(--space-md);
opacity: 0.5;
}
/* ============================================================
AI 生成结果列表
============================================================ */
.xx-ai-results {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-md);
}
.xx-ai-result-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
transition: var(--transition-all);
}
.xx-ai-result-item:hover {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.xx-ai-result-text {
flex: 1;
font-size: var(--font-size-base);
color: var(--text-primary);
line-height: 1.5;
}
.xx-ai-result-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* AI 加载动画 */
.xx-ai-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
padding: var(--space-xl);
color: var(--text-secondary);
}
.xx-ai-loading-dots {
display: flex;
gap: 6px;
}
.xx-ai-loading-dot {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
background: var(--primary-color);
animation: ai-dot-bounce 1.4s ease-in-out infinite both;
}
.xx-ai-loading-dot:nth-child(1) {
animation-delay: 0s;
}
.xx-ai-loading-dot:nth-child(2) {
animation-delay: 0.16s;
}
.xx-ai-loading-dot:nth-child(3) {
animation-delay: 0.32s;
}
@keyframes ai-dot-bounce {
0%,
80%,
100% {
transform: scale(0.4);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1200px) {
.xx-titles-layout {
grid-template-columns: 1fr;
}
.xx-title-category-list {
flex-direction: row;
overflow-x: auto;
position: static;
gap: var(--space-sm);
padding-bottom: var(--space-sm);
}
.xx-title-category-item {
min-width: 160px;
flex-shrink: 0;
}
.xx-title-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.xx-titles-page {
padding: var(--space-md);
}
.xx-title-grid {
grid-template-columns: 1fr;
}
.xx-titles-filters {
flex-direction: column;
align-items: stretch;
}
.xx-titles-filters-left,
.xx-titles-filters-right {
justify-content: flex-start;
}
}
@media (max-width: 480px) {
.xx-titles-page {
padding: var(--space-sm);
}
.xx-title-card {
padding: 12px;
}
}
@@ -0,0 +1,334 @@
/**
* 我的音色页面 — V21 Design System
*
* 展示克隆音色列表,卡片网格布局
* 支持试听、使用、编辑名称、删除操作
*/
import React, { useState, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Tooltip } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import CloneVoiceModal from "@/components/modals/CloneVoiceModal";
import {
getVoiceClones,
deleteVoiceClone,
updateVoiceClone,
formatDuration,
type VoiceClone as VoiceCloneType,
} from "@/api/voiceClone";
import "./voice-clone.css";
/* ── 状态配置 ─────────────────────────────────────────── */
const STATUS_CONFIG: Record<
VoiceCloneType["status"],
{ label: string; className: string }
> = {
ready: { label: "就绪", className: "vc-status-pill--ready" },
processing: { label: "克隆中", className: "vc-status-pill--processing" },
failed: { label: "失败", className: "vc-status-pill--failed" },
};
/* ── Toast 系统 ─────────────────────────────────────────── */
interface Toast {
id: number;
message: string;
type: "success" | "error";
}
let toastId = 0;
/* ── 音色卡片组件 ───────────────────────────────────────── */
interface VoiceCloneCardProps {
voice: VoiceCloneType;
onPlay: (voice: VoiceCloneType) => void;
onUse: (voice: VoiceCloneType) => void;
onEdit: (voice: VoiceCloneType) => void;
onDelete: (voice: VoiceCloneType) => void;
}
const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
voice,
onPlay,
onUse,
onEdit,
onDelete,
}) => {
const statusCfg = STATUS_CONFIG[voice.status];
const isProcessing = voice.status === "processing";
const createdDate = new Date(voice.created_at).toLocaleDateString("zh-CN");
return (
<div className="vc-card">
{/* 右上角操作 */}
<div className="vc-card-actions">
<Tooltip title="编辑名称">
<button
type="button"
className="vc-card-action-btn"
onClick={() => onEdit(voice)}
>
</button>
</Tooltip>
<Tooltip title="删除">
<button
type="button"
className="vc-card-action-btn vc-card-action-btn--danger"
onClick={() => onDelete(voice)}
>
🗑
</button>
</Tooltip>
</div>
{/* 头部:头像 + 名称 + 状态 */}
<div className="vc-card-header">
<div
className={`vc-card-avatar${isProcessing ? " vc-card-avatar--processing" : ""}`}
>
🎤
</div>
<div className="vc-card-info">
<h4 className="vc-card-name">{voice.name}</h4>
<span className={`vc-status-pill ${statusCfg.className}`}>
<span className="vc-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{/* 元信息 */}
<div className="vc-card-meta">
<div className="vc-card-meta-row">
<span className="vc-card-meta-icon">🎵</span>
<span>{formatDuration(voice.duration_seconds)}</span>
</div>
<div className="vc-card-meta-row">
<span className="vc-card-meta-icon">📅</span>
<span>{createdDate}</span>
</div>
</div>
{/* 操作区 */}
<div className="vc-card-footer">
<Button
buttonType="ghost"
buttonSize="sm"
disabled={isProcessing}
onClick={() => onPlay(voice)}
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
disabled={isProcessing}
onClick={() => onUse(voice)}
>
使
</Button>
</div>
</div>
);
};
/* ── 主页面 ─────────────────────────────────────────────── */
const VoiceClone: React.FC = () => {
const queryClient = useQueryClient();
const [toasts, setToasts] = useState<Toast[]>([]);
const [editingVoice, setEditingVoice] = useState<VoiceCloneType | null>(null);
const [editName, setEditName] = useState("");
const [cloneModalOpen, setCloneModalOpen] = useState(false);
/** 显示 toast */
const showToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastId;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
/** 查询克隆音色列表 */
const { data: voices = [], isLoading } = useQuery({
queryKey: ["voiceClones"],
queryFn: () => getVoiceClones(),
});
/** 删除 mutation */
const deleteMutation = useMutation({
mutationFn: deleteVoiceClone,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voiceClones"] });
showToast("音色已删除", "success");
},
onError: () => {
showToast("删除失败", "error");
},
});
/** 编辑 mutation */
const updateMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
updateVoiceClone(id, { name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voiceClones"] });
setEditingVoice(null);
showToast("名称已更新", "success");
},
onError: () => {
showToast("更新失败", "error");
},
});
/** 克隆新音色 — 打开弹窗 */
const handleCloneNew = () => {
setCloneModalOpen(true);
};
/** 试听 */
const handlePlay = (voice: VoiceCloneType) => {
if (voice.sample_url) {
const audio = new Audio(voice.sample_url);
audio.play().catch(() => {
showToast("播放失败", "error");
});
} else {
showToast("暂无试听音频", "error");
}
};
/** 使用音色 — 跳转到生成页面 */
const handleUse = (_voice: VoiceCloneType) => {
showToast("已选择音色,跳转到生成页面", "success");
};
/** 打开编辑弹窗 */
const handleEdit = (voice: VoiceCloneType) => {
setEditingVoice(voice);
setEditName(voice.name);
};
/** 确认编辑 */
const handleEditConfirm = () => {
if (!editingVoice || !editName.trim()) return;
updateMutation.mutate({ id: editingVoice.id, name: editName.trim() });
};
/** 删除确认 */
const handleDelete = (voice: VoiceCloneType) => {
if (window.confirm(`确定删除音色「${voice.name}」吗?`)) {
deleteMutation.mutate(voice.id);
}
};
return (
<div className="vc-page">
<PageHead
title="🎤 我的音色库"
description="克隆和管理你的专属音色,用AI生成个性化配音"
actions={
<Button buttonType="primary" onClick={handleCloneNew}>
</Button>
}
/>
{/* 加载状态 */}
{isLoading && (
<div className="vc-empty">
<div className="vc-empty-icon"></div>
<p className="vc-empty-desc">...</p>
</div>
)}
{/* 卡片网格 */}
{!isLoading && voices.length > 0 && (
<div className="vc-grid">
{voices.map((voice) => (
<VoiceCloneCard
key={voice.id}
voice={voice}
onPlay={handlePlay}
onUse={handleUse}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* 空状态 */}
{!isLoading && voices.length === 0 && (
<div className="vc-empty">
<div className="vc-empty-icon">🎤</div>
<h3 className="vc-empty-title"></h3>
<p className="vc-empty-desc">AI将克隆你的专属音色</p>
<Button buttonType="primary" onClick={handleCloneNew}>
</Button>
</div>
)}
{/* Toast 提示 */}
{toasts.length > 0 && (
<div className="vc-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)}
{/* 编辑弹窗 */}
{editingVoice && (
<div className="vc-edit-overlay" onClick={() => setEditingVoice(null)}>
<div className="vc-edit-dialog" onClick={(e) => e.stopPropagation()}>
<h3 className="vc-edit-title"></h3>
<input
type="text"
className="vc-edit-input"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleEditConfirm();
if (e.key === "Escape") setEditingVoice(null);
}}
autoFocus
placeholder="输入音色名称"
/>
<div className="vc-edit-buttons">
<Button buttonType="ghost" onClick={() => setEditingVoice(null)}>
</Button>
<Button
buttonType="primary"
onClick={handleEditConfirm}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? "保存中..." : "保存"}
</Button>
</div>
</div>
</div>
)}
{/* 克隆音色弹窗 */}
<CloneVoiceModal
open={cloneModalOpen}
onClose={() => setCloneModalOpen(false)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ["voiceClones"] });
showToast("音色克隆已提交", "success");
}}
/>
</div>
);
};
export default VoiceClone;

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