Merge develop → main: 阶段0-3 全量上线(V21 UI + Phase 8 + CosyVoice) #176
@@ -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
|
||||
|
||||
@@ -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")
|
||||
Executable
+54
@@ -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
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
Executable
+334
@@ -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:
|
||||
"""重试失败任务。
|
||||
|
||||
将任务重置为 pending,retry_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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Executable
+109
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
"""总数"""
|
||||
@@ -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
|
||||
Executable
+15
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
Executable
+268
@@ -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)
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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`);
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">支持 MP3、WAV 格式</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 — 音色克隆弹窗
|
||||
*
|
||||
* 功能:上传音频文件 / 录制音频、填写音色名称、提交克隆任务
|
||||
* 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
|
||||
* 对接 API(Mock):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">
|
||||
支持 MP3、WAV、M4A、AAC、OGG 格式
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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">
|
||||
支持 MP3、WAV、M4A 格式,最大 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;
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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"),
|
||||
};
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
|
||||
/** 绑定 mutation(mock) */
|
||||
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;
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 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>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 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>• 支持的视频格式:MP4、AVI、MOV、MKV、WMV、FLV、WebM</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
|
||||
* 三阶段 UI:setup(配置)→ 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 关联后端分类 API(P1-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
@@ -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;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
结果区域
|
||||
============================================================ */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user