Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d494f368d0 | |||
| b9566c7a93 | |||
| 9808a6eef9 | |||
| 91364f15d5 | |||
| b8e466eb75 | |||
| 6800ee31a3 | |||
| 11fa1f31da | |||
| 3b90da1050 | |||
| 4861d298cc | |||
| 2788d00593 | |||
| a9d55dc2cf | |||
| 52ab7a91e0 | |||
| 3e24f8de4a | |||
| 1b0c2a7889 | |||
| 8ea16aae74 | |||
| 598e2b6fb7 | |||
| 82bc193693 | |||
| 9ac15d8edc | |||
| 7ef0366de0 | |||
| 0f73df242e | |||
| 3e9873073e | |||
| 0ef4e1d633 | |||
| 380274e635 | |||
| f395337e6f | |||
| 2f26541188 | |||
| 62909de618 | |||
| d8a4c21556 | |||
| 92b29de511 | |||
| 4bbc71d9d1 | |||
| 2bb8cc3bb3 | |||
| df38778875 | |||
| 09b8a9b52b | |||
| fea91876f9 | |||
| 7f960600da | |||
| 046610ae24 | |||
| bbddcb076a | |||
| 73af3851cb | |||
| 77ea4f607c | |||
| 3b6b32d561 | |||
| 911c3b6866 | |||
| b7ef9fed14 | |||
| 25bbfdda6b | |||
| 74f146bbaf | |||
| 3cbefef773 | |||
| ba1120dec5 | |||
| 49a56afc51 | |||
| abe856b0b6 | |||
| 455ec8f561 | |||
| 24995e01d3 | |||
| b168f1c582 | |||
| f7284534bc | |||
| 446a9029e8 | |||
| 90a39d74ff | |||
| a62cadf654 | |||
| dc38c3fa8b | |||
| 73a3606626 | |||
| a05cc94fdb | |||
| c861e90289 | |||
| e6effdb6f2 | |||
| 265ec7460b | |||
| 406fd20e0e | |||
| 223bb530dd | |||
| 44e6c8e10f | |||
| cfe97ea059 | |||
| 4f09989df5 | |||
| 613c75d041 | |||
| fb29f058e9 | |||
| e27d6d9429 | |||
| 540500d9f8 | |||
| 675945f4fb | |||
| 5e74d0b565 | |||
| ebe68429bc | |||
| 18f534bbd6 | |||
| f36aaea374 | |||
| 80bc63d58b | |||
| 73a566c621 | |||
| 9057ba25c8 | |||
| 35b38ed48c | |||
| 15142e3168 | |||
| 953dd9a6e6 | |||
| 060307197c | |||
| 6930d4543f | |||
| 7b2f35ad3d | |||
| 5256bd0d8b | |||
| 33270dd026 |
@@ -38,7 +38,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Check changed files
|
||||
id: check
|
||||
shell: bash
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -412,7 +412,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -466,7 +466,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -528,7 +528,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -612,7 +612,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -796,7 +796,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -928,7 +928,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -1080,7 +1080,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -1146,7 +1146,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -1235,7 +1235,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -1359,7 +1359,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -1490,7 +1490,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -1550,7 +1550,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -1609,7 +1609,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -1698,9 +1698,8 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
|
||||
- name: Evaluate CI Gate
|
||||
id: gate
|
||||
@@ -1736,7 +1735,7 @@ jobs:
|
||||
# 查询 AI Code Review 状态(跨workflow,读commit status)
|
||||
AI_REVIEW_STATUS="pending"
|
||||
AI_REVIEW_DESC=""
|
||||
STATUS_JSON=$(curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
STATUS_JSON=$(curl -sfH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits/${PR_HEAD_SHA}/status" 2>/dev/null || true)
|
||||
if [ -n "$STATUS_JSON" ]; then
|
||||
AI_STATUS=$(echo "$STATUS_JSON" | python3 -c "
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
"""#1197 - 预览生成:generation_tasks 表新增 is_preview 字段
|
||||
|
||||
Revision ID: 053
|
||||
Revises: 052
|
||||
Create Date: 2026-08-15
|
||||
|
||||
Changes:
|
||||
1. generation_tasks 表新增 is_preview 字段,标记是否为预览生成任务(低清 480p)
|
||||
2. 默认 False,与现有正式生成任务兼容
|
||||
3. 加索引以支持按预览/正式任务筛选
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "053_generation_task_is_preview"
|
||||
down_revision = "052_generation_task_bgm_config"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if context.get_context().dialect.name == "postgresql":
|
||||
# 检查列是否已存在(幂等)
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'generation_tasks' AND column_name = 'is_preview'"
|
||||
)
|
||||
)
|
||||
if result.scalar() is not None:
|
||||
return
|
||||
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("is_preview", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
# 加索引
|
||||
op.create_index(
|
||||
"ix_generation_tasks_is_preview",
|
||||
"generation_tasks",
|
||||
["is_preview"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if context.get_context().dialect.name == "postgresql":
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'generation_tasks' AND column_name = 'is_preview'"
|
||||
)
|
||||
)
|
||||
if result.scalar() is None:
|
||||
return
|
||||
|
||||
op.drop_index("ix_generation_tasks_is_preview", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "is_preview")
|
||||
@@ -7,6 +7,7 @@ from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_preview import router as generation_preview_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.ingest_jobs import router as ingest_jobs_router
|
||||
@@ -87,6 +88,11 @@ api_router.include_router(
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_preview_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
titles_router,
|
||||
prefix="/titles",
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
"""预览生成路由 — Phase 1:单版本预览接口(创建 + 查询)。
|
||||
|
||||
路径前缀:/api/v1/generation/preview(与 /generation/tasks 同体系)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.core.task_enqueue import (
|
||||
GLOBAL_PENDING_LIMIT,
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
CreatePreviewGenerationTaskRequest,
|
||||
PreviewGenerationTaskResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PREVIEW_RESOLUTION = "854x480"
|
||||
|
||||
|
||||
def _calc_preview_resolution(video_ratio: str = "") -> str:
|
||||
"""根据视频比例计算预览分辨率(短边 480,长边按比例)。
|
||||
|
||||
支持的比例:16:9, 9:16, 1:1, 4:3, 3:4, 其他默认 16:9。
|
||||
"""
|
||||
ratio_map = {
|
||||
"16:9": "854x480",
|
||||
"9:16": "480x854",
|
||||
"1:1": "480x480",
|
||||
"4:3": "640x480",
|
||||
"3:4": "480x640",
|
||||
}
|
||||
return ratio_map.get(video_ratio.strip(), PREVIEW_RESOLUTION)
|
||||
|
||||
|
||||
def _mark_task_failed(repo, task, reason: str) -> None:
|
||||
"""入队失败时将任务标记为 failed,避免产生僵尸 pending 数据。"""
|
||||
try:
|
||||
task.mark_failed(error_message=f"入队失败:{reason}")
|
||||
repo.update(task)
|
||||
except Exception:
|
||||
logger.exception("[预览生成] 标记任务失败时异常: task_id=%s", task.id)
|
||||
|
||||
|
||||
def _sign_video_url(raw_url: str) -> str:
|
||||
"""为私有 OSS bucket 的视频 URL 生成预签名下载链接。
|
||||
|
||||
有效期 2 小时,签名失败时降级返回原始 URL。
|
||||
"""
|
||||
if not raw_url:
|
||||
return ""
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
signed = storage.get_download_url(raw_url, expires_seconds=7200)
|
||||
# 如果返回的 URL 与原始 URL 完全不同且不是签名 URL(说明 bucket 未配置),
|
||||
# 降级返回原始 URL
|
||||
if signed and signed != raw_url:
|
||||
return signed
|
||||
if signed == raw_url:
|
||||
return raw_url
|
||||
# signed 为空或与 raw_url 无关,返回原始
|
||||
return raw_url
|
||||
except Exception:
|
||||
logger.warning("[预览] URL签名失败,降级返回原始URL: %s", raw_url[:100], exc_info=True)
|
||||
return raw_url
|
||||
|
||||
|
||||
def _to_preview_response(task, generated_videos: list | None = None) -> PreviewGenerationTaskResponse:
|
||||
"""将领域任务对象转换为预览响应 DTO。
|
||||
|
||||
Args:
|
||||
task: GenerationTask 领域对象
|
||||
generated_videos: 生成的视频列表(可选),取第一个作为 video_url
|
||||
|
||||
Returns:
|
||||
PreviewGenerationTaskResponse
|
||||
"""
|
||||
video_url = ""
|
||||
duration = 0.0
|
||||
file_size = 0
|
||||
if generated_videos:
|
||||
first_video = generated_videos[0]
|
||||
raw_url = getattr(first_video, "file_url", "") or ""
|
||||
# P0 修复:私有 bucket 需要预签名 URL,否则前端 403 → 黑屏
|
||||
video_url = _sign_video_url(raw_url)
|
||||
duration = float(getattr(first_video, "duration", 0.0) or 0.0)
|
||||
file_size = int(getattr(first_video, "file_size", 0) or 0)
|
||||
|
||||
# 从 extra_meta / metadata 中提取统计信息(如果有)
|
||||
extra_meta = getattr(task, "extra_meta", {}) or {}
|
||||
clip_count = int(extra_meta.get("clip_count", len(getattr(task, "asset_ids", [])) or 0))
|
||||
transition_count = int(extra_meta.get("transition_count", max(0, clip_count - 1)))
|
||||
material_usage = extra_meta.get("material_usage", {}) or {}
|
||||
|
||||
# 计算生成耗时
|
||||
generate_duration = 0.0
|
||||
started_at = getattr(task, "started_at", None)
|
||||
completed_at = getattr(task, "completed_at", None)
|
||||
if started_at and completed_at:
|
||||
generate_duration = (completed_at - started_at).total_seconds()
|
||||
|
||||
return PreviewGenerationTaskResponse(
|
||||
task_id=task.id,
|
||||
status=task.status.value if hasattr(task.status, "value") else str(task.status),
|
||||
progress=float(task.progress or 0.0),
|
||||
is_preview=bool(getattr(task, "is_preview", True)),
|
||||
resolution=getattr(task, "resolution", PREVIEW_RESOLUTION) or PREVIEW_RESOLUTION,
|
||||
video_url=video_url,
|
||||
duration=duration,
|
||||
file_size=file_size,
|
||||
clip_count=clip_count,
|
||||
transition_count=transition_count,
|
||||
material_usage=material_usage,
|
||||
error_message=task.error_message or "",
|
||||
created_at=task.created_at,
|
||||
started_at=started_at,
|
||||
finished_at=completed_at,
|
||||
generate_duration=generate_duration,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/preview", response_model=PreviewGenerationTaskResponse, status_code=201)
|
||||
def create_preview_generation_task(
|
||||
request: CreatePreviewGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository=Depends(get_generation_task_repository),
|
||||
) -> PreviewGenerationTaskResponse:
|
||||
"""创建预览生成任务。
|
||||
|
||||
预览为完整时长的低清版(480p + 低码率),效果与正式生成一致,仅清晰度降低。
|
||||
|
||||
Args:
|
||||
request: 预览任务创建请求(template_id + asset_ids 等)
|
||||
|
||||
Returns:
|
||||
201 + 预览任务详情
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
logger.info(
|
||||
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d",
|
||||
user_id,
|
||||
request.template_id,
|
||||
len(request.asset_ids),
|
||||
)
|
||||
|
||||
# 预检查队列限流
|
||||
try:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending + 1 > USER_PENDING_LIMIT:
|
||||
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending + 1, limit=USER_PENDING_LIMIT)
|
||||
if global_pending + 1 > GLOBAL_PENDING_LIMIT:
|
||||
raise GlobalQueueFull(pending_count=global_pending + 1, limit=GLOBAL_PENDING_LIMIT)
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待完成后再提交",
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from e
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
|
||||
try:
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id="",
|
||||
asset_library_id="",
|
||||
strategy_id="",
|
||||
voice_library_id="",
|
||||
template_id=request.template_id,
|
||||
asset_ids=list(request.asset_ids),
|
||||
title_ids=list(request.title_ids),
|
||||
voice_ids=list(request.voice_ids),
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id="",
|
||||
asset_select_mode="",
|
||||
batch_id="",
|
||||
video_title=request.video_title,
|
||||
resolution=_calc_preview_resolution(request.video_ratio),
|
||||
bgm_config=request.bgm_config or {},
|
||||
auto_retry_enabled=False,
|
||||
auto_retry_max=0,
|
||||
is_preview=True,
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning("[预览生成] 创建失败: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后重试") from e
|
||||
|
||||
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
task,
|
||||
generation_task_repository,
|
||||
user_id=user_id,
|
||||
log_prefix="[预览生成]",
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
|
||||
_mark_task_failed(generation_task_repository, task, "任务入队失败")
|
||||
raise HTTPException(status_code=500, detail="任务入队失败,请稍后重试")
|
||||
except UserPendingLimitExceeded:
|
||||
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
_mark_task_failed(generation_task_repository, task, "系统队列已满")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
|
||||
return _to_preview_response(task)
|
||||
|
||||
|
||||
@router.get("/preview/{task_id}", response_model=PreviewGenerationTaskResponse)
|
||||
def get_preview_generation_task(
|
||||
task_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository=Depends(get_generation_task_repository),
|
||||
generated_video_repository=Depends(get_generated_video_repository),
|
||||
) -> PreviewGenerationTaskResponse:
|
||||
"""查询预览生成任务状态。
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
|
||||
Returns:
|
||||
预览任务详情(含状态、进度、结果 URL 等)
|
||||
"""
|
||||
use_case = GetGenerationTaskUseCase(generation_task_repository)
|
||||
task = use_case.execute(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
|
||||
|
||||
# 权限校验:任务必须属于当前用户(统一转 str 比较,避免 UUID/str 类型差异)
|
||||
task_user_id = str(getattr(task, "created_by_user_id", "") or "")
|
||||
if not task_user_id or task_user_id != str(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该任务")
|
||||
|
||||
# 校验是否为预览任务
|
||||
if not getattr(task, "is_preview", False):
|
||||
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
|
||||
|
||||
# 查询生成的视频(取第一个)
|
||||
generated_videos = []
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if status_val == "completed":
|
||||
list_use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||
generated_videos = list_use_case.execute(task_id)
|
||||
|
||||
return _to_preview_response(task, generated_videos=generated_videos)
|
||||
@@ -24,6 +24,94 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
def _build_asset_analyses(
|
||||
asset_ids: list[str],
|
||||
db: Session,
|
||||
) -> dict[str, str]:
|
||||
"""调用 MediaKit 视频理解,返回 {asset_id: 分析文本}.
|
||||
|
||||
如果 MediaKit 不可用或分析失败,返回空 dict(调用方降级处理)。
|
||||
"""
|
||||
if not asset_ids:
|
||||
return {}
|
||||
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
client = get_mediakit_client()
|
||||
if not client.is_available:
|
||||
logger.info("MediaKit 未配置,跳过视频理解分析")
|
||||
return {}
|
||||
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
storage_svc = get_shared_storage_service()
|
||||
|
||||
# 查找素材并获取下载 URL(使用并行列表保持索引对应,避免 URL 重复导致映射覆盖)
|
||||
video_urls: list[str] = []
|
||||
valid_asset_ids: list[str] = []
|
||||
|
||||
for aid in asset_ids[:10]: # MediaKit 单次最多 10 个视频
|
||||
asset = asset_repo.get(aid)
|
||||
if not asset or not asset.storage_key:
|
||||
continue
|
||||
# 只处理视频素材
|
||||
mime = getattr(asset, "mime_type", "")
|
||||
if not mime.startswith("video/"):
|
||||
continue
|
||||
try:
|
||||
url = storage_svc.get_download_url(asset.storage_key)
|
||||
if url:
|
||||
video_urls.append(url)
|
||||
valid_asset_ids.append(aid)
|
||||
except Exception as e:
|
||||
logger.warning("获取素材URL失败: asset_id=%s error=%s", aid, str(e))
|
||||
|
||||
if not video_urls:
|
||||
logger.info("无可用视频素材,跳过视频理解分析")
|
||||
return {}
|
||||
|
||||
# 调用 MediaKit 视频理解
|
||||
prompt = (
|
||||
"请简要描述这段视频的主要内容,包括:场景(室内/室外/具体场所)、"
|
||||
"主体(人物/物体/动物)、动作/活动、氛围/情绪、主要色调。"
|
||||
"控制在100字以内。"
|
||||
)
|
||||
|
||||
# 限制轮询参数以适配 API 网关超时(nginx 60s)
|
||||
# 视频理解最多 30s(poll_interval=2s * max_poll_attempts=15)
|
||||
# 剩余 30s 留给 LLM 调用
|
||||
contents = client.analyze_videos(
|
||||
video_urls=video_urls,
|
||||
prompt=prompt,
|
||||
level="Economy",
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=15,
|
||||
)
|
||||
|
||||
if not contents:
|
||||
logger.warning("MediaKit 视频理解未返回结果")
|
||||
return {}
|
||||
|
||||
# 将结果映射回 asset_id(通过索引对应)
|
||||
analyses: dict[str, str] = {}
|
||||
for i, content in enumerate(contents):
|
||||
if i < len(valid_asset_ids) and content:
|
||||
analyses[valid_asset_ids[i]] = content
|
||||
|
||||
logger.info(
|
||||
"MediaKit 视频理解完成: total=%d analyzed=%d",
|
||||
len(video_urls),
|
||||
len(analyses),
|
||||
)
|
||||
return analyses
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("MediaKit 视频理解异常,将降级到无分析模式: %s", str(e))
|
||||
return {}
|
||||
|
||||
|
||||
@router.post("/ai-recommend", response_model=AIRecommendResponse)
|
||||
def editor_ai_recommend(
|
||||
template_id: str,
|
||||
@@ -46,12 +134,16 @@ def editor_ai_recommend(
|
||||
|
||||
from packages.shared.ai_service import run_ai_recommend
|
||||
|
||||
# 调用 MediaKit 视频理解,获取素材内容分析
|
||||
asset_analyses = _build_asset_analyses(body.asset_ids, db)
|
||||
|
||||
result = run_ai_recommend(
|
||||
plan_id=plan_id,
|
||||
template_id=plan.template_id,
|
||||
asset_ids=body.asset_ids,
|
||||
editing_mode=body.editing_mode,
|
||||
target_duration=body.target_duration,
|
||||
asset_analyses=asset_analyses,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -184,6 +184,29 @@ def editor_generate_cover(
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# 获取第一个视频的下载 URL(用于 MediaKit 抽帧)
|
||||
primary_video_url = None
|
||||
if body.asset_ids and body.cover_type in ("ai_frame", "ai_regenerate"):
|
||||
try:
|
||||
from app.database import get_db_session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
with get_db_session() as session:
|
||||
asset_repo = SQLAlchemyAssetRepository(session)
|
||||
first_asset = asset_repo.get(body.asset_ids[0])
|
||||
if first_asset and first_asset.storage_key:
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_download_url(first_asset.storage_key)
|
||||
logger.info(
|
||||
"获取视频URL用于封面生成: asset_id=%s url=%s",
|
||||
body.asset_ids[0],
|
||||
primary_video_url[:80] if primary_video_url else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("获取视频URL失败,将使用stub封面: %s", str(e))
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
cover_data = run_generate_cover(
|
||||
@@ -191,6 +214,7 @@ def editor_generate_cover(
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
primary_video_url=primary_video_url,
|
||||
)
|
||||
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repo
|
||||
from app.schemas.voice_clone import (
|
||||
CreateVoiceCloneRequest,
|
||||
ListVoiceCloneResponse,
|
||||
VoiceClonePreviewResponse,
|
||||
VoiceCloneProfileResponse,
|
||||
VoiceCloneStatusResponse,
|
||||
)
|
||||
@@ -19,7 +20,7 @@ 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.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.voice_clone.use_cases import (
|
||||
DeleteVoiceCloneUseCase,
|
||||
GetVoiceCloneStatusUseCase,
|
||||
@@ -36,6 +37,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 克隆音色试听缓存(减少重复TTS调用)
|
||||
# key: clone_id, value: (audio_url, duration, file_size, text, timestamp)
|
||||
_clone_preview_cache: dict[str, tuple[str, float, int, str, float]] = {}
|
||||
CLONE_PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
|
||||
# 默认试听文本
|
||||
CLONE_PREVIEW_TEMPLATE = "你好,这是我的克隆音色,很高兴能为你配音。"
|
||||
|
||||
|
||||
def _to_response(profile) -> VoiceCloneProfileResponse:
|
||||
# source_audio_url 是用户传入的原始 URL(可能是外部地址),不做预签名转换
|
||||
@@ -223,3 +231,78 @@ def retry_voice_clone(
|
||||
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
|
||||
|
||||
return _to_response(profile)
|
||||
|
||||
|
||||
@router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse)
|
||||
def get_voice_clone_preview(
|
||||
clone_id: str,
|
||||
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> VoiceClonePreviewResponse:
|
||||
"""获取克隆音色试听音频(实时 TTS 合成)。
|
||||
|
||||
- 克隆音色必须处于 ready 状态
|
||||
- 使用默认试听文本时,结果缓存 7 天
|
||||
- 可传入自定义 text 参数试听不同文本
|
||||
"""
|
||||
import time
|
||||
|
||||
use_case = GetVoiceCloneUseCase(repository)
|
||||
try:
|
||||
profile = use_case.execute(clone_id, authenticated_user.user.id)
|
||||
except VoiceCloneNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
||||
|
||||
if not profile.is_ready:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Voice clone is not ready (current status: {profile.status})",
|
||||
)
|
||||
|
||||
# 有自定义文本时不缓存
|
||||
use_cache = not text.strip()
|
||||
|
||||
if use_cache and clone_id in _clone_preview_cache:
|
||||
audio_url, duration, file_size, cached_text, cached_at = _clone_preview_cache[clone_id]
|
||||
if time.time() - cached_at < CLONE_PREVIEW_CACHE_TTL:
|
||||
return VoiceClonePreviewResponse(
|
||||
clone_id=clone_id,
|
||||
voice_id=profile.voice_id,
|
||||
audio_url=audio_url,
|
||||
text=cached_text,
|
||||
duration=duration,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
# 合成试听音频
|
||||
preview_text = text.strip() or CLONE_PREVIEW_TEMPLATE
|
||||
try:
|
||||
result = cosyvoice.synthesize_speech(
|
||||
text=preview_text,
|
||||
voice_id=profile.voice_id,
|
||||
format="mp3",
|
||||
speed=1.0,
|
||||
)
|
||||
except CosyVoiceError as e:
|
||||
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
|
||||
|
||||
# 缓存(仅默认试听文本)
|
||||
if use_cache:
|
||||
_clone_preview_cache[clone_id] = (
|
||||
result.audio_url,
|
||||
result.duration,
|
||||
result.file_size,
|
||||
preview_text,
|
||||
time.time(),
|
||||
)
|
||||
|
||||
return VoiceClonePreviewResponse(
|
||||
clone_id=clone_id,
|
||||
voice_id=profile.voice_id,
|
||||
audio_url=result.audio_url,
|
||||
text=preview_text,
|
||||
duration=result.duration,
|
||||
file_size=result.file_size,
|
||||
)
|
||||
|
||||
Regular → Executable
+129
-16
@@ -5,6 +5,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Literal, Optional
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
@@ -42,6 +44,7 @@ from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 预置音色试听音频缓存(内存缓存,减少重复TTS调用)
|
||||
# key: voice_id, value: (audio_url, timestamp)
|
||||
@@ -51,6 +54,65 @@ PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
|
||||
PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。"
|
||||
|
||||
|
||||
def _resolve_preset_preview_url(
|
||||
voice_id: str,
|
||||
fallback_url: str,
|
||||
cosyvoice: CosyVoiceService,
|
||||
) -> str:
|
||||
"""为预置音色获取有效的 preview_url.
|
||||
|
||||
优先从内存缓存读取;缓存失效时调用 CosyVoice 重新合成;
|
||||
合成失败时降级返回硬编码 URL(可能已过期,但不会报错)。
|
||||
"""
|
||||
# 检查缓存
|
||||
if voice_id in _preset_preview_cache:
|
||||
audio_url, cached_at = _preset_preview_cache[voice_id]
|
||||
if time.time() - cached_at < PREVIEW_CACHE_TTL:
|
||||
return audio_url
|
||||
|
||||
# 缓存失效,调用 CosyVoice 合成
|
||||
preset = get_preset_voice_by_id(voice_id)
|
||||
if preset is None:
|
||||
return fallback_url
|
||||
|
||||
preview_text = PREVIEW_TEMPLATE.format(name=preset.name)
|
||||
try:
|
||||
result = cosyvoice.synthesize_speech(
|
||||
text=preview_text,
|
||||
voice_id=voice_id,
|
||||
format="mp3",
|
||||
speed=1.0,
|
||||
)
|
||||
audio_url = result.audio_url
|
||||
_preset_preview_cache[voice_id] = (audio_url, time.time())
|
||||
logger.info("Preset voice preview generated: %s", voice_id)
|
||||
return audio_url
|
||||
except Exception as e:
|
||||
logger.warning("Failed to generate preview for %s, using fallback: %s", voice_id, e)
|
||||
return fallback_url
|
||||
|
||||
|
||||
def _resolve_all_preset_preview_urls(
|
||||
presets: list,
|
||||
cosyvoice: CosyVoiceService,
|
||||
) -> dict[str, str]:
|
||||
"""顺序解析所有预置音色的 preview_url.
|
||||
|
||||
采用顺序调用(而非并行)以避免触发 DashScope API 速率限制。
|
||||
首次调用后结果缓存 7 天,后续请求直接命中缓存。
|
||||
|
||||
Returns:
|
||||
voice_id -> preview_url 映射
|
||||
"""
|
||||
result_map: dict[str, str] = {}
|
||||
for p in presets:
|
||||
try:
|
||||
result_map[p.voice_id] = _resolve_preset_preview_url(p.voice_id, p.preview_url, cosyvoice)
|
||||
except Exception:
|
||||
result_map[p.voice_id] = p.preview_url
|
||||
return result_map
|
||||
|
||||
|
||||
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
|
||||
return SQLAlchemyVoiceLibraryRepository(session)
|
||||
|
||||
@@ -118,8 +180,16 @@ def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None
|
||||
)
|
||||
|
||||
|
||||
def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
|
||||
"""将预置音色转换为统一响应格式。"""
|
||||
def _preset_to_unified_response(preset, preview_url_map: dict[str, str] | None = None) -> UnifiedVoiceItemResponse:
|
||||
"""将预置音色转换为统一响应格式。
|
||||
|
||||
Args:
|
||||
preset: 预置音色对象
|
||||
preview_url_map: voice_id -> preview_url 动态映射,优先使用
|
||||
"""
|
||||
preview_url = preset.preview_url
|
||||
if preview_url_map and preset.voice_id in preview_url_map:
|
||||
preview_url = preview_url_map[preset.voice_id]
|
||||
return UnifiedVoiceItemResponse(
|
||||
id=preset.voice_id,
|
||||
type="preset",
|
||||
@@ -129,11 +199,40 @@ def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
|
||||
language=preset.language,
|
||||
voice_id=preset.voice_id,
|
||||
voice_provider="cosyvoice",
|
||||
preview_url=preset.preview_url,
|
||||
preview_url=preview_url,
|
||||
tags=preset.tags or [],
|
||||
)
|
||||
|
||||
|
||||
def _clone_profile_to_unified_response(profile) -> UnifiedVoiceItemResponse:
|
||||
"""将克隆音色档案转换为统一响应格式。
|
||||
|
||||
注意:克隆音色是「音色模型」(可用于 TTS 合成任意文本),
|
||||
不同于配音库条目(具体的配音作品)。
|
||||
"""
|
||||
return UnifiedVoiceItemResponse(
|
||||
id=profile.id,
|
||||
type="clone",
|
||||
name=profile.name,
|
||||
description=profile.description or "",
|
||||
gender=profile.gender or "unknown",
|
||||
language=profile.language or "zh-CN",
|
||||
voice_id=profile.voice_id or "",
|
||||
voice_provider=profile.voice_model or "cosyvoice",
|
||||
audio_url="", # 克隆音色没有预合成音频,需通过 /voice-clones/{id}/preview 试听
|
||||
preview_url="", # 试听需实时合成,前端调用 preview 接口
|
||||
duration=0,
|
||||
file_size=0,
|
||||
status=profile.status.value if hasattr(profile.status, "value") else str(profile.status),
|
||||
tags=[],
|
||||
user_id=profile.user_id,
|
||||
project_id=None,
|
||||
voice_clone_profile_id=profile.id,
|
||||
created_at=profile.created_at,
|
||||
updated_at=profile.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 统一配音列表(预置 + 克隆)====================
|
||||
|
||||
|
||||
@@ -150,6 +249,7 @@ def list_voices_unified(
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> UnifiedVoiceListResponse:
|
||||
"""获取配音列表(预置音色 + 用户克隆音色)。
|
||||
|
||||
@@ -165,19 +265,30 @@ def list_voices_unified(
|
||||
has_preset = type is None or type == "preset"
|
||||
has_clone = type is None or type == "clone"
|
||||
|
||||
# 获取预置音色
|
||||
# 获取预置音色(动态生成 preview_url)
|
||||
if has_preset:
|
||||
preset_items = [_preset_to_unified_response(p) for p in PRESET_VOICES]
|
||||
preview_url_map = _resolve_all_preset_preview_urls(PRESET_VOICES, cosyvoice)
|
||||
preset_items = [_preset_to_unified_response(p, preview_url_map) for p in PRESET_VOICES]
|
||||
preset_count = len(preset_items)
|
||||
|
||||
# 获取克隆音色
|
||||
# 获取克隆音色(从 voice_clone_profile 读取,ready 状态的克隆音色)
|
||||
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, sign_url) for i in clone_items_raw]
|
||||
# status_filter 映射:不传则默认只返回 ready 状态(可用的克隆音色)
|
||||
# 前端可以传 status=all 获取所有状态,或传具体状态过滤
|
||||
filter_status = None
|
||||
if status_filter and status_filter != "all":
|
||||
filter_status = status_filter
|
||||
elif not status_filter:
|
||||
filter_status = "ready"
|
||||
|
||||
clone_profiles = clone_profile_repository.list_by_user(
|
||||
user_id,
|
||||
status=filter_status,
|
||||
limit=limit,
|
||||
offset=skip,
|
||||
)
|
||||
clone_count = clone_profile_repository.count_by_user(user_id, status=filter_status)
|
||||
clone_items = [_clone_profile_to_unified_response(p) for p in clone_profiles]
|
||||
|
||||
# 组装结果
|
||||
if type == "preset":
|
||||
@@ -204,11 +315,15 @@ def list_voices_unified(
|
||||
|
||||
|
||||
@router.get("/presets", response_model=PresetVoiceListResponse)
|
||||
def list_preset_voices() -> PresetVoiceListResponse:
|
||||
def list_preset_voices(
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> PresetVoiceListResponse:
|
||||
"""获取预置音色列表。
|
||||
|
||||
不需要认证,返回所有系统预置的 CosyVoice 音色。
|
||||
preview_url 通过 CosyVoice 动态生成,不依赖硬编码的过期 URL。
|
||||
"""
|
||||
preview_url_map = _resolve_all_preset_preview_urls(PRESET_VOICES, cosyvoice)
|
||||
items = [
|
||||
PresetVoiceItemResponse(
|
||||
voice_id=p.voice_id,
|
||||
@@ -216,7 +331,7 @@ def list_preset_voices() -> PresetVoiceListResponse:
|
||||
description=p.description,
|
||||
gender=p.gender,
|
||||
language=p.language,
|
||||
preview_url=p.preview_url,
|
||||
preview_url=preview_url_map.get(p.voice_id, p.preview_url),
|
||||
tags=p.tags or [],
|
||||
)
|
||||
for p in PRESET_VOICES
|
||||
@@ -236,8 +351,6 @@ def get_preset_voice_preview(
|
||||
- 相同 voice_id 重复调用直接返回缓存的音频URL
|
||||
- 可传入自定义 text 参数试听不同文本
|
||||
"""
|
||||
import time
|
||||
|
||||
preset = get_preset_voice_by_id(voice_id)
|
||||
if preset is None:
|
||||
raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
@@ -122,3 +123,62 @@ class ListGenerationTasksResponse(BaseModel):
|
||||
"""用户级生成任务列表响应(跨 project)。"""
|
||||
|
||||
items: list[GenerationTaskResponse]
|
||||
|
||||
|
||||
# ── 预览生成(Phase 1) ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
"""创建预览生成任务请求。
|
||||
|
||||
仅支持模板模式:template_id + asset_ids 等素材 ID 列表。
|
||||
预览为完整时长低清版(480p + 低码率)。
|
||||
"""
|
||||
|
||||
template_id: str
|
||||
asset_ids: list[str] = Field(default_factory=list)
|
||||
title_ids: list[str] = Field(default_factory=list)
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
||||
duration: float = Field(default=0.0, ge=0, description="期望视频时长(秒),0 表示由模板决定")
|
||||
video_ratio: str = Field(default="", description="视频比例,如 16:9 / 9:16,为空使用模板默认")
|
||||
bgm_config: dict = Field(
|
||||
default_factory=dict,
|
||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
if not self.template_id.strip():
|
||||
raise ValueError("template_id 不能为空")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_asset_ids(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
if not self.asset_ids and not self.title_ids and not self.voice_ids:
|
||||
raise ValueError("asset_ids/title_ids/voice_ids 至少需要提供一个")
|
||||
return self
|
||||
|
||||
|
||||
class PreviewGenerationTaskResponse(BaseModel):
|
||||
"""预览生成任务响应。
|
||||
|
||||
包含任务状态、进度、分辨率、生成结果 URL 等关键字段。
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
status: str
|
||||
progress: float
|
||||
is_preview: bool = True
|
||||
resolution: str = ""
|
||||
video_url: str = ""
|
||||
duration: float = 0.0
|
||||
file_size: int = 0
|
||||
clip_count: int = 0
|
||||
transition_count: int = 0
|
||||
material_usage: dict = Field(default_factory=dict)
|
||||
error_message: str = ""
|
||||
created_at: datetime | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
generate_duration: float = 0.0
|
||||
|
||||
Regular → Executable
+22
@@ -63,3 +63,25 @@ class ListVoiceCloneResponse(BaseModel):
|
||||
|
||||
items: List[VoiceCloneProfileResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class VoiceClonePreviewResponse(BaseModel):
|
||||
"""克隆音色试听响应。"""
|
||||
|
||||
clone_id: str
|
||||
"""音色克隆档案 ID"""
|
||||
|
||||
voice_id: str
|
||||
"""CosyVoice 音色 ID"""
|
||||
|
||||
audio_url: str
|
||||
"""试听音频 URL"""
|
||||
|
||||
text: str
|
||||
"""试听文本"""
|
||||
|
||||
duration: float = 0.0
|
||||
"""音频时长(秒)"""
|
||||
|
||||
file_size: int = 0
|
||||
"""文件大小(字节)"""
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
import axios from "axios"
|
||||
import apiClient from "./client"
|
||||
|
||||
// 类型定义
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 注册
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
|
||||
// 请求密码重置
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 验证邮箱
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ========== 微信登录 ========== */
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
|
||||
// 获取微信授权链接
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 微信回调登录
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 绑定联系方式
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import apiClient from "../client"
|
||||
import type { SendVerificationCodeRequest, BindContactRequest, BindContactResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定联系方式
|
||||
*/
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import apiClient from "../client"
|
||||
import type { User, UserResponse } from "./types"
|
||||
import { normalizeUser } from "./user"
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*/
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
* 保持向后兼容,从子模块 re-export
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
User,
|
||||
UserResponse,
|
||||
WechatAuthUrlResponse,
|
||||
WechatCallbackResponse,
|
||||
SendVerificationCodeRequest,
|
||||
BindContactRequest,
|
||||
BindContactResponse,
|
||||
} from "./types"
|
||||
|
||||
// 用户工具函数
|
||||
export { normalizeUser } from "./user"
|
||||
|
||||
// 登录/注册/登出/刷新
|
||||
export { login, refreshAccessToken, register, logout } from "./login"
|
||||
|
||||
// 当前用户
|
||||
export { getCurrentUser } from "./currentUser"
|
||||
|
||||
// 密码重置
|
||||
export { requestPasswordReset, resetPassword } from "./password"
|
||||
|
||||
// 邮箱验证
|
||||
export { verifyEmail } from "./email"
|
||||
|
||||
// 微信登录
|
||||
export { getWechatAuthUrl, wechatCallback } from "./wechat"
|
||||
|
||||
// 联系方式
|
||||
export { sendVerificationCode, bindContact } from "./contact"
|
||||
@@ -0,0 +1,37 @@
|
||||
import axios from "axios"
|
||||
import apiClient from "../client"
|
||||
import type { LoginRequest, LoginResponse, RegisterRequest } from "./types"
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
*/
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/**
|
||||
* 请求密码重置
|
||||
*/
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 认证相关类型定义
|
||||
*/
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { User, UserResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 规范化用户数据,兼容不同后端返回格式
|
||||
*/
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import apiClient from "../client"
|
||||
import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 获取微信授权链接
|
||||
*/
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回调登录
|
||||
*/
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
@@ -57,7 +57,21 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
|
||||
// 401 → 尝试刷新 Token
|
||||
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||
// 排除 auth 端点:登录/注册/找回密码的 401 是正常业务响应(如密码错误),
|
||||
// 不应触发 token 刷新或登出跳转,走后面的错误提示逻辑即可
|
||||
const requestUrl = originalRequest?.url || ""
|
||||
const isAuthEndpoint =
|
||||
requestUrl.includes("/auth/login") ||
|
||||
requestUrl.includes("/auth/register") ||
|
||||
requestUrl.includes("/auth/forgot-password") ||
|
||||
requestUrl.includes("/auth/reset-password")
|
||||
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
originalRequest &&
|
||||
!originalRequest._retry &&
|
||||
!isAuthEndpoint
|
||||
) {
|
||||
const refreshToken = useAuthStore.getState().refreshToken
|
||||
|
||||
// 无 refresh_token → 直接登出
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
PreviewStatus,
|
||||
CreatePreviewRequest,
|
||||
CreatePreviewResponse,
|
||||
PreviewTaskResponse,
|
||||
} from "./types"
|
||||
|
||||
export { createPreview, getPreviewStatus } from "./preview"
|
||||
@@ -0,0 +1,16 @@
|
||||
import apiClient from "../client"
|
||||
import type { CreatePreviewRequest, CreatePreviewResponse, PreviewTaskResponse } from "./types"
|
||||
|
||||
/** 创建预览生成任务(单版本) */
|
||||
export const createPreview = async (
|
||||
params: CreatePreviewRequest,
|
||||
): Promise<CreatePreviewResponse> => {
|
||||
const response = await apiClient.post<CreatePreviewResponse>("/generation/preview", params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 查询预览任务状态及结果 */
|
||||
export const getPreviewStatus = async (taskId: string): Promise<PreviewTaskResponse> => {
|
||||
const response = await apiClient.get<PreviewTaskResponse>(`/generation/preview/${taskId}`)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** 预览任务状态 */
|
||||
export type PreviewStatus = "pending" | "generating" | "completed" | "failed" | "cancelled"
|
||||
|
||||
/** 创建预览任务请求 */
|
||||
export interface CreatePreviewRequest {
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids?: string[]
|
||||
voice_ids?: string[]
|
||||
video_title?: string
|
||||
duration?: number
|
||||
video_ratio?: string
|
||||
bgm_config?: {
|
||||
enabled: boolean
|
||||
preset_id?: string
|
||||
volume?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建预览任务响应 */
|
||||
export interface CreatePreviewResponse {
|
||||
task_id: string
|
||||
status: PreviewStatus
|
||||
is_preview: boolean
|
||||
resolution: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 预览任务详情响应 */
|
||||
export interface PreviewTaskResponse {
|
||||
task_id: string
|
||||
status: PreviewStatus
|
||||
progress: number
|
||||
is_preview: boolean
|
||||
resolution: string
|
||||
video_url?: string
|
||||
duration?: number
|
||||
file_size?: number
|
||||
clip_count?: number
|
||||
transition_count?: number
|
||||
material_usage?: number
|
||||
error_message?: string
|
||||
created_at: string
|
||||
started_at?: string
|
||||
finished_at?: string
|
||||
generate_duration?: number
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
VoiceCloneListParams,
|
||||
ListVoiceCloneResponse,
|
||||
VoiceCloneStatusResponse,
|
||||
VoiceClonePreviewResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 获取克隆音色列表(返回前端兼容数组) */
|
||||
@@ -85,3 +86,17 @@ export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> =>
|
||||
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取克隆音色试听音频(实时 TTS 合成) */
|
||||
export const getVoiceClonePreview = async (
|
||||
cloneId: string,
|
||||
text?: string,
|
||||
): Promise<VoiceClonePreviewResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (text) searchParams.set("text", text)
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<VoiceClonePreviewResponse>(
|
||||
`/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
VoiceCloneStatusResponse,
|
||||
CreateVoiceCloneRequestFull,
|
||||
VoiceCloneListParams,
|
||||
VoiceClonePreviewResponse,
|
||||
} from "./types"
|
||||
|
||||
// 工具函数
|
||||
@@ -29,4 +30,5 @@ export {
|
||||
updateVoiceClone,
|
||||
getVoiceCloneStatus,
|
||||
retryVoiceClone,
|
||||
getVoiceClonePreview,
|
||||
} from "./clones"
|
||||
|
||||
@@ -90,3 +90,24 @@ export interface VoiceCloneListParams {
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/** 克隆音色试听响应 */
|
||||
export interface VoiceClonePreviewResponse {
|
||||
clone_id: string
|
||||
/** 音色克隆档案 ID */
|
||||
|
||||
voice_id: string
|
||||
/** CosyVoice 音色 ID */
|
||||
|
||||
audio_url: string
|
||||
/** 试听音频 URL */
|
||||
|
||||
text: string
|
||||
/** 试听文本 */
|
||||
|
||||
duration: number
|
||||
/** 音频时长(秒) */
|
||||
|
||||
file_size: number
|
||||
/** 文件大小(字节) */
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Form, message } from "antd"
|
||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
||||
import { useCountdown } from "./useCountdown"
|
||||
|
||||
type ContactType = "email" | "phone"
|
||||
|
||||
interface UseBindContactFormOptions {
|
||||
open: boolean
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定联系方式表单 Hook
|
||||
* 封装表单状态、验证码发送、提交绑定等业务逻辑
|
||||
*/
|
||||
export const useBindContactForm = ({ open, onSuccess }: UseBindContactFormOptions) => {
|
||||
const [activeTab, setActiveTab] = useState<ContactType>("email")
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeLoading, setCodeLoading] = useState(false)
|
||||
const { countdown, isRunning, start: startCountdown, reset: resetCountdown } = useCountdown(60)
|
||||
|
||||
/* ── 打开时重置表单 ── */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields()
|
||||
resetCountdown()
|
||||
}
|
||||
}, [open, form, resetCountdown])
|
||||
|
||||
/* ── 发送验证码 ── */
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const fieldName = activeTab === "email" ? "email" : "phone"
|
||||
const value = form.getFieldValue(fieldName)
|
||||
if (!value) {
|
||||
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
||||
return
|
||||
}
|
||||
setCodeLoading(true)
|
||||
await sendVerificationCode({
|
||||
target: activeTab,
|
||||
value,
|
||||
purpose: "bind",
|
||||
})
|
||||
message.success("验证码已发送")
|
||||
startCountdown()
|
||||
} catch {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setCodeLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 提交绑定 ── */
|
||||
const handleSubmit = async () => {
|
||||
// 1. 表单验证(失败时 Ant Design Form 会自动展示错误信息)
|
||||
let values
|
||||
try {
|
||||
values = await form.validateFields()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 调用绑定接口
|
||||
setLoading(true)
|
||||
try {
|
||||
const payload =
|
||||
activeTab === "email"
|
||||
? { email: values.email, email_code: values.email_code }
|
||||
: { phone: values.phone, phone_code: values.phone_code }
|
||||
|
||||
const result = await bindContact(payload)
|
||||
|
||||
message.success("绑定成功")
|
||||
onSuccess?.(result.user)
|
||||
} catch {
|
||||
// API 错误由全局拦截器统一处理提示
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
form,
|
||||
loading,
|
||||
codeLoading,
|
||||
countdown,
|
||||
codeDisabled: isRunning,
|
||||
handleSendCode,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* 倒计时 Hook
|
||||
* 用于验证码发送等场景的倒计时控制
|
||||
*/
|
||||
export const useCountdown = (initialSeconds = 60) => {
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1)
|
||||
}, 1000)
|
||||
} else if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
const start = useCallback(() => {
|
||||
setCountdown(initialSeconds)
|
||||
}, [initialSeconds])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCountdown(0)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
countdown,
|
||||
isRunning: countdown > 0,
|
||||
start,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
+20
-83
@@ -1,84 +1,20 @@
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { Modal, Tabs, Form, Input, Button, message } from "antd"
|
||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
||||
|
||||
interface BindContactModalProps {
|
||||
open: boolean
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
import React from "react"
|
||||
import { Modal, Tabs, Form, Input, Button } from "antd"
|
||||
import { useBindContactForm } from "./hooks/useBindContactForm"
|
||||
import type { BindContactModalProps } from "./types"
|
||||
|
||||
const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, onCancel }) => {
|
||||
const [activeTab, setActiveTab] = useState<"email" | "phone">("email")
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeLoading, setCodeLoading] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1)
|
||||
}, 1000)
|
||||
} else if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields()
|
||||
setCountdown(0)
|
||||
}
|
||||
}, [open, form])
|
||||
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const value = form.getFieldValue(activeTab === "email" ? "email" : "phone")
|
||||
if (!value) {
|
||||
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
||||
return
|
||||
}
|
||||
setCodeLoading(true)
|
||||
await sendVerificationCode({
|
||||
target: activeTab,
|
||||
value,
|
||||
purpose: "bind",
|
||||
})
|
||||
message.success("验证码已发送")
|
||||
setCountdown(60)
|
||||
} catch (error) {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setCodeLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
|
||||
const payload =
|
||||
activeTab === "email"
|
||||
? { email: values.email, email_code: values.code }
|
||||
: { phone: values.phone, phone_code: values.code }
|
||||
|
||||
const result = await bindContact(payload)
|
||||
|
||||
message.success("绑定成功")
|
||||
onSuccess?.(result.user)
|
||||
} catch (error) {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
form,
|
||||
loading,
|
||||
codeLoading,
|
||||
countdown,
|
||||
codeDisabled,
|
||||
handleSendCode,
|
||||
handleSubmit,
|
||||
} = useBindContactForm({ open, onSuccess, onCancel })
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -111,7 +47,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
<Input placeholder="请输入邮箱地址" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
name="email_code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: "请输入验证码" }]}
|
||||
>
|
||||
@@ -121,7 +57,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
loading={codeLoading}
|
||||
disabled={countdown > 0}
|
||||
disabled={codeDisabled}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
@@ -146,7 +82,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
<Input placeholder="请输入手机号" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
name="phone_code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: "请输入验证码" }]}
|
||||
>
|
||||
@@ -156,7 +92,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
loading={codeLoading}
|
||||
disabled={countdown > 0}
|
||||
disabled={codeDisabled}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
@@ -178,3 +114,4 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
}
|
||||
|
||||
export default BindContactModal
|
||||
export type { BindContactModalProps } from "./types"
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { BindContactResponse } from "@/api/auth"
|
||||
|
||||
/** BindContactModal 组件属性 */
|
||||
export interface BindContactModalProps {
|
||||
/** 是否打开 */
|
||||
open: boolean
|
||||
/** 绑定成功回调 */
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
/** 取消回调 */
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/** 联系方式类型 */
|
||||
export type ContactType = "email" | "phone"
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* PageHead - 页面头部组件(Task 1.4)
|
||||
*
|
||||
* 功能:
|
||||
* - 页面标题展示
|
||||
* - 面包屑导航(自动根据路由生成,也支持手动传入)
|
||||
* - 右侧操作按钮区(slot,由页面自行填充)
|
||||
* - 响应式:移动端简化布局(隐藏面包屑,缩小标题)
|
||||
*
|
||||
* 复用 global.css 中已有的 .xx-page-head 基础样式,
|
||||
* 补充面包屑、操作区等扩展样式。
|
||||
*/
|
||||
import React from "react"
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom"
|
||||
import { RightOutlined, HomeOutlined } from "@ant-design/icons"
|
||||
import "./PageHead.css"
|
||||
|
||||
/* ── 类型定义 ─────────────────────────────────────────────── */
|
||||
|
||||
/** 面包屑项 */
|
||||
export interface BreadcrumbItem {
|
||||
/** 显示文字 */
|
||||
label: string
|
||||
/** 路由路径,不传则为当前页(不可点击) */
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** PageHead 组件属性 */
|
||||
export interface PageHeadProps {
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
/** 页面描述(可选,显示在标题下方) */
|
||||
description?: React.ReactNode
|
||||
/** 面包屑项(可选,不传则自动根据路由生成) */
|
||||
breadcrumb?: BreadcrumbItem[]
|
||||
/** 右侧操作区内容(按钮等) */
|
||||
actions?: React.ReactNode
|
||||
/** 是否隐藏面包屑 */
|
||||
hideBreadcrumb?: boolean
|
||||
}
|
||||
|
||||
/* ── 路由 → 标题映射(用于自动生成面包屑) ────────────────── */
|
||||
|
||||
const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/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/voice-materials": "配音库",
|
||||
"/app/accounts": "账号管理",
|
||||
"/app/duplication": "查重",
|
||||
"/app/duplication/results": "查重结果",
|
||||
}
|
||||
|
||||
/* ── 自动生成面包屑 ─────────────────────────────────────── */
|
||||
|
||||
/** 根据当前路径生成面包屑 */
|
||||
const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => {
|
||||
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }]
|
||||
|
||||
// 首页本身不需要面包屑
|
||||
if (pathname === "/app" || pathname === "/app/dashboard") {
|
||||
return items
|
||||
}
|
||||
|
||||
// 逐级拆分路径,生成中间层级
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
currentPath += `/${segments[i]}`
|
||||
const title = ROUTE_TITLE_MAP[currentPath]
|
||||
|
||||
if (title) {
|
||||
// 最后一级不带 path(当前页面,不可点击)
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: title,
|
||||
path: isLast ? undefined : currentPath,
|
||||
})
|
||||
} else {
|
||||
// 动态路由段(如 :id),用路径片段做 label
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: segments[i],
|
||||
path: isLast ? undefined : currentPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const PageHead: React.FC<PageHeadProps> = ({
|
||||
title,
|
||||
description,
|
||||
breadcrumb,
|
||||
actions,
|
||||
hideBreadcrumb = false,
|
||||
}) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 使用传入的面包屑或自动生成
|
||||
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname)
|
||||
|
||||
// 首页不显示面包屑
|
||||
const showBreadcrumb =
|
||||
!hideBreadcrumb &&
|
||||
breadcrumbItems.length > 1 &&
|
||||
location.pathname !== "/app" &&
|
||||
location.pathname !== "/app/dashboard"
|
||||
|
||||
return (
|
||||
<header className="xx-page-head">
|
||||
<div className="xx-page-head-left">
|
||||
{/* 面包屑导航 */}
|
||||
{showBreadcrumb && (
|
||||
<nav className="xx-page-breadcrumb" aria-label="面包屑导航">
|
||||
<ol>
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1
|
||||
return (
|
||||
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
|
||||
{index > 0 && <RightOutlined className="xx-page-breadcrumb-separator" />}
|
||||
{item.path && !isLast ? (
|
||||
<Link
|
||||
to={item.path}
|
||||
className="xx-page-breadcrumb-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate(item.path!)
|
||||
}}
|
||||
>
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="xx-page-breadcrumb-current" aria-current="page">
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* 标题 + 描述 */}
|
||||
<div className="xx-page-head-title">
|
||||
<h2>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧操作区 */}
|
||||
{actions && <div className="xx-page-head-actions">{actions}</div>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHead
|
||||
@@ -0,0 +1,27 @@
|
||||
/** 路由 → 标题映射(用于自动生成面包屑) */
|
||||
export const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/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/voice-materials": "配音库",
|
||||
"/app/accounts": "账号管理",
|
||||
"/app/duplication": "查重",
|
||||
"/app/duplication/results": "查重结果",
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* PageHead - 页面头部组件(Task 1.4)
|
||||
*
|
||||
* 功能:
|
||||
* - 页面标题展示
|
||||
* - 面包屑导航(自动根据路由生成,也支持手动传入)
|
||||
* - 右侧操作按钮区(slot,由页面自行填充)
|
||||
* - 响应式:移动端简化布局(隐藏面包屑,缩小标题)
|
||||
*
|
||||
* 复用 global.css 中已有的 .xx-page-head 基础样式,
|
||||
* 补充面包屑、操作区等扩展样式。
|
||||
*/
|
||||
import React from "react"
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom"
|
||||
import { RightOutlined, HomeOutlined } from "@ant-design/icons"
|
||||
import type { PageHeadProps } from "./types"
|
||||
import { generateBreadcrumb } from "./utils"
|
||||
import "./PageHead.css"
|
||||
|
||||
const PageHead: React.FC<PageHeadProps> = ({
|
||||
title,
|
||||
description,
|
||||
breadcrumb,
|
||||
actions,
|
||||
hideBreadcrumb = false,
|
||||
}) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 使用传入的面包屑或自动生成
|
||||
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname)
|
||||
|
||||
// 首页不显示面包屑
|
||||
const showBreadcrumb =
|
||||
!hideBreadcrumb &&
|
||||
breadcrumbItems.length > 1 &&
|
||||
location.pathname !== "/app" &&
|
||||
location.pathname !== "/app/dashboard"
|
||||
|
||||
return (
|
||||
<header className="xx-page-head">
|
||||
<div className="xx-page-head-left">
|
||||
{/* 面包屑导航 */}
|
||||
{showBreadcrumb && (
|
||||
<nav className="xx-page-breadcrumb" aria-label="面包屑导航">
|
||||
<ol>
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1
|
||||
return (
|
||||
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
|
||||
{index > 0 && <RightOutlined className="xx-page-breadcrumb-separator" />}
|
||||
{item.path && !isLast ? (
|
||||
<Link
|
||||
to={item.path}
|
||||
className="xx-page-breadcrumb-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate(item.path!)
|
||||
}}
|
||||
>
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="xx-page-breadcrumb-current" aria-current="page">
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* 标题 + 描述 */}
|
||||
<div className="xx-page-head-title">
|
||||
<h2>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧操作区 */}
|
||||
{actions && <div className="xx-page-head-actions">{actions}</div>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHead
|
||||
export type { BreadcrumbItem, PageHeadProps } from "./types"
|
||||
@@ -0,0 +1,23 @@
|
||||
import type React from "react"
|
||||
|
||||
/** 面包屑项 */
|
||||
export interface BreadcrumbItem {
|
||||
/** 显示文字 */
|
||||
label: string
|
||||
/** 路由路径,不传则为当前页(不可点击) */
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** PageHead 组件属性 */
|
||||
export interface PageHeadProps {
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
/** 页面描述(可选,显示在标题下方) */
|
||||
description?: React.ReactNode
|
||||
/** 面包屑项(可选,不传则自动根据路由生成) */
|
||||
breadcrumb?: BreadcrumbItem[]
|
||||
/** 右侧操作区内容(按钮等) */
|
||||
actions?: React.ReactNode
|
||||
/** 是否隐藏面包屑 */
|
||||
hideBreadcrumb?: boolean
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ROUTE_TITLE_MAP } from "./constants"
|
||||
import type { BreadcrumbItem } from "./types"
|
||||
|
||||
/** 根据当前路径生成面包屑 */
|
||||
export const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => {
|
||||
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }]
|
||||
|
||||
// 首页本身不需要面包屑
|
||||
if (pathname === "/app" || pathname === "/app/dashboard") {
|
||||
return items
|
||||
}
|
||||
|
||||
// 逐级拆分路径,生成中间层级
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
currentPath += `/${segments[i]}`
|
||||
const title = ROUTE_TITLE_MAP[currentPath]
|
||||
|
||||
if (title) {
|
||||
// 最后一级不带 path(当前页面,不可点击)
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: title,
|
||||
path: isLast ? undefined : currentPath,
|
||||
})
|
||||
} else {
|
||||
// 动态路由段(如 :id),用路径片段做 label
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: segments[i],
|
||||
path: isLast ? undefined : currentPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* CloneModal — 音色克隆弹窗(共享组件)
|
||||
*
|
||||
* 功能:
|
||||
* - 步骤引导:上传/录制音频 → 填写信息 → 提交克隆
|
||||
* - 两种输入方式:上传文件(wav/mp3/m4a,≤10MB)或浏览器原生录音(MediaRecorder)
|
||||
* - 提交:先 uploadAsset 获取真实 URL,再 createVoiceClone
|
||||
* - 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
|
||||
* - 错误处理:上传失败、格式错误、大小超限、录音权限拒绝等提示
|
||||
*
|
||||
* V21 Design System — 零 antd 直接导入
|
||||
*/
|
||||
import React from "react"
|
||||
import { Modal } from "@/components/ui"
|
||||
import type { CloneModalProps } from "./types/cloneModal"
|
||||
import useCloneModal from "./hooks/useCloneModal"
|
||||
import InputView from "./clone-modal/InputView"
|
||||
import ProgressView from "./clone-modal/ProgressView"
|
||||
import "./clone-modal.css"
|
||||
|
||||
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
||||
const {
|
||||
phase,
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
errorMessage,
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
canSubmit,
|
||||
isProcessing,
|
||||
setVoiceName,
|
||||
setVoiceDescription,
|
||||
setDragActive,
|
||||
handleFileSelect,
|
||||
handleRecordToggle,
|
||||
handleClose,
|
||||
handleSubmit,
|
||||
} = useCloneModal({ open, onClose, onSuccess })
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
title="克隆我的音色"
|
||||
width={560}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
maskClosable={!isProcessing}
|
||||
keyboard={!isProcessing}
|
||||
>
|
||||
{/* 输入阶段 */}
|
||||
{phase === "input" && (
|
||||
<InputView
|
||||
voiceName={voiceName}
|
||||
voiceDescription={voiceDescription}
|
||||
selectedFile={selectedFile}
|
||||
dragActive={dragActive}
|
||||
isRecording={isRecording}
|
||||
recordTime={recordTime}
|
||||
recordedBlob={recordedBlob}
|
||||
errorMessage={errorMessage}
|
||||
canSubmit={canSubmit}
|
||||
onVoiceNameChange={setVoiceName}
|
||||
onVoiceDescChange={setVoiceDescription}
|
||||
onDragActiveChange={setDragActive}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRecordToggle={handleRecordToggle}
|
||||
onClose={handleClose}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 进度 / 完成阶段 */}
|
||||
{phase !== "input" && <ProgressView phase={phase} />}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CloneModal
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ProgressStep } from "./types"
|
||||
|
||||
/** 进度阶段配置 */
|
||||
export const PROGRESS_STEPS: ProgressStep[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
]
|
||||
|
||||
/** 支持的音频扩展名 */
|
||||
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "webm"]
|
||||
|
||||
/** 文件选择器 accept 属性 */
|
||||
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm"
|
||||
|
||||
/** 最大文件大小:10MB */
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** 最长录制时长:5 分钟(秒) */
|
||||
export const MAX_RECORD_SECONDS = 5 * 60
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { MAX_RECORD_SECONDS } from "../constants"
|
||||
import { formatRecordTime } from "../utils"
|
||||
|
||||
/**
|
||||
* 录音 Hook
|
||||
* 封装浏览器 MediaRecorder 录音逻辑,含计时、自动停止、时长上限
|
||||
*/
|
||||
export const useAudioRecorder = (onError?: (msg: string) => void) => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordTime, setRecordTime] = useState(0)
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
||||
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
const isMountedRef = useRef(true)
|
||||
const isStartingRef = useRef(false)
|
||||
|
||||
/** 重置录音状态 */
|
||||
const reset = useCallback(() => {
|
||||
if (!isMountedRef.current) return
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
audioChunksRef.current = []
|
||||
isStartingRef.current = false
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
mediaRecorderRef.current = null
|
||||
}, [])
|
||||
|
||||
/** 开始/停止录制 */
|
||||
const toggleRecord = useCallback(async () => {
|
||||
if (isRecording) {
|
||||
// 停止录制
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 防止快速双击导致重复启动
|
||||
if (isStartingRef.current) return
|
||||
isStartingRef.current = true
|
||||
|
||||
// 开始录制
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
})
|
||||
if (!isMountedRef.current) {
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
isStartingRef.current = false
|
||||
return
|
||||
}
|
||||
const mediaRecorder = new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
audioChunksRef.current = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (!isMountedRef.current) return
|
||||
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器兼容性问题
|
||||
const mimeType = mediaRecorder.mimeType || "audio/webm"
|
||||
const blob = new Blob(audioChunksRef.current, { type: mimeType })
|
||||
setRecordedBlob(blob)
|
||||
// 停止音轨
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
isStartingRef.current = false
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => {
|
||||
const next = prev + 1
|
||||
if (next >= MAX_RECORD_SECONDS) {
|
||||
// 达到时长上限,自动停止
|
||||
setTimeout(() => {
|
||||
if (!isMountedRef.current) return
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
onError?.("已达最长录制时长(5分钟),已自动停止")
|
||||
}, 0)
|
||||
return MAX_RECORD_SECONDS
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, 1000)
|
||||
} catch {
|
||||
isStartingRef.current = false
|
||||
onError?.("无法访问麦克风,请检查浏览器权限设置")
|
||||
}
|
||||
}, [isRecording, onError])
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
isStartingRef.current = false
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
formattedTime: formatRecordTime(recordTime),
|
||||
toggleRecord,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset } from "@/api/assets"
|
||||
import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants"
|
||||
import { validateFile } from "./utils"
|
||||
import { useAudioRecorder } from "./hooks/useAudioRecorder"
|
||||
import type { CloneModalProps, ModalPhase } from "./types"
|
||||
import "./clone-modal.css"
|
||||
|
||||
/** 根据 MIME 类型推断文件扩展名 */
|
||||
const getExtensionFromMime = (mime: string): string => {
|
||||
if (mime.includes("webm")) return "webm"
|
||||
if (mime.includes("mp4") || mime.includes("m4a")) return "m4a"
|
||||
if (mime.includes("ogg")) return "ogg"
|
||||
if (mime.includes("wav")) return "wav"
|
||||
return "webm"
|
||||
}
|
||||
|
||||
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)
|
||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
||||
const cloneCounterRef = useRef(1)
|
||||
/** 组件挂载状态标记,防止卸载后更新 state */
|
||||
const isMountedRef = useRef(true)
|
||||
const isSubmittingRef = useRef(false)
|
||||
|
||||
/* ── 录音 Hook ──────────────────────────────────── */
|
||||
const {
|
||||
isRecording,
|
||||
recordedBlob,
|
||||
formattedTime,
|
||||
toggleRecord,
|
||||
reset: resetRecorder,
|
||||
} = useAudioRecorder(setErrorMessage)
|
||||
|
||||
/** 生成下一个默认音色名称 */
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
const name = `我的声音 ${cloneCounterRef.current}`
|
||||
cloneCounterRef.current += 1
|
||||
return name
|
||||
}, [])
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
setErrorMessage("")
|
||||
resetRecorder()
|
||||
}, [getNextDefaultName, resetRecorder])
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [resetState, onClose])
|
||||
|
||||
/** 弹窗打开时重置状态 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState()
|
||||
}
|
||||
}, [open, resetState])
|
||||
|
||||
/** 组件挂载/卸载标记 + 清理定时器 */
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 文件上传 ──────────────────────────────────── */
|
||||
|
||||
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 {
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
resetRecorder()
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
resetRecorder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 计算属性 ──────────────────────────────────── */
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
const canSubmit = hasAudio && !isProcessing
|
||||
|
||||
const progressIndex = PROGRESS_STEPS.findIndex((s) => s.key === phase)
|
||||
|
||||
/* ── 提交 ──────────────────────────────────────── */
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const name = voiceName.trim()
|
||||
if (!name || name.length < 2 || name.length > 20) {
|
||||
setErrorMessage("音色名称需在 2-20 个字符之间")
|
||||
return
|
||||
}
|
||||
if (!hasAudio) {
|
||||
setErrorMessage("请上传音频文件或录制一段声音")
|
||||
return
|
||||
}
|
||||
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
setPhase("uploading")
|
||||
|
||||
let fileToUpload: File
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile
|
||||
} else {
|
||||
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配
|
||||
const mimeType = recordedBlob?.type || "audio/webm"
|
||||
const ext = getExtensionFromMime(mimeType)
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, {
|
||||
type: mimeType,
|
||||
})
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
|
||||
// 组件已卸载则中止后续操作
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
description: voiceDescription.trim() || undefined,
|
||||
audio_url: uploadResult.url,
|
||||
})
|
||||
|
||||
// 组件已卸载则中止后续操作
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
isSubmittingRef.current = false
|
||||
// 阶段 3:完成
|
||||
setPhase("done")
|
||||
|
||||
// 2秒后自动关闭
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
handleClose()
|
||||
}
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
isSubmittingRef.current = false
|
||||
// 组件已卸载则不更新 state
|
||||
if (!isMountedRef.current) return
|
||||
setPhase("input")
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
<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-divider">
|
||||
<div className="xx-clonemodal-divider-line" />
|
||||
<span className="xx-clonemodal-divider-text">或</span>
|
||||
<div className="xx-clonemodal-divider-line" />
|
||||
</div>
|
||||
|
||||
{/* 录制区域 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">直接录制</label>
|
||||
<div className="xx-clonemodal-record-area">
|
||||
<div className="xx-clonemodal-record-info">
|
||||
<p className="xx-clonemodal-record-hint">
|
||||
{isRecording
|
||||
? `录制中 ${formattedTime}`
|
||||
: recordedBlob
|
||||
? `已录制 ${formattedTime}`
|
||||
: "点击按钮开始录制(最长 5 分钟)"}
|
||||
</p>
|
||||
{isRecording && (
|
||||
<div className="xx-clonemodal-record-wave">
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
||||
onClick={toggleRecord}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* ── 进度阶段(上传中 / 克隆中) ────────────── */}
|
||||
{isProcessing && (
|
||||
<div className="xx-clonemodal-progress-body">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="xx-clonemodal-steps-progress">
|
||||
{PROGRESS_STEPS.map((step, idx) => {
|
||||
const isActive = idx === progressIndex
|
||||
const isDone = idx < progressIndex
|
||||
const stepClass = [
|
||||
"xx-clonemodal-step-progress",
|
||||
isActive ? "xx-clonemodal-step-progress--active" : "",
|
||||
isDone ? "xx-clonemodal-step-progress--done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div
|
||||
className={`xx-clonemodal-step-connector${isDone ? " xx-clonemodal-step-connector--done" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<div className={stepClass}>
|
||||
<div className="xx-clonemodal-step-icon">{isDone ? "✓" : step.icon}</div>
|
||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前阶段描述 */}
|
||||
<div className="xx-clonemodal-progress-info">
|
||||
{phase === "uploading" && (
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner" />
|
||||
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
||||
</>
|
||||
)}
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
||||
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
||||
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 完成阶段 ────────────────────────────────── */}
|
||||
{phase === "done" && (
|
||||
<div className="xx-clonemodal-progress-body">
|
||||
{/* 步骤指示器(全部完成) */}
|
||||
<div className="xx-clonemodal-steps-progress">
|
||||
{PROGRESS_STEPS.map((step, idx) => (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div className="xx-clonemodal-step-connector xx-clonemodal-step-connector--done" />
|
||||
)}
|
||||
<div className="xx-clonemodal-step-progress xx-clonemodal-step-progress--done">
|
||||
<div className="xx-clonemodal-step-icon">✓</div>
|
||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CloneModal
|
||||
export type { CloneModalProps, ModalPhase } from "./types"
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
/** 弹窗阶段 */
|
||||
export type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
||||
|
||||
/** CloneModal 组件属性 */
|
||||
export interface CloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void
|
||||
}
|
||||
|
||||
/** 进度步骤配置 */
|
||||
export interface ProgressStep {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "./constants"
|
||||
|
||||
/**
|
||||
* 验证音频文件
|
||||
* @returns 错误信息,null 表示验证通过
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
|
||||
/** 格式化录制时间 mm:ss */
|
||||
export 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")}`
|
||||
}
|
||||
@@ -31,8 +31,8 @@ export const useLogin = () => {
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, data.access_token, refreshToken)
|
||||
|
||||
// 跳转到登录前页面或首页
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
// 跳转到登录前页面或仪表盘(与 Login.tsx onFinish 保持一致)
|
||||
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
return data
|
||||
|
||||
@@ -3531,52 +3531,143 @@
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
}
|
||||
|
||||
.tts-voice-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
.tts-voice-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-secondary, #f3f4f6);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.tts-voice-card {
|
||||
.tts-voice-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 6px;
|
||||
border: 1px solid var(--border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--bg-surface, #fff);
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tts-voice-card:hover:not(:disabled) {
|
||||
border-color: var(--primary-300, #93c5fd);
|
||||
color: var(--primary-600, #2563eb);
|
||||
background: var(--primary-50, #eff6ff);
|
||||
.tts-voice-tab:hover {
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.tts-voice-card.active {
|
||||
border-color: var(--primary-500, #3b82f6);
|
||||
.tts-voice-tab.active {
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--primary-600, #2563eb);
|
||||
background: var(--primary-50, #eff6ff);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.tts-voice-tab-count {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
background: var(--primary-100, #dbeafe);
|
||||
color: var(--primary-600, #2563eb);
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tts-voice-card:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
.tts-voice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tts-voice-card-icon {
|
||||
font-size: 22px;
|
||||
.tts-voice-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--bg-surface, #fff);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tts-voice-card-name {
|
||||
.tts-voice-item:hover {
|
||||
border-color: var(--primary-300, #93c5fd);
|
||||
background: var(--primary-50, #eff6ff);
|
||||
}
|
||||
|
||||
.tts-voice-item.selected {
|
||||
border-color: var(--primary-500, #3b82f6);
|
||||
background: var(--primary-50, #eff6ff);
|
||||
}
|
||||
|
||||
.tts-voice-item-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary, #f3f4f6);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tts-voice-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tts-voice-item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1f2937);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tts-voice-item-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tts-voice-item-play {
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: var(--primary-500, #3b82f6);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tts-voice-item-play:hover {
|
||||
background: var(--primary-600, #2563eb);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.tts-voice-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
background: var(--bg-secondary, #f3f4f6);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
/* ── 滑块区域 ── */
|
||||
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
import React from "react"
|
||||
import type { PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../../../types"
|
||||
import { GRID_POSITIONS, ANIM_OPTIONS, SLIDE_DIR_OPTIONS } from "../constants"
|
||||
|
||||
export interface LayerConfigProps {
|
||||
layer: PipLayer
|
||||
totalDuration: number
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
onGridClick: (pos: PipGridPosition) => void
|
||||
onWidthChange: (val: number) => void
|
||||
onHeightChange: (val: number) => void
|
||||
}
|
||||
|
||||
export const LayerConfig: React.FC<LayerConfigProps> = ({
|
||||
layer,
|
||||
totalDuration,
|
||||
onUpdate,
|
||||
onGridClick,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="pip-config-area">
|
||||
{/* ── 素材类型 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">素材类型</label>
|
||||
<div className="pip-type-btns">
|
||||
<button
|
||||
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
|
||||
onClick={() => onUpdate(layer.id, { material_type: "image" })}
|
||||
>
|
||||
🖼️ 图片
|
||||
</button>
|
||||
<button
|
||||
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
|
||||
onClick={() => onUpdate(layer.id, { material_type: "video" })}
|
||||
>
|
||||
🎬 视频
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 素材 URL ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">
|
||||
{layer.material_type === "image" ? "图片" : "视频"} URL
|
||||
</label>
|
||||
<input
|
||||
className="pip-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
layer.material_type === "image"
|
||||
? "https://example.com/image.png"
|
||||
: "https://example.com/video.mp4"
|
||||
}
|
||||
value={layer.material_url}
|
||||
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 位置:九宫格 + 坐标 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">位置</label>
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
|
||||
<div className="pip-grid">
|
||||
{GRID_POSITIONS.map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
|
||||
onClick={() => onGridClick(pos)}
|
||||
>
|
||||
<span className="pip-grid-dot" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pip-field-row" style={{ flex: 1 }}>
|
||||
<div>
|
||||
<label className="pip-field-label">X (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.x}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val) && val >= 0 && val <= 100) onUpdate(layer.id, { x: val })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">Y (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.y}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val) && val >= 0 && val <= 100) onUpdate(layer.id, { y: val })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 尺寸 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">尺寸</label>
|
||||
<div className="pip-slider-row">
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>宽</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={layer.width}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val)) onWidthChange(val)
|
||||
}}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.width}%</span>
|
||||
</div>
|
||||
<div className="pip-slider-row" style={{ marginTop: 6 }}>
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>高</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={layer.height}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val)) onHeightChange(val)
|
||||
}}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.height}%</span>
|
||||
</div>
|
||||
<div
|
||||
className="pip-lock-row"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
|
||||
>
|
||||
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
|
||||
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 圆角 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">圆角</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={layer.border_radius}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val)) onUpdate(layer.id, { border_radius: val })
|
||||
}}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.border_radius}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 透明度 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">透明度</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.opacity}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val)) onUpdate(layer.id, { opacity: val })
|
||||
}}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.opacity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 时间 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">时间</label>
|
||||
<div className="pip-field-row">
|
||||
<div>
|
||||
<label className="pip-field-label">开始 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={layer.start_time}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val) && val >= 0 && val <= totalDuration)
|
||||
onUpdate(layer.id, { start_time: val })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">持续 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={layer.duration}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val) && val > 0 && val <= totalDuration)
|
||||
onUpdate(layer.id, { duration: val })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 入场动画 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">入场动画</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={layer.animation}
|
||||
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
|
||||
>
|
||||
{ANIM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 滑入方向(仅 slide_in 时显示) */}
|
||||
{layer.animation === "slide_in" && (
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">滑入方向</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={layer.slide_direction}
|
||||
onChange={(e) =>
|
||||
onUpdate(layer.id, {
|
||||
slide_direction: e.target.value as PipSlideDirection,
|
||||
})
|
||||
}
|
||||
>
|
||||
{SLIDE_DIR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react"
|
||||
import type { PipLayer } from "../../../types"
|
||||
import { LAYER_COLORS } from "../constants"
|
||||
|
||||
export interface LayerListProps {
|
||||
layers: PipLayer[]
|
||||
selectedId: string
|
||||
onSelect: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export const LayerList: React.FC<LayerListProps> = ({ layers, selectedId, onSelect, onDelete }) => {
|
||||
if (layers.length === 0) {
|
||||
return (
|
||||
<div className="pip-layer-list">
|
||||
<div className="pip-layer-empty">暂无图层,点击上方添加</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pip-layer-list">
|
||||
{layers.map((layer, idx) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
|
||||
onClick={() => onSelect(layer.id)}
|
||||
>
|
||||
{layer.thumbnail_url || layer.material_url ? (
|
||||
<img
|
||||
className="pip-layer-thumb"
|
||||
src={layer.thumbnail_url || layer.material_url || undefined}
|
||||
alt={layer.name}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="pip-layer-thumb"
|
||||
style={{
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="pip-layer-name">{layer.name}</span>
|
||||
<button
|
||||
className="pip-layer-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(layer.id)
|
||||
}}
|
||||
title="删除图层"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React from "react"
|
||||
import type { PipLayer } from "../../../types"
|
||||
import { LAYER_COLORS } from "../constants"
|
||||
|
||||
export interface PipPreviewProps {
|
||||
layers: PipLayer[]
|
||||
selectedId: string
|
||||
}
|
||||
|
||||
export const PipPreview: React.FC<PipPreviewProps> = ({ layers, selectedId }) => {
|
||||
return (
|
||||
<div className="pip-preview-box">
|
||||
{layers.map((layer, idx) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${layer.x}%`,
|
||||
top: `${layer.y}%`,
|
||||
width: `${layer.width}%`,
|
||||
height: `${layer.height}%`,
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
opacity: layer.opacity / 100,
|
||||
borderRadius: `${layer.border_radius}%`,
|
||||
zIndex: layer.z_index,
|
||||
}}
|
||||
>
|
||||
<span className="pip-preview-label">{layer.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PipGridPosition, PipAnimType, PipSlideDirection } from "../../types"
|
||||
|
||||
/** 九宫格位置 → 百分比坐标映射 */
|
||||
export const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
|
||||
top_left: { x: 5, y: 5 },
|
||||
top_center: { x: 37.5, y: 5 },
|
||||
top_right: { x: 70, y: 5 },
|
||||
center_left: { x: 5, y: 37.5 },
|
||||
center: { x: 37.5, y: 37.5 },
|
||||
center_right: { x: 70, y: 37.5 },
|
||||
bottom_left: { x: 5, y: 70 },
|
||||
bottom_center: { x: 37.5, y: 70 },
|
||||
bottom_right: { x: 70, y: 70 },
|
||||
}
|
||||
|
||||
/** 九宫格位置选项 */
|
||||
export const GRID_POSITIONS: PipGridPosition[] = [
|
||||
"top_left",
|
||||
"top_center",
|
||||
"top_right",
|
||||
"center_left",
|
||||
"center",
|
||||
"center_right",
|
||||
"bottom_left",
|
||||
"bottom_center",
|
||||
"bottom_right",
|
||||
]
|
||||
|
||||
/** 入场动画选项 */
|
||||
export const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade_in", label: "淡入" },
|
||||
{ value: "slide_in", label: "滑入" },
|
||||
]
|
||||
|
||||
/** 滑入方向选项 */
|
||||
export const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
|
||||
{ value: "left", label: "← 左" },
|
||||
{ value: "right", label: "→ 右" },
|
||||
{ value: "up", label: "↑ 上" },
|
||||
{ value: "down", label: "↓ 下" },
|
||||
]
|
||||
|
||||
/** 预览图层颜色池 */
|
||||
export const LAYER_COLORS = [
|
||||
"rgba(22,119,255,0.5)",
|
||||
"rgba(82,196,26,0.5)",
|
||||
"rgba(250,173,20,0.5)",
|
||||
"rgba(255,77,79,0.5)",
|
||||
"rgba(114,46,209,0.5)",
|
||||
"rgba(19,194,194,0.5)",
|
||||
]
|
||||
|
||||
let layerIdCounter = 0
|
||||
export const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import type { PipConfig, PipLayer, PipGridPosition } from "../../../types"
|
||||
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../../../types"
|
||||
import { GRID_POSITION_MAP, genLayerId } from "../constants"
|
||||
|
||||
const MIN_DIMENSION = 10
|
||||
const MAX_DIMENSION = 80
|
||||
|
||||
interface UsePipConfigPanelOptions {
|
||||
config: PipConfig
|
||||
onChange: (config: PipConfig) => void
|
||||
}
|
||||
|
||||
export const usePipConfigPanel = ({ config, onChange }: UsePipConfigPanelOptions) => {
|
||||
const [selectedId, setSelectedId] = useState<string>("")
|
||||
|
||||
const selectedLayer = useMemo(
|
||||
() => config.layers.find((l) => l.id === selectedId) ?? null,
|
||||
[config.layers, selectedId],
|
||||
)
|
||||
|
||||
/* ── 添加图层 ── */
|
||||
const handleAddLayer = useCallback(() => {
|
||||
const newLayer: PipLayer = {
|
||||
...DEFAULT_PIP_LAYER,
|
||||
id: genLayerId(),
|
||||
name: `图层 ${config.layers.length + 1}`,
|
||||
z_index: config.layers.length + 1,
|
||||
}
|
||||
onChange({
|
||||
...config,
|
||||
layers: [...config.layers, newLayer],
|
||||
})
|
||||
setSelectedId(newLayer.id)
|
||||
}, [config, onChange])
|
||||
|
||||
/* ── 删除图层 ── */
|
||||
const handleDeleteLayer = useCallback(
|
||||
(id: string) => {
|
||||
const newLayers = config.layers.filter((l) => l.id !== id)
|
||||
onChange({ ...config, layers: newLayers })
|
||||
if (selectedId === id) {
|
||||
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "")
|
||||
}
|
||||
},
|
||||
[config, onChange, selectedId],
|
||||
)
|
||||
|
||||
/* ── 更新图层 ── */
|
||||
const updateLayer = useCallback(
|
||||
(id: string, partial: Partial<PipLayer>) => {
|
||||
onChange({
|
||||
...config,
|
||||
layers: config.layers.map((l) => (l.id === id ? { ...l, ...partial } : l)),
|
||||
})
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 切换启用 ── */
|
||||
const handleEnableToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
onChange({ ...config, enabled: checked })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_PIP_CONFIG })
|
||||
setSelectedId("")
|
||||
}, [onChange])
|
||||
|
||||
/* ── 九宫格点击 ── */
|
||||
const handleGridClick = useCallback(
|
||||
(pos: PipGridPosition) => {
|
||||
if (!selectedLayer) return
|
||||
const coords = GRID_POSITION_MAP[pos]
|
||||
updateLayer(selectedLayer.id, {
|
||||
grid_position: pos,
|
||||
x: coords.x,
|
||||
y: coords.y,
|
||||
})
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
)
|
||||
|
||||
/* ── 宽高变化 ── */
|
||||
const handleWidthChange = useCallback(
|
||||
(val: number) => {
|
||||
if (!selectedLayer) return
|
||||
const partial: Partial<PipLayer> = { width: val }
|
||||
if (selectedLayer.aspect_lock && selectedLayer.height > 0) {
|
||||
const ratio = selectedLayer.width / selectedLayer.height
|
||||
const newHeight = Math.round(val / ratio)
|
||||
partial.height = Math.max(MIN_DIMENSION, Math.min(MAX_DIMENSION, newHeight))
|
||||
}
|
||||
updateLayer(selectedLayer.id, partial)
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
)
|
||||
|
||||
const handleHeightChange = useCallback(
|
||||
(val: number) => {
|
||||
if (!selectedLayer) return
|
||||
const partial: Partial<PipLayer> = { height: val }
|
||||
if (selectedLayer.aspect_lock && selectedLayer.width > 0 && selectedLayer.height > 0) {
|
||||
const ratio = selectedLayer.width / selectedLayer.height
|
||||
const newWidth = Math.round(val * ratio)
|
||||
partial.width = Math.max(MIN_DIMENSION, Math.min(MAX_DIMENSION, newWidth))
|
||||
}
|
||||
updateLayer(selectedLayer.id, partial)
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
)
|
||||
|
||||
return {
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
selectedLayer,
|
||||
handleAddLayer,
|
||||
handleDeleteLayer,
|
||||
updateLayer,
|
||||
handleEnableToggle,
|
||||
handleReset,
|
||||
handleGridClick,
|
||||
handleWidthChange,
|
||||
handleHeightChange,
|
||||
}
|
||||
}
|
||||
+31
-17
@@ -4,12 +4,14 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { PipConfig } from "@/pages/editing-planner/types"
|
||||
import LayerList from "./pip-config/LayerList"
|
||||
import LayerConfig from "./pip-config/LayerConfig"
|
||||
import { usePipLayers } from "@/pages/editing-planner/hooks/usePipLayers"
|
||||
import type { PipConfig } from "../../types"
|
||||
|
||||
interface PipConfigPanelProps {
|
||||
import { usePipConfigPanel } from "./hooks/usePipConfigPanel"
|
||||
import { LayerList } from "./components/LayerList"
|
||||
import { PipPreview } from "./components/PipPreview"
|
||||
import { LayerConfig } from "./components/LayerConfig"
|
||||
|
||||
export interface PipConfigPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: PipConfig
|
||||
@@ -36,7 +38,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
handleGridClick,
|
||||
handleWidthChange,
|
||||
handleHeightChange,
|
||||
} = usePipLayers({ config, onChange })
|
||||
} = usePipConfigPanel({ config, onChange })
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -50,7 +52,9 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
{/* ═══ 顶部工具栏 ═══ */}
|
||||
<div className="pip-toolbar">
|
||||
<div className="pip-toolbar-left">
|
||||
<span style={{ fontSize: 13, color: "#666" }}>共 {config.layers.length} 个图层</span>
|
||||
<button className="pip-add-btn" onClick={handleAddLayer}>
|
||||
+ 添加图层
|
||||
</button>
|
||||
</div>
|
||||
<div className="pip-enable-switch">
|
||||
<span>启用</span>
|
||||
@@ -60,22 +64,32 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
|
||||
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
|
||||
<div className="pip-body">
|
||||
{/* 左侧图层列表 */}
|
||||
<LayerList
|
||||
layers={config.layers}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onAdd={handleAddLayer}
|
||||
onDelete={handleDeleteLayer}
|
||||
/>
|
||||
<LayerConfig
|
||||
layer={selectedLayer}
|
||||
layers={config.layers}
|
||||
totalDuration={totalDuration}
|
||||
onUpdate={updateLayer}
|
||||
onGridClick={handleGridClick}
|
||||
onWidthChange={handleWidthChange}
|
||||
onHeightChange={handleHeightChange}
|
||||
/>
|
||||
|
||||
{/* 右侧配置区 */}
|
||||
{!selectedLayer ? (
|
||||
<div className="pip-config-area">
|
||||
<div className="pip-config-empty">选择或添加图层以配置</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PipPreview layers={config.layers} selectedId={selectedId} />
|
||||
<LayerConfig
|
||||
layer={selectedLayer}
|
||||
totalDuration={totalDuration}
|
||||
onUpdate={updateLayer}
|
||||
onGridClick={handleGridClick}
|
||||
onWidthChange={handleWidthChange}
|
||||
onHeightChange={handleHeightChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from "react"
|
||||
import type { StickerItem } from "../../../types"
|
||||
|
||||
export interface StickerListProps {
|
||||
items: StickerItem[]
|
||||
selectedId: string | null
|
||||
onSelect: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
export const StickerList: React.FC<StickerListProps> = ({
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onRemove,
|
||||
}) => {
|
||||
if (items.length === 0) return null
|
||||
|
||||
const getStickerDisplay = (item: StickerItem) => {
|
||||
if (item.type === "emoji") return item.content
|
||||
if (item.type === "text") return item.content.slice(0, 10)
|
||||
return "🖼"
|
||||
}
|
||||
|
||||
const getStickerName = (item: StickerItem) => {
|
||||
if (item.type === "text") return item.content.slice(0, 10)
|
||||
if (item.type === "emoji") return "表情贴纸"
|
||||
return "图片贴纸"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticker-list-section">
|
||||
<div className="sticker-section-title">已添加贴纸 ({items.length})</div>
|
||||
<div className="sticker-list">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<span className="sticker-list-icon">{getStickerDisplay(item)}</span>
|
||||
<span className="sticker-list-name">{getStickerName(item)}</span>
|
||||
<button
|
||||
className="sticker-list-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(item.id)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import React from "react"
|
||||
import type { StickerItem, TextStickerPreset } from "../../../types"
|
||||
import { TEXT_STICKER_PRESET_LABELS } from "../../../types"
|
||||
import { TEXT_PRESET_STYLES } from "../constants"
|
||||
|
||||
export interface StickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
totalDuration: number
|
||||
onChange: (id: string, partial: Partial<StickerItem>) => void
|
||||
}
|
||||
|
||||
export const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
|
||||
sticker,
|
||||
totalDuration,
|
||||
onChange,
|
||||
}) => {
|
||||
const update = (partial: Partial<StickerItem>) => {
|
||||
onChange(sticker.id, partial)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticker-props-section">
|
||||
<div className="sticker-section-title">属性调整</div>
|
||||
|
||||
{/* 位置 X */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">位置 X</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sticker.x}
|
||||
onChange={(e) => update({ x: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.x}%</span>
|
||||
</div>
|
||||
|
||||
{/* 位置 Y */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">位置 Y</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sticker.y}
|
||||
onChange={(e) => update({ y: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.y}%</span>
|
||||
</div>
|
||||
|
||||
{/* 尺寸 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">大小</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={5}
|
||||
max={50}
|
||||
value={sticker.width}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
width: Number(e.target.value),
|
||||
height: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.width}%</span>
|
||||
</div>
|
||||
|
||||
{/* 旋转 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">旋转</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={-180}
|
||||
max={180}
|
||||
value={sticker.rotation}
|
||||
onChange={(e) => update({ rotation: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.rotation}°</span>
|
||||
</div>
|
||||
|
||||
{/* 透明度 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">透明度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sticker.opacity}
|
||||
onChange={(e) => update({ opacity: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.opacity}%</span>
|
||||
</div>
|
||||
|
||||
{/* 时间 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">开始</span>
|
||||
<input
|
||||
type="number"
|
||||
className="sticker-prop-number"
|
||||
min={0}
|
||||
max={totalDuration}
|
||||
step={0.1}
|
||||
value={sticker.start_time}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val) && val >= 0 && val <= totalDuration) update({ start_time: val })
|
||||
}}
|
||||
/>
|
||||
<span className="sticker-prop-label">时长</span>
|
||||
<input
|
||||
type="number"
|
||||
className="sticker-prop-number"
|
||||
min={0}
|
||||
max={totalDuration}
|
||||
step={0.1}
|
||||
value={sticker.duration}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val) && val > 0 && val <= totalDuration) update({ duration: val })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文字贴纸特有属性 */}
|
||||
{sticker.type === "text" && (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) => update({ text_preset: e.target.value as TextStickerPreset })}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => update({ font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => update({ text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.width}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import React, { useState } from "react"
|
||||
import type { StickerType, TextStickerPreset } from "../../../types"
|
||||
import { TEXT_STICKER_PRESET_LABELS } from "../../../types"
|
||||
import { EMOJI_LIST, TEXT_PRESET_STYLES } from "../constants"
|
||||
|
||||
export interface StickerTabsProps {
|
||||
activeTab: StickerType
|
||||
onTabChange: (tab: StickerType) => void
|
||||
textInput: string
|
||||
onTextInputChange: (val: string) => void
|
||||
onAddEmoji: (emoji: string) => void
|
||||
onAddImage: (url: string) => void
|
||||
onAddText: (text: string) => void
|
||||
}
|
||||
|
||||
export const StickerTabs: React.FC<StickerTabsProps> = ({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
textInput,
|
||||
onTextInputChange,
|
||||
onAddEmoji,
|
||||
onAddImage,
|
||||
onAddText,
|
||||
}) => {
|
||||
const [imageUrl, setImageUrl] = useState("")
|
||||
|
||||
const handleImageAdd = () => {
|
||||
if (imageUrl.trim()) {
|
||||
onAddImage(imageUrl.trim())
|
||||
setImageUrl("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextAdd = () => {
|
||||
if (textInput.trim()) {
|
||||
onAddText(textInput.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 类型 Tab */}
|
||||
<div className="sticker-tabs">
|
||||
{(["emoji", "image", "text"] as StickerType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`sticker-tab${activeTab === t ? " active" : ""}`}
|
||||
onClick={() => onTabChange(t)}
|
||||
>
|
||||
{t === "emoji" ? "表情贴纸" : t === "image" ? "图片贴纸" : "文字花字"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab 内容区 */}
|
||||
<div className="sticker-tab-content">
|
||||
{/* Emoji 素材库 */}
|
||||
{activeTab === "emoji" && (
|
||||
<div className="sticker-emoji-grid">
|
||||
{EMOJI_LIST.map((emoji) => (
|
||||
<button key={emoji} className="sticker-emoji-btn" onClick={() => onAddEmoji(emoji)}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片贴纸 */}
|
||||
{activeTab === "image" && (
|
||||
<div className="sticker-image-input">
|
||||
<input
|
||||
type="text"
|
||||
className="sticker-url-input"
|
||||
placeholder="输入图片 URL 添加贴纸..."
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && imageUrl.trim()) {
|
||||
onAddImage(imageUrl.trim())
|
||||
setImageUrl("")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="sticker-url-add-btn" onClick={handleImageAdd}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文字花字 */}
|
||||
{activeTab === "text" && (
|
||||
<div className="sticker-text-section">
|
||||
<div className="sticker-text-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="sticker-text-input"
|
||||
placeholder="输入文字内容..."
|
||||
value={textInput}
|
||||
onChange={(e) => onTextInputChange(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="sticker-text-add-btn"
|
||||
disabled={!textInput.trim()}
|
||||
onClick={handleTextAdd}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="sticker-text-presets">
|
||||
<div className="sticker-preset-title">花字预设预览</div>
|
||||
<div className="sticker-preset-grid">
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<div
|
||||
key={p}
|
||||
className="sticker-preset-preview"
|
||||
style={{
|
||||
background:
|
||||
p === "bubble"
|
||||
? "rgba(0,0,0,0.5)"
|
||||
: p === "gradient"
|
||||
? "linear-gradient(90deg,#f093fb,#f5576c)"
|
||||
: "#1a1a2e",
|
||||
}}
|
||||
>
|
||||
<span style={TEXT_PRESET_STYLES[p]}>示例</span>
|
||||
<div className="sticker-preset-name">{TEXT_STICKER_PRESET_LABELS[p]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { CSSProperties } from "react"
|
||||
import type { TextStickerPreset } from "../../types"
|
||||
|
||||
/** 常用 emoji 素材 */
|
||||
export const EMOJI_LIST = [
|
||||
"😀",
|
||||
"😂",
|
||||
"🥰",
|
||||
"😎",
|
||||
"🤩",
|
||||
"😱",
|
||||
"🤔",
|
||||
"😴",
|
||||
"🥳",
|
||||
"😍",
|
||||
"❤️",
|
||||
"🔥",
|
||||
"⭐",
|
||||
"✨",
|
||||
"💯",
|
||||
"👍",
|
||||
"👏",
|
||||
"🎉",
|
||||
"🎵",
|
||||
"💪",
|
||||
"📌",
|
||||
"💡",
|
||||
"🎯",
|
||||
"✅",
|
||||
"❌",
|
||||
"⬆️",
|
||||
"⬇️",
|
||||
"➡️",
|
||||
"⭕",
|
||||
"🔔",
|
||||
]
|
||||
|
||||
/** 文字花字预设对应的 CSS 样式预览 */
|
||||
export const TEXT_PRESET_STYLES: Record<TextStickerPreset, CSSProperties> = {
|
||||
normal: { color: "#fff", textShadow: "none" },
|
||||
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
|
||||
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
|
||||
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
|
||||
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
|
||||
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
|
||||
gradient: {
|
||||
color: "transparent",
|
||||
background: "linear-gradient(90deg,#f093fb,#f5576c)",
|
||||
WebkitBackgroundClip: "text",
|
||||
},
|
||||
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
|
||||
}
|
||||
|
||||
export const genStickerId = () => `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import type { StickerConfig, StickerItem, StickerType } from "../../../types"
|
||||
import { DEFAULT_STICKER_CONFIG, DEFAULT_STICKER_ITEM } from "../../../types"
|
||||
import { genStickerId } from "../constants"
|
||||
|
||||
interface UseStickerPanelOptions {
|
||||
config: StickerConfig
|
||||
onChange: (config: StickerConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
export const useStickerPanel = ({ config, onChange, totalDuration }: UseStickerPanelOptions) => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<StickerType>("emoji")
|
||||
const [textInput, setTextInput] = useState("")
|
||||
|
||||
const selectedSticker = useMemo(
|
||||
() => config.items.find((s) => s.id === selectedId) ?? null,
|
||||
[config.items, selectedId],
|
||||
)
|
||||
|
||||
/** 更新单个贴纸 */
|
||||
const updateItem = useCallback(
|
||||
(id: string, partial: Partial<StickerItem>) => {
|
||||
onChange({
|
||||
...config,
|
||||
items: config.items.map((s) => (s.id === id ? { ...s, ...partial } : s)),
|
||||
})
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/** 添加贴纸 */
|
||||
const addSticker = useCallback(
|
||||
(type: StickerType, content: string) => {
|
||||
const newItem: StickerItem = {
|
||||
...DEFAULT_STICKER_ITEM,
|
||||
id: genStickerId(),
|
||||
type,
|
||||
content,
|
||||
duration: totalDuration > 0 ? totalDuration : 5,
|
||||
z_index: config.items.length > 0 ? Math.max(...config.items.map((i) => i.z_index)) + 1 : 1,
|
||||
}
|
||||
onChange({
|
||||
...config,
|
||||
enabled: true,
|
||||
items: [...config.items, newItem],
|
||||
})
|
||||
setSelectedId(newItem.id)
|
||||
},
|
||||
[config, onChange, totalDuration],
|
||||
)
|
||||
|
||||
/** 删除贴纸 */
|
||||
const removeSticker = useCallback(
|
||||
(id: string) => {
|
||||
onChange({
|
||||
...config,
|
||||
items: config.items.filter((s) => s.id !== id),
|
||||
})
|
||||
if (selectedId === id) setSelectedId(null)
|
||||
},
|
||||
[config, onChange, selectedId],
|
||||
)
|
||||
|
||||
/** 重置所有 */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled })
|
||||
setSelectedId(null)
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
return {
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
selectedSticker,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
textInput,
|
||||
setTextInput,
|
||||
updateItem,
|
||||
addSticker,
|
||||
removeSticker,
|
||||
handleReset,
|
||||
}
|
||||
}
|
||||
+36
-24
@@ -1,16 +1,13 @@
|
||||
/**
|
||||
* 贴纸配置面板
|
||||
* 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { StickerConfig } from "@/pages/editing-planner/types"
|
||||
import StickerLibrary from "./sticker/StickerLibrary"
|
||||
import StickerList from "./sticker/StickerList"
|
||||
import StickerPropsEditor from "./sticker/StickerPropsEditor"
|
||||
import { useStickerItems } from "@/pages/editing-planner/hooks/useStickerItems"
|
||||
import type { StickerConfig } from "../../types"
|
||||
|
||||
interface StickerPanelProps {
|
||||
import { useStickerPanel } from "./hooks/useStickerPanel"
|
||||
import { StickerTabs } from "./components/StickerTabs"
|
||||
import { StickerList } from "./components/StickerList"
|
||||
import { StickerPropsEditor } from "./components/StickerPropsEditor"
|
||||
|
||||
export interface StickerPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: StickerConfig
|
||||
@@ -28,14 +25,28 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
|
||||
const {
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
selectedSticker,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
selectedSticker,
|
||||
textInput,
|
||||
setTextInput,
|
||||
updateItem,
|
||||
addSticker,
|
||||
removeSticker,
|
||||
handleReset,
|
||||
} = useStickerItems({ config, onChange, totalDuration })
|
||||
} = useStickerPanel({ config, onChange, totalDuration })
|
||||
|
||||
const handleEmojiAdd = (emoji: string) => {
|
||||
addSticker("emoji", emoji)
|
||||
}
|
||||
|
||||
const handleImageAdd = (url: string) => {
|
||||
addSticker("image", url)
|
||||
}
|
||||
|
||||
const handleTextAdd = (text: string) => {
|
||||
addSticker("text", text)
|
||||
setTextInput("")
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -56,15 +67,23 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 素材库 + 类型Tab */}
|
||||
<StickerLibrary activeTab={activeTab} onTabChange={setActiveTab} onAddSticker={addSticker} />
|
||||
{/* Tab 选择器 + 内容 */}
|
||||
<StickerTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
textInput={textInput}
|
||||
onTextInputChange={setTextInput}
|
||||
onAddEmoji={handleEmojiAdd}
|
||||
onAddImage={handleImageAdd}
|
||||
onAddText={handleTextAdd}
|
||||
/>
|
||||
|
||||
{/* 已添加贴纸列表 */}
|
||||
<StickerList
|
||||
items={config.items}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onDelete={removeSticker}
|
||||
onRemove={removeSticker}
|
||||
/>
|
||||
|
||||
{/* 选中贴纸的属性编辑 */}
|
||||
@@ -72,16 +91,9 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
|
||||
<StickerPropsEditor
|
||||
sticker={selectedSticker}
|
||||
totalDuration={totalDuration}
|
||||
onUpdate={updateItem}
|
||||
onChange={updateItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="sticker-footer">
|
||||
<button className="sticker-reset-btn" onClick={handleReset}>
|
||||
清空所有贴纸
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,32 @@
|
||||
/**
|
||||
* 水平轨道时间线 — 支持裁剪手柄、分割、右键菜单
|
||||
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
|
||||
* "+" 卡片 → 类型+时长选择器
|
||||
* 时间标尺 + 片段轨道 + HTML5拖拽排序
|
||||
*
|
||||
* 裁剪交互:
|
||||
* - 鼠标悬停片段两端显示拖拽手柄,拖动调整入点/出点
|
||||
* - 拖动时实时显示裁剪预览(入点/出点/时长)
|
||||
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
|
||||
* 子组件(timeline/):
|
||||
* - TimeRuler - 时间标尺
|
||||
* - ClipCard - 片段卡片
|
||||
* - ClipTrack - 片段轨道(播放头+片段列表+添加卡片)
|
||||
* - TimelineHeader - 时间线头部(标题+缩放+操作按钮)
|
||||
* - AddClipPicker - 添加片段选择器
|
||||
* - TrimPreview - 裁剪预览 tooltip
|
||||
* - ContextMenu - 右键菜单
|
||||
*
|
||||
* Hooks:
|
||||
* - useClipDrag - 片段拖拽排序
|
||||
* - useTrimDrag - 裁剪拖拽
|
||||
* - useTimelineMenus - 菜单 & 面板
|
||||
* - usePlayheadDrag - 播放头拖拽
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType, TrimConfig } from "../types"
|
||||
import { DEFAULT_PIXELS_PER_SECOND } from "../constants/timeline"
|
||||
import { formatTime } from "../utils/timeline"
|
||||
import { useClipDrag } from "../hooks/useClipDrag"
|
||||
import { useTrimDrag } from "../hooks/useTrimDrag"
|
||||
import { useTimelineMenus } from "../hooks/useTimelineMenus"
|
||||
import { ClipCard } from "./timeline/ClipCard"
|
||||
import { usePlayheadDrag } from "./timeline/hooks/usePlayheadDrag"
|
||||
import { TimeRuler } from "./timeline/TimeRuler"
|
||||
import { ClipTrack } from "./timeline/ClipTrack"
|
||||
import { TimelineHeader } from "./timeline/TimelineHeader"
|
||||
import { AddClipPicker } from "./timeline/AddClipPicker"
|
||||
import { TrimPreview } from "./timeline/TrimPreview"
|
||||
import { ContextMenu } from "./timeline/ContextMenu"
|
||||
@@ -105,106 +115,32 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
setHoveredClipId,
|
||||
} = useTimelineMenus(clips, currentMode, onAddClip, onClipSplit, onClipResetTrim, onClipRemove)
|
||||
|
||||
/* ── 播放头拖拽状态 ── */
|
||||
const [playheadDragging, setPlayheadDragging] = useState(false)
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
/* ── 播放头拖拽 ── */
|
||||
const { trackRef, handlePlayheadMouseDown, handleRulerClick } = usePlayheadDrag({
|
||||
currentTime,
|
||||
pps,
|
||||
totalDuration,
|
||||
onSeek,
|
||||
})
|
||||
|
||||
/* ── 播放头拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!playheadDragging) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const trackEl = trackRef.current
|
||||
if (!trackEl) return
|
||||
const rect = trackEl.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left + trackEl.scrollLeft
|
||||
const time = Math.max(0, Math.min(x / pps, totalDuration))
|
||||
onSeek?.(Math.round(time * 10) / 10)
|
||||
/* ── 撤销最后一个片段 ── */
|
||||
const handleUndoClip = () => {
|
||||
if (clips.length > 1) {
|
||||
const last = clips[clips.length - 1]
|
||||
onClipRemove(last.id)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setPlayheadDragging(false)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [playheadDragging, pps, totalDuration, onSeek])
|
||||
|
||||
/* ── 标尺点击跳转播放头 ── */
|
||||
const handleRulerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const time = Math.max(0, Math.min(x / pps, totalDuration))
|
||||
onSeek?.(Math.round(time * 10) / 10)
|
||||
},
|
||||
[pps, totalDuration, onSeek],
|
||||
)
|
||||
|
||||
/* ── 播放头拖拽开始 ── */
|
||||
const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setPlayheadDragging(true)
|
||||
}, [])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ep-timeline-area">
|
||||
{/* 时间线头部 */}
|
||||
<div className="ep-timeline-header">
|
||||
<div className="ep-timeline-title">
|
||||
<span>🎬 时间线</span>
|
||||
<span className="ep-timeline-duration">总时长: {formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
<div className="ep-timeline-actions">
|
||||
{/* 缩放控件 */}
|
||||
<div className="ep-timeline-zoom">
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
|
||||
title="缩小"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="ep-zoom-slider"
|
||||
min={10}
|
||||
max={120}
|
||||
step={5}
|
||||
value={pps}
|
||||
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
|
||||
title="放大"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="ep-zoom-label">{pps}px/s</span>
|
||||
</div>
|
||||
<button
|
||||
className="ep-timeline-action-btn"
|
||||
onClick={() => {
|
||||
if (clips.length > 1) {
|
||||
const last = clips[clips.length - 1]
|
||||
onClipRemove(last.id)
|
||||
}
|
||||
}}
|
||||
title="删除最后一个片段"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
<button className="ep-timeline-action-btn" title="重做">
|
||||
↪
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TimelineHeader
|
||||
totalDuration={totalDuration}
|
||||
pps={pps}
|
||||
onZoomChange={onZoomChange}
|
||||
onUndoClip={handleUndoClip}
|
||||
clipsCount={clips.length}
|
||||
/>
|
||||
|
||||
{/* 一镜到底提示 */}
|
||||
{currentMode === "one_take" && <div className="ep-one-take-hint">🎥 一镜到底模式无片段</div>}
|
||||
@@ -216,61 +152,33 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
{/* 水平片段轨道 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<div className="ep-clip-track" ref={trackRef} onDragOver={handleEmptyDragOver}>
|
||||
{/* 播放头 */}
|
||||
{currentMode !== "one_take" && totalDuration > 0 && (
|
||||
<div
|
||||
className="ep-playhead"
|
||||
style={{ left: currentTime * pps }}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
>
|
||||
<div className="ep-playhead-handle" />
|
||||
</div>
|
||||
)}
|
||||
{clips.length === 0 ? (
|
||||
<div className="ep-track-empty">
|
||||
<div className="ep-track-empty-icon">🎬</div>
|
||||
<div className="ep-track-empty-text">点击右侧 + 添加片段</div>
|
||||
</div>
|
||||
) : (
|
||||
clips.map((clip, idx) => (
|
||||
<ClipCard
|
||||
key={clip.id}
|
||||
clip={clip}
|
||||
idx={idx}
|
||||
isSelected={selectedClipId === clip.id}
|
||||
isDragging={dragIdx === idx}
|
||||
isDragOver={dragOverIdx === idx}
|
||||
isHovered={hoveredClipId === clip.id}
|
||||
pps={pps}
|
||||
trimDragActive={!!trimDrag}
|
||||
showTrimHandles={!!onClipTrim}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={handleDrop}
|
||||
onSelect={onClipSelect}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={() => setHoveredClipId(clip.id)}
|
||||
onMouseLeave={() => setHoveredClipId(null)}
|
||||
onTrimHandleMouseDown={handleTrimHandleMouseDown}
|
||||
onRemove={onClipRemove}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* ── 轨道末尾 "+" 添加卡片 ── */}
|
||||
<div className="ep-track-add-card-wrapper">
|
||||
<div
|
||||
ref={addCardRef}
|
||||
className="ep-track-add-card"
|
||||
onClick={handleTogglePicker}
|
||||
title="添加片段"
|
||||
>
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClipTrack
|
||||
clips={clips}
|
||||
selectedClipId={selectedClipId}
|
||||
pps={pps}
|
||||
currentTime={currentTime}
|
||||
totalDuration={totalDuration}
|
||||
dragIdx={dragIdx}
|
||||
dragOverIdx={dragOverIdx}
|
||||
hoveredClipId={hoveredClipId}
|
||||
trimDragActive={!!trimDrag}
|
||||
showTrimHandles={!!onClipTrim}
|
||||
trackRef={trackRef}
|
||||
addCardRef={addCardRef}
|
||||
onPlayheadMouseDown={handlePlayheadMouseDown}
|
||||
onClipSelect={onClipSelect}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={handleDrop}
|
||||
onEmptyDragOver={handleEmptyDragOver}
|
||||
onContextMenu={handleContextMenu}
|
||||
onClipMouseEnter={setHoveredClipId}
|
||||
onClipMouseLeave={() => setHoveredClipId(null)}
|
||||
onTrimHandleMouseDown={handleTrimHandleMouseDown}
|
||||
onClipRemove={onClipRemove}
|
||||
onTogglePicker={handleTogglePicker}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 裁剪预览 tooltip */}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from "react"
|
||||
import { Slider } from "antd"
|
||||
|
||||
export interface TtsSliderProps {
|
||||
label: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
onChange: (val: number) => void
|
||||
formatter: (val: number) => string
|
||||
marks?: string[]
|
||||
}
|
||||
|
||||
export const TtsSlider: React.FC<TtsSliderProps> = ({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
onChange,
|
||||
formatter,
|
||||
marks,
|
||||
}) => {
|
||||
return (
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">{label}</span>
|
||||
<span className="tts-slider-value">{formatter(value)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
tooltip={{ formatter: (v) => (v != null ? formatter(v as number) : "") }}
|
||||
/>
|
||||
{marks && (
|
||||
<div className="tts-slider-marks">
|
||||
{marks.map((m, i) => (
|
||||
<span key={i}>{m}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useState } from "react"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface VoiceSelectorProps {
|
||||
presetVoices: UnifiedVoiceItem[]
|
||||
cloneVoices: UnifiedVoiceItem[]
|
||||
selectedVoiceId: string
|
||||
loading: boolean
|
||||
playingVoiceId: string | null
|
||||
loadingSampleVoiceId: string | null
|
||||
onSelect: (voiceId: string) => void
|
||||
onPlaySample: (voice: UnifiedVoiceItem) => void
|
||||
}
|
||||
|
||||
type VoiceTab = "preset" | "clone"
|
||||
|
||||
const GENDER_ICON: Record<string, string> = {
|
||||
male: "👨",
|
||||
female: "👩",
|
||||
young: "🧑",
|
||||
}
|
||||
|
||||
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
presetVoices,
|
||||
cloneVoices,
|
||||
selectedVoiceId,
|
||||
loading,
|
||||
playingVoiceId,
|
||||
loadingSampleVoiceId,
|
||||
onSelect,
|
||||
onPlaySample,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<VoiceTab>("preset")
|
||||
|
||||
const currentList = activeTab === "preset" ? presetVoices : cloneVoices
|
||||
|
||||
return (
|
||||
<div className="tts-voice-section">
|
||||
<div className="tts-voice-label">
|
||||
选择音色
|
||||
{loading && <span className="tts-voice-loading">加载中...</span>}
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="tts-voice-tabs">
|
||||
<button
|
||||
className={`tts-voice-tab${activeTab === "preset" ? " active" : ""}`}
|
||||
onClick={() => setActiveTab("preset")}
|
||||
>
|
||||
预设音色
|
||||
</button>
|
||||
<button
|
||||
className={`tts-voice-tab${activeTab === "clone" ? " active" : ""}`}
|
||||
onClick={() => setActiveTab("clone")}
|
||||
>
|
||||
我的克隆
|
||||
{cloneVoices.length > 0 && (
|
||||
<span className="tts-voice-tab-count">{cloneVoices.length}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 音色列表 */}
|
||||
{loading ? (
|
||||
<div className="tts-voice-empty">⏳ 加载中...</div>
|
||||
) : currentList.length === 0 ? (
|
||||
<div className="tts-voice-empty">
|
||||
{activeTab === "preset" ? "暂无预设音色" : "暂无克隆音色,快去克隆一个吧"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tts-voice-list">
|
||||
{currentList.map((voice) => {
|
||||
const isSelected = selectedVoiceId === voice.voice_id
|
||||
const isPlaying = playingVoiceId === voice.voice_id
|
||||
const isLoadingSample = loadingSampleVoiceId === voice.voice_id
|
||||
const icon = GENDER_ICON[voice.gender] ?? "✨"
|
||||
return (
|
||||
<div
|
||||
key={voice.voice_id}
|
||||
className={`tts-voice-item${isSelected ? " selected" : ""}`}
|
||||
onClick={() => onSelect(voice.voice_id)}
|
||||
>
|
||||
<div className="tts-voice-item-icon">{icon}</div>
|
||||
<div className="tts-voice-item-info">
|
||||
<div className="tts-voice-item-name">{voice.name}</div>
|
||||
<div className="tts-voice-item-desc">{voice.description || voice.language}</div>
|
||||
</div>
|
||||
<button
|
||||
className="tts-voice-item-play"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlaySample(voice)
|
||||
}}
|
||||
disabled={isLoadingSample}
|
||||
title={isPlaying ? "暂停" : isLoadingSample ? "合成中" : "试听"}
|
||||
>
|
||||
{isLoadingSample ? <LoadingOutlined /> : isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { TtsMode } from "../../types"
|
||||
|
||||
/** 音色卡片分类图标 */
|
||||
export const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
|
||||
male: { icon: "👨", label: "男声" },
|
||||
female: { icon: "👩", label: "女声" },
|
||||
young: { icon: "🧑", label: "少年" },
|
||||
service: { icon: "🎧", label: "客服" },
|
||||
news: { icon: "📰", label: "新闻" },
|
||||
emotion: { icon: "🎭", label: "情感" },
|
||||
}
|
||||
|
||||
/** 配音模式选项 */
|
||||
export const TTS_MODE_OPTIONS: { mode: TtsMode; icon: string; label: string }[] = [
|
||||
{ mode: "none", icon: "🔇", label: "无配音" },
|
||||
{ mode: "upload", icon: "📁", label: "上传配音" },
|
||||
{ mode: "tts", icon: "🤖", label: "TTS 合成" },
|
||||
]
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "react"
|
||||
import { message } from "antd"
|
||||
import type { TtsConfig, TtsMode } from "../../../types"
|
||||
import { DEFAULT_TTS_CONFIG } from "../../../types"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { getVoiceClonePreview } from "@/api/voice-clone"
|
||||
import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices"
|
||||
|
||||
interface UseTtsPanelOptions {
|
||||
open: boolean
|
||||
config: TtsConfig
|
||||
onChange: (config: TtsConfig) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => {
|
||||
/* ── 音色列表(统一音色:预设 + 克隆) ── */
|
||||
const [presetVoices, setPresetVoices] = useState<UnifiedVoiceItem[]>([])
|
||||
const [cloneVoices, setCloneVoices] = useState<UnifiedVoiceItem[]>([])
|
||||
const [voicesLoading, setVoicesLoading] = useState(false)
|
||||
|
||||
// 用 ref 保存最新 config 和 onChange,避免 useEffect 闭包陷阱
|
||||
const configRef = useRef(config)
|
||||
const onChangeRef = useRef(onChange)
|
||||
configRef.current = config
|
||||
onChangeRef.current = onChange
|
||||
|
||||
/* ── 试听状态 ── */
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
/* ── 加载音色列表 + 重置挂载状态 ── */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
mountedRef.current = true
|
||||
let active = true
|
||||
setVoicesLoading(true)
|
||||
fetchVoices({ status: "ready", limit: 100 })
|
||||
.then((res) => {
|
||||
if (!active) return
|
||||
const presets = res.items.filter((v) => v.type === "preset")
|
||||
const clones = res.items.filter((v) => v.type === "clone")
|
||||
setPresetVoices(presets)
|
||||
setCloneVoices(clones)
|
||||
// 如果当前没有选中音色,默认选第一个预设(用 ref 读最新值,避免闭包旧值覆盖用户选择)
|
||||
if (!configRef.current.voice_id && presets.length > 0) {
|
||||
onChangeRef.current({
|
||||
...configRef.current,
|
||||
voice_id: presets[0].voice_id,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) message.error("加载音色列表失败")
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setVoicesLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
/* ── 组件卸载时清理音频资源 ── */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 切换配音模式 ── */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: TtsMode) => {
|
||||
onChange({ ...config, mode })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 文本输入 ── */
|
||||
const handleTextChange = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value.slice(0, 5000)
|
||||
onChange({ ...config, text })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 选择音色 ── */
|
||||
const handleVoiceSelect = useCallback(
|
||||
(voiceId: string) => {
|
||||
onChange({ ...config, voice_id: voiceId })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 语速 ── */
|
||||
const handleSpeedChange = useCallback(
|
||||
(speed: number) => {
|
||||
onChange({ ...config, speed })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 语调 ── */
|
||||
const handlePitchChange = useCallback(
|
||||
(pitch: number) => {
|
||||
onChange({ ...config, pitch })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 音量 ── */
|
||||
const handleVolumeChange = useCallback(
|
||||
(volume: number) => {
|
||||
onChange({ ...config, volume })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 字幕联动 ── */
|
||||
const handleSubtitleSyncToggle = useCallback(() => {
|
||||
onChange({ ...config, subtitle_sync: !config.subtitle_sync })
|
||||
}, [config, onChange])
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!config.text.trim()) {
|
||||
message.warning("请先输入合成文本")
|
||||
return
|
||||
}
|
||||
if (!config.voice_id) {
|
||||
message.warning("请先选择音色")
|
||||
return
|
||||
}
|
||||
setPreviewLoading(true)
|
||||
try {
|
||||
const res = await previewTts({
|
||||
text: config.text.slice(0, 200),
|
||||
voice_id: config.voice_id,
|
||||
speed: config.speed,
|
||||
pitch: config.pitch,
|
||||
})
|
||||
if (!mountedRef.current) return
|
||||
audioRef.current?.pause()
|
||||
const audio = new Audio(res.audio_url)
|
||||
audioRef.current = audio
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
if (mountedRef.current) message.success("试听播放中")
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current) message.error("播放失败")
|
||||
})
|
||||
audio.onended = () => {
|
||||
if (mountedRef.current) audioRef.current = null
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current) message.error("试听生成失败")
|
||||
} finally {
|
||||
if (mountedRef.current) setPreviewLoading(false)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
/* ── 单音色示例试听 ── */
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null)
|
||||
const [loadingSampleVoiceId, setLoadingSampleVoiceId] = useState<string | null>(null)
|
||||
const handlePlayVoiceSample = useCallback(
|
||||
async (voice: UnifiedVoiceItem) => {
|
||||
const voiceId = voice.voice_id
|
||||
if (playingVoiceId === voiceId) {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setPlayingVoiceId(null)
|
||||
return
|
||||
}
|
||||
// 暂停上一个音频并清除事件监听,避免竞态
|
||||
if (audioRef.current) {
|
||||
audioRef.current.onended = null
|
||||
audioRef.current.pause()
|
||||
}
|
||||
|
||||
let audioUrl: string | null = voice.preview_url ?? voice.audio_url
|
||||
|
||||
// 克隆音色:调用试听接口实时合成
|
||||
if (voice.type === "clone" && voice.voice_clone_profile_id) {
|
||||
setLoadingSampleVoiceId(voiceId)
|
||||
try {
|
||||
const preview = await getVoiceClonePreview(voice.voice_clone_profile_id)
|
||||
audioUrl = preview.audio_url
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
message.error("试听音频生成失败,请重试")
|
||||
}
|
||||
setLoadingSampleVoiceId(null)
|
||||
return
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoadingSampleVoiceId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioUrl) {
|
||||
message.warning("该音色暂无示例音频")
|
||||
return
|
||||
}
|
||||
|
||||
const audio = new Audio(audioUrl)
|
||||
audioRef.current = audio
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
// 只有当前活跃的音频实例才能更新状态,防止快速切换竞态
|
||||
if (mountedRef.current && audioRef.current === audio) {
|
||||
setPlayingVoiceId(voiceId)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current && audioRef.current === audio) {
|
||||
message.error("播放失败")
|
||||
audioRef.current = null
|
||||
}
|
||||
})
|
||||
audio.onended = () => {
|
||||
if (mountedRef.current && audioRef.current === audio) {
|
||||
setPlayingVoiceId(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
}
|
||||
},
|
||||
[playingVoiceId],
|
||||
)
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_TTS_CONFIG })
|
||||
}, [onChange])
|
||||
|
||||
/* ── 关闭时停止音频 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
mountedRef.current = false
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setPlayingVoiceId(null)
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
return {
|
||||
presetVoices,
|
||||
cloneVoices,
|
||||
voicesLoading,
|
||||
previewLoading,
|
||||
playingVoiceId,
|
||||
loadingSampleVoiceId,
|
||||
handleModeChange,
|
||||
handleTextChange,
|
||||
handleVoiceSelect,
|
||||
handlePlayVoiceSample,
|
||||
handleSpeedChange,
|
||||
handlePitchChange,
|
||||
handleVolumeChange,
|
||||
handleSubtitleSyncToggle,
|
||||
handlePreview,
|
||||
handleReset,
|
||||
handleClose,
|
||||
}
|
||||
}
|
||||
Executable → Regular
+27
-21
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* TTS 配音面板 — Drawer 形式
|
||||
* 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动
|
||||
* 配音模式切换 + 文本输入 + 音色选择(预设+克隆) + 语速/语调/音量 + 试听 + 字幕联动
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { TtsConfig } from "@/pages/editing-planner/types"
|
||||
import { TTS_MODE_OPTIONS } from "@/pages/editing-planner/constants/tts"
|
||||
import VoiceSelector from "./tts/VoiceSelector"
|
||||
import TtsSlider from "./tts/TtsSlider"
|
||||
import { useTtsPanel } from "@/pages/editing-planner/hooks/useTtsPanel"
|
||||
import type { TtsConfig } from "../../types"
|
||||
|
||||
interface TtsPanelProps {
|
||||
import { useTtsPanel } from "./hooks/useTtsPanel"
|
||||
import { VoiceSelector } from "./components/VoiceSelector"
|
||||
import { TtsSlider } from "./components/TtsSlider"
|
||||
import { TTS_MODE_OPTIONS } from "./constants"
|
||||
|
||||
export interface TtsPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 当前片段 TTS 配置 */
|
||||
@@ -20,12 +21,16 @@ interface TtsPanelProps {
|
||||
|
||||
const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
const {
|
||||
voices,
|
||||
presetVoices,
|
||||
cloneVoices,
|
||||
voicesLoading,
|
||||
previewLoading,
|
||||
playingVoiceId,
|
||||
loadingSampleVoiceId,
|
||||
handleModeChange,
|
||||
handleTextChange,
|
||||
handleVoiceSelect,
|
||||
handlePlayVoiceSample,
|
||||
handleSpeedChange,
|
||||
handlePitchChange,
|
||||
handleVolumeChange,
|
||||
@@ -74,7 +79,7 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
|
||||
className="tts-text-input"
|
||||
placeholder="请输入需要合成的文本内容..."
|
||||
value={config.text}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onChange={handleTextChange}
|
||||
maxLength={5000}
|
||||
rows={5}
|
||||
/>
|
||||
@@ -82,48 +87,49 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
|
||||
|
||||
{/* 音色选择 */}
|
||||
<VoiceSelector
|
||||
voices={voices}
|
||||
voicesLoading={voicesLoading}
|
||||
presetVoices={presetVoices}
|
||||
cloneVoices={cloneVoices}
|
||||
selectedVoiceId={config.voice_id}
|
||||
onVoiceSelect={handleVoiceSelect}
|
||||
loading={voicesLoading}
|
||||
playingVoiceId={playingVoiceId}
|
||||
loadingSampleVoiceId={loadingSampleVoiceId}
|
||||
onSelect={handleVoiceSelect}
|
||||
onPlaySample={handlePlayVoiceSample}
|
||||
/>
|
||||
|
||||
{/* 语速滑块 */}
|
||||
{/* 语速 */}
|
||||
<TtsSlider
|
||||
label="语速"
|
||||
value={config.speed}
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.05}
|
||||
unit="x"
|
||||
onChange={handleSpeedChange}
|
||||
formatter={(v) => `${v.toFixed(2)}x`}
|
||||
marks={["0.5x", "1.0x", "2.0x"]}
|
||||
tooltipFormatter={(v) => `${v.toFixed(2)}x`}
|
||||
/>
|
||||
|
||||
{/* 语调滑块 */}
|
||||
{/* 语调 */}
|
||||
<TtsSlider
|
||||
label="语调"
|
||||
value={config.pitch}
|
||||
min={-12}
|
||||
max={12}
|
||||
step={1}
|
||||
unit="半音"
|
||||
onChange={handlePitchChange}
|
||||
formatter={(v) => `${v > 0 ? "+" : ""}${v} 半音`}
|
||||
marks={["-12", "0", "+12"]}
|
||||
tooltipFormatter={(v) => `${v}半音`}
|
||||
/>
|
||||
|
||||
{/* 音量滑块 */}
|
||||
{/* 音量 */}
|
||||
<TtsSlider
|
||||
label="音量"
|
||||
value={config.volume}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
unit="%"
|
||||
onChange={handleVolumeChange}
|
||||
tooltipFormatter={(v) => `${v}%`}
|
||||
formatter={(v) => `${v}%`}
|
||||
/>
|
||||
|
||||
{/* 试听按钮 */}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
import { TEXT_PRESET_STYLES } from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface StickerPreviewProps {
|
||||
sticker: StickerItem
|
||||
}
|
||||
|
||||
export const StickerPreview: React.FC<StickerPreviewProps> = ({ sticker }) => (
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.height}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
Regular → Executable
+5
-75
@@ -2,11 +2,9 @@
|
||||
* 选中贴纸的属性编辑器
|
||||
*/
|
||||
import React from "react"
|
||||
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
|
||||
import {
|
||||
TEXT_PRESET_STYLES,
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "@/pages/editing-planner/constants/sticker"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
import { StickerPreview } from "./StickerPreview"
|
||||
import { TextStickerPropsEditor } from "./TextStickerPropsEditor"
|
||||
|
||||
interface StickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
@@ -121,78 +119,10 @@ const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 文字贴纸特有属性 */}
|
||||
{sticker.type === "text" && (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) =>
|
||||
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
|
||||
}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TextStickerPropsEditor sticker={sticker} onUpdate={onUpdate} />
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.width}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StickerPreview sticker={sticker} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import React from "react"
|
||||
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
|
||||
import { TEXT_STICKER_PRESET_LABELS } from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface TextStickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
onUpdate: (id: string, partial: Partial<StickerItem>) => void
|
||||
}
|
||||
|
||||
export const TextStickerPropsEditor: React.FC<TextStickerPropsEditorProps> = ({
|
||||
sticker,
|
||||
onUpdate,
|
||||
}) => {
|
||||
if (sticker.type !== "text") return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) =>
|
||||
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
|
||||
}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import React from "react"
|
||||
import type { ClipData } from "../../types"
|
||||
import { ClipCard } from "./ClipCard"
|
||||
|
||||
interface ClipTrackProps {
|
||||
/** 片段列表 */
|
||||
clips: ClipData[]
|
||||
/** 选中的片段ID */
|
||||
selectedClipId: string | null
|
||||
/** 缩放:每秒像素数 */
|
||||
pps: number
|
||||
/** 当前播放时间(秒) */
|
||||
currentTime: number
|
||||
/** 总时长(秒) */
|
||||
totalDuration: number
|
||||
/** 正在拖拽的片段索引 */
|
||||
dragIdx: number | null
|
||||
/** 拖拽悬停的片段索引 */
|
||||
dragOverIdx: number | null
|
||||
/** 悬停的片段ID */
|
||||
hoveredClipId: string | null
|
||||
/** 是否正在裁剪拖拽 */
|
||||
trimDragActive: boolean
|
||||
/** 是否显示裁剪手柄 */
|
||||
showTrimHandles: boolean
|
||||
/** 轨道ref */
|
||||
trackRef: React.RefObject<HTMLDivElement>
|
||||
/** 添加卡片ref */
|
||||
addCardRef: React.RefObject<HTMLDivElement>
|
||||
/** 播放头拖拽开始 */
|
||||
onPlayheadMouseDown: (e: React.MouseEvent) => void
|
||||
/** 片段选中 */
|
||||
onClipSelect: (clipId: string) => void
|
||||
/** 拖拽开始 */
|
||||
onDragStart: (e: React.DragEvent, idx: number) => void
|
||||
/** 拖拽悬停 */
|
||||
onDragOver: (e: React.DragEvent, idx: number) => void
|
||||
/** 拖拽结束 */
|
||||
onDragEnd: () => void
|
||||
/** 放置 */
|
||||
onDrop: (e: React.DragEvent, idx: number) => void
|
||||
/** 空区域拖拽悬停 */
|
||||
onEmptyDragOver: (e: React.DragEvent) => void
|
||||
/** 右键菜单 */
|
||||
onContextMenu: (e: React.MouseEvent, clipId: string) => void
|
||||
/** 片段悬停进入 */
|
||||
onClipMouseEnter: (clipId: string) => void
|
||||
/** 片段悬停离开 */
|
||||
onClipMouseLeave: () => void
|
||||
/** 裁剪手柄按下 */
|
||||
onTrimHandleMouseDown: (e: React.MouseEvent, clipId: string, direction: "left" | "right") => void
|
||||
/** 删除片段 */
|
||||
onClipRemove: (clipId: string) => void
|
||||
/** 点击添加卡片 */
|
||||
onTogglePicker: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 片段轨道组件
|
||||
* 包含播放头、片段列表、空状态和添加卡片
|
||||
*/
|
||||
export const ClipTrack: React.FC<ClipTrackProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
pps,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
dragIdx,
|
||||
dragOverIdx,
|
||||
hoveredClipId,
|
||||
trimDragActive,
|
||||
showTrimHandles,
|
||||
trackRef,
|
||||
addCardRef,
|
||||
onPlayheadMouseDown,
|
||||
onClipSelect,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
onEmptyDragOver,
|
||||
onContextMenu,
|
||||
onClipMouseEnter,
|
||||
onClipMouseLeave,
|
||||
onTrimHandleMouseDown,
|
||||
onClipRemove,
|
||||
onTogglePicker,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-clip-track" ref={trackRef} onDragOver={onEmptyDragOver}>
|
||||
{/* 播放头 */}
|
||||
{totalDuration > 0 && (
|
||||
<div
|
||||
className="ep-playhead"
|
||||
style={{ left: currentTime * pps }}
|
||||
onMouseDown={onPlayheadMouseDown}
|
||||
>
|
||||
<div className="ep-playhead-handle" />
|
||||
</div>
|
||||
)}
|
||||
{clips.length === 0 ? (
|
||||
<div className="ep-track-empty">
|
||||
<div className="ep-track-empty-icon">🎬</div>
|
||||
<div className="ep-track-empty-text">点击右侧 + 添加片段</div>
|
||||
</div>
|
||||
) : (
|
||||
clips.map((clip, idx) => (
|
||||
<ClipCard
|
||||
key={clip.id}
|
||||
clip={clip}
|
||||
idx={idx}
|
||||
isSelected={selectedClipId === clip.id}
|
||||
isDragging={dragIdx === idx}
|
||||
isDragOver={dragOverIdx === idx}
|
||||
isHovered={hoveredClipId === clip.id}
|
||||
pps={pps}
|
||||
trimDragActive={trimDragActive}
|
||||
showTrimHandles={showTrimHandles}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={onDrop}
|
||||
onSelect={onClipSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
onMouseEnter={() => onClipMouseEnter(clip.id)}
|
||||
onMouseLeave={onClipMouseLeave}
|
||||
onTrimHandleMouseDown={onTrimHandleMouseDown}
|
||||
onRemove={onClipRemove}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* ── 轨道末尾 "+" 添加卡片 ── */}
|
||||
<div className="ep-track-add-card-wrapper">
|
||||
<div
|
||||
ref={addCardRef}
|
||||
className="ep-track-add-card"
|
||||
onClick={onTogglePicker}
|
||||
title="添加片段"
|
||||
>
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from "react"
|
||||
import { formatTime } from "../../utils/timeline"
|
||||
|
||||
interface TimelineHeaderProps {
|
||||
/** 总时长(秒) */
|
||||
totalDuration: number
|
||||
/** 缩放:每秒像素数 */
|
||||
pps: number
|
||||
/** 缩放变更回调 */
|
||||
onZoomChange?: (pps: number) => void
|
||||
/** 撤销最后一个片段 */
|
||||
onUndoClip?: () => void
|
||||
/** 片段数量 */
|
||||
clipsCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间线头部组件
|
||||
* 包含标题、总时长、缩放控件和操作按钮
|
||||
*/
|
||||
export const TimelineHeader: React.FC<TimelineHeaderProps> = ({
|
||||
totalDuration,
|
||||
pps,
|
||||
onZoomChange,
|
||||
onUndoClip,
|
||||
clipsCount,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-timeline-header">
|
||||
<div className="ep-timeline-title">
|
||||
<span>🎬 时间线</span>
|
||||
<span className="ep-timeline-duration">总时长: {formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
<div className="ep-timeline-actions">
|
||||
{/* 缩放控件 */}
|
||||
<div className="ep-timeline-zoom">
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
|
||||
title="缩小"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="ep-zoom-slider"
|
||||
min={10}
|
||||
max={120}
|
||||
step={5}
|
||||
value={pps}
|
||||
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
|
||||
title="放大"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="ep-zoom-label">{pps}px/s</span>
|
||||
</div>
|
||||
<button
|
||||
className="ep-timeline-action-btn"
|
||||
onClick={onUndoClip}
|
||||
title="删除最后一个片段"
|
||||
disabled={clipsCount <= 1}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
<button className="ep-timeline-action-btn" title="重做">
|
||||
↪
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
|
||||
interface UsePlayheadDragOptions {
|
||||
/** 当前播放时间(秒) */
|
||||
currentTime: number
|
||||
/** 缩放:每秒像素数 */
|
||||
pps: number
|
||||
/** 总时长(秒) */
|
||||
totalDuration: number
|
||||
/** 播放头跳转回调 */
|
||||
onSeek?: (time: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放头拖拽 Hook
|
||||
* 封装播放头的 mousedown/mousemove/mouseup 拖拽逻辑
|
||||
*/
|
||||
export const usePlayheadDrag = ({ pps, totalDuration, onSeek }: UsePlayheadDragOptions) => {
|
||||
const [playheadDragging, setPlayheadDragging] = useState(false)
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/* ── 播放头拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!playheadDragging) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const trackEl = trackRef.current
|
||||
if (!trackEl) return
|
||||
const rect = trackEl.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left + trackEl.scrollLeft
|
||||
const time = Math.max(0, Math.min(x / pps, totalDuration))
|
||||
onSeek?.(Math.round(time * 10) / 10)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setPlayheadDragging(false)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [playheadDragging, pps, totalDuration, onSeek])
|
||||
|
||||
/* ── 播放头拖拽开始 ── */
|
||||
const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setPlayheadDragging(true)
|
||||
}, [])
|
||||
|
||||
/* ── 标尺点击跳转播放头 ── */
|
||||
const handleRulerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const time = Math.max(0, Math.min(x / pps, totalDuration))
|
||||
onSeek?.(Math.round(time * 10) / 10)
|
||||
},
|
||||
[pps, totalDuration, onSeek],
|
||||
)
|
||||
|
||||
return {
|
||||
trackRef,
|
||||
playheadDragging,
|
||||
handlePlayheadMouseDown,
|
||||
handleRulerClick,
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,13 @@ import CloneModal from "@/components/voice/CloneModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateResultPanel from "./components/GenerateResultPanel"
|
||||
import PreviewVideoPanel from "./components/PreviewVideoPanel"
|
||||
import GenerateStepContent from "./components/GenerateStepContent"
|
||||
import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import { useStep3Preview } from "./hooks/useStep3Preview"
|
||||
import "./generate.css"
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
@@ -78,6 +80,17 @@ const GeneratePage: React.FC = () => {
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
|
||||
/* ── Step3 预览生成 ── */
|
||||
const step3Preview = useStep3Preview({
|
||||
templates: userTemplates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
})
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
currentStep,
|
||||
@@ -87,6 +100,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady: step3Preview.canProceed,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
@@ -173,6 +187,14 @@ const GeneratePage: React.FC = () => {
|
||||
onDismissError={handleDismissError}
|
||||
presetVoices={presetVoices}
|
||||
videoRatio={videoRatio}
|
||||
previewStatus={step3Preview.previewStatus}
|
||||
previewResult={step3Preview.previewResult}
|
||||
previewError={step3Preview.previewError}
|
||||
previewTemplateName={step3Preview.templateName}
|
||||
previewMaterialCount={step3Preview.materialCount}
|
||||
previewProgress={step3Preview.progress}
|
||||
onGeneratePreview={step3Preview.generatePreview}
|
||||
onRegeneratePreview={step3Preview.regeneratePreview}
|
||||
/>
|
||||
|
||||
<GenerateStepActions
|
||||
@@ -186,21 +208,36 @@ const GeneratePage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ════ 右侧:生成结果 ════ */}
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
generating={generating}
|
||||
progress={progress}
|
||||
generateError={generateError}
|
||||
generatedVideos={generatedVideos}
|
||||
onVideoPreview={(video) => {
|
||||
setPreviewVideo(video)
|
||||
setPreviewModalOpen(true)
|
||||
}}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onGoToLibrary={() => navigate("/app/products")}
|
||||
/>
|
||||
{/* ════ 右侧:预览 + 生成结果 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{/* 预览视频面板(Step3+ 常驻) */}
|
||||
{currentStep >= 3 && (
|
||||
<PreviewVideoPanel
|
||||
previewStatus={step3Preview.previewStatus}
|
||||
previewResult={step3Preview.previewResult}
|
||||
previewError={step3Preview.previewError}
|
||||
progress={step3Preview.progress}
|
||||
videoRatio={videoRatio}
|
||||
onRegenerate={step3Preview.regeneratePreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 正式生成结果 */}
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
generating={generating}
|
||||
progress={progress}
|
||||
generateError={generateError}
|
||||
generatedVideos={generatedVideos}
|
||||
onVideoPreview={(video) => {
|
||||
setPreviewVideo(video)
|
||||
setPreviewModalOpen(true)
|
||||
}}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onGoToLibrary={() => navigate("/app/products")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 视频预览弹窗 ── */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3GeneratePreview from "../components/Step3GeneratePreview"
|
||||
@@ -62,6 +63,15 @@ export interface GenerateStepContentProps {
|
||||
/* 其他 */
|
||||
presetVoices: PresetVoiceItem[]
|
||||
videoRatio: string
|
||||
/* Step3 预览 */
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
previewTemplateName: string
|
||||
previewMaterialCount: string
|
||||
previewProgress: number
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
|
||||
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
|
||||
@@ -103,6 +113,14 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
videoRatio,
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
previewTemplateName,
|
||||
previewMaterialCount,
|
||||
previewProgress,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
} = props
|
||||
|
||||
switch (currentStep) {
|
||||
@@ -128,13 +146,16 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
case 3:
|
||||
return (
|
||||
<Step3GeneratePreview
|
||||
templates={userTemplates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
materialMode={materialMode}
|
||||
selectedMaterials={selectedMaterials}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
templateName={previewTemplateName}
|
||||
materialCount={previewMaterialCount}
|
||||
duration={duration}
|
||||
videoRatio={videoRatio}
|
||||
previewStatus={previewStatus}
|
||||
previewResult={previewResult}
|
||||
previewError={previewError}
|
||||
progress={previewProgress}
|
||||
onGeneratePreview={onGeneratePreview}
|
||||
onRegeneratePreview={onRegeneratePreview}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 右侧预览视频面板
|
||||
* Step3 生成预览后常驻显示预览视频
|
||||
*/
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
progress: number
|
||||
videoRatio: string
|
||||
onRegenerate: () => void
|
||||
}
|
||||
|
||||
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
videoRatio,
|
||||
onRegenerate,
|
||||
}) => {
|
||||
const hasPreview = previewStatus === "ready" && previewResult
|
||||
const isLoading = previewStatus === "pending" || previewStatus === "generating"
|
||||
const isError = previewStatus === "error"
|
||||
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
<div className="xx-preview-header">
|
||||
<h3>预览视频</h3>
|
||||
{hasPreview && <span className="xx-preview-badge">480p 预览版</span>}
|
||||
</div>
|
||||
|
||||
{/* 空状态:还没生成预览 */}
|
||||
{previewStatus === "idle" && (
|
||||
<div className="xx-preview-empty">
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
<p className="xx-preview-empty-title">暂无预览</p>
|
||||
<p className="xx-preview-empty-desc">在第 3 步生成预览后在此查看</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{isLoading && (
|
||||
<div className="xx-preview-loading-panel">
|
||||
<div className="xx-preview-video">
|
||||
<div className="xx-preview-loading-center">
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
|
||||
{previewStatus === "pending" ? "排队中..." : `生成中 ${progress}%`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-preview-progress-bar-wrap">
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{isError && (
|
||||
<div className="xx-preview-error-panel">
|
||||
<div className="xx-preview-video xx-preview-video--error">
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14 }}>预览生成失败</p>
|
||||
</div>
|
||||
<p className="xx-preview-error-msg">
|
||||
{typeof previewError === "string" && previewError ? previewError : "请重试"}
|
||||
</p>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-block" onClick={onRegenerate}>
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览成功 */}
|
||||
{hasPreview && (
|
||||
<div className="xx-preview-video">
|
||||
<video src={previewResult.videoUrl} controls preload="metadata" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览信息 */}
|
||||
{hasPreview && previewResult && (
|
||||
<div className="xx-preview-info">
|
||||
<div className="xx-preview-info-row">
|
||||
<span>时长</span>
|
||||
<span>
|
||||
{(typeof previewResult.duration === "number" ? previewResult.duration : 0).toFixed(1)}{" "}
|
||||
秒
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>片段数</span>
|
||||
<span>{previewResult.clipCount} 段</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>比例</span>
|
||||
<span>{videoRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreviewVideoPanel
|
||||
@@ -1,55 +1,168 @@
|
||||
/**
|
||||
* Step 3 生成预览组件
|
||||
* 调用后端预览生成接口,展示真实视频预览
|
||||
*/
|
||||
import React from "react"
|
||||
import { CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { useStep3Preview } from "../hooks/useStep3Preview"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
|
||||
interface Step3GeneratePreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
templateName: string
|
||||
materialCount: string
|
||||
duration: number
|
||||
videoRatio: string
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
progress: number
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
|
||||
const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = (props) => {
|
||||
const { templateName, materialCount, duration, videoRatio } = useStep3Preview(props)
|
||||
|
||||
const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
|
||||
templateName,
|
||||
materialCount,
|
||||
videoRatio,
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>素材已选好,AI 将为您智能匹配剪辑方案</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-card">
|
||||
<div className="xx-preview-plan-title">模板草稿预览</div>
|
||||
<div className="xx-preview-plan-info">
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">模板</span>
|
||||
<span className="xx-preview-plan-value">{templateName}</span>
|
||||
|
||||
{/* 预览生成按钮 */}
|
||||
{previewStatus === "idle" && (
|
||||
<div className="xx-preview-generate-section">
|
||||
<div className="xx-preview-generate-hint">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
|
||||
<p className="xx-preview-generate-title">一键生成剪辑预览</p>
|
||||
<p className="xx-preview-generate-desc">
|
||||
AI 将根据您选择的模板和素材,智能生成完整视频预览(480p 低清版)
|
||||
</p>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">素材数量</span>
|
||||
<span className="xx-preview-plan-value">{materialCount}</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">预计时长</span>
|
||||
<span className="xx-preview-plan-value">{duration} 秒</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">视频比例</span>
|
||||
<span className="xx-preview-plan-value">{videoRatio}</span>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary xx-preview-generate-btn"
|
||||
onClick={onGeneratePreview}
|
||||
>
|
||||
✨ 生成预览
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 排队中 */}
|
||||
{previewStatus === "pending" && (
|
||||
<div className="xx-preview-loading">
|
||||
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
|
||||
<p className="xx-preview-loading-text">预览排队中...</p>
|
||||
<p className="xx-preview-loading-desc">正在等待渲染资源,请稍候</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{previewStatus === "generating" && (
|
||||
<div className="xx-preview-loading">
|
||||
<LoadingOutlined style={{ fontSize: 32, color: "#3b82f6" }} spin />
|
||||
<p className="xx-preview-loading-text">正在生成预览视频... {progress}%</p>
|
||||
<p className="xx-preview-loading-desc">AI 正在剪辑素材并合成预览视频</p>
|
||||
<div className="xx-preview-progress-bar">
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-preview-plan-hint">
|
||||
💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题
|
||||
)}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{previewStatus === "error" && (
|
||||
<div className="xx-preview-error">
|
||||
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
<p className="xx-preview-error-text">预览生成失败</p>
|
||||
<p className="xx-preview-error-desc">
|
||||
{typeof previewError === "string" && previewError ? previewError : "请稍后重试"}
|
||||
</p>
|
||||
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成成功 - 视频预览 */}
|
||||
{previewStatus === "ready" && previewResult && (
|
||||
<>
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>预览生成成功,确认效果后进入下一步</span>
|
||||
<button
|
||||
className="xx-preview-regenerate-btn"
|
||||
onClick={onRegeneratePreview}
|
||||
title="重新生成"
|
||||
>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 视频播放器 */}
|
||||
<div className="xx-preview-video-wrapper">
|
||||
<video
|
||||
className="xx-preview-video"
|
||||
src={previewResult.videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 剪辑方案信息 */}
|
||||
<div className="xx-preview-plan-card">
|
||||
<div className="xx-preview-plan-title">剪辑方案信息</div>
|
||||
<div className="xx-preview-plan-info">
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">模板</span>
|
||||
<span className="xx-preview-plan-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">素材数量</span>
|
||||
<span className="xx-preview-plan-value">{materialCount}</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">片段数</span>
|
||||
<span className="xx-preview-plan-value">{previewResult.clipCount} 段</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">转场次数</span>
|
||||
<span className="xx-preview-plan-value">{previewResult.transitionCount} 次</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">素材使用率</span>
|
||||
<span className="xx-preview-plan-value">{previewResult.materialUsage}%</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">实际时长</span>
|
||||
<span className="xx-preview-plan-value">
|
||||
{(typeof previewResult.duration === "number"
|
||||
? previewResult.duration
|
||||
: 0
|
||||
).toFixed(1)}{" "}
|
||||
秒
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">视频比例</span>
|
||||
<span className="xx-preview-plan-value">{videoRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-preview-plan-hint">💡 这是 480p 预览版,正式生成将输出高清视频</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -126,6 +126,8 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = (props) => {
|
||||
selectedClonedVoice={v.selectedClonedVoice}
|
||||
onSelect={v.handleSelectClonedVoice}
|
||||
onOpenCloneModal={v.handleOpenCloneModal}
|
||||
playingVoiceId={v.playingCloneVoice}
|
||||
onPlaySample={v.handlePlayCloneSample}
|
||||
CLONE_STATUS_CONFIG={v.CLONE_STATUS_CONFIG}
|
||||
formatDuration={v.formatDuration}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
/**
|
||||
* 克隆声音展开区域
|
||||
* 克隆按钮、轮询提示、已克隆列表、空状态
|
||||
* 支持克隆音色试听播放
|
||||
* TODO: 后端克隆音色试听接口出来后,替换 sample_url 为接口返回的试听音频
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import { ThunderboltOutlined, AudioOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
AudioOutlined,
|
||||
CheckCircleFilled,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
const { Text } = Typography
|
||||
@@ -15,6 +23,10 @@ interface CloneVoiceSectionProps {
|
||||
selectedClonedVoice: string
|
||||
onSelect: (voiceId: string) => void
|
||||
onOpenCloneModal: () => void
|
||||
/** 当前正在播放的音色ID */
|
||||
playingVoiceId: string | null
|
||||
/** 播放/暂停试听 */
|
||||
onPlaySample: (voiceId: VoiceClone) => void
|
||||
CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }>
|
||||
formatDuration: (seconds: number) => string
|
||||
}
|
||||
@@ -25,6 +37,8 @@ const CloneVoiceSection: React.FC<CloneVoiceSectionProps> = ({
|
||||
selectedClonedVoice,
|
||||
onSelect,
|
||||
onOpenCloneModal,
|
||||
playingVoiceId,
|
||||
onPlaySample,
|
||||
CLONE_STATUS_CONFIG,
|
||||
formatDuration,
|
||||
}) => {
|
||||
@@ -60,6 +74,7 @@ const CloneVoiceSection: React.FC<CloneVoiceSectionProps> = ({
|
||||
const statusCfg = CLONE_STATUS_CONFIG[cv.status]
|
||||
const isReady = cv.status === "ready"
|
||||
const selected = selectedClonedVoice === cv.id
|
||||
const isPlaying = playingVoiceId === cv.id
|
||||
return (
|
||||
<div
|
||||
key={cv.id}
|
||||
@@ -93,7 +108,26 @@ const CloneVoiceSection: React.FC<CloneVoiceSectionProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selected && isReady && (
|
||||
|
||||
{/* 试听播放按钮 */}
|
||||
{isReady && (
|
||||
<button
|
||||
className="xx-clone-voice-play-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlaySample(cv)
|
||||
}}
|
||||
title={isPlaying ? "暂停试听" : "试听克隆音色"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<PauseCircleOutlined style={{ fontSize: 18 }} />
|
||||
) : (
|
||||
<PlayCircleOutlined style={{ fontSize: 18 }} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selected && isReady && !isPlaying && (
|
||||
<CheckCircleFilled style={{ color: "var(--primary-color, #4f46e5)" }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -558,6 +558,32 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── 克隆音色试听播放按钮 ── */
|
||||
.xx-clone-voice-play-btn {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.xx-clone-voice-play-btn:hover {
|
||||
transform: scale(1.1);
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.xx-clone-voice-row.selected .xx-clone-voice-play-btn {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.xx-clone-status {
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
@@ -2356,6 +2382,103 @@
|
||||
生成预览步骤
|
||||
================================================================ */
|
||||
|
||||
/* ── 初始状态:生成按钮 ── */
|
||||
.xx-preview-generate-section {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 2px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.xx-preview-generate-hint {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.xx-preview-generate-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.xx-preview-generate-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-preview-generate-btn {
|
||||
min-width: 160px;
|
||||
height: 42px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── 加载中状态 ── */
|
||||
.xx-preview-loading {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-preview-loading-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 16px 0 8px 0;
|
||||
}
|
||||
|
||||
.xx-preview-loading-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 错误状态 ── */
|
||||
.xx-preview-error {
|
||||
text-align: center;
|
||||
padding: 32px 20px;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.xx-preview-error-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--error-color, #ef4444);
|
||||
margin: 12px 0 6px 0;
|
||||
}
|
||||
|
||||
.xx-preview-error-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
/* ── 成功状态的操作按钮 ── */
|
||||
.xx-preview-regenerate-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-preview-regenerate-btn:hover {
|
||||
color: var(--primary-color);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.xx-preview-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2421,6 +2544,146 @@
|
||||
生成结果(右侧)
|
||||
================================================================ */
|
||||
|
||||
.xx-generate-right-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-preview-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-preview-badge {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-preview-empty {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.xx-preview-empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.xx-preview-empty-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-preview-loading-panel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-preview-loading-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.xx-preview-progress-bar-wrap {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-preview-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-preview-error-panel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-preview-video--error {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.xx-preview-error-msg {
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.xx-preview-info {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-preview-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-preview-info-row span:last-child {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Step3 内部进度条 */
|
||||
.xx-preview-loading .xx-preview-progress-bar {
|
||||
width: 200px;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
margin: 12px auto 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-preview-loading .xx-preview-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-preview-video-wrapper {
|
||||
margin: 16px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-preview-video-wrapper .xx-preview-video {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-generate-result {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
|
||||
@@ -137,7 +137,7 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [generateCount, setGenerateCount] = useState(1)
|
||||
|
||||
/* ── 高级设置(隐藏但保留) ── */
|
||||
const [videoRatio] = useState("16:9")
|
||||
const [videoRatio] = useState("9:16")
|
||||
const [duration] = useState(30)
|
||||
const [style] = useState("business")
|
||||
const [autoSubtitles] = useState(true)
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
/**
|
||||
* Step 3 生成预览 Hook
|
||||
* 封装预览信息的计算逻辑
|
||||
* 调用 /generation/preview 接口创建预览任务,轮询状态直到完成
|
||||
*/
|
||||
import { useMemo } from "react"
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation"
|
||||
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { safeExtractError } from "./generate-video/errorUtils"
|
||||
|
||||
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
|
||||
const safeString = (val: unknown, fallback: string): string => {
|
||||
if (val == null) return fallback
|
||||
const s = safeExtractError(val)
|
||||
return s || fallback
|
||||
}
|
||||
|
||||
/** 安全地将值转为数字,防止非数字值进入渲染 */
|
||||
const safeNumber = (val: unknown, fallback = 0): number => {
|
||||
if (typeof val === "number" && !Number.isNaN(val)) return val
|
||||
if (typeof val === "string") {
|
||||
const n = Number(val)
|
||||
return Number.isNaN(n) ? fallback : n
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface UseStep3PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
@@ -13,8 +33,27 @@ interface UseStep3PreviewProps {
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
titleText?: string
|
||||
voiceId?: string
|
||||
}
|
||||
|
||||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||||
|
||||
/** 预览生成结果 */
|
||||
export interface PreviewResult {
|
||||
taskId: string
|
||||
videoUrl: string
|
||||
clipCount: number
|
||||
transitionCount: number
|
||||
materialUsage: number
|
||||
duration: number
|
||||
fileSize: number
|
||||
generateDuration: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
// 轮询超时时间(10 分钟)
|
||||
const POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||||
export function useStep3Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
@@ -36,11 +75,233 @@ export function useStep3Preview({
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
|
||||
|
||||
/* ── 预览生成状态 ── */
|
||||
const [previewStatus, setPreviewStatus] = useState<PreviewStatus>("idle")
|
||||
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
|
||||
const [previewError, setPreviewError] = useState<string>("")
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
// 任务 ID + 轮询定时器,用于防止竞态条件
|
||||
const currentTaskIdRef = useRef<string | null>(null)
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const startTimeRef = useRef<number>(0)
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current)
|
||||
pollTimerRef.current = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 参数变化时重置预览状态 ── */
|
||||
const prevDepsRef = useRef({
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const currentKey = [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
[...selectedMaterials].sort().join(","),
|
||||
[...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
].join("|")
|
||||
|
||||
const prevKey = [
|
||||
prevDepsRef.current.selectedTemplate,
|
||||
prevDepsRef.current.materialMode,
|
||||
prevDepsRef.current.selectedMaterials,
|
||||
prevDepsRef.current.smartSelectedIds,
|
||||
prevDepsRef.current.duration,
|
||||
prevDepsRef.current.videoRatio,
|
||||
].join("|")
|
||||
|
||||
if (prevKey !== currentKey && previewStatus !== "idle") {
|
||||
// 参数变化,作废当前任务
|
||||
currentTaskIdRef.current = null
|
||||
clearPollTimer()
|
||||
setPreviewStatus("idle")
|
||||
setPreviewResult(null)
|
||||
setPreviewError("")
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
prevDepsRef.current = {
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
}
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
previewStatus,
|
||||
clearPollTimer,
|
||||
])
|
||||
|
||||
// 组件卸载时清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer()
|
||||
}
|
||||
}, [clearPollTimer])
|
||||
|
||||
/** 轮询预览任务状态 */
|
||||
const pollPreviewStatus = useCallback((taskId: string) => {
|
||||
const poll = async () => {
|
||||
// 检查是否已被取消(参数变化或重新生成)
|
||||
if (currentTaskIdRef.current !== taskId) return
|
||||
|
||||
// 超时检查
|
||||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||||
setPreviewError("预览生成超时,请重试")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||||
|
||||
if (currentTaskIdRef.current !== taskId) return
|
||||
|
||||
const status = data.status as ApiPreviewStatus
|
||||
|
||||
if (status === "completed") {
|
||||
setProgress(100)
|
||||
setPreviewResult({
|
||||
taskId: safeString(data.task_id, ""),
|
||||
videoUrl: safeString(data.video_url, ""),
|
||||
clipCount: safeNumber(data.clip_count),
|
||||
transitionCount: safeNumber(data.transition_count),
|
||||
materialUsage: safeNumber(data.material_usage),
|
||||
duration: safeNumber(data.duration),
|
||||
fileSize: safeNumber(data.file_size),
|
||||
generateDuration: safeNumber(data.generate_duration),
|
||||
progress: 100,
|
||||
})
|
||||
setPreviewStatus("ready")
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setPreviewError(safeString(data.error_message, "预览生成失败,请重试"))
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setPreviewError("预览任务已取消")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
setProgress(safeNumber(data.progress))
|
||||
if (status === "pending") {
|
||||
setPreviewStatus("pending")
|
||||
pollTimerRef.current = setTimeout(poll, 5000)
|
||||
} else {
|
||||
setPreviewStatus("generating")
|
||||
pollTimerRef.current = setTimeout(poll, 2000)
|
||||
}
|
||||
} catch (pollErr) {
|
||||
if (currentTaskIdRef.current !== taskId) return
|
||||
// 轮询出错,延迟后重试
|
||||
pollTimerRef.current = setTimeout(poll, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// 首次延迟 1 秒开始轮询
|
||||
pollTimerRef.current = setTimeout(poll, 1000)
|
||||
}, [])
|
||||
|
||||
/** 生成预览 */
|
||||
const generatePreview = useCallback(async () => {
|
||||
if (!selectedTemplate) {
|
||||
setPreviewError("请先选择模板")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
if (materialTotal === 0) {
|
||||
setPreviewError("请先选择素材")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
// 取消之前的轮询
|
||||
clearPollTimer()
|
||||
|
||||
setPreviewStatus("pending")
|
||||
setPreviewError("")
|
||||
setProgress(0)
|
||||
setPreviewResult(null)
|
||||
startTimeRef.current = Date.now()
|
||||
|
||||
try {
|
||||
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
const response = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || undefined,
|
||||
video_ratio: videoRatio,
|
||||
})
|
||||
|
||||
// 竞态检查
|
||||
if (startTimeRef.current === 0) return // 已被重置
|
||||
|
||||
currentTaskIdRef.current = response.task_id
|
||||
pollPreviewStatus(response.task_id)
|
||||
} catch (e) {
|
||||
setPreviewError(safeString(e instanceof Error ? e.message : e, "预览生成失败"))
|
||||
setPreviewStatus("error")
|
||||
}
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialTotal,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
selectedMaterials,
|
||||
duration,
|
||||
videoRatio,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
])
|
||||
|
||||
/** 重新生成预览 */
|
||||
const regeneratePreview = useCallback(() => {
|
||||
generatePreview()
|
||||
}, [generatePreview])
|
||||
|
||||
/** 是否可以下一步(预览已生成) */
|
||||
const canProceed = previewStatus === "ready"
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
// 预览生成状态
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
canProceed,
|
||||
generatePreview,
|
||||
regeneratePreview,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Step 5 配音选择 Hook
|
||||
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
|
||||
*/
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { message } from "antd"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
|
||||
@@ -100,6 +101,73 @@ export function useStep5Voice({
|
||||
onCloneModalOpenChange,
|
||||
})
|
||||
|
||||
/* ── 克隆音色试听播放 ── */
|
||||
const [playingCloneVoice, setPlayingCloneVoice] = useState<string | null>(null)
|
||||
const cloneAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const cloneMountedRef = useRef(true)
|
||||
|
||||
// 组件卸载时清理克隆音色试听的音频资源
|
||||
useEffect(() => {
|
||||
cloneMountedRef.current = true
|
||||
return () => {
|
||||
cloneMountedRef.current = false
|
||||
if (cloneAudioRef.current) {
|
||||
cloneAudioRef.current.onended = null
|
||||
cloneAudioRef.current.pause()
|
||||
cloneAudioRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePlayCloneSample = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
if (playingCloneVoice === voice.id) {
|
||||
if (cloneAudioRef.current) {
|
||||
cloneAudioRef.current.onended = null
|
||||
cloneAudioRef.current.pause()
|
||||
}
|
||||
cloneAudioRef.current = null
|
||||
setPlayingCloneVoice(null)
|
||||
return
|
||||
}
|
||||
// 暂停上一个音频并清除事件监听,避免竞态
|
||||
if (cloneAudioRef.current) {
|
||||
cloneAudioRef.current.onended = null
|
||||
cloneAudioRef.current.pause()
|
||||
}
|
||||
// TODO: 后端克隆音色试听接口出来后,改为调用接口获取试听音频
|
||||
// 目前使用 sample_url 字段,如果没有则提示
|
||||
const audioUrl = voice.sample_url
|
||||
if (!audioUrl) {
|
||||
message.warning("该克隆音色暂无试听音频")
|
||||
return
|
||||
}
|
||||
const audio = new Audio(audioUrl)
|
||||
cloneAudioRef.current = audio
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
// 只有当前活跃的音频实例才能更新状态,防止快速切换竞态
|
||||
if (cloneMountedRef.current && cloneAudioRef.current === audio) {
|
||||
setPlayingCloneVoice(voice.id)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cloneMountedRef.current && cloneAudioRef.current === audio) {
|
||||
message.error("播放失败,请检查网络")
|
||||
cloneAudioRef.current = null
|
||||
}
|
||||
})
|
||||
audio.onended = () => {
|
||||
if (cloneMountedRef.current && cloneAudioRef.current === audio) {
|
||||
setPlayingCloneVoice(null)
|
||||
cloneAudioRef.current = null
|
||||
}
|
||||
}
|
||||
},
|
||||
[playingCloneVoice],
|
||||
)
|
||||
|
||||
const handleCloneSuccess = (voice: VoiceClone) => {
|
||||
rawCloneSuccess(voice)
|
||||
message.success("音色克隆成功!")
|
||||
@@ -124,6 +192,9 @@ export function useStep5Voice({
|
||||
// 音频播放
|
||||
playingVoice,
|
||||
toggleVoicePlay,
|
||||
// 克隆音色试听播放
|
||||
playingCloneVoice,
|
||||
handlePlayCloneSample,
|
||||
// 预设音色操作
|
||||
handleSelectPresetVoice,
|
||||
handleSelectCloneVoice,
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface UseStepNavigationOptions {
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** Step3 是否已生成预览(Step3校验用) */
|
||||
previewReady: boolean
|
||||
}
|
||||
|
||||
export interface UseStepNavigationReturn {
|
||||
@@ -29,6 +31,7 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady,
|
||||
} = options
|
||||
|
||||
const goNext = () => {
|
||||
@@ -44,6 +47,10 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 3 && !previewReady) {
|
||||
message.warning("请先生成剪辑预览")
|
||||
return
|
||||
}
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
@@ -61,3 +68,5 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
|
||||
return { goNext, goPrev }
|
||||
}
|
||||
|
||||
export default useStepNavigation
|
||||
|
||||
@@ -26,6 +26,7 @@ const MyVoices: React.FC = () => {
|
||||
loading,
|
||||
hasProcessing,
|
||||
playingId,
|
||||
loadingPreviewId,
|
||||
toasts,
|
||||
editModalOpen,
|
||||
editName,
|
||||
@@ -75,6 +76,7 @@ const MyVoices: React.FC = () => {
|
||||
key={voice.id}
|
||||
voice={voice}
|
||||
isPlaying={playingId === voice.id}
|
||||
isLoading={loadingPreviewId === voice.id}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
EditOutlined,
|
||||
SoundOutlined,
|
||||
ClockCircleOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button, Tooltip } from "@/components/ui"
|
||||
import { formatDuration, type VoiceClone } from "@/api/voice-clone"
|
||||
@@ -15,6 +16,7 @@ import { formatDate } from "../utils"
|
||||
interface VoiceCardProps {
|
||||
voice: VoiceClone
|
||||
isPlaying: boolean
|
||||
isLoading?: boolean
|
||||
onTogglePlay: (voice: VoiceClone) => void
|
||||
onEdit: (voice: VoiceClone) => void
|
||||
onDelete: (voice: VoiceClone) => void
|
||||
@@ -26,6 +28,7 @@ interface VoiceCardProps {
|
||||
export const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
voice,
|
||||
isPlaying,
|
||||
isLoading = false,
|
||||
onTogglePlay,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -74,8 +77,13 @@ export const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
buttonType={isPlaying ? "secondary" : "ghost"}
|
||||
buttonSize="sm"
|
||||
onClick={() => onTogglePlay(voice)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isPlaying ? (
|
||||
{isLoading ? (
|
||||
<>
|
||||
<LoadingOutlined /> 合成中…
|
||||
</>
|
||||
) : isPlaying ? (
|
||||
<>
|
||||
<PauseCircleOutlined /> 暂停
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import { deleteVoiceClone, updateVoiceClone } from "@/api/voice-clone"
|
||||
import { deleteVoiceClone, updateVoiceClone, getVoiceClonePreview } from "@/api/voice-clone"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { ToastItem } from "../types"
|
||||
|
||||
@@ -15,6 +15,7 @@ export const useMyVoices = () => {
|
||||
const navigate = useNavigate()
|
||||
const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress()
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const [loadingPreviewId, setLoadingPreviewId] = useState<string | null>(null)
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(null)
|
||||
@@ -31,26 +32,44 @@ export const useMyVoices = () => {
|
||||
|
||||
// 试听播放
|
||||
const handleTogglePlay = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
async (voice: VoiceClone) => {
|
||||
// 正在播放同一个 → 暂停
|
||||
if (playingId === voice.id) {
|
||||
audioRef.current?.pause()
|
||||
setPlayingId(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 先停掉当前播放
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
if (!voice.sample_url) {
|
||||
showToast("暂无试听音频", "error")
|
||||
|
||||
// 如果正在加载其他试听,不重复触发
|
||||
if (loadingPreviewId && loadingPreviewId !== voice.id) {
|
||||
return
|
||||
}
|
||||
const audio = new Audio(voice.sample_url)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"))
|
||||
audio.onended = () => setPlayingId(null)
|
||||
setPlayingId(voice.id)
|
||||
|
||||
// 调用试听接口获取音频 URL
|
||||
setLoadingPreviewId(voice.id)
|
||||
try {
|
||||
const preview = await getVoiceClonePreview(voice.id)
|
||||
const audio = new Audio(preview.audio_url)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {
|
||||
showToast("播放失败,请检查音频文件", "error")
|
||||
setPlayingId(null)
|
||||
})
|
||||
audio.onended = () => setPlayingId(null)
|
||||
setPlayingId(voice.id)
|
||||
} catch {
|
||||
showToast("试听音频生成失败,请重试", "error")
|
||||
} finally {
|
||||
setLoadingPreviewId(null)
|
||||
}
|
||||
},
|
||||
[playingId, showToast],
|
||||
[playingId, loadingPreviewId, showToast],
|
||||
)
|
||||
|
||||
// 编辑
|
||||
@@ -117,6 +136,7 @@ export const useMyVoices = () => {
|
||||
hasProcessing,
|
||||
// 状态
|
||||
playingId,
|
||||
loadingPreviewId,
|
||||
toasts,
|
||||
editModalOpen,
|
||||
editingVoice,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Auth API 测试
|
||||
* 对应 api/auth/ 目录化后的模块
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
normalizeUser,
|
||||
@@ -10,7 +14,6 @@ import {
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
} from "@/api/auth"
|
||||
|
||||
const mockPost = vi.fn()
|
||||
const mockGet = vi.fn()
|
||||
const mockAxiosPost = vi.fn()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import BindContactModal from "@/components/auth/BindContactModal"
|
||||
import type { BindContactModalProps, ContactType } from "@/components/auth/BindContactModal/types"
|
||||
import { useBindContactForm } from "@/components/auth/BindContactModal/hooks/useBindContactForm"
|
||||
import { useCountdown } from "@/components/auth/BindContactModal/hooks/useCountdown"
|
||||
|
||||
describe("BindContactModal", () => {
|
||||
it("主组件可正常导入", () => {
|
||||
expect(BindContactModal).toBeDefined()
|
||||
})
|
||||
|
||||
it("Hook 可正常导入", () => {
|
||||
expect(useBindContactForm).toBeDefined()
|
||||
expect(useCountdown).toBeDefined()
|
||||
})
|
||||
|
||||
it("类型导出正常", () => {
|
||||
const props: BindContactModalProps = { open: false }
|
||||
expect(props.open).toBe(false)
|
||||
|
||||
const contactType: ContactType = "email"
|
||||
expect(contactType).toBe("email")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for constants
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/components/voice/CloneModal/constants"
|
||||
|
||||
describe("constants smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { renderHook } from "@testing-library/react"
|
||||
import { useAudioRecorder } from "@/components/voice/CloneModal/hooks/useAudioRecorder"
|
||||
|
||||
// Mock MediaRecorder
|
||||
const mockMediaRecorder = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
mimeType: "audio/webm",
|
||||
state: "inactive",
|
||||
ondataavailable: null,
|
||||
onstop: null,
|
||||
}
|
||||
|
||||
vi.stubGlobal(
|
||||
"MediaRecorder",
|
||||
vi.fn().mockImplementation(() => mockMediaRecorder),
|
||||
)
|
||||
|
||||
vi.stubGlobal("navigator", {
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn().mockResolvedValue({
|
||||
getTracks: () => [{ stop: vi.fn() }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
describe("useAudioRecorder", () => {
|
||||
it("returns initial state", () => {
|
||||
const { result } = renderHook(() => useAudioRecorder())
|
||||
expect(result.current.isRecording).toBe(false)
|
||||
expect(result.current.recordTime).toBe(0)
|
||||
expect(result.current.recordedBlob).toBe(null)
|
||||
expect(typeof result.current.toggleRecord).toBe("function")
|
||||
expect(typeof result.current.reset).toBe("function")
|
||||
expect(typeof result.current.formattedTime).toBe("string")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for index
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/components/voice/CloneModal"
|
||||
|
||||
describe("index smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for types
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/components/voice/CloneModal/types"
|
||||
|
||||
describe("types smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for utils
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/components/voice/CloneModal/utils"
|
||||
|
||||
describe("utils smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for LayerConfig.test
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/pages/editing-planner/components/PipConfigPanel/components/LayerConfig"
|
||||
|
||||
describe("LayerConfig.test smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for LayerList.test
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/pages/editing-planner/components/PipConfigPanel/components/LayerList"
|
||||
|
||||
describe("LayerList.test smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for PipPreview.test
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/pages/editing-planner/components/PipConfigPanel/components/PipPreview"
|
||||
|
||||
describe("PipPreview.test smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for constants.test
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/pages/editing-planner/components/PipConfigPanel/constants"
|
||||
|
||||
describe("constants.test smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for usePipConfigPanel.test
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/pages/editing-planner/components/PipConfigPanel/hooks/usePipConfigPanel"
|
||||
|
||||
describe("usePipConfigPanel.test smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for StickerList.test
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/pages/editing-planner/components/StickerPanel/components/StickerList"
|
||||
|
||||
describe("StickerList.test smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Smoke test for StickerPropsEditor.test
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/pages/editing-planner/components/StickerPanel/components/StickerPropsEditor"
|
||||
|
||||
describe("StickerPropsEditor.test smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user