Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3002fe0135 | |||
| 6ca886f6cf | |||
| 32868e54ad | |||
| d50edcb2e6 | |||
| a9aface0b8 | |||
| dc62884c5f | |||
| 9b06d814bd | |||
| 248eb3266d | |||
| c575a4b835 | |||
| 22a34aebf1 | |||
| 40776cd4fa | |||
| e9bc340612 | |||
| 71faf2ba44 | |||
| 26db4ac44d | |||
| aa0b0de90f | |||
| 3204bf2d1f | |||
| ee2cc0e7a1 | |||
| be0b4f4dac | |||
| 1e16e81344 | |||
| 7a72cfd709 | |||
| b82b303d6a | |||
| baa3bf6300 | |||
| d7788013de | |||
| ff3038a291 | |||
| 10f9e67c8e | |||
| c192398db6 | |||
| 17cf970ae6 | |||
| 5b21763aea | |||
| c90b4819c0 | |||
| 464f6ea155 | |||
| d2bdb462c6 | |||
| 745c851758 | |||
| 18bd0de3fa | |||
| 04bcd2e6f9 | |||
| f7151ea2be | |||
| cea83329c8 | |||
| 2df7bc9dc8 | |||
| f6a1798124 | |||
| 3ca32f3532 | |||
| 03b42e1561 | |||
| 972ff89990 | |||
| 37d111dcd5 | |||
| 0e86062f17 | |||
| 6e70a7ad29 | |||
| c9c7da0c74 | |||
| cf544881fd | |||
| 42f92017de | |||
| 58fe71c483 | |||
| 3ae15eb4fc | |||
| 68e55df83b | |||
| 78afa40458 | |||
| e38f13eb96 | |||
| 42335eff42 | |||
| 9a51cad137 |
@@ -0,0 +1,162 @@
|
||||
name: ACR Cleanup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_sha:
|
||||
description: "PR commit SHA(仅清理指定PR镜像,留空则全量清理)"
|
||||
required: false
|
||||
default: ""
|
||||
dry_run:
|
||||
description: "预览模式(dry-run),不实际删除"
|
||||
required: false
|
||||
default: "true"
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
branches: [develop, main]
|
||||
|
||||
concurrency:
|
||||
group: acr-cleanup-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: ACR Image Cleanup
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
ACR_REGISTRY: xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com
|
||||
ACR_NAMESPACE: xiaoxiakeji
|
||||
ACR_SERVICE: registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa
|
||||
GITEA_URL: https://git.xiaoxiajianji.com
|
||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
|
||||
- name: Get staging running images (whitelist)
|
||||
id: protected_images
|
||||
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
|
||||
env:
|
||||
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||
run: |
|
||||
set +e
|
||||
echo "获取staging服务器运行中镜像作为白名单..."
|
||||
mkdir -p ~/.ssh
|
||||
echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_port="${STAGING_SSH_PORT:-22222}"
|
||||
|
||||
ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# 获取所有运行容器的镜像,提取tag部分
|
||||
IMAGES=$(ssh -p "$staging_port" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no \
|
||||
"root@$staging_host" "docker ps --format '{{.Image}}' 2>/dev/null" 2>/dev/null | grep -v "^$" | sort -u)
|
||||
|
||||
PROTECTED_TAGS=""
|
||||
if [ -n "$IMAGES" ]; then
|
||||
while IFS= read -r img; do
|
||||
# 从完整镜像名中提取tag(最后一个冒号后)
|
||||
tag=$(echo "$img" | rev | cut -d: -f1 | rev)
|
||||
if [ -n "$tag" ] && [ "$tag" != "latest" ] && [ ${#tag} -gt 5 ]; then
|
||||
if [ -z "$PROTECTED_TAGS" ]; then
|
||||
PROTECTED_TAGS="$tag"
|
||||
else
|
||||
PROTECTED_TAGS="$PROTECTED_TAGS,$tag"
|
||||
fi
|
||||
fi
|
||||
done <<< "$IMAGES"
|
||||
fi
|
||||
|
||||
echo "staging运行中镜像tag: ${PROTECTED_TAGS:-(无)}"
|
||||
echo "protected_tags=$PROTECTED_TAGS" >> $GITEA_OUTPUT
|
||||
|
||||
# ====== Docker登录 ======
|
||||
- name: Docker login to ACR
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
run: |
|
||||
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_REGISTRY" -u "$ACR_USERNAME" --password-stdin
|
||||
|
||||
# ====== 模式1:PR关闭时清理 ======
|
||||
- name: Cleanup PR images (PR closed)
|
||||
if: gitea.event_name == 'pull_request_target'
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
PR_SHA: ${{ gitea.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " PR #$PR_NUMBER 已关闭,清理对应镜像"
|
||||
echo " Head SHA: ${PR_SHA::12}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
python3 scripts/ci/acr_cleanup.py \
|
||||
--pr-sha "$PR_SHA" \
|
||||
--execute
|
||||
|
||||
# ====== 模式2:Cron全量清理 ======
|
||||
- name: Full cleanup (cron / manual)
|
||||
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
PROTECTED_TAGS: ${{ steps.protected_images.outputs.protected_tags }}
|
||||
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " ACR 全量清理(${{ gitea.event_name }})"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# 决定是否dry-run
|
||||
DRY_RUN_FLAG=""
|
||||
if [ "$DRY_RUN_INPUT" = "true" ]; then
|
||||
DRY_RUN_FLAG="--dry-run"
|
||||
echo "模式: 预览模式 (dry-run)"
|
||||
else
|
||||
echo "模式: 执行模式"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
python3 scripts/ci/acr_cleanup.py \
|
||||
--keep 20 \
|
||||
--protected-tags "$PROTECTED_TAGS" \
|
||||
$DRY_RUN_FLAG
|
||||
|
||||
# ====== 模式3:手动指定PR SHA清理 ======
|
||||
- name: Cleanup specific PR image (manual)
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.event.inputs.pr_sha
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
PR_SHA: ${{ gitea.event.inputs.pr_sha }}
|
||||
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
|
||||
run: |
|
||||
echo "手动清理PR镜像: ${PR_SHA::12}"
|
||||
echo ""
|
||||
|
||||
DRY_RUN_FLAG=""
|
||||
if [ "$DRY_RUN_INPUT" = "true" ]; then
|
||||
DRY_RUN_FLAG="--dry-run"
|
||||
fi
|
||||
|
||||
python3 scripts/ci/acr_cleanup.py \
|
||||
--pr-sha "$PR_SHA" \
|
||||
$DRY_RUN_FLAG
|
||||
@@ -26,7 +26,7 @@ concurrency:
|
||||
cancel-in-progress: ${{ gitea.event_name == 'pull_request' }}
|
||||
env:
|
||||
CI_PG_HOST: host.docker.internal
|
||||
CI_PG_PORT: "5432"
|
||||
CI_LOCAL_PG_PORT: "5432"
|
||||
CI_PG_USER: postgres
|
||||
CI_PG_PASSWORD: postgres
|
||||
CI_PG_DB: xiaoxia_saas
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_PG_PORT }}/${{ env.CI_PG_DB }}
|
||||
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }}
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
steps:
|
||||
@@ -346,6 +346,7 @@ jobs:
|
||||
OSS_ACCESS_KEY_SECRET: placeholder
|
||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||
JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -410,13 +411,14 @@ jobs:
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_PG_PORT }}/${{ env.CI_PG_DB }}
|
||||
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }}
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
OSS_ACCESS_KEY_ID: placeholder
|
||||
OSS_ACCESS_KEY_SECRET: placeholder
|
||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||
JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -638,65 +640,83 @@ jobs:
|
||||
echo "Docker login failed ($i/3), retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Pre-build worker base images (fallback if not exist)
|
||||
- name: Pre-build worker base images (3-level cache)
|
||||
if: matrix.service == 'worker'
|
||||
id: prebuild
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
|
||||
BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest"
|
||||
|
||||
# 尝试拉取基础镜像
|
||||
echo "检查基础镜像..."
|
||||
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
|
||||
echo "基础镜像已存在,使用远程镜像"
|
||||
echo "fallback=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "基础镜像不存在,本地构建(fallback模式)..."
|
||||
|
||||
# 构建builder基础镜像
|
||||
echo "构建 worker-base-builder..."
|
||||
# 用buildx docker-container驱动构建(兼容DooD模式:普通docker build看不到容器内文件)
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap > /dev/null 2>&1
|
||||
|
||||
# 构建builder基础镜像(带重试,buildx容器偶发不稳定)
|
||||
echo "构建 worker-base-builder..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-builder.Dockerfile -t "$BASE_BUILDER" .; then
|
||||
echo "worker-base-builder 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-builder 构建失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 构建runtime基础镜像
|
||||
echo "构建 worker-base-runtime..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-runtime.Dockerfile -t "$BASE_RUNTIME" .; then
|
||||
echo "worker-base-runtime 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-runtime 构建失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "fallback=true" >> $GITHUB_OUTPUT
|
||||
echo "基础镜像本地构建完成"
|
||||
fi
|
||||
GITEA_REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||
ACR_REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
GITEA_BUILDER="${GITEA_REGISTRY}/worker-base-builder:latest"
|
||||
GITEA_RUNTIME="${GITEA_REGISTRY}/worker-base-runtime:latest"
|
||||
ACR_BUILDER="${ACR_REGISTRY}/worker-base-builder:latest"
|
||||
ACR_RUNTIME="${ACR_REGISTRY}/worker-base-runtime:latest"
|
||||
|
||||
# L1: 本地daemon缓存(DooD模式8runner共享宿主机daemon)
|
||||
echo "=== L1 本地缓存 ==="
|
||||
if docker image inspect "$ACR_BUILDER" > /dev/null 2>&1 \
|
||||
&& docker image inspect "$ACR_RUNTIME" > /dev/null 2>&1; then
|
||||
echo "本地缓存命中"
|
||||
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
echo "本地无缓存"
|
||||
|
||||
# L2: Gitea registry缓存(内网快)
|
||||
echo "=== L2 Registry拉取 ==="
|
||||
if docker pull "$GITEA_BUILDER" 2>/dev/null && docker pull "$GITEA_RUNTIME" 2>/dev/null; then
|
||||
echo "Registry拉取成功,重tag供Dockerfile使用"
|
||||
docker tag "$GITEA_BUILDER" "$ACR_BUILDER"
|
||||
docker tag "$GITEA_RUNTIME" "$ACR_RUNTIME"
|
||||
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
echo "Registry无缓存,需本地构建"
|
||||
|
||||
# L3: 本地构建
|
||||
echo "=== L3 本地构建 ==="
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap > /dev/null 2>&1
|
||||
|
||||
echo "构建 worker-base-builder..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-builder.Dockerfile -t "$ACR_BUILDER" .; then
|
||||
echo "worker-base-builder 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-builder 失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "构建 worker-base-runtime..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-runtime.Dockerfile -t "$ACR_RUNTIME" .; then
|
||||
echo "worker-base-runtime 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-runtime 失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 推送到Gitea registry供后续复用
|
||||
echo "=== 推送缓存到Registry ==="
|
||||
docker tag "$ACR_BUILDER" "$GITEA_BUILDER"
|
||||
docker tag "$ACR_RUNTIME" "$GITEA_RUNTIME"
|
||||
docker push "$GITEA_BUILDER" 2>/dev/null || echo "push builder失败(不影响)"
|
||||
docker push "$GITEA_RUNTIME" 2>/dev/null || echo "push runtime失败(不影响)"
|
||||
|
||||
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
||||
echo "基础镜像构建完成"
|
||||
- name: Build PR image (verify only, no push)
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -710,15 +730,15 @@ jobs:
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
|
||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
|
||||
echo "Fallback模式:用普通docker build(基础镜像本地已构建)"
|
||||
# Worker有本地base镜像时:用BuildKit直接构建(快,无需起buildx容器)
|
||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.has_local_base }}" = "true" ]; then
|
||||
echo "本地base镜像已就绪,BuildKit快速构建"
|
||||
BUILD_ARG_STR=""
|
||||
for arg in $EXTRA_BUILD_ARGS; do
|
||||
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
|
||||
done
|
||||
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||
echo "Fallback PR Build successful"
|
||||
DOCKER_BUILDKIT=1 docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||
echo "快速构建成功"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -1210,7 +1230,7 @@ jobs:
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1287,10 +1307,16 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_REF_NAME}"
|
||||
# 根据ref类型设置镜像标签:tag用版本号,分支用分支名+sha
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
TAG_NAME="${GITHUB_REF_NAME}"
|
||||
else
|
||||
TAG_NAME="${GITHUB_REF_NAME}-${GITHUB_SHA::8}"
|
||||
fi
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}"
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_REF_NAME}\""
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf"
|
||||
fi
|
||||
@@ -1588,4 +1614,89 @@ jobs:
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
canary-release:
|
||||
name: Canary Release to Production
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 120
|
||||
concurrency:
|
||||
group: canary-release-production
|
||||
cancel-in-progress: false
|
||||
if: github.event_name == 'push' && github.ref_name == 'main'
|
||||
needs:
|
||||
- build-production
|
||||
- staging-api-tests
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
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
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Notify canary release start
|
||||
continue-on-error: true
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=start JOB_NAME="Canary Release" python3 scripts/ci_notify.py
|
||||
- name: Install SSH client
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client curl >/dev/null 2>&1
|
||||
echo "openssh-client installed"
|
||||
- name: Run canary release
|
||||
shell: bash
|
||||
env:
|
||||
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
PRODUCTION_SSH_PORT: ${{ secrets.PRODUCTION_SSH_PORT }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set -eu
|
||||
IMAGE_TAG="main-${GITHUB_SHA::8}"
|
||||
export IMAGE_TAG
|
||||
echo "Canary release version: $IMAGE_TAG"
|
||||
bash scripts/ci/canary_release.sh
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on success
|
||||
continue-on-error: true
|
||||
if: success()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=success JOB_NAME="Canary Release" python3 scripts/ci_notify.py
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Canary Release" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
@@ -115,11 +115,11 @@ jobs:
|
||||
fi
|
||||
if [ "$CACHE_VALID" = "false" ]; then
|
||||
echo "Cache miss or invalid: running npm ci..."
|
||||
if ! npm ci --include=dev; then
|
||||
if ! npm ci; then
|
||||
echo "npm ci failed, cleaning node_modules and retrying..."
|
||||
rm -rf node_modules
|
||||
mkdir -p node_modules
|
||||
npm ci --include=dev
|
||||
npm ci
|
||||
fi
|
||||
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
|
||||
echo "Dependencies installed, cache updated"
|
||||
|
||||
Generated
+6117
File diff suppressed because it is too large
Load Diff
@@ -4,36 +4,8 @@
|
||||
* 使用 useQuery 对接后端真实 API(api/assets.ts)
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import {
|
||||
Upload,
|
||||
Modal as AntModal,
|
||||
message,
|
||||
Popconfirm,
|
||||
Drawer,
|
||||
Tag,
|
||||
Input as AntInput,
|
||||
Radio,
|
||||
Select as AntSelect,
|
||||
} from "antd"
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
InboxOutlined,
|
||||
VideoCameraOutlined,
|
||||
PictureOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
ExperimentOutlined,
|
||||
LoadingOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
TagsOutlined,
|
||||
FolderOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
AudioOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Upload, message } from "antd"
|
||||
import { InboxOutlined, PictureOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
getAssetLibraries,
|
||||
@@ -51,307 +23,24 @@ import {
|
||||
type AssetItem as ApiAssetItem,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets"
|
||||
import { Button, Input, Select } from "@/components/ui"
|
||||
import { Button } from "@/components/ui"
|
||||
import { type AssetItem, type AssetKind, mapLibrary, mapAsset } from "@/pages/assets/types"
|
||||
import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "@/pages/assets/constants"
|
||||
import AssetCard from "@/pages/assets/components/AssetCard"
|
||||
import { SkeletonCard } from "@/pages/assets/components/AssetSkeleton"
|
||||
import LibrarySidebar from "@/pages/assets/components/LibrarySidebar"
|
||||
import AssetFilterBar from "@/pages/assets/components/AssetFilterBar"
|
||||
import BatchOperationBar from "@/pages/assets/components/BatchOperationBar"
|
||||
import CreateLibraryModal from "@/pages/assets/components/CreateLibraryModal"
|
||||
import PlayModal from "@/pages/assets/components/PlayModal"
|
||||
import BatchTagModal from "@/pages/assets/components/BatchTagModal"
|
||||
import BatchClassifyModal from "@/pages/assets/components/BatchClassifyModal"
|
||||
import BatchMarkModal from "@/pages/assets/components/BatchMarkModal"
|
||||
import type { SmartViewType } from "@/pages/assets/components/BatchMarkModal"
|
||||
import ResultDrawer from "@/pages/assets/components/ResultDrawer"
|
||||
import UploadProgressModal from "@/pages/assets/components/UploadProgressModal"
|
||||
import "./assets.css"
|
||||
|
||||
/* ============================================================
|
||||
* 类型
|
||||
* ============================================================ */
|
||||
type AssetKind = "video" | "image" | "voice"
|
||||
type StatusType = "ok" | "warn" | "bad" | "info"
|
||||
|
||||
interface LibraryItem {
|
||||
id: string
|
||||
name: string
|
||||
kind: AssetKind
|
||||
count: number
|
||||
}
|
||||
|
||||
interface AssetItem {
|
||||
id: string
|
||||
name: string
|
||||
kind: AssetKind
|
||||
thumbUrl?: string
|
||||
fileUrl?: string
|
||||
status: StatusType
|
||||
statusLabel: string
|
||||
/** 是否处于处理中状态(上传中/入库中/诊断中) */
|
||||
loading?: boolean
|
||||
duration?: string
|
||||
size: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 映射:后端 → 前端
|
||||
* ============================================================ */
|
||||
|
||||
/** 根据 mime_type 推断前端 AssetKind */
|
||||
const inferKind = (mimeType: string): AssetKind => {
|
||||
if (mimeType.startsWith("video/")) return "video"
|
||||
if (mimeType.startsWith("audio/")) return "voice"
|
||||
return "image"
|
||||
}
|
||||
|
||||
/** 根据 quality_score / classification_status / asset status 推断前端状态 */
|
||||
const inferStatus = (
|
||||
score?: number,
|
||||
classificationStatus?: string,
|
||||
assetStatus?: string,
|
||||
): { status: StatusType; label: string; loading?: boolean } => {
|
||||
// 已删除素材(正常情况列表已过滤,这里是防御性处理)
|
||||
if (assetStatus === "deleted") {
|
||||
return { status: "bad", label: "已删除" }
|
||||
}
|
||||
// 处理中状态:上传中 / 入库中 / 处理中
|
||||
if (
|
||||
assetStatus === "uploading" ||
|
||||
assetStatus === "ingesting" ||
|
||||
assetStatus === "processing" ||
|
||||
assetStatus === "pending"
|
||||
) {
|
||||
return { status: "info", label: "处理中", loading: true }
|
||||
}
|
||||
// 失败状态
|
||||
if (assetStatus === "error" || assetStatus === "failed") {
|
||||
return { status: "bad", label: "处理失败" }
|
||||
}
|
||||
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
|
||||
if (assetStatus === "ready") {
|
||||
if (score == null) return { status: "info", label: "待诊断" }
|
||||
if (score >= 70) return { status: "ok", label: "合格" }
|
||||
if (score >= 40) return { status: "warn", label: "待优化" }
|
||||
return { status: "bad", label: "不合格" }
|
||||
}
|
||||
// 素材未就绪:classification 正在处理中
|
||||
if (classificationStatus === "processing" || classificationStatus === "pending") {
|
||||
return { status: "info", label: "处理中", loading: true }
|
||||
}
|
||||
if (score == null) return { status: "info", label: "待诊断" }
|
||||
if (score >= 70) return { status: "ok", label: "合格" }
|
||||
if (score >= 40) return { status: "warn", label: "待优化" }
|
||||
return { status: "bad", label: "不合格" }
|
||||
}
|
||||
|
||||
/** 格式化时长(秒 → mm:ss) */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 将后端 AssetLibraryItem 映射为前端 LibraryItem */
|
||||
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: item.kind || inferKind("video"),
|
||||
count: item.asset_count ?? 0,
|
||||
})
|
||||
|
||||
/** 将后端 ApiAssetItem 映射为前端 AssetItem */
|
||||
const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
const { status, label, loading } = inferStatus(
|
||||
item.quality_score ?? undefined,
|
||||
item.classification_status ?? undefined,
|
||||
item.status ?? undefined,
|
||||
)
|
||||
const metadata = item.metadata || {}
|
||||
const kind = inferKind(item.mime_type || "")
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind,
|
||||
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
|
||||
// 处理中的素材没有缩略图,显示占位符
|
||||
thumbUrl: loading
|
||||
? undefined
|
||||
: (item.thumbnail_url as string | undefined) ||
|
||||
(metadata.thumbnail_url as string | undefined) ||
|
||||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
|
||||
fileUrl: (item.file_url as string | undefined) || (metadata.file_url as string | undefined),
|
||||
status,
|
||||
statusLabel: label,
|
||||
loading,
|
||||
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
|
||||
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 常量
|
||||
* ============================================================ */
|
||||
const MAX_FILE_SIZE = 2048 * 1024 * 1024
|
||||
const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024
|
||||
|
||||
/* ============================================================
|
||||
* 工具函数
|
||||
* ============================================================ */
|
||||
const kindIcon = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return <VideoCameraOutlined />
|
||||
case "image":
|
||||
return <PictureOutlined />
|
||||
case "voice":
|
||||
return <AudioOutlined />
|
||||
}
|
||||
}
|
||||
|
||||
const kindLabel = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "视频"
|
||||
case "image":
|
||||
return "图片"
|
||||
case "voice":
|
||||
return "配音"
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据素材类型返回渐变背景 */
|
||||
const thumbGradient = (kind: AssetKind): string => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"
|
||||
case "image":
|
||||
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"
|
||||
case "voice":
|
||||
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* StatusPill 组件
|
||||
* ============================================================ */
|
||||
const StatusPill: React.FC<{ status: StatusType; label: string }> = ({ status, label }) => (
|
||||
<span className={`xx-status-pill xx-status-pill-${status}`}>{label}</span>
|
||||
)
|
||||
|
||||
/* ============================================================
|
||||
* SkeletonCard — 骨架屏卡片(素材列表加载时占位)
|
||||
* ============================================================ */
|
||||
const SkeletonCard: React.FC = () => (
|
||||
<div className="xx-asset-card xx-asset-skeleton">
|
||||
<div className="xx-asset-thumb xx-skeleton-pulse" />
|
||||
<div className="xx-asset-info">
|
||||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "70%" }} />
|
||||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "40%", marginTop: 8 }} />
|
||||
<div
|
||||
className="xx-skeleton-line xx-skeleton-pulse"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 28,
|
||||
marginTop: 8,
|
||||
borderRadius: "var(--radius-xs)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ============================================================
|
||||
* AssetCard 组件
|
||||
* ============================================================ */
|
||||
const AssetCard: React.FC<{
|
||||
asset: AssetItem
|
||||
selected: boolean
|
||||
diagnosing?: boolean
|
||||
onToggle: () => void
|
||||
onDiagnose: () => void
|
||||
onPlay: () => void
|
||||
onDelete: () => void
|
||||
}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay, onDelete }) => (
|
||||
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
|
||||
{/* 缩略图区 */}
|
||||
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
|
||||
{asset.thumbUrl ? (
|
||||
<img src={asset.thumbUrl} alt={asset.name} />
|
||||
) : (
|
||||
<span className="xx-asset-thumb-placeholder">
|
||||
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 处理中遮罩 */}
|
||||
{asset.loading && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
|
||||
<LoadingOutlined />
|
||||
<span>处理中</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败状态标识 */}
|
||||
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
|
||||
<CloseCircleOutlined />
|
||||
<span>处理失败</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
|
||||
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlay()
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,确定要删除这个素材吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
|
||||
<DeleteOutlined />
|
||||
</span>
|
||||
</Popconfirm>
|
||||
|
||||
{/* 选中态勾选 */}
|
||||
{selected && (
|
||||
<span className="xx-asset-check">
|
||||
<CheckOutlined />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-asset-info">
|
||||
<p className="xx-asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="xx-asset-meta">
|
||||
<StatusPill status={asset.status} label={asset.statusLabel} />
|
||||
{asset.duration && <span>{asset.duration}</span>}
|
||||
</div>
|
||||
<button
|
||||
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
|
||||
disabled={diagnosing || asset.loading || asset.status === "bad"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDiagnose()
|
||||
}}
|
||||
>
|
||||
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
|
||||
{diagnosing ? "诊断中..." : "诊断"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
@@ -465,9 +154,7 @@ const AssetLibrary: React.FC = () => {
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
|
||||
/* 批量智能标记 */
|
||||
const [batchSmartView, setBatchSmartView] = useState<"recommended" | "caution" | "high_risk">(
|
||||
"recommended",
|
||||
)
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
|
||||
/* 操作结果 */
|
||||
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
|
||||
@@ -611,7 +298,6 @@ const AssetLibrary: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量删除 */
|
||||
/* 单个素材删除 */
|
||||
const handleSingleDelete = async (assetId: string) => {
|
||||
try {
|
||||
@@ -630,6 +316,7 @@ const AssetLibrary: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量删除 */
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
@@ -741,7 +428,7 @@ const AssetLibrary: React.FC = () => {
|
||||
setMarkModalOpen(false)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
setSelectedIds(new Set())
|
||||
const labelMap = {
|
||||
const labelMap: Record<SmartViewType, string> = {
|
||||
recommended: "推荐",
|
||||
caution: "慎用",
|
||||
high_risk: "高风险",
|
||||
@@ -793,94 +480,19 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="xx-assets-page">
|
||||
{/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */}
|
||||
<AntModal
|
||||
open={uploading}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={260}
|
||||
maskClosable={false}
|
||||
className="xx-upload-progress-modal"
|
||||
>
|
||||
<div className="xx-upload-progress-body">
|
||||
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
|
||||
{/* 背景圆环 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--border-primary, #e5e7eb)"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
{/* 进度圆弧 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--primary-color, #6366f1)"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 52}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 52 * (1 - uploadProgress / 100)}`}
|
||||
transform="rotate(-90 60 60)"
|
||||
style={{ transition: "stroke-dashoffset 0.3s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="xx-upload-progress-text">
|
||||
<span className="xx-upload-progress-pct">{uploadProgress}%</span>
|
||||
<span className="xx-upload-progress-label">上传中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
{/* ─── 上传进度弹窗 ─── */}
|
||||
<UploadProgressModal open={uploading} progress={uploadProgress} />
|
||||
|
||||
{/* 两栏布局 */}
|
||||
<div className="xx-assets-layout">
|
||||
{/* ─── 左侧:视频库列表 ─── */}
|
||||
<div className="xx-asset-library-list">
|
||||
{libraries.map((lib) => (
|
||||
<div
|
||||
key={lib.id}
|
||||
className={`xx-asset-library-item${lib.id === effectiveLibId ? " active" : ""}`}
|
||||
onClick={() => setActiveLibId(lib.id)}
|
||||
>
|
||||
<div className="xx-asset-library-header">
|
||||
<h4>
|
||||
{kindIcon(lib.kind)} {lib.name}
|
||||
</h4>
|
||||
<Popconfirm
|
||||
title={`确定删除视频库 "${lib.name}"?`}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
handleDeleteLibrary(lib.id)
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button
|
||||
className="xx-asset-library-delete"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="删除视频库"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<span>
|
||||
{kindLabel(lib.kind)} · {lib.count} 个素材
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 新建视频库 */}
|
||||
<div className="xx-asset-library-add" onClick={() => setCreateModalOpen(true)}>
|
||||
<PlusOutlined />
|
||||
新建视频库
|
||||
</div>
|
||||
</div>
|
||||
<LibrarySidebar
|
||||
libraries={libraries}
|
||||
activeLibId={effectiveLibId}
|
||||
onSelect={setActiveLibId}
|
||||
onDelete={handleDeleteLibrary}
|
||||
onCreateClick={() => setCreateModalOpen(true)}
|
||||
/>
|
||||
|
||||
{/* ─── 右侧:内容区 ─── */}
|
||||
<div className="xx-assets-content">
|
||||
@@ -908,88 +520,27 @@ const AssetLibrary: React.FC = () => {
|
||||
</Upload.Dragger>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="xx-assets-filters">
|
||||
<div className="xx-assets-filters-left">
|
||||
<Input
|
||||
placeholder="搜索素材名称..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={setFilterType}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterTime}
|
||||
onChange={setFilterTime}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部时间" },
|
||||
{ value: "today", label: "今天" },
|
||||
{ value: "week", label: "近一周" },
|
||||
{ value: "month", label: "近一月" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-assets-filters-right">
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={selectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<span className="xx-assets-filter-count">共 {filteredAssets.length} 个素材</span>
|
||||
</div>
|
||||
</div>
|
||||
<AssetFilterBar
|
||||
searchText={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
filterType={filterType}
|
||||
onFilterTypeChange={setFilterType}
|
||||
filterTime={filterTime}
|
||||
onFilterTimeChange={setFilterTime}
|
||||
onSelectAll={selectAll}
|
||||
totalCount={filteredAssets.length}
|
||||
/>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="xx-assets-batch-bar">
|
||||
<span className="xx-assets-batch-count">已选 {selectedIds.size} 项</span>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={deselectAll}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<TagsOutlined />}
|
||||
onClick={() => setTagModalOpen(true)}
|
||||
>
|
||||
打标签
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<FolderOutlined />}
|
||||
onClick={() => setClassifyModalOpen(true)}
|
||||
>
|
||||
改分类
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => setMarkModalOpen(true)}
|
||||
>
|
||||
智能标记
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除 ${selectedIds.size} 个素材?`}
|
||||
onConfirm={handleBatchDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<BatchOperationBar
|
||||
selectedCount={selectedIds.size}
|
||||
onDeselectAll={deselectAll}
|
||||
onTagClick={() => setTagModalOpen(true)}
|
||||
onClassifyClick={() => setClassifyModalOpen(true)}
|
||||
onMarkClick={() => setMarkModalOpen(true)}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 素材网格 */}
|
||||
@@ -1036,242 +587,75 @@ const AssetLibrary: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* ─── 新建视频库弹窗 ─── */}
|
||||
<AntModal
|
||||
title="新建视频库"
|
||||
<CreateLibraryModal
|
||||
open={createModalOpen}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
onOk={handleCreateLibrary}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
name={newLibName}
|
||||
onNameChange={setNewLibName}
|
||||
kind={newLibKind}
|
||||
onKindChange={setNewLibKind}
|
||||
confirmLoading={createLibMutation.isPending}
|
||||
>
|
||||
<div className="xx-asset-form-body">
|
||||
<div>
|
||||
<div className="xx-asset-form-label">名称</div>
|
||||
<Input
|
||||
placeholder="请输入视频库名称"
|
||||
value={newLibName}
|
||||
onChange={(e) => setNewLibName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="xx-asset-form-label">类型</div>
|
||||
<Select
|
||||
value={newLibKind}
|
||||
onChange={(v) => setNewLibKind(v)}
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
/>
|
||||
|
||||
{/* ─── 视频/音频播放弹窗 ─── */}
|
||||
<AntModal
|
||||
title={playingAsset?.name ?? "播放"}
|
||||
open={!!playingAsset}
|
||||
onCancel={() => setPlayingAsset(null)}
|
||||
footer={null}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
{playingAsset?.fileUrl ? (
|
||||
<video src={playingAsset.fileUrl} controls autoPlay className="xx-asset-video-player" />
|
||||
) : (
|
||||
<div className="xx-asset-empty-fallback">
|
||||
<p>暂无可播放的文件地址</p>
|
||||
<p className="xx-asset-empty-fallback-id">素材 ID: {playingAsset?.id}</p>
|
||||
</div>
|
||||
)}
|
||||
</AntModal>
|
||||
<PlayModal open={!!playingAsset} asset={playingAsset} onClose={() => setPlayingAsset(null)} />
|
||||
|
||||
{/* ─── 批量打标签弹窗 ─── */}
|
||||
<AntModal
|
||||
title={`批量打标签(${selectedIds.size} 个素材)`}
|
||||
<BatchTagModal
|
||||
open={tagModalOpen}
|
||||
selectedCount={selectedIds.size}
|
||||
onCancel={() => {
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
}}
|
||||
onOk={handleBatchTag}
|
||||
tags={batchTags}
|
||||
onTagInputChange={setBatchTagInput}
|
||||
onTagInputKeyDown={handleTagInputKeyDown}
|
||||
onRemoveTag={removeBatchTag}
|
||||
tagInput={batchTagInput}
|
||||
tagMode={tagMode}
|
||||
onTagModeChange={setTagMode}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认打标签"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-tag-modal">
|
||||
<div className="xx-batch-tag-mode">
|
||||
<span className="xx-batch-tag-mode-label">模式:</span>
|
||||
<Radio.Group value={tagMode} onChange={(e) => setTagMode(e.target.value)}>
|
||||
<Radio value="add">追加标签</Radio>
|
||||
<Radio value="replace">替换全部标签</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div className="xx-batch-tag-input-row">
|
||||
<AntInput
|
||||
placeholder="输入标签后按 Enter 添加"
|
||||
value={batchTagInput}
|
||||
onChange={(e) => setBatchTagInput(e.target.value)}
|
||||
onKeyDown={handleTagInputKeyDown}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
{batchTags.length > 0 && (
|
||||
<div className="xx-batch-tag-list">
|
||||
{batchTags.map((tag) => (
|
||||
<Tag key={tag} closable onClose={() => removeBatchTag(tag)} color="blue">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tagMode === "replace" && batchTags.length > 0 && (
|
||||
<div className="xx-batch-tag-warning">
|
||||
<ExclamationCircleOutlined /> 替换模式将清除素材原有全部标签
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AntModal>
|
||||
/>
|
||||
|
||||
{/* ─── 批量改分类弹窗 ─── */}
|
||||
<AntModal
|
||||
title={`批量改分类(${selectedIds.size} 个素材)`}
|
||||
<BatchClassifyModal
|
||||
open={classifyModalOpen}
|
||||
selectedCount={selectedIds.size}
|
||||
onCancel={() => {
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
}}
|
||||
onOk={handleBatchClassify}
|
||||
category={batchCategory}
|
||||
onCategoryChange={setBatchCategory}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认修改"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-classify-modal">
|
||||
<p className="xx-batch-classify-hint">
|
||||
将选中的 {selectedIds.size} 个素材统一修改为以下分类:
|
||||
</p>
|
||||
<AntSelect
|
||||
value={batchCategory || undefined}
|
||||
onChange={(v) => setBatchCategory(v)}
|
||||
placeholder="请选择分类"
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ value: "person", label: "人物" },
|
||||
{ value: "scenic", label: "风景" },
|
||||
{ value: "product", label: "产品" },
|
||||
{ value: "food", label: "美食" },
|
||||
{ value: "animal", label: "动物" },
|
||||
{ value: "architecture", label: "建筑" },
|
||||
{ value: "other", label: "其他" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</AntModal>
|
||||
/>
|
||||
|
||||
{/* ─── 批量智能标记弹窗 ─── */}
|
||||
<AntModal
|
||||
title={`批量智能标记(${selectedIds.size} 个素材)`}
|
||||
<BatchMarkModal
|
||||
open={markModalOpen}
|
||||
selectedCount={selectedIds.size}
|
||||
onCancel={() => setMarkModalOpen(false)}
|
||||
onOk={handleBatchMark}
|
||||
smartView={batchSmartView}
|
||||
onSmartViewChange={setBatchSmartView}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认标记"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-mark-modal">
|
||||
<p className="xx-batch-mark-hint">将选中的 {selectedIds.size} 个素材标记为:</p>
|
||||
<Radio.Group
|
||||
value={batchSmartView}
|
||||
onChange={(e) => setBatchSmartView(e.target.value)}
|
||||
className="xx-batch-mark-options"
|
||||
>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="recommended">
|
||||
<Tag color="success">推荐</Tag>
|
||||
<span className="xx-batch-mark-desc">质量优良,可直接用于生产</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="caution">
|
||||
<Tag color="warning">慎用</Tag>
|
||||
<span className="xx-batch-mark-desc">存在一定问题,需人工审核后再使用</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="high_risk">
|
||||
<Tag color="error">高风险</Tag>
|
||||
<span className="xx-batch-mark-desc">存在严重问题,不建议使用</span>
|
||||
</Radio>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</AntModal>
|
||||
/>
|
||||
|
||||
{/* ─── 操作结果 Drawer ─── */}
|
||||
<Drawer
|
||||
title={`${operationTitle} — 操作结果`}
|
||||
<ResultDrawer
|
||||
open={resultDrawerOpen}
|
||||
title={operationTitle}
|
||||
result={operationResult}
|
||||
onClose={() => {
|
||||
setResultDrawerOpen(false)
|
||||
setOperationResult(null)
|
||||
}}
|
||||
width={420}
|
||||
>
|
||||
{operationResult && (
|
||||
<div className="xx-batch-result">
|
||||
<div className="xx-batch-result-summary">
|
||||
<div className="xx-batch-result-stat">
|
||||
<span className="xx-batch-result-total">总计 {operationResult.total} 个</span>
|
||||
</div>
|
||||
<div className="xx-batch-result-stat success">
|
||||
<CheckCircleOutlined />
|
||||
<span>成功 {operationResult.success_count} 个</span>
|
||||
</div>
|
||||
{operationResult.failure_count > 0 && (
|
||||
<div className="xx-batch-result-stat fail">
|
||||
<CloseCircleOutlined />
|
||||
<span>失败 {operationResult.failure_count} 个</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(operationResult?.succeeded?.length ?? 0) > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title success">
|
||||
<CheckCircleOutlined /> 成功列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{operationResult?.succeeded?.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(operationResult?.failed?.length ?? 0) > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title fail">
|
||||
<CloseCircleOutlined /> 失败列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{operationResult?.failed?.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id fail">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from "react"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
ExperimentOutlined,
|
||||
LoadingOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Popconfirm } from "antd"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
import { thumbGradient } from "@/pages/assets/utils/asset"
|
||||
import { kindIcon } from "@/pages/assets/utils/kindIcon"
|
||||
import { StatusPill } from "./AssetSkeleton"
|
||||
|
||||
/* ============================================================
|
||||
* AssetCard — 素材卡片(网格视图)
|
||||
* ============================================================ */
|
||||
export interface AssetCardProps {
|
||||
asset: AssetItem
|
||||
selected: boolean
|
||||
diagnosing?: boolean
|
||||
onToggle: () => void
|
||||
onDiagnose: () => void
|
||||
onPlay: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const AssetCard: React.FC<AssetCardProps> = ({
|
||||
asset,
|
||||
selected,
|
||||
diagnosing,
|
||||
onToggle,
|
||||
onDiagnose,
|
||||
onPlay,
|
||||
onDelete,
|
||||
}) => (
|
||||
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
|
||||
{/* 缩略图区 */}
|
||||
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
|
||||
{asset.thumbUrl ? (
|
||||
<img src={asset.thumbUrl} alt={asset.name} />
|
||||
) : (
|
||||
<span className="xx-asset-thumb-placeholder">
|
||||
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 处理中遮罩 */}
|
||||
{asset.loading && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
|
||||
<LoadingOutlined />
|
||||
<span>处理中</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败状态标识 */}
|
||||
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
|
||||
<CloseCircleOutlined />
|
||||
<span>处理失败</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
|
||||
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlay()
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,确定要删除这个素材吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
|
||||
<DeleteOutlined />
|
||||
</span>
|
||||
</Popconfirm>
|
||||
|
||||
{/* 选中态勾选 */}
|
||||
{selected && (
|
||||
<span className="xx-asset-check">
|
||||
<CheckOutlined />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-asset-info">
|
||||
<p className="xx-asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="xx-asset-meta">
|
||||
<StatusPill status={asset.status} label={asset.statusLabel} />
|
||||
{asset.duration && <span>{asset.duration}</span>}
|
||||
</div>
|
||||
<button
|
||||
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
|
||||
disabled={diagnosing || asset.loading || asset.status === "bad"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDiagnose()
|
||||
}}
|
||||
>
|
||||
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
|
||||
{diagnosing ? "诊断中..." : "诊断"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default AssetCard
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react"
|
||||
import { SearchOutlined } from "@ant-design/icons"
|
||||
import { Button, Input, Select } from "@/components/ui"
|
||||
import { TYPE_FILTER_OPTIONS, TIME_FILTER_OPTIONS } from "@/pages/assets/constants"
|
||||
|
||||
/* ============================================================
|
||||
* AssetFilterBar — 筛选栏(搜索/类型/时间 + 全选/计数)
|
||||
* ============================================================ */
|
||||
export interface AssetFilterBarProps {
|
||||
searchText: string
|
||||
onSearchChange: (value: string) => void
|
||||
filterType: string
|
||||
onFilterTypeChange: (value: string) => void
|
||||
filterTime: string
|
||||
onFilterTimeChange: (value: string) => void
|
||||
onSelectAll: () => void
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
const AssetFilterBar: React.FC<AssetFilterBarProps> = ({
|
||||
searchText,
|
||||
onSearchChange,
|
||||
filterType,
|
||||
onFilterTypeChange,
|
||||
filterTime,
|
||||
onFilterTimeChange,
|
||||
onSelectAll,
|
||||
totalCount,
|
||||
}) => (
|
||||
<div className="xx-assets-filters">
|
||||
<div className="xx-assets-filters-left">
|
||||
<Input
|
||||
placeholder="搜索素材名称..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={onFilterTypeChange}
|
||||
style={{ width: 120 }}
|
||||
options={TYPE_FILTER_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
value={filterTime}
|
||||
onChange={onFilterTimeChange}
|
||||
style={{ width: 120 }}
|
||||
options={TIME_FILTER_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-assets-filters-right">
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={onSelectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<span className="xx-assets-filter-count">共 {totalCount} 个素材</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default AssetFilterBar
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react"
|
||||
import type { StatusType } from "@/pages/assets/types"
|
||||
|
||||
/* ============================================================
|
||||
* StatusPill — 状态标签
|
||||
* ============================================================ */
|
||||
export interface StatusPillProps {
|
||||
status: StatusType
|
||||
label: string
|
||||
}
|
||||
|
||||
export const StatusPill: React.FC<StatusPillProps> = ({ status, label }) => (
|
||||
<span className={`xx-status-pill xx-status-pill-${status}`}>{label}</span>
|
||||
)
|
||||
|
||||
/* ============================================================
|
||||
* SkeletonCard — 骨架屏卡片(素材列表加载时占位)
|
||||
* ============================================================ */
|
||||
export const SkeletonCard: React.FC = () => (
|
||||
<div className="xx-asset-card xx-asset-skeleton">
|
||||
<div className="xx-asset-thumb xx-skeleton-pulse" />
|
||||
<div className="xx-asset-info">
|
||||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "70%" }} />
|
||||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "40%", marginTop: 8 }} />
|
||||
<div
|
||||
className="xx-skeleton-line xx-skeleton-pulse"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 28,
|
||||
marginTop: 8,
|
||||
borderRadius: "var(--radius-xs)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal, Select as AntSelect } from "antd"
|
||||
import { CATEGORY_OPTIONS } from "@/pages/assets/constants"
|
||||
|
||||
/* ============================================================
|
||||
* BatchClassifyModal — 批量改分类弹窗
|
||||
* ============================================================ */
|
||||
export interface BatchClassifyModalProps {
|
||||
open: boolean
|
||||
selectedCount: number
|
||||
onCancel: () => void
|
||||
onOk: () => void
|
||||
category: string
|
||||
onCategoryChange: (value: string) => void
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
|
||||
open,
|
||||
selectedCount,
|
||||
onCancel,
|
||||
onOk,
|
||||
category,
|
||||
onCategoryChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
title={`批量改分类(${selectedCount} 个素材)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
confirmLoading={confirmLoading}
|
||||
okText="确认修改"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-classify-modal">
|
||||
<p className="xx-batch-classify-hint">将选中的 {selectedCount} 个素材统一修改为以下分类:</p>
|
||||
<AntSelect
|
||||
value={category || undefined}
|
||||
onChange={(v) => onCategoryChange(v)}
|
||||
placeholder="请选择分类"
|
||||
style={{ width: "100%" }}
|
||||
options={CATEGORY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default BatchClassifyModal
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal, Tag, Radio } from "antd"
|
||||
|
||||
/* ============================================================
|
||||
* BatchMarkModal — 批量智能标记弹窗
|
||||
* ============================================================ */
|
||||
export type SmartViewType = "recommended" | "caution" | "high_risk"
|
||||
|
||||
export interface BatchMarkModalProps {
|
||||
open: boolean
|
||||
selectedCount: number
|
||||
onCancel: () => void
|
||||
onOk: () => void
|
||||
smartView: SmartViewType
|
||||
onSmartViewChange: (value: SmartViewType) => void
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
const BatchMarkModal: React.FC<BatchMarkModalProps> = ({
|
||||
open,
|
||||
selectedCount,
|
||||
onCancel,
|
||||
onOk,
|
||||
smartView,
|
||||
onSmartViewChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
title={`批量智能标记(${selectedCount} 个素材)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
confirmLoading={confirmLoading}
|
||||
okText="确认标记"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-mark-modal">
|
||||
<p className="xx-batch-mark-hint">将选中的 {selectedCount} 个素材标记为:</p>
|
||||
<Radio.Group
|
||||
value={smartView}
|
||||
onChange={(e) => onSmartViewChange(e.target.value)}
|
||||
className="xx-batch-mark-options"
|
||||
>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="recommended">
|
||||
<Tag color="success">推荐</Tag>
|
||||
<span className="xx-batch-mark-desc">质量优良,可直接用于生产</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="caution">
|
||||
<Tag color="warning">慎用</Tag>
|
||||
<span className="xx-batch-mark-desc">存在一定问题,需人工审核后再使用</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="high_risk">
|
||||
<Tag color="error">高风险</Tag>
|
||||
<span className="xx-batch-mark-desc">存在严重问题,不建议使用</span>
|
||||
</Radio>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default BatchMarkModal
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react"
|
||||
import {
|
||||
TagsOutlined,
|
||||
FolderOutlined,
|
||||
ThunderboltOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Popconfirm } from "antd"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
/* ============================================================
|
||||
* BatchOperationBar — 批量操作栏
|
||||
* ============================================================ */
|
||||
export interface BatchOperationBarProps {
|
||||
selectedCount: number
|
||||
onDeselectAll: () => void
|
||||
onTagClick: () => void
|
||||
onClassifyClick: () => void
|
||||
onMarkClick: () => void
|
||||
onBatchDelete: () => void
|
||||
}
|
||||
|
||||
const BatchOperationBar: React.FC<BatchOperationBarProps> = ({
|
||||
selectedCount,
|
||||
onDeselectAll,
|
||||
onTagClick,
|
||||
onClassifyClick,
|
||||
onMarkClick,
|
||||
onBatchDelete,
|
||||
}) => (
|
||||
<div className="xx-assets-batch-bar">
|
||||
<span className="xx-assets-batch-count">已选 {selectedCount} 项</span>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={onDeselectAll}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />} onClick={onTagClick}>
|
||||
打标签
|
||||
</Button>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<FolderOutlined />} onClick={onClassifyClick}>
|
||||
改分类
|
||||
</Button>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<ThunderboltOutlined />} onClick={onMarkClick}>
|
||||
智能标记
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除 ${selectedCount} 个素材?`}
|
||||
onConfirm={onBatchDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default BatchOperationBar
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal, Tag, Radio, Input as AntInput } from "antd"
|
||||
import { ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
|
||||
/* ============================================================
|
||||
* BatchTagModal — 批量打标签弹窗
|
||||
* ============================================================ */
|
||||
export interface BatchTagModalProps {
|
||||
open: boolean
|
||||
selectedCount: number
|
||||
onCancel: () => void
|
||||
onOk: () => void
|
||||
tags: string[]
|
||||
onTagInputChange: (value: string) => void
|
||||
onTagInputKeyDown: (e: React.KeyboardEvent) => void
|
||||
onRemoveTag: (tag: string) => void
|
||||
tagInput: string
|
||||
tagMode: "add" | "replace"
|
||||
onTagModeChange: (mode: "add" | "replace") => void
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
const BatchTagModal: React.FC<BatchTagModalProps> = ({
|
||||
open,
|
||||
selectedCount,
|
||||
onCancel,
|
||||
onOk,
|
||||
tags,
|
||||
onTagInputChange,
|
||||
onTagInputKeyDown,
|
||||
onRemoveTag,
|
||||
tagInput,
|
||||
tagMode,
|
||||
onTagModeChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
title={`批量打标签(${selectedCount} 个素材)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
confirmLoading={confirmLoading}
|
||||
okText="确认打标签"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-tag-modal">
|
||||
<div className="xx-batch-tag-mode">
|
||||
<span className="xx-batch-tag-mode-label">模式:</span>
|
||||
<Radio.Group value={tagMode} onChange={(e) => onTagModeChange(e.target.value)}>
|
||||
<Radio value="add">追加标签</Radio>
|
||||
<Radio value="replace">替换全部标签</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div className="xx-batch-tag-input-row">
|
||||
<AntInput
|
||||
placeholder="输入标签后按 Enter 添加"
|
||||
value={tagInput}
|
||||
onChange={(e) => onTagInputChange(e.target.value)}
|
||||
onKeyDown={onTagInputKeyDown}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="xx-batch-tag-list">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} closable onClose={() => onRemoveTag(tag)} color="blue">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tagMode === "replace" && tags.length > 0 && (
|
||||
<div className="xx-batch-tag-warning">
|
||||
<ExclamationCircleOutlined /> 替换模式将清除素材原有全部标签
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default BatchTagModal
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import { Input, Select } from "@/components/ui"
|
||||
import type { AssetKind } from "@/pages/assets/types"
|
||||
import { LIBRARY_KIND_OPTIONS } from "@/pages/assets/constants"
|
||||
|
||||
/* ============================================================
|
||||
* CreateLibraryModal — 新建视频库弹窗
|
||||
* ============================================================ */
|
||||
export interface CreateLibraryModalProps {
|
||||
open: boolean
|
||||
onCancel: () => void
|
||||
onOk: () => void
|
||||
name: string
|
||||
onNameChange: (value: string) => void
|
||||
kind: AssetKind
|
||||
onKindChange: (value: AssetKind) => void
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
const CreateLibraryModal: React.FC<CreateLibraryModalProps> = ({
|
||||
open,
|
||||
onCancel,
|
||||
onOk,
|
||||
name,
|
||||
onNameChange,
|
||||
kind,
|
||||
onKindChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
title="新建视频库"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
confirmLoading={confirmLoading}
|
||||
>
|
||||
<div className="xx-asset-form-body">
|
||||
<div>
|
||||
<div className="xx-asset-form-label">名称</div>
|
||||
<Input
|
||||
placeholder="请输入视频库名称"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="xx-asset-form-label">类型</div>
|
||||
<Select
|
||||
value={kind}
|
||||
onChange={(v) => onKindChange(v)}
|
||||
style={{ width: "100%" }}
|
||||
options={LIBRARY_KIND_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default CreateLibraryModal
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from "react"
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||
import { Popconfirm } from "antd"
|
||||
import type { LibraryItem } from "@/pages/assets/types"
|
||||
import { kindLabel } from "@/pages/assets/utils/asset"
|
||||
import { kindIcon } from "@/pages/assets/utils/kindIcon"
|
||||
|
||||
/* ============================================================
|
||||
* LibrarySidebar — 左侧素材库列表
|
||||
* ============================================================ */
|
||||
export interface LibrarySidebarProps {
|
||||
libraries: LibraryItem[]
|
||||
activeLibId: string
|
||||
onSelect: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onCreateClick: () => void
|
||||
}
|
||||
|
||||
const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
|
||||
libraries,
|
||||
activeLibId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onCreateClick,
|
||||
}) => (
|
||||
<div className="xx-asset-library-list">
|
||||
{libraries.map((lib) => (
|
||||
<div
|
||||
key={lib.id}
|
||||
className={`xx-asset-library-item${lib.id === activeLibId ? " active" : ""}`}
|
||||
onClick={() => onSelect(lib.id)}
|
||||
>
|
||||
<div className="xx-asset-library-header">
|
||||
<h4>
|
||||
{kindIcon(lib.kind)} {lib.name}
|
||||
</h4>
|
||||
<Popconfirm
|
||||
title={`确定删除视频库 "${lib.name}"?`}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
onDelete(lib.id)
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button
|
||||
className="xx-asset-library-delete"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="删除视频库"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<span>
|
||||
{kindLabel(lib.kind)} · {lib.count} 个素材
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 新建视频库 */}
|
||||
<div className="xx-asset-library-add" onClick={onCreateClick}>
|
||||
<PlusOutlined />
|
||||
新建视频库
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default LibrarySidebar
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
|
||||
/* ============================================================
|
||||
* PlayModal — 视频/音频播放弹窗
|
||||
* ============================================================ */
|
||||
export interface PlayModalProps {
|
||||
open: boolean
|
||||
asset: AssetItem | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const PlayModal: React.FC<PlayModalProps> = ({ open, asset, onClose }) => (
|
||||
<AntModal
|
||||
title={asset?.name ?? "播放"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
{asset?.fileUrl ? (
|
||||
<video src={asset.fileUrl} controls autoPlay className="xx-asset-video-player" />
|
||||
) : (
|
||||
<div className="xx-asset-empty-fallback">
|
||||
<p>暂无可播放的文件地址</p>
|
||||
<p className="xx-asset-empty-fallback-id">素材 ID: {asset?.id}</p>
|
||||
</div>
|
||||
)}
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default PlayModal
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { BatchOperationResult } from "@/api/assets"
|
||||
|
||||
/* ============================================================
|
||||
* ResultDrawer — 操作结果抽屉
|
||||
* ============================================================ */
|
||||
export interface ResultDrawerProps {
|
||||
open: boolean
|
||||
title: string
|
||||
result: BatchOperationResult | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ResultDrawer: React.FC<ResultDrawerProps> = ({ open, title, result, onClose }) => (
|
||||
<Drawer title={`${title} — 操作结果`} open={open} onClose={onClose} width={420}>
|
||||
{result && (
|
||||
<div className="xx-batch-result">
|
||||
<div className="xx-batch-result-summary">
|
||||
<div className="xx-batch-result-stat">
|
||||
<span className="xx-batch-result-total">总计 {result.total} 个</span>
|
||||
</div>
|
||||
<div className="xx-batch-result-stat success">
|
||||
<CheckCircleOutlined />
|
||||
<span>成功 {result.success_count} 个</span>
|
||||
</div>
|
||||
{result.failure_count > 0 && (
|
||||
<div className="xx-batch-result-stat fail">
|
||||
<CloseCircleOutlined />
|
||||
<span>失败 {result.failure_count} 个</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(result?.succeeded?.length ?? 0) > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title success">
|
||||
<CheckCircleOutlined /> 成功列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{result?.succeeded?.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(result?.failed?.length ?? 0) > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title fail">
|
||||
<CloseCircleOutlined /> 失败列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{result?.failed?.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id fail">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
export default ResultDrawer
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
|
||||
/* ============================================================
|
||||
* UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比)
|
||||
* ============================================================ */
|
||||
export interface UploadProgressModalProps {
|
||||
open: boolean
|
||||
progress: number
|
||||
}
|
||||
|
||||
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({ open, progress }) => (
|
||||
<AntModal
|
||||
open={open}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={260}
|
||||
maskClosable={false}
|
||||
className="xx-upload-progress-modal"
|
||||
>
|
||||
<div className="xx-upload-progress-body">
|
||||
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
|
||||
{/* 背景圆环 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--border-primary, #e5e7eb)"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
{/* 进度圆弧 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--primary-color, #6366f1)"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 52}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 52 * (1 - progress / 100)}`}
|
||||
transform="rotate(-90 60 60)"
|
||||
style={{ transition: "stroke-dashoffset 0.3s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="xx-upload-progress-text">
|
||||
<span className="xx-upload-progress-pct">{progress}%</span>
|
||||
<span className="xx-upload-progress-label">上传中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default UploadProgressModal
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 素材库常量
|
||||
*/
|
||||
import type { AssetKind } from "./types"
|
||||
|
||||
/** 文件大小限制 */
|
||||
export const MAX_FILE_SIZE = 2048 * 1024 * 1024
|
||||
export const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024
|
||||
|
||||
/** 素材类型标签 */
|
||||
export const KIND_LABELS: Record<AssetKind, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
voice: "配音",
|
||||
}
|
||||
|
||||
/** 类型筛选选项 */
|
||||
export const TYPE_FILTER_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "image", label: "图片" },
|
||||
]
|
||||
|
||||
/** 时间筛选选项 */
|
||||
export const TIME_FILTER_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "all", label: "全部时间" },
|
||||
{ value: "today", label: "今天" },
|
||||
{ value: "week", label: "近一周" },
|
||||
{ value: "month", label: "近一月" },
|
||||
]
|
||||
|
||||
/** 新建库类型选项(当前仅支持视频和图片) */
|
||||
export const LIBRARY_KIND_OPTIONS: { value: AssetKind; label: string }[] = [
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "image", label: "图片" },
|
||||
]
|
||||
|
||||
/** 分类选项 */
|
||||
export const CATEGORY_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "person", label: "人物" },
|
||||
{ value: "scenic", label: "风景" },
|
||||
{ value: "product", label: "产品" },
|
||||
{ value: "food", label: "美食" },
|
||||
{ value: "animal", label: "动物" },
|
||||
{ value: "architecture", label: "建筑" },
|
||||
{ value: "other", label: "其他" },
|
||||
]
|
||||
|
||||
/** 智能标记视图 */
|
||||
export const SMART_VIEW_OPTIONS: {
|
||||
value: "recommended" | "caution" | "high_risk"
|
||||
label: string
|
||||
color: string
|
||||
}[] = [
|
||||
{ value: "recommended", label: "推荐", color: "success" },
|
||||
{ value: "caution", label: "慎用", color: "warning" },
|
||||
{ value: "high_risk", label: "高风险", color: "error" },
|
||||
]
|
||||
|
||||
/** 状态配置 */
|
||||
export const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
|
||||
ok: { label: "合格", className: "xx-status-pill xx-status-pill-ok" },
|
||||
warn: { label: "待优化", className: "xx-status-pill xx-status-pill-warn" },
|
||||
bad: { label: "不合格", className: "xx-status-pill xx-status-pill-bad" },
|
||||
info: { label: "待诊断", className: "xx-status-pill xx-status-pill-info" },
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 素材库类型定义
|
||||
*/
|
||||
import type { AssetLibraryItem, AssetItem as ApiAssetItem } from "@/api/assets"
|
||||
import { formatDuration } from "./utils/format"
|
||||
|
||||
export type AssetKind = "video" | "image" | "voice"
|
||||
export type StatusType = "ok" | "warn" | "bad" | "info"
|
||||
|
||||
export interface LibraryItem {
|
||||
id: string
|
||||
name: string
|
||||
kind: AssetKind
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AssetItem {
|
||||
id: string
|
||||
name: string
|
||||
kind: AssetKind
|
||||
thumbUrl?: string
|
||||
fileUrl?: string
|
||||
status: StatusType
|
||||
statusLabel: string
|
||||
/** 是否处于处理中状态(上传中/入库中/诊断中) */
|
||||
loading?: boolean
|
||||
duration?: string
|
||||
size: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 根据 mime_type 推断前端 AssetKind */
|
||||
export const inferKind = (mimeType: string): AssetKind => {
|
||||
if (mimeType.startsWith("video/")) return "video"
|
||||
if (mimeType.startsWith("audio/")) return "voice"
|
||||
return "image"
|
||||
}
|
||||
|
||||
/** 根据 quality_score / classification_status / asset status 推断前端状态 */
|
||||
export const inferStatus = (
|
||||
score?: number,
|
||||
classificationStatus?: string,
|
||||
assetStatus?: string,
|
||||
): { status: StatusType; label: string; loading?: boolean } => {
|
||||
// 已删除素材(正常情况列表已过滤,这里是防御性处理)
|
||||
if (assetStatus === "deleted") {
|
||||
return { status: "bad", label: "已删除" }
|
||||
}
|
||||
// 处理中状态:上传中 / 入库中 / 处理中
|
||||
if (
|
||||
assetStatus === "uploading" ||
|
||||
assetStatus === "ingesting" ||
|
||||
assetStatus === "processing" ||
|
||||
assetStatus === "pending"
|
||||
) {
|
||||
return { status: "info", label: "处理中", loading: true }
|
||||
}
|
||||
// 失败状态
|
||||
if (assetStatus === "error" || assetStatus === "failed") {
|
||||
return { status: "bad", label: "处理失败" }
|
||||
}
|
||||
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
|
||||
if (assetStatus === "ready") {
|
||||
if (score == null) return { status: "info", label: "待诊断" }
|
||||
if (score >= 70) return { status: "ok", label: "合格" }
|
||||
if (score >= 40) return { status: "warn", label: "待优化" }
|
||||
return { status: "bad", label: "不合格" }
|
||||
}
|
||||
// 素材未就绪:classification 正在处理中
|
||||
if (classificationStatus === "processing" || classificationStatus === "pending") {
|
||||
return { status: "info", label: "处理中", loading: true }
|
||||
}
|
||||
if (score == null) return { status: "info", label: "待诊断" }
|
||||
if (score >= 70) return { status: "ok", label: "合格" }
|
||||
if (score >= 40) return { status: "warn", label: "待优化" }
|
||||
return { status: "bad", label: "不合格" }
|
||||
}
|
||||
|
||||
/** 将后端 AssetLibraryItem 映射为前端 LibraryItem */
|
||||
export const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: item.kind || inferKind("video"),
|
||||
count: item.asset_count ?? 0,
|
||||
})
|
||||
|
||||
/** 将后端 ApiAssetItem 映射为前端 AssetItem */
|
||||
export const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
const { status, label, loading } = inferStatus(
|
||||
item.quality_score ?? undefined,
|
||||
item.classification_status ?? undefined,
|
||||
item.status ?? undefined,
|
||||
)
|
||||
const metadata = item.metadata || {}
|
||||
const kind = inferKind(item.mime_type || "")
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind,
|
||||
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
|
||||
// 处理中的素材没有缩略图,显示占位符
|
||||
thumbUrl: loading
|
||||
? undefined
|
||||
: (item.thumbnail_url as string | undefined) ||
|
||||
(metadata.thumbnail_url as string | undefined) ||
|
||||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
|
||||
fileUrl: (item.file_url as string | undefined) || (metadata.file_url as string | undefined),
|
||||
status,
|
||||
statusLabel: label,
|
||||
loading,
|
||||
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
|
||||
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 素材相关工具函数
|
||||
*/
|
||||
import type { AssetKind } from "../types"
|
||||
import { KIND_LABELS } from "../constants"
|
||||
|
||||
export const kindLabel = (kind: AssetKind): string => KIND_LABELS[kind] ?? kind
|
||||
|
||||
/** 根据素材类型返回渐变背景 */
|
||||
export const thumbGradient = (kind: AssetKind): string => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"
|
||||
case "image":
|
||||
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"
|
||||
case "voice":
|
||||
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 格式化工具函数
|
||||
*/
|
||||
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export const formatDate = (iso: string): string =>
|
||||
new Date(iso).toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from "react"
|
||||
import { VideoCameraOutlined, PictureOutlined, AudioOutlined } from "@ant-design/icons"
|
||||
import type { AssetKind } from "../types"
|
||||
|
||||
/** 根据素材类型返回对应图标 */
|
||||
export const kindIcon = (kind: AssetKind): React.ReactNode => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return <VideoCameraOutlined />
|
||||
case "image":
|
||||
return <PictureOutlined />
|
||||
case "voice":
|
||||
return <AudioOutlined />
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Step 1 模板选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { MODE_GRADIENTS } from "../constants"
|
||||
import { useStep1Template } from "../hooks/useStep1Template"
|
||||
|
||||
interface Step1TemplateSelectProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
onSelectTemplate: (id: string) => void
|
||||
}
|
||||
|
||||
const Step1TemplateSelect: React.FC<Step1TemplateSelectProps> = (props) => {
|
||||
const { templates, selectedTemplate, handleSelect, handleKeySelect } = useStep1Template(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎨 选择模板</h3>
|
||||
{templates.length === 0 ? (
|
||||
<div className="xx-empty-state">
|
||||
<p>暂无可用模板</p>
|
||||
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
|
||||
请先在「模板编辑器」中创建模板
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-choice-list">
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`xx-choice-item ${selectedTemplate === tpl.id ? "selected" : ""}`}
|
||||
onClick={() => handleSelect(tpl.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selectedTemplate === tpl.id}
|
||||
onKeyDown={(e) => handleKeySelect(e, tpl.id)}
|
||||
>
|
||||
<span className="xx-choice-check">✓</span>
|
||||
<div
|
||||
className="xx-choice-thumb"
|
||||
style={{
|
||||
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
|
||||
}}
|
||||
>
|
||||
🎬
|
||||
</div>
|
||||
<h4>{tpl.name}</h4>
|
||||
<p>
|
||||
{tpl.estimated_duration}s · {tpl.segments.length}片段
|
||||
</p>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{tpl.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 6,
|
||||
background: "var(--bg-secondary)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step1TemplateSelect
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Step 2 素材选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { useStep2Materials } from "../hooks/useStep2Materials"
|
||||
import MaterialModeTabs from "./material/MaterialModeTabs"
|
||||
import ManualMaterialList from "./material/ManualMaterialList"
|
||||
import SmartMatchInput from "./material/SmartMatchInput"
|
||||
import SmartMatchResults from "./material/SmartMatchResults"
|
||||
|
||||
interface Step2MaterialSelectProps {
|
||||
materialMode: "manual" | "auto"
|
||||
onMaterialModeChange: (mode: "manual" | "auto") => void
|
||||
selectedMaterials: string[]
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
const m = useStep2Materials(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>📦 选择素材</h3>
|
||||
|
||||
<MaterialModeTabs mode={m.materialMode} onModeChange={m.onMaterialModeChange} />
|
||||
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>选择视频库</label>
|
||||
<select
|
||||
value={m.selectedLibraryId}
|
||||
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
|
||||
>
|
||||
{m.libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{m.materialMode === "manual" && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span className="xx-pill xx-pill-ok">已选 {m.selectedMaterials.length} 个素材</span>
|
||||
</div>
|
||||
|
||||
<ManualMaterialList
|
||||
materials={m.materials}
|
||||
materialsLoading={m.materialsLoading}
|
||||
selectedMaterials={m.selectedMaterials}
|
||||
onToggle={m.handleToggleMaterial}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{m.materialMode === "auto" && (
|
||||
<div className="xx-smart-match-section">
|
||||
<SmartMatchInput
|
||||
inputValue={m.smartMatchInput}
|
||||
onInputChange={m.setSmartMatchInput}
|
||||
matching={m.smartMatching}
|
||||
onMatch={m.handleSmartMatch}
|
||||
hasMatched={m.hasMatched}
|
||||
onRefresh={m.handleRefreshMatch}
|
||||
materialsCount={m.materials.items.length}
|
||||
loading={m.materialsLoading}
|
||||
/>
|
||||
|
||||
<SmartMatchResults
|
||||
results={m.smartMatchedResults}
|
||||
selectedIds={m.smartSelectedIds}
|
||||
matching={m.smartMatching}
|
||||
hasMatched={m.hasMatched}
|
||||
onToggle={m.handleToggleSmartSelect}
|
||||
onSelectAll={m.handleSelectAllMatched}
|
||||
onClear={m.handleClearSmartSelect}
|
||||
formatDuration={m.formatDuration}
|
||||
selectedTotalDuration={m.smartSelectedTotalDuration}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step2MaterialSelect
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Step 3 生成预览组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { useStep3Preview } from "../hooks/useStep3Preview"
|
||||
|
||||
interface Step3GeneratePreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
}
|
||||
|
||||
const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = (props) => {
|
||||
const { templateName, materialCount, duration, videoRatio } = useStep3Preview(props)
|
||||
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-preview-plan-hint">
|
||||
💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step3GeneratePreview
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Step 4 标题设置组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { Select } from "antd"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
|
||||
interface Step4TitleSettingsProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
const t = useStep4Title(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
<AiTitleGenerator
|
||||
inputValue={t.aiTitleInput}
|
||||
onInputChange={t.setAiTitleInput}
|
||||
generating={t.aiTitleGenerating}
|
||||
onGenerate={t.handleGenerateAiTitles}
|
||||
results={t.aiTitleResults}
|
||||
hasGenerated={t.hasGeneratedTitles}
|
||||
onSelect={t.handleSelectAiTitle}
|
||||
selectedTitle={t.titleSettings.title}
|
||||
onRefresh={t.handleRefreshAiTitles}
|
||||
/>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>或手动选择</span>
|
||||
</div>
|
||||
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div
|
||||
className={`xx-switch ${t.titleSettings.aiAutoSelect ? "active" : ""}`}
|
||||
onClick={t.toggleAiAutoSelect}
|
||||
>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!t.titleSettings.aiAutoSelect && (
|
||||
<>
|
||||
<div className="xx-form-field">
|
||||
<label>从标题库选择</label>
|
||||
<Select
|
||||
placeholder="请选择标题…"
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
value={t.titleSettings.title || undefined}
|
||||
onChange={(val) => t.updateTitle(val || "")}
|
||||
options={t.userTitles.map((ut) => ({
|
||||
label: ut.content,
|
||||
value: ut.content,
|
||||
}))}
|
||||
filterOption={(input, option) =>
|
||||
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
t.userTitles.length === 0 ? (
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
|
||||
标题库为空,请前往「标题管理」添加
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-form-field" style={{ marginTop: 14 }}>
|
||||
<label>或手动输入</label>
|
||||
<input
|
||||
placeholder="输入自定义标题…"
|
||||
value={t.titleSettings.title}
|
||||
onChange={(e) => t.updateTitle(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TitleStylePanel
|
||||
settings={t.titleSettings}
|
||||
onUpdatePosition={t.updatePosition}
|
||||
onUpdateFont={t.updateFont}
|
||||
onUpdateSize={t.updateSize}
|
||||
onToggleBold={t.toggleBold}
|
||||
onToggleItalic={t.toggleItalic}
|
||||
onToggleStroke={t.toggleStroke}
|
||||
onToggleShadow={t.toggleShadow}
|
||||
onApplyPreset={t.applyPreset}
|
||||
activePreset={t.activePreset}
|
||||
titlePresets={t.titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step4TitleSettings
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Step 5 配音选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useStep5Voice } from "../hooks/useStep5Voice"
|
||||
import VoiceRecommendSection from "./voice/VoiceRecommendSection"
|
||||
import VoiceChoiceCard from "./voice/VoiceChoiceCard"
|
||||
import PresetVoiceDetail from "./voice/PresetVoiceDetail"
|
||||
import CustomVoicePanel from "./voice/CustomVoicePanel"
|
||||
import SaveVoiceModal from "./voice/SaveVoiceModal"
|
||||
import CloneVoiceSection from "./voice/CloneVoiceSection"
|
||||
|
||||
interface Step5VoiceSelectProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (voiceId: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
onSelectedClonedVoiceChange: (voiceId: string) => void
|
||||
clonedVoices: VoiceClone[]
|
||||
addClone: (voice: VoiceClone) => void
|
||||
hasProcessing: boolean
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
titleText: string
|
||||
}
|
||||
|
||||
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = (props) => {
|
||||
const v = useStep5Voice(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
|
||||
<VoiceRecommendSection
|
||||
presetVoices={v.presetVoices}
|
||||
voiceRecommendLoading={v.voiceRecommendLoading}
|
||||
voiceRecommendations={v.voiceRecommendations}
|
||||
hasVoiceRecommend={v.hasVoiceRecommend}
|
||||
onRecommend={v.handleVoiceRecommend}
|
||||
onSelectVoice={v.handleSelectRecommendedVoice}
|
||||
selectedVoiceId={v.selectedVoice}
|
||||
voiceMode={v.voiceMode}
|
||||
VOICE_GENDER_ICON={v.VOICE_GENDER_ICON}
|
||||
/>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>全部音色</span>
|
||||
</div>
|
||||
|
||||
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
|
||||
{v.presetVoices.slice(0, 3).map((pv) => (
|
||||
<VoiceChoiceCard
|
||||
key={pv.voice_id}
|
||||
selected={v.voiceMode === "preset" && v.selectedVoice === pv.voice_id}
|
||||
onClick={() => v.handleSelectPresetVoice(pv.voice_id)}
|
||||
avatar={v.VOICE_GENDER_ICON[pv.gender] ?? "✨"}
|
||||
title={pv.name}
|
||||
description={pv.description}
|
||||
/>
|
||||
))}
|
||||
{v.presetVoicesLoading && (
|
||||
<VoiceChoiceCard
|
||||
selected={false}
|
||||
onClick={() => {}}
|
||||
avatar="⏳"
|
||||
title="加载中…"
|
||||
loading
|
||||
/>
|
||||
)}
|
||||
<VoiceChoiceCard
|
||||
selected={v.voiceMode === "clone"}
|
||||
onClick={v.handleSelectCloneVoice}
|
||||
avatar="🎤"
|
||||
title="克隆我的声音"
|
||||
description="上传语音样本克隆"
|
||||
avatarStyle={{ background: "linear-gradient(135deg, #10b981, #059669)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{v.voiceMode === "preset" && (
|
||||
<PresetVoiceDetail
|
||||
presetVoices={v.presetVoices}
|
||||
selectedVoice={v.selectedVoice}
|
||||
onSelect={v.handleSelectPresetVoice}
|
||||
playingVoice={v.playingVoice}
|
||||
onTogglePlay={v.toggleVoicePlay}
|
||||
presetVoicesLoading={v.presetVoicesLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{v.voiceMode === "custom" && (
|
||||
<>
|
||||
<CustomVoicePanel
|
||||
customVoiceText={v.customVoiceText}
|
||||
onTextChange={v.setCustomVoiceText}
|
||||
synthesizePending={v.synthesizeMutation.isPending}
|
||||
onSynthesize={v.handleSynthesizeVoice}
|
||||
ttsError={v.ttsError}
|
||||
customAudioUrl={v.customAudioUrl}
|
||||
completedTtsJobId={v.completedTtsJobId}
|
||||
onOpenSaveModal={v.handleOpenSaveModal}
|
||||
/>
|
||||
<SaveVoiceModal
|
||||
open={v.saveModalOpen}
|
||||
onClose={() => v.setSaveModalOpen(false)}
|
||||
saveName={v.saveName}
|
||||
onNameChange={v.setSaveName}
|
||||
saveTagIds={v.saveTagIds}
|
||||
onTagIdsChange={v.setSaveTagIds}
|
||||
saveNewTag={v.saveNewTag}
|
||||
onNewTagChange={v.setSaveNewTag}
|
||||
onAddTag={v.handleAddTagInModal}
|
||||
allTags={v.allTags}
|
||||
savePending={v.saveToLibraryMutation.isPending}
|
||||
onConfirm={v.handleConfirmSave}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{v.voiceMode === "clone" && (
|
||||
<CloneVoiceSection
|
||||
clonedVoices={v.clonedVoices}
|
||||
hasProcessing={v.hasProcessing}
|
||||
selectedClonedVoice={v.selectedClonedVoice}
|
||||
onSelect={v.handleSelectClonedVoice}
|
||||
onOpenCloneModal={v.handleOpenCloneModal}
|
||||
CLONE_STATUS_CONFIG={v.CLONE_STATUS_CONFIG}
|
||||
formatDuration={v.formatDuration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step5VoiceSelect
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Step 6 封面设置组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const {
|
||||
coverSettings,
|
||||
formatTime,
|
||||
toggleEnabled,
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
totalDuration,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
} = useStep6Cover(props)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className="xx-cover-header">
|
||||
<span className="xx-cover-header-label">启用自定义封面</span>
|
||||
<label className="xx-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={coverSettings.enabled}
|
||||
onChange={(e) => toggleEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="xx-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{coverSettings.enabled && (
|
||||
<>
|
||||
{/* 模式选择 */}
|
||||
<div className="xx-section-title">封面来源</div>
|
||||
<div className="xx-cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
|
||||
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 智能封面 */}
|
||||
{coverSettings.mode === "auto" && (
|
||||
<div className="xx-cover-auto">
|
||||
<div className="xx-cover-auto-desc">
|
||||
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
||||
</div>
|
||||
<div className="xx-cover-auto-badge">
|
||||
<span style={{ fontSize: 24 }}>🤖</span>
|
||||
<span>AI 智能选帧</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{coverSettings.mode === "frame" && (
|
||||
<div className="xx-cover-frame">
|
||||
<div className="xx-cover-frame-preview">
|
||||
<div className="xx-cover-frame-placeholder">
|
||||
<span className="xx-cover-frame-icon">🎞️</span>
|
||||
<span className="xx-cover-frame-time">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-slider">
|
||||
<div className="xx-cover-frame-slider-header">
|
||||
<span>拖动选择封面帧</span>
|
||||
<span className="xx-cover-frame-value">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={coverSettings.frame_time}
|
||||
onChange={(e) => setFrameTime(Number(e.target.value))}
|
||||
className="xx-cover-range"
|
||||
/>
|
||||
<div className="xx-cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-quick">
|
||||
<span className="xx-cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="xx-cover-quick-btn"
|
||||
onClick={() => setFrameTime(t)}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{coverSettings.mode === "upload" && (
|
||||
<div className="xx-cover-upload">
|
||||
<div
|
||||
className="xx-cover-upload-area"
|
||||
onClick={() => {
|
||||
const input = document.getElementById("cover-upload-input")
|
||||
input?.click()
|
||||
}}
|
||||
>
|
||||
{coverSettings.upload_url ? (
|
||||
<div className="xx-cover-upload-preview">
|
||||
<img src={coverSettings.upload_url} alt="封面预览" />
|
||||
<div className="xx-cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-cover-upload-placeholder">
|
||||
<span style={{ fontSize: 32 }}>📤</span>
|
||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="cover-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{coverSettings.upload_url ? (
|
||||
<img src={coverSettings.upload_url} alt="封面预览" className="xx-cover-preview-img" />
|
||||
) : (
|
||||
<div className="xx-cover-preview-placeholder">
|
||||
<span style={{ fontSize: 28 }}>🖼️</span>
|
||||
<span>
|
||||
{coverSettings.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: coverSettings.mode === "frame"
|
||||
? `帧 ${formatTime(coverSettings.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step6CoverSettings
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Step 7 确认生成组件
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleOutlined,
|
||||
MinusOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { useStep7Generate } from "../hooks/useStep7Generate"
|
||||
|
||||
interface Step7ConfirmGenerateProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
title: string
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
presetVoices: PresetVoiceItem[]
|
||||
clonedVoices: VoiceClone[]
|
||||
coverSettings: CoverConfig
|
||||
generateCount: number
|
||||
onGenerateCountChange: (count: number) => void
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
onRetry: () => void
|
||||
onDismissError: () => void
|
||||
}
|
||||
|
||||
const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
|
||||
const {
|
||||
templateName,
|
||||
materialSummary,
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generateCount,
|
||||
handleDecrement,
|
||||
handleIncrement,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
handleScrollToPreview,
|
||||
} = useStep7Generate(props)
|
||||
|
||||
const { onRetry, onDismissError } = props
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>✨ 确认生成</h3>
|
||||
<div className="xx-summary-card">
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">模板</span>
|
||||
<span className="xx-summary-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">素材</span>
|
||||
<span className="xx-summary-value">{materialSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">标题</span>
|
||||
<span className="xx-summary-value">{title || "未选择"}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{voiceName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">封面</span>
|
||||
<span className="xx-summary-value">{coverSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
<div className="xx-count-stepper">
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount <= 1 || generating}
|
||||
onClick={handleDecrement}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-value">{generateCount}</span>
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount >= 10 || generating}
|
||||
onClick={handleIncrement}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-hint">条视频</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成进度 / 结果反馈 */}
|
||||
{(generating || generated || generateError) && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-gen-progress-tip">
|
||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-icon">
|
||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||
</div>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleScrollToPreview}
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-icon">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
</div>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">
|
||||
{typeof generateError === "string"
|
||||
? generateError
|
||||
: JSON.stringify(generateError)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onDismissError}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step7ConfirmGenerate
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 手动选择素材列表
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface ManualMaterialListProps {
|
||||
materials: { items: AssetItem[]; total: number }
|
||||
materialsLoading: boolean
|
||||
selectedMaterials: string[]
|
||||
onToggle: (materialId: string) => void
|
||||
}
|
||||
|
||||
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
materials,
|
||||
materialsLoading,
|
||||
selectedMaterials,
|
||||
onToggle,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{materialsLoading ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>加载素材中…</Text>
|
||||
) : materials.items.length === 0 ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
|
||||
暂无素材,请先在视频库中上传
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{materials.items.map((m) => {
|
||||
const checked = selectedMaterials.includes(m.id)
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
border: checked
|
||||
? "1px solid var(--primary-color, #4f46e5)"
|
||||
: "1px solid transparent",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(m.id)}
|
||||
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
{m.mime_type.split("/")[1].toUpperCase()}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ManualMaterialList
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 素材模式切换 Tab
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
interface MaterialModeTabsProps {
|
||||
mode: "manual" | "auto"
|
||||
onModeChange: (mode: "manual" | "auto") => void
|
||||
}
|
||||
|
||||
const MaterialModeTabs: React.FC<MaterialModeTabsProps> = ({ mode, onModeChange }) => {
|
||||
return (
|
||||
<div className="xx-material-mode-tabs">
|
||||
<button
|
||||
className={`xx-material-mode-tab ${mode === "manual" ? "active" : ""}`}
|
||||
onClick={() => onModeChange("manual")}
|
||||
type="button"
|
||||
>
|
||||
手动选择素材
|
||||
</button>
|
||||
<button
|
||||
className={`xx-material-mode-tab ${mode === "auto" ? "active" : ""}`}
|
||||
onClick={() => onModeChange("auto")}
|
||||
type="button"
|
||||
>
|
||||
选择视频库自动匹配
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MaterialModeTabs
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 单个智能匹配卡片
|
||||
*/
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface SmartMatchResultItem {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
|
||||
interface SmartMatchCardProps {
|
||||
result: SmartMatchResultItem
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
formatDuration: (seconds: number) => string
|
||||
}
|
||||
|
||||
const SmartMatchCard: React.FC<SmartMatchCardProps> = ({
|
||||
result,
|
||||
selected,
|
||||
onClick,
|
||||
formatDuration,
|
||||
}) => {
|
||||
const { asset, matchScore, matchReason } = result
|
||||
return (
|
||||
<div className={`xx-smart-match-card ${selected ? "selected" : ""}`} onClick={onClick}>
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-smart-match-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div className="xx-smart-match-thumb-placeholder">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-smart-match-score">{matchScore}%</div>
|
||||
{selected && (
|
||||
<div className="xx-smart-match-check">
|
||||
<CheckCircleFilled style={{ fontSize: 20, color: "#fff" }} />
|
||||
</div>
|
||||
)}
|
||||
{asset.duration && (
|
||||
<div className="xx-smart-match-duration">{formatDuration(asset.duration)}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 信息区 */}
|
||||
<div className="xx-smart-match-info">
|
||||
<div className="xx-smart-match-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="xx-smart-match-reason">🎯 {matchReason}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmartMatchCard
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 智能匹配输入区
|
||||
* textarea + 提示 + 按钮组
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
|
||||
interface SmartMatchInputProps {
|
||||
inputValue: string
|
||||
onInputChange: (value: string) => void
|
||||
matching: boolean
|
||||
onMatch: () => void
|
||||
hasMatched: boolean
|
||||
onRefresh: () => void
|
||||
materialsCount: number
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const SmartMatchInput: React.FC<SmartMatchInputProps> = ({
|
||||
inputValue,
|
||||
onInputChange,
|
||||
matching,
|
||||
onMatch,
|
||||
hasMatched,
|
||||
onRefresh,
|
||||
materialsCount,
|
||||
loading,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-smart-match-input-area">
|
||||
<label className="xx-smart-match-label">🤖 描述你想要的视频内容</label>
|
||||
<textarea
|
||||
className="xx-smart-match-input"
|
||||
placeholder="例如:一个科技感十足的产品宣传视频,画面要有现代办公场景、团队协作、数据分析图表…"
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
rows={3}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
onMatch()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="xx-smart-match-input-footer">
|
||||
<span className="xx-smart-match-tip">
|
||||
{loading ? "扫描视频库中…" : `当前视频库共 ${materialsCount} 个素材可供匹配`}
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{hasMatched && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onRefresh}
|
||||
disabled={matching || loading}
|
||||
>
|
||||
🔄 换一批
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={onMatch}
|
||||
disabled={matching || loading || !inputValue.trim()}
|
||||
>
|
||||
{matching ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
匹配中…
|
||||
</>
|
||||
) : (
|
||||
"✨ 智能匹配"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmartMatchInput
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 智能匹配结果区(含加载/空状态/已选汇总)
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import SmartMatchCard from "./SmartMatchCard"
|
||||
|
||||
interface SmartMatchResultItem {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
|
||||
interface SmartMatchResultsProps {
|
||||
results: SmartMatchResultItem[]
|
||||
selectedIds: string[]
|
||||
matching: boolean
|
||||
hasMatched: boolean
|
||||
onToggle: (assetId: string) => void
|
||||
onSelectAll: () => void
|
||||
onClear: () => void
|
||||
formatDuration: (seconds: number) => string
|
||||
selectedTotalDuration: number
|
||||
}
|
||||
|
||||
const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
|
||||
results,
|
||||
selectedIds,
|
||||
matching,
|
||||
hasMatched,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
formatDuration,
|
||||
selectedTotalDuration,
|
||||
}) => {
|
||||
// 匹配中状态
|
||||
if (matching) {
|
||||
return (
|
||||
<div className="xx-smart-match-loading">
|
||||
<LoadingOutlined
|
||||
style={{ fontSize: 32, color: "var(--primary-color)", marginBottom: 12 }}
|
||||
/>
|
||||
<div style={{ color: "var(--text-primary)", fontSize: 14 }}>AI 正在分析素材…</div>
|
||||
<div style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
|
||||
正在根据描述从视频库中匹配最合适的素材
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 未匹配状态提示
|
||||
if (!hasMatched) {
|
||||
return (
|
||||
<div className="xx-smart-match-empty">
|
||||
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
|
||||
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
输入视频内容描述,点击「智能匹配」让 AI 帮你选素材
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 无结果
|
||||
if (results.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="xx-smart-match-results">
|
||||
<div className="xx-smart-match-results-header">
|
||||
<span className="xx-smart-match-results-title">推荐素材 ({results.length}个)</span>
|
||||
<div className="xx-smart-match-results-actions">
|
||||
<button type="button" className="xx-link-btn" onClick={onSelectAll}>
|
||||
全选
|
||||
</button>
|
||||
<span style={{ color: "var(--border-color)" }}>|</span>
|
||||
<button type="button" className="xx-link-btn" onClick={onClear}>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-smart-match-grid">
|
||||
{results.map((result) => {
|
||||
const isSelected = selectedIds.includes(result.asset.id)
|
||||
return (
|
||||
<SmartMatchCard
|
||||
key={result.asset.id}
|
||||
result={result}
|
||||
selected={isSelected}
|
||||
onClick={() => onToggle(result.asset.id)}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已选素材汇总 */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="xx-smart-match-summary">
|
||||
<div className="xx-smart-match-summary-header">
|
||||
<span className="xx-pill xx-pill-ok">已选 {selectedIds.length} 个素材</span>
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 12 }}>
|
||||
预计总时长约 {selectedTotalDuration.toFixed(0)} 秒
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmartMatchResults
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 单个 AI 标题卡片
|
||||
*/
|
||||
import React from "react"
|
||||
import { CheckCircleFilled } from "@ant-design/icons"
|
||||
|
||||
interface AiTitleCardProps {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const AiTitleCard: React.FC<AiTitleCardProps> = ({
|
||||
title,
|
||||
highlight,
|
||||
style,
|
||||
selected,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`xx-ai-title-card ${selected ? "selected" : ""} ${style}`} onClick={onClick}>
|
||||
<div className="xx-ai-title-card-text">{title}</div>
|
||||
<div className="xx-ai-title-card-tag">{highlight}</div>
|
||||
{selected && (
|
||||
<div className="xx-ai-title-card-check">
|
||||
<CheckCircleFilled style={{ color: "#fff", fontSize: 14 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiTitleCard
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* AI 智能生成标题
|
||||
* 输入框 + 生成按钮 + 结果列表 + 加载状态
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import AiTitleCard from "./AiTitleCard"
|
||||
|
||||
interface AiTitleItem {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
}
|
||||
|
||||
interface AiTitleGeneratorProps {
|
||||
inputValue: string
|
||||
onInputChange: (value: string) => void
|
||||
generating: boolean
|
||||
onGenerate: () => void
|
||||
results: AiTitleItem[]
|
||||
hasGenerated: boolean
|
||||
onSelect: (title: string) => void
|
||||
selectedTitle: string
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const AiTitleGenerator: React.FC<AiTitleGeneratorProps> = ({
|
||||
inputValue,
|
||||
onInputChange,
|
||||
generating,
|
||||
onGenerate,
|
||||
results,
|
||||
hasGenerated,
|
||||
onSelect,
|
||||
selectedTitle,
|
||||
onRefresh,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-ai-title-section">
|
||||
<div className="xx-ai-title-header">
|
||||
<span className="xx-ai-title-label">✨ AI 智能生成标题</span>
|
||||
</div>
|
||||
<div className="xx-ai-title-input-row">
|
||||
<input
|
||||
className="xx-ai-title-input"
|
||||
placeholder="输入视频内容描述或关键词,如:职场成长、副业赚钱…"
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onGenerate()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={onGenerate}
|
||||
disabled={generating || !inputValue.trim()}
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
生成中
|
||||
</>
|
||||
) : (
|
||||
"生成标题"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 生成结果 */}
|
||||
{hasGenerated && !generating && results.length > 0 && (
|
||||
<div className="xx-ai-title-results">
|
||||
<div className="xx-ai-title-results-header">
|
||||
<span className="xx-ai-title-results-count">为你生成 {results.length} 个标题</span>
|
||||
<button type="button" className="xx-link-btn" onClick={onRefresh} disabled={generating}>
|
||||
🔄 换一批
|
||||
</button>
|
||||
</div>
|
||||
<div className="xx-ai-title-list">
|
||||
{results.map((item, idx) => (
|
||||
<AiTitleCard
|
||||
key={idx}
|
||||
title={item.title}
|
||||
highlight={item.highlight}
|
||||
style={item.style}
|
||||
selected={selectedTitle === item.title}
|
||||
onClick={() => onSelect(item.title)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{generating && (
|
||||
<div className="xx-ai-title-loading">
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
|
||||
AI 正在为你创作标题…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiTitleGenerator
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 标题预设样式网格
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
interface TitlePresetItem {
|
||||
key: string
|
||||
label: string
|
||||
previewStyle: React.CSSProperties
|
||||
}
|
||||
|
||||
interface TitlePresetsGridProps {
|
||||
presets: TitlePresetItem[]
|
||||
activePreset: string | null
|
||||
onApply: (presetKey: string) => void
|
||||
}
|
||||
|
||||
const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({ presets, activePreset, onApply }) => {
|
||||
return (
|
||||
<div className="xx-title-presets-grid">
|
||||
{presets.map((p) => {
|
||||
const isActive = activePreset === p.key
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`xx-title-preset-card${isActive ? " active" : ""}`}
|
||||
onClick={() => onApply(p.key)}
|
||||
title={p.label}
|
||||
>
|
||||
<span className="xx-title-preset-preview-text" style={p.previewStyle}>
|
||||
标题
|
||||
</span>
|
||||
<span className="xx-title-preset-card-label">{p.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitlePresetsGrid
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 标题样式设置区
|
||||
* 位置/字体/字号/样式按钮/预设
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import TitlePresetsGrid from "./TitlePresetsGrid"
|
||||
|
||||
interface PositionOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface TitlePresetItem {
|
||||
key: string
|
||||
label: string
|
||||
previewStyle: React.CSSProperties
|
||||
}
|
||||
|
||||
interface TitleStylePanelProps {
|
||||
settings: TitleSettings
|
||||
onUpdatePosition: (position: string) => void
|
||||
onUpdateFont: (font: string) => void
|
||||
onUpdateSize: (size: number) => void
|
||||
onToggleBold: () => void
|
||||
onToggleItalic: () => void
|
||||
onToggleStroke: () => void
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
activePreset: string | null
|
||||
titlePresets: TitlePresetItem[]
|
||||
POSITION_OPTIONS: PositionOption[]
|
||||
FONT_OPTIONS: string[]
|
||||
}
|
||||
|
||||
const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
|
||||
settings,
|
||||
onUpdatePosition,
|
||||
onUpdateFont,
|
||||
onUpdateSize,
|
||||
onToggleBold,
|
||||
onToggleItalic,
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-title-style-section">
|
||||
<h4 className="xx-section-subtitle">标题样式</h4>
|
||||
|
||||
{/* 位置 + 字体 一行 */}
|
||||
<div className="xx-title-style-row">
|
||||
<div className="xx-form-field xx-half-field">
|
||||
<label>位置</label>
|
||||
<select
|
||||
className="xx-form-select"
|
||||
value={settings.position}
|
||||
onChange={(e) => onUpdatePosition(e.target.value)}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="xx-form-field xx-half-field">
|
||||
<label>字体</label>
|
||||
<select
|
||||
className="xx-form-select"
|
||||
value={settings.font}
|
||||
onChange={(e) => onUpdateFont(e.target.value)}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字号滑块 */}
|
||||
<div className="xx-form-field">
|
||||
<div className="xx-field-label-row">
|
||||
<label>字号</label>
|
||||
<span className="xx-field-value">{settings.size}px</span>
|
||||
</div>
|
||||
<input
|
||||
className="xx-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
value={settings.size}
|
||||
onChange={(e) => onUpdateSize(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预设样式 */}
|
||||
<div className="xx-form-field">
|
||||
<label>预设样式</label>
|
||||
<TitlePresetsGrid
|
||||
presets={titlePresets}
|
||||
activePreset={activePreset}
|
||||
onApply={onApplyPreset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 样式按钮:粗体/斜体/描边/阴影 */}
|
||||
<div className="xx-form-field">
|
||||
<label>样式</label>
|
||||
<div className="xx-style-btns">
|
||||
<button
|
||||
className={`xx-style-btn ${settings.bold ? "active" : ""}`}
|
||||
onClick={onToggleBold}
|
||||
title="粗体"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${settings.italic ? "active" : ""}`}
|
||||
onClick={onToggleItalic}
|
||||
title="斜体"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${settings.stroke ? "active" : ""}`}
|
||||
onClick={onToggleStroke}
|
||||
title="描边"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${settings.shadow ? "active" : ""}`}
|
||||
onClick={onToggleShadow}
|
||||
title="阴影"
|
||||
>
|
||||
☁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleStylePanel
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 克隆声音展开区域
|
||||
* 克隆按钮、轮询提示、已克隆列表、空状态
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import { ThunderboltOutlined, AudioOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface CloneVoiceSectionProps {
|
||||
clonedVoices: VoiceClone[]
|
||||
hasProcessing: boolean
|
||||
selectedClonedVoice: string
|
||||
onSelect: (voiceId: string) => void
|
||||
onOpenCloneModal: () => void
|
||||
CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }>
|
||||
formatDuration: (seconds: number) => string
|
||||
}
|
||||
|
||||
const CloneVoiceSection: React.FC<CloneVoiceSectionProps> = ({
|
||||
clonedVoices,
|
||||
hasProcessing,
|
||||
selectedClonedVoice,
|
||||
onSelect,
|
||||
onOpenCloneModal,
|
||||
CLONE_STATUS_CONFIG,
|
||||
formatDuration,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-clone-section">
|
||||
<p className="xx-clone-section-title">克隆我的声音</p>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)" }}>
|
||||
上传一段您的语音样本,AI 将克隆您的声音用于视频配音
|
||||
</Text>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ height: 36, fontSize: 13 }}
|
||||
onClick={onOpenCloneModal}
|
||||
>
|
||||
<ThunderboltOutlined /> 克隆新声音
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 轮询提示 */}
|
||||
{hasProcessing && (
|
||||
<div className="xx-clone-polling-hint" style={{ marginTop: 10 }}>
|
||||
<span className="xx-clone-polling-dot" />
|
||||
正在同步克隆进度...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已克隆声音列表 */}
|
||||
{clonedVoices.length > 0 && (
|
||||
<div className="xx-clone-voices-list">
|
||||
{clonedVoices.map((cv) => {
|
||||
const statusCfg = CLONE_STATUS_CONFIG[cv.status]
|
||||
const isReady = cv.status === "ready"
|
||||
const selected = selectedClonedVoice === cv.id
|
||||
return (
|
||||
<div
|
||||
key={cv.id}
|
||||
className={`xx-clone-voice-row ${selected ? "selected" : ""} ${
|
||||
!isReady ? "disabled" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (isReady) onSelect(cv.id)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={isReady ? 0 : -1}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<div className={`xx-clone-avatar ${cv.status}`}>
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div className="xx-clone-info">
|
||||
<div className="xx-clone-name">{cv.name}</div>
|
||||
<div className="xx-clone-status">
|
||||
<span className="xx-clone-status-dot" style={{ background: statusCfg.color }} />
|
||||
<span style={{ color: statusCfg.color }}>{statusCfg.label}</span>
|
||||
{isReady && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{formatDuration(cv.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selected && isReady && (
|
||||
<CheckCircleFilled style={{ color: "var(--primary-color, #4f46e5)" }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clonedVoices.length === 0 && (
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
padding: "16px 0",
|
||||
}}
|
||||
>
|
||||
暂无克隆音色,点击「克隆新声音」开始
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
marginTop: 10,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
💡 提示:录音环境越安静,克隆效果越好。
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CloneVoiceSection
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 自定义录制面板
|
||||
* textarea + 合成按钮 + 结果区 + 存为素材按钮
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import { AudioOutlined, SaveOutlined } from "@ant-design/icons"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface CustomVoicePanelProps {
|
||||
customVoiceText: string
|
||||
onTextChange: (text: string) => void
|
||||
synthesizePending: boolean
|
||||
onSynthesize: () => void
|
||||
ttsError: string | null
|
||||
customAudioUrl: string | null
|
||||
completedTtsJobId: string | null
|
||||
onOpenSaveModal: () => void
|
||||
}
|
||||
|
||||
const CustomVoicePanel: React.FC<CustomVoicePanelProps> = ({
|
||||
customVoiceText,
|
||||
onTextChange,
|
||||
synthesizePending,
|
||||
onSynthesize,
|
||||
ttsError,
|
||||
customAudioUrl,
|
||||
completedTtsJobId,
|
||||
onOpenSaveModal,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<textarea
|
||||
placeholder="输入配音文案,点击合成按钮生成语音…"
|
||||
value={customVoiceText}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
maxLength={500}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 100,
|
||||
border: "1px solid var(--border-color, #e2e8f0)",
|
||||
borderRadius: "var(--radius-sm, 10px)",
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
resize: "vertical",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost"
|
||||
disabled={!customVoiceText.trim() || synthesizePending}
|
||||
onClick={onSynthesize}
|
||||
>
|
||||
<AudioOutlined /> {synthesizePending ? "合成中…" : "合成语音"}
|
||||
</button>
|
||||
</div>
|
||||
{ttsError && (
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--error, #ef4444)",
|
||||
marginTop: 8,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</Text>
|
||||
)}
|
||||
{customAudioUrl && completedTtsJobId && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "var(--success, #10b981)" }}>✓ 语音合成完成</Text>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ height: 30, padding: "0 14px", fontSize: 12 }}
|
||||
onClick={onOpenSaveModal}
|
||||
>
|
||||
<SaveOutlined /> 存为素材
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomVoicePanel
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 预设音色下拉选择 + 试听按钮
|
||||
* voiceMode === "preset" 时显示的详情区
|
||||
*/
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, PauseCircleOutlined } from "@ant-design/icons"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
|
||||
interface PresetVoiceDetailProps {
|
||||
presetVoices: PresetVoiceItem[]
|
||||
selectedVoice: string
|
||||
onSelect: (voiceId: string) => void
|
||||
playingVoice: string | null
|
||||
onTogglePlay: (voiceId: string, previewUrl: string | null) => void
|
||||
presetVoicesLoading: boolean
|
||||
}
|
||||
|
||||
const PresetVoiceDetail: React.FC<PresetVoiceDetailProps> = ({
|
||||
presetVoices,
|
||||
selectedVoice,
|
||||
onSelect,
|
||||
playingVoice,
|
||||
onTogglePlay,
|
||||
presetVoicesLoading,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="xx-form-field">
|
||||
<label>从配音库选择</label>
|
||||
<select value={selectedVoice} onChange={(e) => onSelect(e.target.value)}>
|
||||
<option value="">请选择配音…</option>
|
||||
{presetVoicesLoading ? (
|
||||
<option disabled>加载中…</option>
|
||||
) : (
|
||||
presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name} — {v.description}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
{/* 试听按钮 */}
|
||||
{presetVoices.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{presetVoices.slice(0, 4).map((v) => (
|
||||
<button
|
||||
key={v.voice_id}
|
||||
className="xx-btn xx-btn-ghost"
|
||||
style={{ height: 32, padding: "0 12px", fontSize: 12 }}
|
||||
onClick={() => onTogglePlay(v.voice_id, v.preview_url)}
|
||||
>
|
||||
{playingVoice === v.voice_id ? (
|
||||
<>
|
||||
<PauseCircleOutlined /> 停止
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlined /> {v.name}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PresetVoiceDetail
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 保存到配音库弹窗
|
||||
*/
|
||||
import React from "react"
|
||||
import { CloseOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
import type { TagItem } from "@/api/tags"
|
||||
|
||||
interface SaveVoiceModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
saveName: string
|
||||
onNameChange: (name: string) => void
|
||||
saveTagIds: string[]
|
||||
onTagIdsChange: (tags: string[] | ((prev: string[]) => string[])) => void
|
||||
saveNewTag: string
|
||||
onNewTagChange: (tag: string) => void
|
||||
onAddTag: (tagName: string) => void
|
||||
allTags: TagItem[]
|
||||
savePending: boolean
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const SaveVoiceModal: React.FC<SaveVoiceModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
saveName,
|
||||
onNameChange,
|
||||
saveTagIds,
|
||||
onTagIdsChange,
|
||||
saveNewTag,
|
||||
onNewTagChange,
|
||||
onAddTag,
|
||||
allTags,
|
||||
savePending,
|
||||
onConfirm,
|
||||
}) => {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="xx-save-modal-overlay" onClick={onClose}>
|
||||
<div className="xx-save-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="xx-save-modal-header">
|
||||
<span>保存到配音库</span>
|
||||
<button className="xx-save-modal-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="xx-save-modal-body">
|
||||
<label className="xx-save-modal-label">素材名称</label>
|
||||
<input
|
||||
className="xx-save-modal-input"
|
||||
placeholder="留空则自动生成名称"
|
||||
value={saveName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
<label className="xx-save-modal-label">
|
||||
标签
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
(可选)
|
||||
</span>
|
||||
</label>
|
||||
<div className="xx-save-modal-tags">
|
||||
{saveTagIds.map((id) => {
|
||||
const tag = allTags.find((t) => t.id === id)
|
||||
return tag ? (
|
||||
<span key={id} className="xx-save-modal-tag active">
|
||||
{tag.name}
|
||||
<CloseOutlined
|
||||
className="xx-save-modal-tag-remove"
|
||||
onClick={() => onTagIdsChange((prev: string[]) => prev.filter((x) => x !== id))}
|
||||
/>
|
||||
</span>
|
||||
) : null
|
||||
})}
|
||||
<input
|
||||
className="xx-save-modal-tag-input"
|
||||
placeholder="输入标签名回车添加"
|
||||
value={saveNewTag}
|
||||
onChange={(e) => onNewTagChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
onAddTag(saveNewTag)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{allTags.length > 0 && (
|
||||
<div className="xx-save-modal-tag-presets">
|
||||
{allTags
|
||||
.filter((t) => !saveTagIds.includes(t.id))
|
||||
.slice(0, 12)
|
||||
.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className="xx-save-modal-tag-preset"
|
||||
onClick={() => onTagIdsChange((prev: string[]) => [...prev, t.id])}
|
||||
>
|
||||
{t.name}
|
||||
<PlusOutlined style={{ fontSize: 10, marginLeft: 4 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-save-modal-footer">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-primary" disabled={savePending} onClick={onConfirm}>
|
||||
{savePending ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SaveVoiceModal
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 音色选择卡片
|
||||
* 用于顶部预设音色卡片和克隆入口卡片
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
interface VoiceChoiceCardProps {
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
avatar: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
avatarStyle?: React.CSSProperties
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const VoiceChoiceCard: React.FC<VoiceChoiceCardProps> = ({
|
||||
selected,
|
||||
onClick,
|
||||
avatar,
|
||||
title,
|
||||
description,
|
||||
avatarStyle,
|
||||
loading = false,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`xx-voice-choice-item ${selected ? "selected" : ""}`}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
}}
|
||||
style={loading ? { opacity: 0.5 } : undefined}
|
||||
>
|
||||
<span className="xx-voice-choice-check">✓</span>
|
||||
<div className="xx-voice-choice-avatar" style={avatarStyle}>
|
||||
{avatar}
|
||||
</div>
|
||||
<div className="xx-voice-choice-info">
|
||||
<h4>{title}</h4>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceChoiceCard
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* AI 智能推荐配音区域
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
|
||||
interface VoiceRecommendSectionProps {
|
||||
presetVoices: PresetVoiceItem[]
|
||||
voiceRecommendLoading: boolean
|
||||
voiceRecommendations: string[]
|
||||
hasVoiceRecommend: boolean
|
||||
onRecommend: () => void
|
||||
onSelectVoice: (voiceId: string) => void
|
||||
selectedVoiceId: string
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
VOICE_GENDER_ICON: Record<string, string>
|
||||
}
|
||||
|
||||
const VoiceRecommendSection: React.FC<VoiceRecommendSectionProps> = ({
|
||||
presetVoices,
|
||||
voiceRecommendLoading,
|
||||
voiceRecommendations,
|
||||
hasVoiceRecommend,
|
||||
onRecommend,
|
||||
onSelectVoice,
|
||||
selectedVoiceId,
|
||||
voiceMode,
|
||||
VOICE_GENDER_ICON,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-voice-recommend-section">
|
||||
<div className="xx-voice-recommend-header">
|
||||
<span className="xx-voice-recommend-label">✨ AI 智能推荐</span>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={onRecommend}
|
||||
disabled={voiceRecommendLoading}
|
||||
>
|
||||
{voiceRecommendLoading ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
推荐中
|
||||
</>
|
||||
) : hasVoiceRecommend ? (
|
||||
"换一批"
|
||||
) : (
|
||||
"智能推荐"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{voiceRecommendLoading && (
|
||||
<div className="xx-voice-recommend-loading">
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
|
||||
根据视频内容为你匹配最合适的音色…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!voiceRecommendLoading && hasVoiceRecommend && voiceRecommendations.length > 0 && (
|
||||
<div className="xx-voice-recommend-list">
|
||||
{voiceRecommendations.map((voiceId) => {
|
||||
const v = presetVoices.find((pv) => pv.voice_id === voiceId)
|
||||
if (!v) return null
|
||||
const isSelected = voiceMode === "preset" && selectedVoiceId === v.voice_id
|
||||
return (
|
||||
<div
|
||||
key={v.voice_id}
|
||||
className={`xx-voice-recommend-card ${isSelected ? "selected" : ""}`}
|
||||
onClick={() => onSelectVoice(v.voice_id)}
|
||||
>
|
||||
<div className="xx-voice-recommend-avatar">
|
||||
{VOICE_GENDER_ICON[v.gender] ?? "✨"}
|
||||
</div>
|
||||
<div className="xx-voice-recommend-info">
|
||||
<div className="xx-voice-recommend-name">{v.name}</div>
|
||||
<div className="xx-voice-recommend-desc">{v.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="xx-voice-recommend-check">
|
||||
<CheckCircleFilled style={{ color: "#fff", fontSize: 16 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!voiceRecommendLoading && !hasVoiceRecommend && (
|
||||
<div className="xx-voice-recommend-empty">
|
||||
<span>点击「智能推荐」,AI 根据视频内容匹配音色</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceRecommendSection
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 视频生成 Hook
|
||||
* 封装视频生成的核心逻辑、状态管理、轮询等
|
||||
*/
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
|
||||
import {
|
||||
generateEditPlan,
|
||||
updateEditPlan,
|
||||
getGenerationTaskResults,
|
||||
getGenerationStatus,
|
||||
getEditPlan,
|
||||
} from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
interface UseGenerateVideoProps {
|
||||
titleSettings: TitleSettings
|
||||
selectedTemplate: string
|
||||
selectedMaterials: string[]
|
||||
materialMode: "manual" | "auto"
|
||||
smartSelectedIds: string[]
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
coverSettings: CoverConfig
|
||||
videoRatio: string
|
||||
style: string
|
||||
duration: number
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
}
|
||||
|
||||
export function useGenerateVideo({
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
}: UseGenerateVideoProps) {
|
||||
/* ── 生成状态 ── */
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [generated, setGenerated] = useState(false)
|
||||
const [generateError, setGenerateError] = useState<string | null>(null)
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
||||
|
||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined)
|
||||
|
||||
/* ── 生成阶段映射 ── */
|
||||
const getGenerationPhase = (p: number) => {
|
||||
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
|
||||
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
|
||||
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
|
||||
return { label: "即将完成", icon: "✨" }
|
||||
}
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
console.log("[handleGenerate] 开始生成, 参数:", {
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
voiceMode,
|
||||
})
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请先选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
message.warning("请先选择一个克隆音色")
|
||||
return
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
setProgress(0)
|
||||
setGenerated(false)
|
||||
setGenerateError(null)
|
||||
|
||||
try {
|
||||
const voiceConfig: Pick<
|
||||
EditPlanConfig,
|
||||
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
|
||||
> = {}
|
||||
if (voiceMode === "preset") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined
|
||||
} else if (voiceMode === "clone") {
|
||||
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined
|
||||
} else if (voiceMode === "custom") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined
|
||||
// 注意:customAudioUrl / customVoiceText 在 step5 hook 中,
|
||||
// 自定义配音模式需从 step5 组件传回
|
||||
}
|
||||
|
||||
// 获取或创建草稿
|
||||
await getEditPlan(selectedTemplate)
|
||||
|
||||
// 更新草稿内容 + 切换到 editing 状态
|
||||
await updateEditPlan(selectedTemplate, {
|
||||
name: titleSettings.title.trim(),
|
||||
config: {
|
||||
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
status: "editing",
|
||||
})
|
||||
|
||||
await generateEditPlan(selectedTemplate)
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await getGenerationStatus(selectedTemplate)
|
||||
|
||||
if (data.plan_status === "completed") {
|
||||
setProgress(100)
|
||||
setGenerating(false)
|
||||
setGenerated(true)
|
||||
|
||||
// 获取生成的视频结果
|
||||
if (data.generation_task_id) {
|
||||
try {
|
||||
const videos = await getGenerationTaskResults(data.generation_task_id)
|
||||
setGeneratedVideos(videos)
|
||||
} catch (err) {
|
||||
console.error("[获取生成结果失败]", err)
|
||||
}
|
||||
}
|
||||
|
||||
message.success("视频生成完成!")
|
||||
return
|
||||
}
|
||||
if (data.plan_status === "failed") {
|
||||
setGenerating(false)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const dataAny = data as Record<string, any>
|
||||
const rawMsg =
|
||||
dataAny.error_message ||
|
||||
dataAny.error ||
|
||||
dataAny.message ||
|
||||
(data.clips || []).find((c: { status: string }) => c.status === "failed")
|
||||
?.error_message ||
|
||||
"视频生成失败,请联系管理员或重试"
|
||||
const safeExtract = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (obj.message && typeof obj.message === "object") return safeExtract(obj.message)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return String(val ?? "")
|
||||
}
|
||||
const errorMsg = safeExtract(rawMsg)
|
||||
console.error("[生成失败] templateId:", selectedTemplate, "响应:", data)
|
||||
setGenerateError(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
const clips = data.clips || []
|
||||
const total = clips.length || 1
|
||||
const done = clips.filter((c: { status: string }) => c.status === "completed").length
|
||||
setProgress(Math.round((done / total) * 100))
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<
|
||||
typeof setInterval
|
||||
>
|
||||
} catch (pollErr) {
|
||||
console.error("[轮询出错] templateId:", selectedTemplate, pollErr)
|
||||
progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType<
|
||||
typeof setInterval
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<typeof setInterval>
|
||||
} catch (err: unknown) {
|
||||
console.error("[handleGenerate] 生成失败:", err)
|
||||
setGenerating(false)
|
||||
const axiosErr = err as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string | object
|
||||
error?: string | object
|
||||
detail?: string | object
|
||||
msg?: string | object
|
||||
}
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
const extractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (typeof obj.message === "object" && obj.message !== null)
|
||||
return extractString(obj.message)
|
||||
if (typeof obj.msg === "object" && obj.msg !== null) return extractString(obj.msg)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
const backendMsg =
|
||||
extractString(axiosErr.response?.data?.message) ||
|
||||
extractString(axiosErr.response?.data?.error) ||
|
||||
extractString(axiosErr.response?.data?.detail) ||
|
||||
extractString(axiosErr.response?.data?.msg) ||
|
||||
axiosErr.message ||
|
||||
""
|
||||
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", axiosErr)
|
||||
const safeExtractErr = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (typeof obj.message === "object") return safeExtractErr(obj.message)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return String(val ?? "")
|
||||
}
|
||||
const rawError = safeExtractErr(backendMsg)
|
||||
const translateError = (msg: string): string => {
|
||||
if (!msg) return "生成失败,请检查网络后重试或联系管理员"
|
||||
if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) {
|
||||
return "正在准备生成,请稍候再试"
|
||||
}
|
||||
if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) {
|
||||
return "所选模板或素材不可用,请重新选择"
|
||||
}
|
||||
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
|
||||
return "素材数据异常,请返回视频库重新检查"
|
||||
}
|
||||
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
|
||||
return "网络连接超时,请检查网络后重试"
|
||||
}
|
||||
if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) {
|
||||
return "已达到生成次数上限,请稍后再试或联系客服"
|
||||
}
|
||||
if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg
|
||||
return "生成失败,请稍后重试或联系管理员"
|
||||
}
|
||||
const finalMsg = translateError(rawError)
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [
|
||||
titleSettings,
|
||||
selectedMaterials,
|
||||
selectedVoice,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
selectedTemplate,
|
||||
generateCount,
|
||||
materialMode,
|
||||
coverSettings,
|
||||
smartSelectedIds,
|
||||
])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
generate()
|
||||
}, [generate])
|
||||
|
||||
/* 清除错误 */
|
||||
const dismissError = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
}, [])
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
const download = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
const video = generatedVideos[0]
|
||||
try {
|
||||
const url = video.download_url || video.file_url
|
||||
if (url) {
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = video.name || "generated-video.mp4"
|
||||
a.target = "_blank"
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[下载失败]", err)
|
||||
message.error("下载失败,请重试")
|
||||
}
|
||||
}, [generatedVideos])
|
||||
|
||||
/* ── 分享视频 ── */
|
||||
const share = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
const video = generatedVideos[0]
|
||||
const shareUrl = video.file_url || window.location.href
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
message.success("视频链接已复制到剪贴板")
|
||||
} catch {
|
||||
message.info(`视频链接: ${shareUrl}`)
|
||||
}
|
||||
}, [generatedVideos])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
generating,
|
||||
progress,
|
||||
generated,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
// 操作
|
||||
generate,
|
||||
retry,
|
||||
dismissError,
|
||||
download,
|
||||
share,
|
||||
// 工具
|
||||
getGenerationPhase,
|
||||
}
|
||||
}
|
||||
|
||||
export default useGenerateVideo
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Step 1 模板选择 Hook
|
||||
* 封装模板选择的交互逻辑
|
||||
*/
|
||||
import { useCallback } from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface UseStep1TemplateProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
onSelectTemplate: (id: string) => void
|
||||
}
|
||||
|
||||
export function useStep1Template({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
onSelectTemplate,
|
||||
}: UseStep1TemplateProps) {
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelectTemplate(id)
|
||||
},
|
||||
[onSelectTemplate],
|
||||
)
|
||||
|
||||
const handleKeySelect = useCallback(
|
||||
(e: React.KeyboardEvent, id: string) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onSelectTemplate(id)
|
||||
}
|
||||
},
|
||||
[onSelectTemplate],
|
||||
)
|
||||
|
||||
return {
|
||||
templates,
|
||||
selectedTemplate,
|
||||
handleSelect,
|
||||
handleKeySelect,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep1Template
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Step 2 素材选择 Hook
|
||||
* 封装素材库加载、手动选择、智能匹配等逻辑
|
||||
*/
|
||||
import { useState, useCallback, useEffect, useMemo } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { SMART_MATCH_REASONS } from "../constants"
|
||||
|
||||
interface SmartMatchedResult {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
|
||||
interface UseStep2MaterialsProps {
|
||||
materialMode: "manual" | "auto"
|
||||
onMaterialModeChange: (mode: "manual" | "auto") => void
|
||||
selectedMaterials: string[]
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
export function useStep2Materials({
|
||||
materialMode,
|
||||
onMaterialModeChange,
|
||||
selectedMaterials,
|
||||
onSelectedMaterialsChange,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
}: UseStep2MaterialsProps) {
|
||||
/* ── 素材库数据 API ── */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
||||
|
||||
// 自动选中第一个视频库
|
||||
useEffect(() => {
|
||||
if (libraries.length > 0 && !selectedLibraryId) {
|
||||
setSelectedLibraryId(libraries[0].id)
|
||||
}
|
||||
}, [libraries, selectedLibraryId])
|
||||
|
||||
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
|
||||
items: AssetItem[]
|
||||
total: number
|
||||
}>({
|
||||
queryKey: ["generate-assets", selectedLibraryId],
|
||||
queryFn: () => getAssets(selectedLibraryId),
|
||||
enabled: !!selectedLibraryId,
|
||||
})
|
||||
|
||||
/* ── 智能素材匹配状态 ── */
|
||||
const [smartMatchInput, setSmartMatchInput] = useState("")
|
||||
const [smartMatching, setSmartMatching] = useState(false)
|
||||
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
|
||||
const [hasMatched, setHasMatched] = useState(false)
|
||||
|
||||
/* ── 手动选择素材 ── */
|
||||
const handleToggleMaterial = useCallback(
|
||||
(materialId: string) => {
|
||||
onSelectedMaterialsChange(
|
||||
selectedMaterials.includes(materialId)
|
||||
? selectedMaterials.filter((id) => id !== materialId)
|
||||
: [...selectedMaterials, materialId],
|
||||
)
|
||||
},
|
||||
[selectedMaterials, onSelectedMaterialsChange],
|
||||
)
|
||||
|
||||
/* ── 智能素材匹配 ── */
|
||||
const handleSmartMatch = useCallback(async () => {
|
||||
if (!smartMatchInput.trim()) {
|
||||
message.warning("请先输入视频内容描述")
|
||||
return
|
||||
}
|
||||
if (materials.items.length === 0) {
|
||||
message.warning("当前视频库暂无素材")
|
||||
return
|
||||
}
|
||||
|
||||
setSmartMatching(true)
|
||||
setHasMatched(true)
|
||||
|
||||
// 模拟 AI 匹配延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 从素材库中随机选取 5-8 个作为推荐结果
|
||||
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
|
||||
}))
|
||||
|
||||
// 按匹配度从高到低排序
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
// 默认选中匹配度 >= 90 的素材
|
||||
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
|
||||
onSmartSelectedIdsChange(
|
||||
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
|
||||
)
|
||||
setSmartMatching(false)
|
||||
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
const handleToggleSmartSelect = useCallback(
|
||||
(assetId: string) => {
|
||||
onSmartSelectedIdsChange(
|
||||
smartSelectedIds.includes(assetId)
|
||||
? smartSelectedIds.filter((id) => id !== assetId)
|
||||
: [...smartSelectedIds, assetId],
|
||||
)
|
||||
},
|
||||
[smartSelectedIds, onSmartSelectedIdsChange],
|
||||
)
|
||||
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
if (materials.items.length <= 5) {
|
||||
message.info("视频库素材较少,无法换一批")
|
||||
return
|
||||
}
|
||||
setSmartMatching(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
const remaining = materials.items.filter(
|
||||
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
|
||||
)
|
||||
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(80 + Math.random() * 19),
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",节奏明快" : ""),
|
||||
}))
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
onSmartSelectedIdsChange([])
|
||||
setSmartMatching(false)
|
||||
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleSelectAllMatched = useCallback(() => {
|
||||
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
|
||||
}, [smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleClearSmartSelect = useCallback(() => {
|
||||
onSmartSelectedIdsChange([])
|
||||
}, [onSmartSelectedIdsChange])
|
||||
|
||||
/* ── 计算已选智能匹配素材的总时长 ── */
|
||||
const smartSelectedTotalDuration = useMemo(() => {
|
||||
return smartMatchedResults
|
||||
.filter((r) => smartSelectedIds.includes(r.asset.id))
|
||||
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
|
||||
}, [smartMatchedResults, smartSelectedIds])
|
||||
|
||||
return {
|
||||
// 素材库
|
||||
libraries,
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
materialsLoading,
|
||||
// 模式
|
||||
materialMode,
|
||||
onMaterialModeChange,
|
||||
// 手动选择
|
||||
selectedMaterials,
|
||||
handleToggleMaterial,
|
||||
// 智能匹配
|
||||
smartMatchInput,
|
||||
setSmartMatchInput,
|
||||
smartMatching,
|
||||
smartMatchedResults,
|
||||
hasMatched,
|
||||
smartSelectedIds,
|
||||
handleSmartMatch,
|
||||
handleToggleSmartSelect,
|
||||
handleRefreshMatch,
|
||||
handleSelectAllMatched,
|
||||
handleClearSmartSelect,
|
||||
smartSelectedTotalDuration,
|
||||
// utils
|
||||
formatDuration,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep2Materials
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Step 3 生成预览 Hook
|
||||
* 封装预览信息的计算逻辑
|
||||
*/
|
||||
import { useMemo } from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface UseStep3PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
}
|
||||
|
||||
export function useStep3Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
}: UseStep3PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialCount = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep3Preview
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Step 4 标题设置 Hook
|
||||
* 封装 AI 标题生成、标题样式设置等逻辑
|
||||
*/
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import { TITLE_PRESETS, AI_TITLE_TEMPLATES } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
interface UseStep4TitleProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
interface AiTitleItem {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
}
|
||||
|
||||
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
|
||||
/* ── 标题库 API ── */
|
||||
const { data: userTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: () => getTitles(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
/* ── AI 标题生成状态 ── */
|
||||
const [aiTitleInput, setAiTitleInput] = useState("")
|
||||
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
|
||||
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
|
||||
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
|
||||
|
||||
/* ── 辅助函数 ── */
|
||||
const extractTopic = (text: string): string => {
|
||||
const keywords = text
|
||||
.replace(/[,。!?、,.!?]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
if (keywords.length === 0) return "这个话题"
|
||||
// 取前3个关键词组合
|
||||
return keywords.slice(0, 3).join("")
|
||||
}
|
||||
|
||||
const getActivePreset = (settings: TitleSettings): string | null => {
|
||||
for (const p of TITLE_PRESETS) {
|
||||
if (
|
||||
settings.size === p.style.size &&
|
||||
settings.color === p.style.color &&
|
||||
settings.bold === p.style.bold &&
|
||||
settings.italic === p.style.italic &&
|
||||
settings.stroke === p.style.stroke &&
|
||||
settings.shadow === p.style.shadow
|
||||
) {
|
||||
return p.key
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings])
|
||||
|
||||
/* ── AI 标题生成 ── */
|
||||
const handleGenerateAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) {
|
||||
message.warning("请先输入视频描述或关键词")
|
||||
return
|
||||
}
|
||||
setAiTitleGenerating(true)
|
||||
setHasGeneratedTitles(true)
|
||||
|
||||
// 模拟 AI 生成延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
const results: AiTitleItem[] = []
|
||||
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
// 每种风格随机选2个
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
const title = tpl.replace(/\{topic\}/g, topic)
|
||||
const highlights = {
|
||||
catchy: "吸睛标题",
|
||||
emotional: "情感共鸣",
|
||||
informative: "知识干货",
|
||||
}
|
||||
results.push({
|
||||
title,
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 打乱顺序
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
setAiTitleResults(results)
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
const handleSelectAiTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false })
|
||||
message.success("已选用此标题")
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const handleRefreshAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) return
|
||||
setAiTitleGenerating(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
// 重新生成一批
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
const results: AiTitleItem[] = []
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
results.push({
|
||||
title: tpl.replace(/\{topic\}/g, topic),
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
setAiTitleResults(results)
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
/* ── 标题设置更新 ── */
|
||||
const updateTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const toggleAiAutoSelect = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, aiAutoSelect: !titleSettings.aiAutoSelect })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const updatePosition = useCallback(
|
||||
(position: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, position })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateFont = useCallback(
|
||||
(font: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, font })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateSize = useCallback(
|
||||
(size: number) => {
|
||||
onTitleSettingsChange({ ...titleSettings, size })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateColor = useCallback(
|
||||
(color: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, color })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const toggleBold = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleItalic = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleStroke = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleShadow = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(presetKey: string) => {
|
||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return
|
||||
onTitleSettingsChange({
|
||||
...titleSettings,
|
||||
size: preset.style.size,
|
||||
color: preset.style.color,
|
||||
bold: preset.style.bold,
|
||||
italic: preset.style.italic,
|
||||
stroke: preset.style.stroke,
|
||||
shadow: preset.style.shadow,
|
||||
})
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
return {
|
||||
// 数据
|
||||
userTitles,
|
||||
titleSettings,
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
activePreset,
|
||||
titlePresets: TITLE_PRESETS,
|
||||
// AI 标题操作
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
// 标题设置操作
|
||||
updateTitle,
|
||||
toggleAiAutoSelect,
|
||||
updatePosition,
|
||||
updateFont,
|
||||
updateSize,
|
||||
updateColor,
|
||||
toggleBold,
|
||||
toggleItalic,
|
||||
toggleStroke,
|
||||
toggleShadow,
|
||||
applyPreset,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep4Title
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Step 5 配音选择 Hook
|
||||
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
|
||||
*/
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { message } from "antd"
|
||||
import { useQuery, useMutation } from "@tanstack/react-query"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { getTags, createTag } from "@/api/tags"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
|
||||
|
||||
interface UseStep5VoiceProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (voiceId: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
onSelectedClonedVoiceChange: (voiceId: string) => void
|
||||
clonedVoices: VoiceClone[]
|
||||
addClone: (voice: VoiceClone) => void
|
||||
hasProcessing: boolean
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
titleText: string
|
||||
}
|
||||
|
||||
export function useStep5Voice({
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
voiceMode,
|
||||
onVoiceModeChange,
|
||||
selectedClonedVoice,
|
||||
onSelectedClonedVoiceChange,
|
||||
clonedVoices,
|
||||
addClone,
|
||||
hasProcessing,
|
||||
cloneModalOpen,
|
||||
onCloneModalOpenChange,
|
||||
titleText,
|
||||
}: UseStep5VoiceProps) {
|
||||
const navigate = useNavigate()
|
||||
/* ── 预置音色 API ── */
|
||||
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||
() => presetVoicesData?.items ?? [],
|
||||
[presetVoicesData],
|
||||
)
|
||||
|
||||
/* ── 音频播放 ── */
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
|
||||
|
||||
const toggleVoicePlay = useCallback(
|
||||
(voiceId: string, previewUrl: string | null) => {
|
||||
if (playingVoice === voiceId) {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setPlayingVoice(null)
|
||||
return
|
||||
}
|
||||
audioRef.current?.pause()
|
||||
if (!previewUrl) {
|
||||
message.warning("该音色暂无试听音频")
|
||||
return
|
||||
}
|
||||
const audio = new Audio(previewUrl)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {
|
||||
message.error("播放失败,请检查网络")
|
||||
})
|
||||
audio.onended = () => {
|
||||
setPlayingVoice(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
setPlayingVoice(voiceId)
|
||||
},
|
||||
[playingVoice],
|
||||
)
|
||||
|
||||
/* ── 智能配音推荐 ── */
|
||||
const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false)
|
||||
const [voiceRecommendations, setVoiceRecommendations] = useState<string[]>([])
|
||||
const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false)
|
||||
|
||||
const handleVoiceRecommend = useCallback(async () => {
|
||||
if (presetVoices.length === 0) return
|
||||
setVoiceRecommendLoading(true)
|
||||
setHasVoiceRecommend(true)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年
|
||||
const title = titleText.toLowerCase()
|
||||
let recommended: string[] = []
|
||||
|
||||
const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id)
|
||||
const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id)
|
||||
const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id)
|
||||
|
||||
if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) {
|
||||
recommended = femaleVoices.slice(0, 3)
|
||||
} else if (/教程|知识|科普|干货|讲解|分析/.test(title)) {
|
||||
recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1))
|
||||
} else if (/活力|热血|运动|搞笑|有趣/.test(title)) {
|
||||
recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1))
|
||||
} else {
|
||||
// 默认推荐前3个
|
||||
recommended = presetVoices.slice(0, 3).map((v) => v.voice_id)
|
||||
}
|
||||
|
||||
// 不足3个时补足
|
||||
if (recommended.length < 3) {
|
||||
const others = presetVoices
|
||||
.filter((v) => !recommended.includes(v.voice_id))
|
||||
.map((v) => v.voice_id)
|
||||
recommended = recommended.concat(others.slice(0, 3 - recommended.length))
|
||||
}
|
||||
|
||||
setVoiceRecommendations(recommended)
|
||||
setVoiceRecommendLoading(false)
|
||||
}, [presetVoices, titleText])
|
||||
|
||||
const handleSelectRecommendedVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onVoiceModeChange("preset")
|
||||
onSelectedVoiceChange(voiceId)
|
||||
},
|
||||
[onVoiceModeChange, onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
/* ── TTS 自定义合成状态 ── */
|
||||
const [customVoiceText, setCustomVoiceText] = useState("")
|
||||
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
|
||||
const [ttsError, setTtsError] = useState<string | null>(null)
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||
/** 合成完成后保留的 job ID,用于"存为素材" */
|
||||
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(null)
|
||||
|
||||
/* ── TTS mutation ── */
|
||||
const synthesizeMutation = useMutation({
|
||||
mutationFn: synthesizeSpeech,
|
||||
onSuccess: (data) => {
|
||||
setTtsJobId(data.job_id)
|
||||
message.info("语音合成已提交,等待处理…")
|
||||
},
|
||||
onError: () => {
|
||||
setTtsError("语音合成请求失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
/** 轮询 TTS 任务状态 */
|
||||
useEffect(() => {
|
||||
if (!ttsJobId) return
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await getTTSJobStatus(ttsJobId)
|
||||
if (cancelled) return
|
||||
if (status.status === "completed") {
|
||||
setCustomAudioUrl(status.output_audio_url)
|
||||
setCompletedTtsJobId(ttsJobId)
|
||||
setTtsJobId(null)
|
||||
setTtsError(null)
|
||||
message.success("语音合成完成!")
|
||||
return
|
||||
}
|
||||
if (status.status === "failed" || status.status === "cancelled") {
|
||||
setTtsError(status.error_message || "语音合成失败")
|
||||
setTtsJobId(null)
|
||||
return
|
||||
}
|
||||
timer = setTimeout(poll, 2000)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setTtsError("查询合成状态失败")
|
||||
setTtsJobId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timer = setTimeout(poll, 2000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [ttsJobId])
|
||||
|
||||
/** 触发自定义文本 TTS 合成 */
|
||||
const handleSynthesizeVoice = useCallback(() => {
|
||||
if (!customVoiceText.trim()) {
|
||||
message.warning("请先输入配音文案")
|
||||
return
|
||||
}
|
||||
setTtsError(null)
|
||||
setCustomAudioUrl(null)
|
||||
synthesizeMutation.mutate({
|
||||
text: customVoiceText.trim(),
|
||||
voice_id: selectedVoice || undefined,
|
||||
language: "zh-CN",
|
||||
})
|
||||
}, [customVoiceText, selectedVoice, synthesizeMutation])
|
||||
|
||||
/* ── 存为素材弹窗状态 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false)
|
||||
const [saveName, setSaveName] = useState("")
|
||||
const [saveTagIds, setSaveTagIds] = useState<string[]>([])
|
||||
const [saveNewTag, setSaveNewTag] = useState("")
|
||||
|
||||
/* ── 标签列表(用于存为素材弹窗) ── */
|
||||
const { data: allTags = [] } = useQuery({
|
||||
queryKey: ["generate-save-tags"],
|
||||
queryFn: getTags,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
/* ── 存为素材 mutation ── */
|
||||
const saveToLibraryMutation = useMutation({
|
||||
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
|
||||
saveTtsToLibrary(completedTtsJobId!, params),
|
||||
onSuccess: () => {
|
||||
message.success({
|
||||
content: (
|
||||
<span>
|
||||
已保存到配音库!{" "}
|
||||
<a
|
||||
onClick={handleGoToLibrary}
|
||||
style={{
|
||||
color: "var(--primary-500, #6366f1)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
去视频库查看
|
||||
</a>
|
||||
</span>
|
||||
),
|
||||
duration: 5,
|
||||
})
|
||||
setSaveModalOpen(false)
|
||||
setSaveName("")
|
||||
setSaveTagIds([])
|
||||
setSaveNewTag("")
|
||||
setCompletedTtsJobId(null)
|
||||
setCustomAudioUrl(null)
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(`保存失败:${err.message || "请重试"}`)
|
||||
},
|
||||
})
|
||||
|
||||
/** 打开存为素材弹窗 */
|
||||
const handleOpenSaveModal = useCallback(() => {
|
||||
setSaveName("")
|
||||
setSaveTagIds([])
|
||||
setSaveNewTag("")
|
||||
setSaveModalOpen(true)
|
||||
}, [])
|
||||
|
||||
/** 确认保存 */
|
||||
const handleConfirmSave = useCallback(() => {
|
||||
if (!completedTtsJobId) return
|
||||
saveToLibraryMutation.mutate({
|
||||
name: saveName.trim() || undefined,
|
||||
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
|
||||
})
|
||||
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation])
|
||||
|
||||
/** 在弹窗中新增标签(先创建再选中) */
|
||||
const handleAddTagInModal = useCallback(
|
||||
async (tagName: string) => {
|
||||
const trimmed = tagName.trim()
|
||||
if (!trimmed) return
|
||||
/* 已在选中列表则跳过 */
|
||||
const existing = allTags.find((t) => t.name === trimmed)
|
||||
if (existing) {
|
||||
if (!saveTagIds.includes(existing.id)) {
|
||||
setSaveTagIds((prev) => [...prev, existing.id])
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const created = await createTag(trimmed)
|
||||
setSaveTagIds((prev) => [...prev, created.id])
|
||||
setSaveNewTag("")
|
||||
} catch {
|
||||
message.error(`创建标签"${trimmed}"失败`)
|
||||
}
|
||||
},
|
||||
[allTags, saveTagIds],
|
||||
)
|
||||
|
||||
/** 保存成功后跳转到视频库 */
|
||||
const handleGoToLibrary = useCallback(() => {
|
||||
navigate("/app/voice-materials")
|
||||
}, [navigate])
|
||||
|
||||
/* ── 克隆成功回调 ── */
|
||||
const handleCloneSuccess = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
addClone(voice)
|
||||
onCloneModalOpenChange(false)
|
||||
message.success("音色克隆成功!")
|
||||
},
|
||||
[addClone, onCloneModalOpenChange],
|
||||
)
|
||||
|
||||
/* ── 预设音色选择操作 ── */
|
||||
const handleSelectPresetVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onVoiceModeChange("preset")
|
||||
onSelectedVoiceChange(voiceId)
|
||||
},
|
||||
[onVoiceModeChange, onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
const handleSelectCloneVoice = useCallback(() => {
|
||||
onVoiceModeChange("clone")
|
||||
}, [onVoiceModeChange])
|
||||
|
||||
const handleSelectClonedVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onSelectedClonedVoiceChange(voiceId)
|
||||
},
|
||||
[onSelectedClonedVoiceChange],
|
||||
)
|
||||
|
||||
const handleOpenCloneModal = useCallback(() => {
|
||||
onCloneModalOpenChange(true)
|
||||
}, [onCloneModalOpenChange])
|
||||
|
||||
return {
|
||||
// 数据
|
||||
presetVoices,
|
||||
presetVoicesLoading,
|
||||
clonedVoices,
|
||||
hasProcessing,
|
||||
// 模式 & 选择
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
// AI 推荐
|
||||
voiceRecommendLoading,
|
||||
voiceRecommendations,
|
||||
hasVoiceRecommend,
|
||||
handleVoiceRecommend,
|
||||
handleSelectRecommendedVoice,
|
||||
// 音频播放
|
||||
playingVoice,
|
||||
toggleVoicePlay,
|
||||
// 预设音色操作
|
||||
handleSelectPresetVoice,
|
||||
handleSelectCloneVoice,
|
||||
// 自定义 TTS
|
||||
customVoiceText,
|
||||
setCustomVoiceText,
|
||||
customAudioUrl,
|
||||
ttsError,
|
||||
ttsJobId,
|
||||
completedTtsJobId,
|
||||
synthesizeMutation,
|
||||
handleSynthesizeVoice,
|
||||
// 存为素材
|
||||
saveModalOpen,
|
||||
setSaveModalOpen,
|
||||
saveName,
|
||||
setSaveName,
|
||||
saveTagIds,
|
||||
setSaveTagIds,
|
||||
saveNewTag,
|
||||
setSaveNewTag,
|
||||
allTags,
|
||||
saveToLibraryMutation,
|
||||
handleOpenSaveModal,
|
||||
handleConfirmSave,
|
||||
handleAddTagInModal,
|
||||
// 克隆
|
||||
cloneModalOpen,
|
||||
handleOpenCloneModal,
|
||||
handleCloneSuccess,
|
||||
handleSelectClonedVoice,
|
||||
// utils
|
||||
VOICE_GENDER_ICON,
|
||||
CLONE_STATUS_CONFIG,
|
||||
formatDuration,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep5Voice
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Step 6 封面设置 Hook
|
||||
* 封装封面设置的交互逻辑
|
||||
*/
|
||||
import { useCallback } from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { COVER_MODE_LABELS, COVER_MODE_ICONS, DEFAULT_COVER_SETTINGS } from "../constants"
|
||||
|
||||
interface UseStep6CoverProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
}
|
||||
|
||||
export function useStep6Cover({
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
duration,
|
||||
}: UseStep6CoverProps) {
|
||||
const formatTime = useCallback((seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds % 1) * 10)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
|
||||
}, [])
|
||||
|
||||
const toggleEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
onCoverSettingsChange({ ...coverSettings, enabled })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const setMode = useCallback(
|
||||
(mode: CoverConfig["mode"]) => {
|
||||
onCoverSettingsChange({ ...coverSettings, mode })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const setFrameTime = useCallback(
|
||||
(frameTime: number) => {
|
||||
onCoverSettingsChange({ ...coverSettings, frame_time: frameTime })
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const handleUpload = useCallback(
|
||||
(file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const url = ev.target?.result as string
|
||||
onCoverSettingsChange({
|
||||
...coverSettings,
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
mode: "upload",
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
const totalDuration = duration || 30
|
||||
|
||||
return {
|
||||
coverSettings,
|
||||
formatTime,
|
||||
toggleEnabled,
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
totalDuration,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
DEFAULT_COVER_SETTINGS,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep6Cover
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Step 7 确认生成 Hook
|
||||
* 封装生成确认页的展示逻辑
|
||||
*/
|
||||
import { useMemo } from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { COVER_MODE_LABELS } from "../constants"
|
||||
|
||||
interface UseStep7GenerateProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
title: string
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
presetVoices: PresetVoiceItem[]
|
||||
clonedVoices: VoiceClone[]
|
||||
coverSettings: CoverConfig
|
||||
generateCount: number
|
||||
onGenerateCountChange: (count: number) => void
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
}
|
||||
|
||||
export function useStep7Generate({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
title,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
coverSettings,
|
||||
generateCount,
|
||||
onGenerateCountChange,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
}: UseStep7GenerateProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialSummary = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
const voiceName = useMemo(() => {
|
||||
if (voiceMode === "clone") {
|
||||
const cv = clonedVoices.find((v) => v.id === selectedClonedVoice)
|
||||
return cv ? cv.name : "未选择"
|
||||
}
|
||||
const pv = presetVoices.find((v) => v.voice_id === selectedVoice)
|
||||
return pv ? pv.name : "未选择"
|
||||
}, [voiceMode, selectedVoice, selectedClonedVoice, presetVoices, clonedVoices])
|
||||
|
||||
const coverSummary = useMemo(() => {
|
||||
if (!coverSettings.enabled) return "不使用"
|
||||
return COVER_MODE_LABELS[coverSettings.mode] || "智能封面"
|
||||
}, [coverSettings])
|
||||
|
||||
const getGenerationPhase = (p: number) => {
|
||||
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
|
||||
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
|
||||
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
|
||||
return { label: "即将完成", icon: "✨" }
|
||||
}
|
||||
|
||||
const handleDecrement = () => {
|
||||
onGenerateCountChange(Math.max(1, generateCount - 1))
|
||||
}
|
||||
|
||||
const handleIncrement = () => {
|
||||
onGenerateCountChange(Math.min(10, generateCount + 1))
|
||||
}
|
||||
|
||||
const handleScrollToPreview = () => {
|
||||
const el = document.querySelector(".xx-preview-section")
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialSummary,
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generateCount,
|
||||
handleDecrement,
|
||||
handleIncrement,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
handleScrollToPreview,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep7Generate
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 时长格式化工具
|
||||
* 秒数转分秒格式,如 65 -> "1:05"
|
||||
*/
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
Regular → Executable
+96
-1459
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
import React, { useState, useRef } from "react"
|
||||
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
|
||||
import { Button, Input } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
import { type VoiceGender, type VoiceMaterial } from "../types"
|
||||
import { GENDER_OPTIONS } from "../constants"
|
||||
import { genderClass, formatFileSize } from "../utils/format"
|
||||
import TagSelector from "./TagSelector"
|
||||
|
||||
export interface MaterialFormProps {
|
||||
initial?: VoiceMaterial
|
||||
onSubmit: (data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => void
|
||||
onCancel: () => void
|
||||
loading?: boolean
|
||||
uploadProgress?: number | null
|
||||
tags?: TagItem[]
|
||||
tagMap?: Map<string, TagItem>
|
||||
onCreateTag?: (name: string) => Promise<TagItem>
|
||||
}
|
||||
|
||||
const MaterialForm: React.FC<MaterialFormProps> = ({
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading,
|
||||
uploadProgress,
|
||||
tags = [],
|
||||
tagMap = new Map(),
|
||||
onCreateTag,
|
||||
}) => {
|
||||
const [name, setName] = useState(initial?.name ?? "")
|
||||
const [description, setDescription] = useState(initial?.description ?? "")
|
||||
const [gender, setGender] = useState<VoiceGender>(initial?.gender ?? "female")
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(initial?.tagIds ?? [])
|
||||
const [file, setFile] = useState<File | undefined>(undefined)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name.trim()) return
|
||||
if (!initial && !file) return
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
gender,
|
||||
tagIds: selectedTagIds,
|
||||
fileName: file?.name ?? initial?.fileName ?? "",
|
||||
fileSize: file?.size ?? initial?.fileSize ?? 0,
|
||||
duration: initial?.duration ?? 0,
|
||||
mimeType: file?.type ?? initial?.mimeType ?? "audio/mpeg",
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vmat-form">
|
||||
{/* 音频文件上传(编辑模式不显示) */}
|
||||
{!initial && (
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">音频文件 *</label>
|
||||
<div
|
||||
className="vmat-upload-zone"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (f?.type.startsWith("audio/")) setFile(f)
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) setFile(f)
|
||||
}}
|
||||
/>
|
||||
{file ? (
|
||||
<div className="vmat-upload-selected">
|
||||
<SoundOutlined className="vmat-upload-icon" />
|
||||
<span className="vmat-upload-filename">{file.name}</span>
|
||||
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-upload-clear"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setFile(undefined)
|
||||
}}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vmat-upload-placeholder">
|
||||
<UploadOutlined className="vmat-upload-icon" />
|
||||
<p>点击或拖拽音频文件到此处</p>
|
||||
<span>支持 MP3、WAV、AAC、FLAC 等格式</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 上传进度条 */}
|
||||
{uploadProgress !== null && uploadProgress !== undefined && (
|
||||
<div className="vmat-upload-progress">
|
||||
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
|
||||
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 名称 */}
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">名称 *</label>
|
||||
<Input
|
||||
placeholder="输入配音素材名称"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色描述 */}
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">音色描述</label>
|
||||
<Input.TextArea
|
||||
placeholder="描述音色特点,如:适合产品宣传的男声配音..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别 */}
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">性别</label>
|
||||
<div className="vmat-gender-group">
|
||||
{GENDER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`vmat-gender-btn${gender === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
|
||||
onClick={() => setGender(opt.value)}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 风格标签 */}
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">风格标签</label>
|
||||
<TagSelector
|
||||
value={selectedTagIds}
|
||||
onChange={setSelectedTagIds}
|
||||
tags={tags}
|
||||
tagMap={tagMap}
|
||||
onCreateTag={onCreateTag ?? (async () => ({ id: "", name: "" }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="vmat-form-actions">
|
||||
<Button buttonType="ghost" buttonSize="md" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={!name.trim() || (!initial && !file)}
|
||||
>
|
||||
{initial ? "保存修改" : "上传"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MaterialForm
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { useState, useRef, useCallback, useMemo } from "react"
|
||||
import { CheckOutlined } from "@ant-design/icons"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
|
||||
export interface TagSelectorProps {
|
||||
/** 已选标签 ID 列表 */
|
||||
value: string[]
|
||||
onChange: (tagIds: string[]) => void
|
||||
/** 所有可用标签(来自 API) */
|
||||
tags: TagItem[]
|
||||
/** 标签 ID → TagItem 映射 */
|
||||
tagMap: Map<string, TagItem>
|
||||
/** 创建新标签,返回带 ID 的 TagItem */
|
||||
onCreateTag: (name: string) => Promise<TagItem>
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const TagSelector: React.FC<TagSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
tags,
|
||||
tagMap,
|
||||
onCreateTag,
|
||||
placeholder = "输入标签后回车添加",
|
||||
}) => {
|
||||
const [inputVal, setInputVal] = useState("")
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/** 按名称查找已有标签(大小写不敏感) */
|
||||
const findTagByName = useCallback(
|
||||
(name: string) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase()),
|
||||
[tags],
|
||||
)
|
||||
|
||||
/** 去重添加标签(按 ID) */
|
||||
const addTagId = useCallback(
|
||||
(tagId: string) => {
|
||||
if (value.includes(tagId)) return
|
||||
onChange([...value, tagId])
|
||||
setInputVal("")
|
||||
setShowSuggestions(false)
|
||||
},
|
||||
[value, onChange],
|
||||
)
|
||||
|
||||
/** 输入自定义标签名:若已存在则直接选,否则创建新标签 */
|
||||
const addTagByName = useCallback(
|
||||
async (name: string) => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
const existing = findTagByName(trimmed)
|
||||
if (existing) {
|
||||
addTagId(existing.id)
|
||||
} else {
|
||||
try {
|
||||
const created = await onCreateTag(trimmed)
|
||||
addTagId(created.id)
|
||||
} catch {
|
||||
/* 创建失败静默忽略 */
|
||||
}
|
||||
}
|
||||
},
|
||||
[findTagByName, addTagId, onCreateTag],
|
||||
)
|
||||
|
||||
const removeTagId = useCallback(
|
||||
(tagId: string) => {
|
||||
onChange(value.filter((t) => t !== tagId))
|
||||
},
|
||||
[value, onChange],
|
||||
)
|
||||
|
||||
/** 输入补全建议(排除已选) */
|
||||
const suggestions = useMemo(() => {
|
||||
if (!inputVal.trim()) return []
|
||||
const lower = inputVal.toLowerCase()
|
||||
return tags.filter((t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id))
|
||||
}, [inputVal, tags, value])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
if (suggestions.length > 0) {
|
||||
addTagId(suggestions[0].id)
|
||||
} else {
|
||||
addTagByName(inputVal)
|
||||
}
|
||||
} else if (e.key === "Backspace" && !inputVal && value.length > 0) {
|
||||
removeTagId(value[value.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vmat-tag-selector-wrapper">
|
||||
<div className="vmat-tag-selector" onClick={() => inputRef.current?.focus()}>
|
||||
{value.map((tagId) => (
|
||||
<Tag key={tagId} variant="info" closable onClose={() => removeTagId(tagId)}>
|
||||
{tagMap.get(tagId)?.name ?? tagId}
|
||||
</Tag>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="vmat-tag-selector-input"
|
||||
value={inputVal}
|
||||
onChange={(e) => {
|
||||
setInputVal(e.target.value)
|
||||
setShowSuggestions(true)
|
||||
}}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 自动补全下拉 */}
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="vmat-tag-suggestions">
|
||||
{suggestions.slice(0, 6).map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className="vmat-tag-suggestion-item"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
addTagId(tag.id)
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已有标签快捷选择 */}
|
||||
{tags.length > 0 && (
|
||||
<div className="vmat-tag-selector-presets">
|
||||
{tags.map((tag) => {
|
||||
const isSelected = value.includes(tag.id)
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className={`vmat-tag-selector-preset${isSelected ? " selected" : ""}`}
|
||||
onClick={() => {
|
||||
if (isSelected) removeTagId(tag.id)
|
||||
else addTagId(tag.id)
|
||||
}}
|
||||
>
|
||||
{isSelected && <CheckOutlined style={{ fontSize: 10, marginRight: 2 }} />}
|
||||
{tag.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagSelector
|
||||
@@ -0,0 +1,245 @@
|
||||
import React, { useRef } from "react"
|
||||
import {
|
||||
AudioOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CheckOutlined,
|
||||
SoundOutlined,
|
||||
MutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Tooltip } from "antd"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
import { type VoiceMaterial } from "../types"
|
||||
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../constants"
|
||||
import {
|
||||
genderClass,
|
||||
genderIcon,
|
||||
genderLabel,
|
||||
formatDuration,
|
||||
formatFileSize,
|
||||
formatDate,
|
||||
} from "../utils/format"
|
||||
|
||||
export interface VoiceCardProps {
|
||||
material: VoiceMaterial
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
volume: number
|
||||
tagMap: Map<string, TagItem>
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek: (time: number) => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onToggleMute: () => void
|
||||
}
|
||||
|
||||
const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
|
||||
material,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
isSelected,
|
||||
batchMode,
|
||||
volume,
|
||||
tagMap,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleSelect,
|
||||
onVolumeChange,
|
||||
onToggleMute,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
e.preventDefault()
|
||||
const doSeek = (ev: MouseEvent) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * material.duration)
|
||||
}
|
||||
doSeek(e.nativeEvent)
|
||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||
const handleUp = () => {
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleUp)
|
||||
}
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
}
|
||||
|
||||
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(material.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 批量选择 checkbox */}
|
||||
{(batchMode || isSelected) && (
|
||||
<div
|
||||
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(material.id)
|
||||
}}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="vmat-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-card-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
title="编辑"
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-card-action-btn vmat-card-action-btn--danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 头部:图标 + 名称 + 性别 */}
|
||||
<div className="vmat-card-header">
|
||||
<div className="vmat-card-avatar">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div className="vmat-card-title-area">
|
||||
<h4 className="vmat-card-name" title={material.name}>
|
||||
{material.name}
|
||||
</h4>
|
||||
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
|
||||
{genderIcon(material.gender)}
|
||||
{genderLabel(material.gender)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
{material.description && <p className="vmat-card-desc">{material.description}</p>}
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="vmat-card-tags">
|
||||
{material.tagIds.length === 0 ? (
|
||||
<span
|
||||
className="vmat-tag-empty"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
添加标签
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{material.tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
|
||||
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
|
||||
{tagMap.get(tagId)?.name ?? tagId}
|
||||
</Tag>
|
||||
))}
|
||||
{material.tagIds.length > MAX_CARD_TAGS && (
|
||||
<Tooltip
|
||||
title={material.tagIds
|
||||
.slice(MAX_CARD_TAGS)
|
||||
.map((id) => tagMap.get(id)?.name ?? id)
|
||||
.join("、")}
|
||||
>
|
||||
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_CARD_TAGS}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="vmat-card-meta">
|
||||
<span>{formatDuration(material.duration)}</span>
|
||||
<span>{formatFileSize(material.fileSize)}</span>
|
||||
<span>{formatDate(material.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* 播放控制 */}
|
||||
<div className="vmat-card-player">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-play-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
disabled={!material.fileUrl}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
|
||||
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
|
||||
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
|
||||
</div>
|
||||
<span className="vmat-time">
|
||||
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
|
||||
</span>
|
||||
{/* 音量控制 */}
|
||||
<div className="vmat-volume">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-volume-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleMute()
|
||||
}}
|
||||
title={volume === 0 ? "取消静音" : "静音"}
|
||||
>
|
||||
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="vmat-volume-slider"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
onVolumeChange(e)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceMaterialCard
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useRef } from "react"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CheckOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Tooltip } from "antd"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
import { type VoiceMaterial } from "../types"
|
||||
import { MAX_ROW_TAGS, TAG_VARIANTS } from "../constants"
|
||||
import {
|
||||
genderClass,
|
||||
genderIcon,
|
||||
genderLabel,
|
||||
formatDuration,
|
||||
formatFileSize,
|
||||
} from "../utils/format"
|
||||
|
||||
export interface VoiceRowProps {
|
||||
material: VoiceMaterial
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
tagMap: Map<string, TagItem>
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek: (time: number) => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onToggleSelect: (id: string) => void
|
||||
}
|
||||
|
||||
const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
|
||||
material,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
isSelected,
|
||||
batchMode,
|
||||
tagMap,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleSelect,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
e.preventDefault()
|
||||
const doSeek = (ev: MouseEvent) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * material.duration)
|
||||
}
|
||||
doSeek(e.nativeEvent)
|
||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||
const handleUp = () => {
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleUp)
|
||||
}
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
}
|
||||
|
||||
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`vmat-row ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
|
||||
>
|
||||
{/* 批量选择 checkbox */}
|
||||
{(batchMode || isSelected) && (
|
||||
<div
|
||||
className={`vmat-row-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(material.id)
|
||||
}}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-row-play"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
disabled={!material.fileUrl}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 名称 + 描述 */}
|
||||
<div className="vmat-row-info">
|
||||
<h4 className="vmat-row-name">{material.name}</h4>
|
||||
{material.description && <p className="vmat-row-desc">{material.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* 性别 */}
|
||||
<span className={`vmat-row-gender ${genderClass(material.gender)}`}>
|
||||
{genderIcon(material.gender)}
|
||||
{genderLabel(material.gender)}
|
||||
</span>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="vmat-row-tags">
|
||||
{material.tagIds.length === 0 ? (
|
||||
<span className="vmat-tag-empty" onClick={() => onEdit()}>
|
||||
添加标签
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{material.tagIds.slice(0, MAX_ROW_TAGS).map((tagId, i) => (
|
||||
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
|
||||
{tagMap.get(tagId)?.name ?? tagId}
|
||||
</Tag>
|
||||
))}
|
||||
{material.tagIds.length > MAX_ROW_TAGS && (
|
||||
<Tooltip
|
||||
title={material.tagIds
|
||||
.slice(MAX_ROW_TAGS)
|
||||
.map((id) => tagMap.get(id)?.name ?? id)
|
||||
.join("、")}
|
||||
>
|
||||
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_ROW_TAGS}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 进度条(可拖拽) */}
|
||||
<div ref={progressRef} className="vmat-row-progress" onMouseDown={handleProgressMouseDown}>
|
||||
<div className="vmat-row-progress-bar" style={{ width: `${progress}%` }} />
|
||||
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
|
||||
</div>
|
||||
|
||||
{/* 时长 */}
|
||||
<span className="vmat-row-time">
|
||||
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
|
||||
</span>
|
||||
|
||||
{/* 文件大小 */}
|
||||
<span className="vmat-row-size">{formatFileSize(material.fileSize)}</span>
|
||||
|
||||
{/* 操作 */}
|
||||
<div className="vmat-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-row-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
title="编辑"
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-row-action-btn vmat-row-action-btn--danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceMaterialRow
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 配音素材库常量
|
||||
*/
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React from "react"
|
||||
import { ManOutlined, WomanOutlined, UserOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import type { VoiceGender } from "./types"
|
||||
|
||||
/** 标签溢出限制 */
|
||||
export const MAX_CARD_TAGS = 3
|
||||
export const MAX_ROW_TAGS = 2
|
||||
export const TAG_VARIANTS = ["info", "primary", "success", "warning", "error"] as const
|
||||
|
||||
/** 性别选项 */
|
||||
export const GENDER_OPTIONS: {
|
||||
value: VoiceGender
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
}[] = [
|
||||
{ value: "male", label: "男声", icon: <ManOutlined /> },
|
||||
{ value: "female", label: "女声", icon: <WomanOutlined /> },
|
||||
{ value: "child", label: "童声", icon: <UserOutlined /> },
|
||||
{ value: "neutral", label: "中性", icon: <SoundOutlined /> },
|
||||
]
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { VoiceMaterial } from "../types"
|
||||
|
||||
/**
|
||||
* 音频播放控制 Hook
|
||||
* 封装当前播放音频状态、播放/暂停、进度控制、音量控制
|
||||
*/
|
||||
export function useAudioPlayer() {
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [volume, setVolume] = useState(0.7)
|
||||
const [pausedMaterial, setPausedMaterial] = useState<VoiceMaterial | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
/** 停止当前播放并重置状态 */
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
setCurrentTime(0)
|
||||
setPausedMaterial(null)
|
||||
}, [])
|
||||
|
||||
/** 从头开始播放指定素材 */
|
||||
const startPlayback = useCallback(
|
||||
(material: VoiceMaterial) => {
|
||||
if (!material.fileUrl) return
|
||||
stopPlayback()
|
||||
|
||||
const audio = new Audio(material.fileUrl)
|
||||
audio.volume = volume
|
||||
audioRef.current = audio
|
||||
|
||||
audio.addEventListener("timeupdate", () => {
|
||||
setCurrentTime(audio.currentTime)
|
||||
})
|
||||
|
||||
audio.addEventListener("ended", () => {
|
||||
setPlayingId(null)
|
||||
setCurrentTime(0)
|
||||
audioRef.current = null
|
||||
setPausedMaterial(null)
|
||||
})
|
||||
|
||||
audio.play().catch(() => {
|
||||
audioRef.current = null
|
||||
setPlayingId(null)
|
||||
})
|
||||
|
||||
setPlayingId(material.id)
|
||||
setCurrentTime(0)
|
||||
setPausedMaterial(null)
|
||||
},
|
||||
[stopPlayback, volume],
|
||||
)
|
||||
|
||||
/** 播放素材(若为暂停状态则恢复) */
|
||||
const handlePlay = useCallback(
|
||||
(material: VoiceMaterial) => {
|
||||
if (playingId === material.id) return
|
||||
// 恢复暂停
|
||||
if (pausedMaterial?.id === material.id && audioRef.current && audioRef.current.paused) {
|
||||
audioRef.current.play().catch(() => {})
|
||||
setPlayingId(material.id)
|
||||
setPausedMaterial(null)
|
||||
return
|
||||
}
|
||||
startPlayback(material)
|
||||
},
|
||||
[playingId, pausedMaterial, startPlayback],
|
||||
)
|
||||
|
||||
/** 暂停播放 */
|
||||
const handlePause = useCallback((material?: VoiceMaterial) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
setPlayingId(null)
|
||||
if (material) setPausedMaterial(material)
|
||||
}, [])
|
||||
|
||||
/** 跳转到指定播放时间 */
|
||||
const handleSeek = useCallback(
|
||||
(material: VoiceMaterial, time: number) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = time
|
||||
setCurrentTime(time)
|
||||
} else {
|
||||
startPlayback(material)
|
||||
setTimeout(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = time
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
[startPlayback],
|
||||
)
|
||||
|
||||
/** 音量调节 */
|
||||
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = parseFloat(e.target.value)
|
||||
setVolume(v)
|
||||
if (audioRef.current) audioRef.current.volume = v
|
||||
}, [])
|
||||
|
||||
/** 静音/取消静音切换 */
|
||||
const toggleMute = useCallback(() => {
|
||||
if (volume > 0) {
|
||||
setVolume(0)
|
||||
if (audioRef.current) audioRef.current.volume = 0
|
||||
} else {
|
||||
setVolume(0.7)
|
||||
if (audioRef.current) audioRef.current.volume = 0.7
|
||||
}
|
||||
}, [volume])
|
||||
|
||||
// 组件卸载时清理 audio
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
playingId,
|
||||
currentTime,
|
||||
volume,
|
||||
pausedMaterial,
|
||||
stopPlayback,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleSeek,
|
||||
handleVolumeChange,
|
||||
toggleMute,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { deleteAsset } from "@/api/assets"
|
||||
import { type TagItem, createTag, tagAsset } from "@/api/tags"
|
||||
import type { VoiceMaterial } from "../types"
|
||||
|
||||
/**
|
||||
* 批量操作 Hook
|
||||
* 封装批量选择、批量删除、批量打标签等逻辑
|
||||
*/
|
||||
interface UseBatchOperationsProps {
|
||||
/** 当前筛选后的素材列表 */
|
||||
filtered: VoiceMaterial[]
|
||||
/** 标签 ID → TagItem 映射 */
|
||||
tagMap: Map<string, TagItem>
|
||||
/** 所有可用标签 */
|
||||
tags: TagItem[]
|
||||
/** 当前播放中的素材 ID */
|
||||
playingId: string | null
|
||||
/** 停止播放回调 */
|
||||
stopPlayback: () => void
|
||||
}
|
||||
|
||||
export function useBatchOperations({
|
||||
filtered,
|
||||
tagMap,
|
||||
tags,
|
||||
playingId,
|
||||
stopPlayback,
|
||||
}: UseBatchOperationsProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [batchCustomTag, setBatchCustomTag] = useState("")
|
||||
|
||||
const batchMode = useMemo(() => selectedIds.size > 0, [selectedIds])
|
||||
const allSelected = useMemo(
|
||||
() => filtered.length > 0 && filtered.every((m) => selectedIds.has(m.id)),
|
||||
[filtered, selectedIds],
|
||||
)
|
||||
|
||||
/** 切换单个素材的选中状态 */
|
||||
const handleToggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** 全选 / 取消全选 */
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (allSelected) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(filtered.map((m) => m.id)))
|
||||
}, [allSelected, filtered])
|
||||
|
||||
/** 批量删除 */
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
let successCount = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await deleteAsset(id)
|
||||
successCount++
|
||||
} catch {
|
||||
/* ignore individual failures */
|
||||
}
|
||||
if (playingId === id) stopPlayback()
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
setSelectedIds(new Set())
|
||||
message.success(`已批量删除 ${successCount}/${ids.length} 个素材`)
|
||||
}, [selectedIds, playingId, stopPlayback, queryClient])
|
||||
|
||||
/** 批量打标签(已有标签) */
|
||||
const handleBatchTag = useCallback(
|
||||
async (tagId: string) => {
|
||||
const ids = Array.from(selectedIds)
|
||||
let successCount = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await tagAsset(id, [tagId])
|
||||
successCount++
|
||||
} catch {
|
||||
/* ignore individual failures */
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
setSelectedIds(new Set())
|
||||
const tagName = tagMap.get(tagId)?.name ?? tagId
|
||||
if (successCount === 0) {
|
||||
message.error(`批量打标签失败,请重试`)
|
||||
} else {
|
||||
message.success(`已为 ${successCount}/${ids.length} 个素材添加标签「${tagName}」`)
|
||||
}
|
||||
},
|
||||
[selectedIds, queryClient, tagMap],
|
||||
)
|
||||
|
||||
/** 批量打标签(自定义输入:按名称查找或创建标签,再批量打标) */
|
||||
const handleBatchCustomTag = useCallback(
|
||||
async (name: string) => {
|
||||
// 先查找同名标签(不区分大小写)
|
||||
let existing = tags.find((t) => t.name.toLowerCase() === name.toLowerCase())
|
||||
if (!existing) {
|
||||
try {
|
||||
existing = await createTag(name)
|
||||
} catch {
|
||||
message.error(`创建标签「${name}」失败`)
|
||||
return
|
||||
}
|
||||
}
|
||||
await handleBatchTag(existing.id)
|
||||
},
|
||||
[tags, handleBatchTag],
|
||||
)
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
batchCustomTag,
|
||||
setBatchCustomTag,
|
||||
handleToggleSelect,
|
||||
handleSelectAll,
|
||||
handleBatchDelete,
|
||||
handleBatchTag,
|
||||
handleBatchCustomTag,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices"
|
||||
|
||||
/**
|
||||
* TTS 合成 Hook
|
||||
* 封装合成弹窗状态、合成请求、轮询、保存到素材库等逻辑
|
||||
*/
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
export function useTtsSynthesize() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [ttsOpen, setTtsOpen] = useState(false)
|
||||
const [ttsText, setTtsText] = useState("")
|
||||
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
|
||||
const [ttsSpeed, setTtsSpeed] = useState(1.0)
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
|
||||
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null)
|
||||
const [ttsError, setTtsError] = useState<string | null>(null)
|
||||
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// 预设音色列表
|
||||
const { data: presetVoicesData } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? []
|
||||
|
||||
/** 开始 AI 配音合成 */
|
||||
const handleTtsSynthesize = useCallback(async () => {
|
||||
if (!ttsText.trim()) {
|
||||
message.warning("请输入要合成的文本")
|
||||
return
|
||||
}
|
||||
setTtsError(null)
|
||||
setTtsStatus("synthesizing")
|
||||
setTtsAudioUrl(null)
|
||||
setTtsJobId(null)
|
||||
|
||||
try {
|
||||
const resp = await synthesizeSpeech({
|
||||
text: ttsText.trim(),
|
||||
voice_id: ttsVoiceId || undefined,
|
||||
speed: ttsSpeed,
|
||||
})
|
||||
setTtsJobId(resp.job_id)
|
||||
|
||||
// 轮询任务状态
|
||||
ttsTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const job = await getTTSJobStatus(resp.job_id)
|
||||
if (job.status === "completed") {
|
||||
clearInterval(ttsTimerRef.current!)
|
||||
ttsTimerRef.current = null
|
||||
setTtsStatus("done")
|
||||
setTtsAudioUrl(job.output_audio_url)
|
||||
} else if (job.status === "failed") {
|
||||
clearInterval(ttsTimerRef.current!)
|
||||
ttsTimerRef.current = null
|
||||
setTtsStatus("error")
|
||||
setTtsError(job.error_message || "合成失败")
|
||||
}
|
||||
} catch {
|
||||
clearInterval(ttsTimerRef.current!)
|
||||
ttsTimerRef.current = null
|
||||
setTtsStatus("error")
|
||||
setTtsError("查询合成状态失败")
|
||||
}
|
||||
}, 2000)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "合成请求失败"
|
||||
setTtsStatus("error")
|
||||
setTtsError(msg)
|
||||
}
|
||||
}, [ttsText, ttsVoiceId, ttsSpeed])
|
||||
|
||||
/** 保存 TTS 结果到素材库 */
|
||||
const handleTtsSave = useCallback(async () => {
|
||||
if (!ttsJobId) return
|
||||
try {
|
||||
await saveTtsToLibrary(ttsJobId, {
|
||||
name: ttsText.slice(0, 20) || "AI配音",
|
||||
})
|
||||
message.success("已保存到配音库")
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
setTtsOpen(false)
|
||||
} catch {
|
||||
message.error("保存失败")
|
||||
}
|
||||
}, [ttsJobId, ttsText, queryClient])
|
||||
|
||||
/** 关闭 TTS 弹窗并清理状态 */
|
||||
const handleTtsClose = useCallback(() => {
|
||||
setTtsOpen(false)
|
||||
if (ttsTimerRef.current) {
|
||||
clearInterval(ttsTimerRef.current)
|
||||
ttsTimerRef.current = null
|
||||
}
|
||||
setTtsStatus("idle")
|
||||
setTtsAudioUrl(null)
|
||||
setTtsError(null)
|
||||
setTtsJobId(null)
|
||||
}, [])
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
ttsOpen,
|
||||
ttsText,
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsJobId,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
setTtsOpen,
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
setTtsSpeed,
|
||||
handleTtsSynthesize,
|
||||
handleTtsSave,
|
||||
handleTtsClose,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
getAssetsByKind,
|
||||
createAsset,
|
||||
updateAsset,
|
||||
deleteAsset,
|
||||
uploadAssetDirect,
|
||||
getAssetLibraries,
|
||||
createAssetLibrary,
|
||||
} from "@/api/assets"
|
||||
import { type TagItem, getTags, createTag, tagAsset, untagAsset } from "@/api/tags"
|
||||
import {
|
||||
type VoiceGender,
|
||||
type ViewMode,
|
||||
type VoiceMaterial,
|
||||
mapAssetToMaterial,
|
||||
buildMetadata,
|
||||
} from "../types"
|
||||
import { getAudioDuration } from "../utils/audio"
|
||||
|
||||
/**
|
||||
* 配音素材数据 Hook
|
||||
* 封装素材列表查询、筛选状态管理、增删改等数据操作逻辑
|
||||
*/
|
||||
export function useVoiceMaterials() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// ── 获取 voice 类型素材库(用于上传) ──────────────────────
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const voiceLibrary = useMemo(() => libraries.find((lib) => lib.kind === "voice"), [libraries])
|
||||
|
||||
// 自动创建 voice 素材库(如果不存在)
|
||||
const createLibMutation = useMutation({
|
||||
mutationFn: () => createAssetLibrary({ name: "配音库", kind: "voice" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (libraries.length > 0 && !voiceLibrary && !createLibMutation.isPending) {
|
||||
createLibMutation.mutate()
|
||||
}
|
||||
}, [libraries, voiceLibrary, createLibMutation])
|
||||
|
||||
// ── 获取标签列表 ───────────────────────────────────────────
|
||||
const { data: tags = [] } = useQuery({
|
||||
queryKey: ["tags"],
|
||||
queryFn: getTags,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
/** 标签 ID → TagItem 映射(用于卡片/行渲染) */
|
||||
const tagMap = useMemo(() => {
|
||||
const m = new Map<string, TagItem>()
|
||||
tags.forEach((t) => m.set(t.id, t))
|
||||
return m
|
||||
}, [tags])
|
||||
|
||||
/** 创建标签 mutation(供 TagSelector 调用) */
|
||||
const createTagMutation = useMutation({
|
||||
mutationFn: (name: string) => createTag(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
||||
},
|
||||
})
|
||||
|
||||
/** 创建标签并返回 TagItem(供 TagSelector 使用) */
|
||||
const handleCreateTag = useCallback(
|
||||
async (name: string): Promise<TagItem> => {
|
||||
return createTagMutation.mutateAsync(name)
|
||||
},
|
||||
[createTagMutation],
|
||||
)
|
||||
|
||||
// ── 视图 & 筛选状态 ────────────────────────────────────────
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("card")
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterGender, setFilterGender] = useState<string>("all")
|
||||
const [filterTagId, setFilterTagId] = useState<string>("all")
|
||||
|
||||
// ── 获取配音素材列表(筛选参数透传后端) ─────────────────
|
||||
const filterKeyword = searchText.trim() || undefined
|
||||
const filterGenderParam = filterGender !== "all" ? filterGender : undefined
|
||||
const filterTagIdsParam = filterTagId !== "all" ? [filterTagId] : undefined
|
||||
|
||||
const { data: assets = [], isLoading } = useQuery({
|
||||
queryKey: [
|
||||
"assets",
|
||||
"voice",
|
||||
{
|
||||
keyword: filterKeyword,
|
||||
gender: filterGenderParam,
|
||||
tag_ids: filterTagIdsParam,
|
||||
},
|
||||
],
|
||||
queryFn: () =>
|
||||
getAssetsByKind("voice", {
|
||||
keyword: filterKeyword,
|
||||
gender: filterGenderParam,
|
||||
tag_ids: filterTagIdsParam,
|
||||
}),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets])
|
||||
|
||||
// ── 弹窗状态 ──────────────────────────────────────────────
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null)
|
||||
|
||||
// ── 上传进度 ──────────────────────────────────────────────
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
|
||||
// ── 上传 mutation ─────────────────────────────────────────
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
file: File
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
tagIds: string[]
|
||||
}) => {
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
// 1. 获取或等待 voice library
|
||||
let lib = voiceLibrary
|
||||
if (!lib) {
|
||||
if (createLibMutation.isPending) {
|
||||
await createLibMutation.mutateAsync()
|
||||
}
|
||||
const libs = await queryClient.fetchQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
lib = libs.find((l) => l.kind === "voice")
|
||||
if (!lib) throw new Error("无法创建配音库")
|
||||
}
|
||||
|
||||
// 2. 上传文件(带进度)
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
// 3. 获取音频时长
|
||||
const duration = await getAudioDuration(data.file)
|
||||
|
||||
// 4. 创建素材记录
|
||||
const asset = await createAsset({
|
||||
library_id: lib.id,
|
||||
name: data.name,
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
})
|
||||
|
||||
// 5. 打标签(标签走独立 API)
|
||||
if (data.tagIds.length > 0) {
|
||||
await tagAsset(asset.id, data.tagIds)
|
||||
}
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(err.message || "上传失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
// ── 编辑 mutation ─────────────────────────────────────────
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
id: string
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
tagIds: string[]
|
||||
}) => {
|
||||
// 1. 更新基础信息
|
||||
await updateAsset(data.id, {
|
||||
name: data.name,
|
||||
metadata: buildMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
}),
|
||||
})
|
||||
|
||||
// 2. 对比标签差异,调用 tag/untag API
|
||||
const currentAsset = materials.find((m) => m.id === data.id)
|
||||
const oldTagIds = currentAsset?.tagIds ?? []
|
||||
const newTagIds = data.tagIds
|
||||
|
||||
const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id))
|
||||
const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id))
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
await tagAsset(data.id, toAdd)
|
||||
}
|
||||
for (const tagId of toRemove) {
|
||||
await untagAsset(data.id, tagId)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
||||
},
|
||||
})
|
||||
|
||||
// ── 删除 mutation ─────────────────────────────────────────
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (assetId: string) => deleteAsset(assetId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 前端二次筛选(与后端筛选同时存在) ──────────────────── */
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = materials
|
||||
if (filterGender !== "all") {
|
||||
list = list.filter((m) => m.gender === filterGender)
|
||||
}
|
||||
if (filterTagId !== "all") {
|
||||
list = list.filter((m) => m.tagIds.includes(filterTagId))
|
||||
}
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.description.toLowerCase().includes(q) ||
|
||||
m.tagIds.some((id) => tagMap.get(id)?.name?.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [materials, filterGender, filterTagId, searchText, tagMap])
|
||||
|
||||
/* ── 标签使用计数(药丸条展示,按 tag ID 统计) ──────────── */
|
||||
|
||||
const tagCountMap = useMemo(() => {
|
||||
const map: Record<string, number> = {}
|
||||
materials.forEach((m) =>
|
||||
m.tagIds.forEach((id) => {
|
||||
map[id] = (map[id] || 0) + 1
|
||||
}),
|
||||
)
|
||||
return map
|
||||
}, [materials])
|
||||
|
||||
/* ── 数据操作 handlers ──────────────────────────────────── */
|
||||
|
||||
const handleUpload = useCallback(
|
||||
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
|
||||
if (!data.file) return
|
||||
uploadMutation.mutate(
|
||||
{
|
||||
file: data.file,
|
||||
name: data.name,
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
tagIds: data.tagIds,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUploadOpen(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
[uploadMutation],
|
||||
)
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
|
||||
if (!editingMaterial) return
|
||||
editMutation.mutate({
|
||||
id: editingMaterial.id,
|
||||
name: data.name,
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
tagIds: data.tagIds,
|
||||
})
|
||||
setEditingMaterial(null)
|
||||
},
|
||||
[editingMaterial, editMutation],
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string, onBeforeDelete?: () => void) => {
|
||||
const material = materials.find((m) => m.id === id)
|
||||
if (!material) return
|
||||
if (onBeforeDelete) onBeforeDelete()
|
||||
deleteMutation.mutate(id)
|
||||
},
|
||||
[materials, deleteMutation],
|
||||
)
|
||||
|
||||
return {
|
||||
// 数据
|
||||
libraries,
|
||||
voiceLibrary,
|
||||
tags,
|
||||
tagMap,
|
||||
materials,
|
||||
filtered,
|
||||
tagCountMap,
|
||||
isLoading,
|
||||
// 视图 & 筛选状态
|
||||
viewMode,
|
||||
searchText,
|
||||
filterGender,
|
||||
filterTagId,
|
||||
// 上传 & 编辑状态
|
||||
uploadProgress,
|
||||
isUploading: uploadMutation.isPending,
|
||||
isEditing: editMutation.isPending,
|
||||
// 弹窗状态
|
||||
uploadOpen,
|
||||
editingMaterial,
|
||||
// 视图控制
|
||||
setViewMode,
|
||||
setSearchText,
|
||||
setFilterGender,
|
||||
setFilterTagId,
|
||||
setUploadOpen,
|
||||
setEditingMaterial,
|
||||
// 操作
|
||||
handleCreateTag,
|
||||
handleUpload,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 配音素材库类型定义
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
export type VoiceGender = "male" | "female" | "child" | "neutral"
|
||||
export type ViewMode = "card" | "list"
|
||||
|
||||
/** 前端配音素材数据模型(从 AssetItem 映射) */
|
||||
export interface VoiceMaterial {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
gender: VoiceGender
|
||||
tagIds: string[]
|
||||
fileName: string
|
||||
fileSize: number
|
||||
duration: number
|
||||
mimeType: string
|
||||
createdAt: string
|
||||
fileUrl?: string
|
||||
}
|
||||
|
||||
/** 配音素材上传元数据(传递给 createAsset 的 metadata) */
|
||||
export interface VoiceAssetMetadata {
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
duration: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 后端 AssetItem → 前端 VoiceMaterial */
|
||||
export const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
|
||||
const meta = asset.metadata || {}
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
description: (meta.description as string) || "",
|
||||
gender: (meta.gender as VoiceGender) || "neutral",
|
||||
tagIds: Array.isArray(asset.tag_ids) ? asset.tag_ids : [],
|
||||
fileName: asset.storage_key?.split("/").pop() || asset.name,
|
||||
fileSize: asset.file_size || 0,
|
||||
duration: (meta.duration as number) || 0,
|
||||
mimeType: asset.mime_type || "audio/mpeg",
|
||||
createdAt: asset.created_at || new Date().toISOString(),
|
||||
fileUrl: asset.file_url,
|
||||
}
|
||||
}
|
||||
|
||||
/** 前端表单数据 → 后端 metadata(标签走独立 API,不再写 metadata.style) */
|
||||
export const buildMetadata = (data: {
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
duration?: number
|
||||
}): VoiceAssetMetadata => ({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration: data.duration || 0,
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 音频相关工具函数
|
||||
*/
|
||||
|
||||
/** 获取音频文件时长(秒) */
|
||||
export const getAudioDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve) => {
|
||||
const audio = new Audio()
|
||||
const url = URL.createObjectURL(file)
|
||||
audio.addEventListener("loadedmetadata", () => {
|
||||
resolve(audio.duration)
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
audio.addEventListener("error", () => {
|
||||
resolve(0)
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
audio.src = url
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 格式化工具函数
|
||||
*/
|
||||
import { GENDER_OPTIONS } from "../constants"
|
||||
import type { VoiceGender } from "../types"
|
||||
|
||||
export const genderLabel = (g: VoiceGender) => GENDER_OPTIONS.find((o) => o.value === g)?.label ?? g
|
||||
|
||||
export const genderIcon = (g: VoiceGender) =>
|
||||
GENDER_OPTIONS.find((o) => o.value === g)?.icon ?? null
|
||||
|
||||
export const genderClass = (g: VoiceGender) => `vmat-gender--${g}`
|
||||
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export const formatDate = (iso: string): string =>
|
||||
new Date(iso).toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
import React from "react"
|
||||
|
||||
/** 克隆音色卡片骨架屏 */
|
||||
const CloneCardSkeleton: React.FC = () => (
|
||||
<div className="xx-voice-card xx-skeleton-clone">
|
||||
<div className="xx-skeleton-clone-avatar" />
|
||||
<div className="xx-skeleton-clone-info">
|
||||
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--name" />
|
||||
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--status" />
|
||||
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--desc" />
|
||||
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--meta" />
|
||||
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--footer" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default CloneCardSkeleton
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import React from "react"
|
||||
import {
|
||||
SoundOutlined,
|
||||
CloseCircleOutlined,
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
|
||||
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
|
||||
|
||||
export interface CloneDetailModalProps {
|
||||
voice: ClonedVoiceDisplay
|
||||
onClose: () => void
|
||||
onUse: () => void
|
||||
onDelete: () => void
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
/** 克隆音色详情弹窗 */
|
||||
const CloneDetailModal: React.FC<CloneDetailModalProps> = ({
|
||||
voice,
|
||||
onClose,
|
||||
onUse,
|
||||
onDelete,
|
||||
onRetry,
|
||||
}) => {
|
||||
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
|
||||
const genderText =
|
||||
voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender || "未知"
|
||||
const langText = voice.language || "未知"
|
||||
|
||||
return (
|
||||
<div className="xx-clone-detail-overlay" onClick={onClose}>
|
||||
<div className="xx-clone-detail" onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" className="xx-clone-detail-close" onClick={onClose}>
|
||||
<CloseCircleOutlined />
|
||||
</button>
|
||||
|
||||
<div className="xx-clone-detail-header">
|
||||
<div className="xx-clone-avatar">
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="xx-clone-detail-name">{voice.name}</h3>
|
||||
<span className={`xx-clone-status ${statusCfg.className}`}>
|
||||
<span className="xx-clone-status-dot" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{voice.description && <p className="xx-clone-detail-desc">{voice.description}</p>}
|
||||
|
||||
<div className="xx-clone-detail-info">
|
||||
<div className="xx-clone-detail-row">
|
||||
<span className="xx-clone-detail-label">性别</span>
|
||||
<span>{genderText}</span>
|
||||
</div>
|
||||
<div className="xx-clone-detail-row">
|
||||
<span className="xx-clone-detail-label">语言</span>
|
||||
<span>{langText}</span>
|
||||
</div>
|
||||
<div className="xx-clone-detail-row">
|
||||
<span className="xx-clone-detail-label">来源</span>
|
||||
<span>{voice.sourceName}</span>
|
||||
</div>
|
||||
<div className="xx-clone-detail-row">
|
||||
<span className="xx-clone-detail-label">创建时间</span>
|
||||
<span>{voice.createdAt}</span>
|
||||
</div>
|
||||
{voice.errorMessage && (
|
||||
<div className="xx-clone-detail-row" style={{ color: "var(--error-color, #ef4444)" }}>
|
||||
<span className="xx-clone-detail-label">错误</span>
|
||||
<span>{voice.errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="xx-clone-detail-actions">
|
||||
{voice.status === "failed" && (
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<ReloadOutlined />} onClick={onRetry}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<DeleteOutlined />} onClick={onDelete}>
|
||||
删除
|
||||
</Button>
|
||||
{voice.status === "ready" && (
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onUse}>
|
||||
使用此音色
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CloneDetailModal
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
import React from "react"
|
||||
import {
|
||||
SoundOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
CloseCircleOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Tooltip } from "antd"
|
||||
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
|
||||
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
|
||||
|
||||
export interface CloneVoiceCardProps {
|
||||
voice: ClonedVoiceDisplay
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onUse: () => void
|
||||
onDelete: () => void
|
||||
onRetry: () => void
|
||||
onShowDetail: () => void
|
||||
}
|
||||
|
||||
/** 克隆音色卡片 */
|
||||
const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
voice,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
onPlay,
|
||||
onPause,
|
||||
onUse,
|
||||
onDelete,
|
||||
onRetry,
|
||||
onShowDetail,
|
||||
}) => {
|
||||
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
|
||||
const isFailed = voice.status === "failed"
|
||||
const isProcessing = voice.status === "processing"
|
||||
const genderText =
|
||||
voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-clone-card${isPlaying ? " playing" : ""}${isFailed ? " failed" : ""}`}
|
||||
onClick={isFailed ? undefined : onShowDetail}
|
||||
>
|
||||
{/* 右上角操作按钮 */}
|
||||
<div className="xx-clone-card-actions">
|
||||
<Tooltip title="删除">
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-action-btn xx-clone-action-btn--danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{isFailed && (
|
||||
<Tooltip title="重试">
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRetry()
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 头部:头像 + 名称 + 状态 */}
|
||||
<div className="xx-clone-card-header">
|
||||
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<div className="xx-clone-header-info">
|
||||
<h4 className="xx-clone-name" title={voice.name}>
|
||||
{voice.name}
|
||||
</h4>
|
||||
<span className={`xx-clone-status ${statusCfg.className}`}>
|
||||
<span className="xx-clone-status-dot" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="xx-clone-meta">
|
||||
{(voice.gender || voice.language) && (
|
||||
<span className="xx-clone-meta-item">
|
||||
<UserOutlined />
|
||||
{genderText}
|
||||
{voice.language ? ` · ${voice.language}` : ""}
|
||||
</span>
|
||||
)}
|
||||
<span className="xx-clone-meta-item">{voice.createdAt}</span>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{isFailed && voice.errorMessage && (
|
||||
<div className="xx-clone-error">
|
||||
<CloseCircleOutlined />
|
||||
<span>{voice.errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部操作区 */}
|
||||
<div className="xx-clone-footer">
|
||||
{voice.status === "ready" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-play-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
<div className="xx-clone-progress">
|
||||
<div
|
||||
className="xx-clone-progress-bar"
|
||||
style={{
|
||||
width: isPlaying
|
||||
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-use-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUse()
|
||||
}}
|
||||
>
|
||||
使用
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isProcessing && (
|
||||
<div className="xx-clone-processing-hint">
|
||||
<ReloadOutlined spin />
|
||||
克隆处理中,请稍候...
|
||||
</div>
|
||||
)}
|
||||
{isFailed && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-retry-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRetry()
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
重试克隆
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CloneVoiceCard
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
import { AudioOutlined } from "@ant-design/icons"
|
||||
import { type AssetItem } from "@/api/assets"
|
||||
import { formatFileSize } from "@/pages/voices/utils/format"
|
||||
|
||||
export interface MaterialVoiceCardProps {
|
||||
asset: AssetItem
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/** 配音素材卡片 */
|
||||
const MaterialVoiceCard: React.FC<MaterialVoiceCardProps> = ({ asset, onClick }) => {
|
||||
const duration = (asset.metadata?.duration as number) || 0
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
|
||||
return (
|
||||
<div className="vmat-card" onClick={onClick}>
|
||||
<div className="vmat-thumb">
|
||||
<AudioOutlined className="vmat-thumb-icon" />
|
||||
<span className="vmat-duration">
|
||||
{minutes}:{seconds.toString().padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="vmat-meta">
|
||||
<span>{asset.file_size ? formatFileSize(asset.file_size) : "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MaterialVoiceCard
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
import React from "react"
|
||||
import { RobotOutlined } from "@ant-design/icons"
|
||||
import { Modal, message } from "antd"
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import { genderLabel } from "@/pages/voices/utils/format"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
export interface TtsModalProps {
|
||||
open: boolean
|
||||
ttsText: string
|
||||
ttsVoiceId: string
|
||||
ttsSpeed: number
|
||||
ttsStatus: TtsStatus
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
onClose: () => void
|
||||
onTextChange: (text: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
onSpeedChange: (speed: number) => void
|
||||
onSynthesize: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
/** AI 配音弹窗 */
|
||||
const TtsModal: React.FC<TtsModalProps> = ({
|
||||
open,
|
||||
ttsText,
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
onClose,
|
||||
onTextChange,
|
||||
onVoiceChange,
|
||||
onSpeedChange,
|
||||
onSynthesize,
|
||||
onSave,
|
||||
}) => {
|
||||
return (
|
||||
<Modal title="AI 配音" open={open} onCancel={onClose} footer={null} width={560}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{/* 文本输入 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
输入文本
|
||||
</div>
|
||||
<textarea
|
||||
value={ttsText}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
placeholder="输入要配音的文本内容..."
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary)",
|
||||
textAlign: "right",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{ttsText.length}/2000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
选择音色
|
||||
</div>
|
||||
<select
|
||||
value={ttsVoiceId}
|
||||
onChange={(e) => onVoiceChange(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} — {genderLabel(v.gender)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语速 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
语速:{ttsSpeed.toFixed(1)}x
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={ttsSpeed}
|
||||
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
|
||||
style={{ width: "100%", accentColor: "var(--primary-color)" }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary)",
|
||||
}}
|
||||
>
|
||||
<span>0.5x</span>
|
||||
<span>1.0x</span>
|
||||
<span>2.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 合成按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!ttsText.trim()) {
|
||||
message.warning("请输入要合成的文本")
|
||||
return
|
||||
}
|
||||
onSynthesize()
|
||||
}}
|
||||
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 0",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background:
|
||||
ttsStatus === "synthesizing" || !ttsText.trim()
|
||||
? "var(--text-tertiary)"
|
||||
: "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: ttsStatus === "synthesizing" || !ttsText.trim() ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<RobotOutlined />
|
||||
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
|
||||
</button>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{ttsError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--error-soft, #fff2f0)",
|
||||
borderRadius: 8,
|
||||
color: "var(--error-color, #ff4d4f)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合成结果 */}
|
||||
{ttsStatus === "done" && ttsAudioUrl && (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--success-color, #52c41a)",
|
||||
}}
|
||||
>
|
||||
✅ 合成完成
|
||||
</div>
|
||||
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--primary-color)",
|
||||
background: "var(--primary-soft)",
|
||||
color: "var(--primary-color)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
保存到配音库
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TtsModal
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
import React from "react"
|
||||
import { UploadOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import { Modal, Upload, message } from "antd"
|
||||
import { type VoiceGender } from "@/pages/voices/types"
|
||||
import { formatFileSize } from "@/pages/voices/utils/format"
|
||||
|
||||
export interface UploadVoiceModalProps {
|
||||
open: boolean
|
||||
uploadFile: File | null
|
||||
uploadName: string
|
||||
uploadGender: VoiceGender
|
||||
uploadDesc: string
|
||||
uploadProgress: number | null
|
||||
onClose: () => void
|
||||
onFileSelect: (file: File) => void
|
||||
onFileRemove: () => void
|
||||
onNameChange: (name: string) => void
|
||||
onGenderChange: (gender: VoiceGender) => void
|
||||
onDescChange: (desc: string) => void
|
||||
onUpload: () => void
|
||||
}
|
||||
|
||||
/** 上传音频弹窗 */
|
||||
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
open,
|
||||
uploadFile,
|
||||
uploadName,
|
||||
uploadGender,
|
||||
uploadDesc,
|
||||
uploadProgress,
|
||||
onClose,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onNameChange,
|
||||
onGenderChange,
|
||||
onDescChange,
|
||||
onUpload,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title="上传音频"
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
if (uploadProgress !== null) return // 上传中不可关闭
|
||||
onClose()
|
||||
}}
|
||||
footer={null}
|
||||
width={520}
|
||||
maskClosable={uploadProgress === null}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{/* 拖拽上传区 */}
|
||||
<Upload.Dragger
|
||||
accept="audio/*"
|
||||
maxCount={1}
|
||||
beforeUpload={(file) => {
|
||||
onFileSelect(file)
|
||||
return false
|
||||
}}
|
||||
onRemove={() => {
|
||||
onFileRemove()
|
||||
}}
|
||||
showUploadList={false}
|
||||
disabled={uploadProgress !== null}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "var(--primary-color)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
|
||||
点击或拖拽音频文件到此处
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 200MB
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
|
||||
{/* 已选文件信息 */}
|
||||
{uploadFile && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{uploadFile.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
||||
{formatFileSize(uploadFile.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传进度 */}
|
||||
{uploadProgress !== null && (
|
||||
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--primary-color)",
|
||||
}}
|
||||
>
|
||||
{uploadProgress}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{uploadProgress < 100 ? "上传中..." : "处理中..."}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${uploadProgress}%`,
|
||||
background: "var(--primary-color)",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
素材名称
|
||||
</div>
|
||||
<input
|
||||
value={uploadName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="输入素材名称"
|
||||
maxLength={100}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别选择 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
音色性别
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
onClick={() => onGenderChange(g)}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 0",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||
background: uploadGender === g ? "var(--primary-soft)" : "transparent",
|
||||
color: uploadGender === g ? "var(--primary-color)" : "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: uploadGender === g ? 600 : 400,
|
||||
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
音色描述(可选)
|
||||
</div>
|
||||
<textarea
|
||||
value={uploadDesc}
|
||||
onChange={(e) => onDescChange(e.target.value)}
|
||||
placeholder="描述这个音色的特点..."
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 10,
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border-color)",
|
||||
background: "transparent",
|
||||
fontSize: 13,
|
||||
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!uploadFile) {
|
||||
message.warning("请先选择音频文件")
|
||||
return
|
||||
}
|
||||
if (!uploadName.trim()) {
|
||||
message.warning("请输入素材名称")
|
||||
return
|
||||
}
|
||||
onUpload()
|
||||
}}
|
||||
disabled={!uploadFile || !uploadName.trim() || uploadProgress !== null}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background:
|
||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
||||
? "var(--text-tertiary)"
|
||||
: "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor:
|
||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
}}
|
||||
>
|
||||
{uploadProgress !== null ? "上传中..." : "开始上传"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadVoiceModal
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import React, { useRef } from "react"
|
||||
import {
|
||||
SoundOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
HeartOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { type VoiceGender } from "@/pages/voices/types"
|
||||
import { genderClass, formatTime } from "@/pages/voices/utils/format"
|
||||
|
||||
export interface VoiceCardProps {
|
||||
id: string
|
||||
name: string
|
||||
subtitle: string
|
||||
tags: string[]
|
||||
duration: number
|
||||
gender: VoiceGender
|
||||
isPlaying: boolean
|
||||
isSelected: boolean
|
||||
currentTime: number
|
||||
starred?: boolean
|
||||
status?: "ready" | "processing" | "failed"
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek: (time: number) => void
|
||||
onSelect?: () => void
|
||||
onToggleStar?: () => void
|
||||
}
|
||||
|
||||
/** 音色卡片组件(预置音色 + 克隆音色统一) */
|
||||
const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
id: _id,
|
||||
name,
|
||||
subtitle,
|
||||
tags,
|
||||
duration,
|
||||
gender,
|
||||
isPlaying,
|
||||
isSelected,
|
||||
currentTime,
|
||||
starred,
|
||||
status = "ready",
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onSelect,
|
||||
onToggleStar,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current || status !== "ready") return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
onSeek(percent * duration)
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-voice-card ${genderClass(gender)}${isSelected ? " selected" : ""}${isPlaying ? " playing" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="xx-voice-avatar">
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
|
||||
<div className="xx-voice-info">
|
||||
<div className="xx-voice-name-row">
|
||||
<h4 className="xx-voice-name" title={name}>
|
||||
{name}
|
||||
</h4>
|
||||
{starred !== undefined && (
|
||||
<button
|
||||
className={`xx-voice-star${starred ? " active" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleStar?.()
|
||||
}}
|
||||
title={starred ? "取消收藏" : "收藏"}
|
||||
>
|
||||
<HeartOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-voice-subtitle">{subtitle}</div>
|
||||
<div className="xx-voice-tags">
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="xx-voice-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === "processing" && (
|
||||
<div className="xx-voice-status xx-voice-status--processing">
|
||||
<span className="xx-voice-status-dot" />
|
||||
处理中...
|
||||
</div>
|
||||
)}
|
||||
{status === "failed" && (
|
||||
<div className="xx-voice-status xx-voice-status--failed">克隆失败</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && <div className="xx-voice-wave" />}
|
||||
|
||||
{status === "ready" && (
|
||||
<div className="xx-voice-controls">
|
||||
<button
|
||||
className="xx-voice-play-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
<div ref={progressRef} className="xx-voice-progress" onClick={handleProgressClick}>
|
||||
<div className="xx-voice-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span className="xx-voice-time">
|
||||
{isPlaying ? formatTime(currentTime) : formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceCard
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import React from "react"
|
||||
import { SearchOutlined } from "@ant-design/icons"
|
||||
import { Input, Select } from "@/components/ui"
|
||||
|
||||
export interface VoiceFilterBarProps {
|
||||
searchText: string
|
||||
filterGender: string
|
||||
filterLang: string
|
||||
onSearchChange: (value: string) => void
|
||||
onGenderChange: (value: string) => void
|
||||
onLangChange: (value: string) => void
|
||||
}
|
||||
|
||||
/** 音色筛选栏(搜索 + 性别 + 语言) */
|
||||
const VoiceFilterBar: React.FC<VoiceFilterBarProps> = ({
|
||||
searchText,
|
||||
filterGender,
|
||||
filterLang,
|
||||
onSearchChange,
|
||||
onGenderChange,
|
||||
onLangChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-voices-filters">
|
||||
<Input
|
||||
placeholder="搜索音色名称、标签..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterGender}
|
||||
onChange={onGenderChange}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部音色" },
|
||||
{ value: "male", label: "男声" },
|
||||
{ value: "female", label: "女声" },
|
||||
{ value: "child", label: "童声" },
|
||||
{ value: "elderly", label: "老年" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterLang}
|
||||
onChange={onLangChange}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部语言" },
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "en", label: "英文" },
|
||||
{ value: "ja", label: "日文" },
|
||||
{ value: "ko", label: "韩文" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceFilterBar
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 配音库常量
|
||||
*/
|
||||
import type { VoiceGender, VoiceLanguage, ClonedVoiceDisplay } from "./types"
|
||||
|
||||
/** 性别选项 */
|
||||
export const GENDER_OPTIONS: { value: VoiceGender; label: string }[] = [
|
||||
{ value: "male", label: "男声" },
|
||||
{ value: "female", label: "女声" },
|
||||
{ value: "child", label: "童声" },
|
||||
{ value: "elderly", label: "老年" },
|
||||
]
|
||||
|
||||
/** 语言选项 */
|
||||
export const LANGUAGE_OPTIONS: { value: VoiceLanguage; label: string }[] = [
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "en", label: "英文" },
|
||||
{ value: "ja", label: "日文" },
|
||||
{ value: "ko", label: "韩文" },
|
||||
]
|
||||
|
||||
/** 克隆音色状态配置 */
|
||||
export const CLONE_STATUS_CONFIG: Record<
|
||||
ClonedVoiceDisplay["status"],
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
ready: { label: "可用", className: "xx-clone-status--ready" },
|
||||
processing: { label: "处理中", className: "xx-clone-status--processing" },
|
||||
failed: { label: "失败", className: "xx-clone-status--failed" },
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
|
||||
/**
|
||||
* 音频播放控制 Hook
|
||||
* 封装当前播放状态、播放/暂停/跳转控制,使用 setInterval 模拟进度更新
|
||||
* (适用于预置音色/克隆音色卡片的播放按钮交互)
|
||||
*/
|
||||
export function useAudioPlayer() {
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const intervalRef = useRef<number | null>(null)
|
||||
|
||||
/** 开始播放指定音色(从 startTime 开始,默认从 0 开始) */
|
||||
const handlePlay = useCallback(
|
||||
(voiceId: string, duration: number, startTime: number = 0) => {
|
||||
if (playingId === voiceId) return
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
setPlayingId(voiceId)
|
||||
setCurrentTime(startTime)
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
if (prev >= duration) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
return 0
|
||||
}
|
||||
return prev + 0.1
|
||||
})
|
||||
}, 100)
|
||||
},
|
||||
[playingId],
|
||||
)
|
||||
|
||||
/** 暂停播放 */
|
||||
const handlePause = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
}, [])
|
||||
|
||||
/** 跳转到指定时间 */
|
||||
const handleSeek = useCallback(
|
||||
(voiceId: string, time: number, duration: number) => {
|
||||
if (playingId !== voiceId) {
|
||||
// 不同音色:从指定时间开始播放
|
||||
handlePlay(voiceId, duration, time)
|
||||
} else {
|
||||
// 同一音色:直接跳转
|
||||
setCurrentTime(time)
|
||||
}
|
||||
},
|
||||
[playingId, handlePlay],
|
||||
)
|
||||
|
||||
/** 切换播放/暂停 */
|
||||
const handleTogglePlay = useCallback(
|
||||
(voiceId: string, duration: number) => {
|
||||
if (playingId === voiceId) {
|
||||
handlePause()
|
||||
} else {
|
||||
handlePlay(voiceId, duration)
|
||||
}
|
||||
},
|
||||
[playingId, handlePlay, handlePause],
|
||||
)
|
||||
|
||||
/** 停止所有播放(切换 Tab 时调用) */
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
setCurrentTime(0)
|
||||
}, [])
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
playingId,
|
||||
currentTime,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleSeek,
|
||||
handleTogglePlay,
|
||||
stopPlayback,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { deleteVoiceClone, retryVoiceClone, type VoiceClone } from "@/api/voice-clone"
|
||||
import { type ClonedVoiceDisplay } from "../types"
|
||||
|
||||
/**
|
||||
* 克隆音色操作 Hook
|
||||
* 封装删除、重试、详情弹窗等克隆音色相关操作
|
||||
*/
|
||||
interface ToastShowFn {
|
||||
(message: string, type: "success" | "error"): void
|
||||
}
|
||||
|
||||
interface UseCloneOperationsProps {
|
||||
showToast: ToastShowFn
|
||||
}
|
||||
|
||||
export function useCloneOperations({ showToast }: UseCloneOperationsProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [detailVoice, setDetailVoice] = useState<ClonedVoiceDisplay | null>(null)
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/** 删除克隆音色 */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteVoiceClone,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-clones"] })
|
||||
showToast("音色已删除", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("删除失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 重试克隆 */
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryVoiceClone,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-clones"] })
|
||||
showToast("已重新提交克隆", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("重试失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
const handleCloneDelete = useCallback(
|
||||
(voice: ClonedVoiceDisplay) => {
|
||||
if (window.confirm(`确定删除音色「${voice.name}」吗?`)) {
|
||||
deleteMutation.mutate(voice.id)
|
||||
if (detailVoice?.id === voice.id) setDetailVoice(null)
|
||||
}
|
||||
},
|
||||
[deleteMutation, detailVoice],
|
||||
)
|
||||
|
||||
const handleCloneRetry = useCallback(
|
||||
(voice: ClonedVoiceDisplay) => {
|
||||
retryMutation.mutate(voice.id)
|
||||
},
|
||||
[retryMutation],
|
||||
)
|
||||
|
||||
const handleCloneUse = useCallback(
|
||||
(_voice: ClonedVoiceDisplay) => {
|
||||
showToast("已选择音色", "success")
|
||||
},
|
||||
[showToast],
|
||||
)
|
||||
|
||||
const handleShowDetail = useCallback((voice: ClonedVoiceDisplay) => {
|
||||
setDetailVoice(voice)
|
||||
}, [])
|
||||
|
||||
const handleCloseDetail = useCallback(() => {
|
||||
setDetailVoice(null)
|
||||
}, [])
|
||||
|
||||
/** 克隆成功回调 — 刷新列表 */
|
||||
const handleCloneSuccess = useCallback(
|
||||
(_voice: VoiceClone) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-clones"] })
|
||||
showToast("克隆已提交,正在生成中", "success")
|
||||
},
|
||||
[queryClient, showToast],
|
||||
)
|
||||
|
||||
return {
|
||||
// 状态
|
||||
detailVoice,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
// Mutations
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isRetrying: retryMutation.isPending,
|
||||
// Handlers
|
||||
handleCloneDelete,
|
||||
handleCloneRetry,
|
||||
handleCloneUse,
|
||||
handleShowDetail,
|
||||
handleCloseDetail,
|
||||
handleCloneSuccess,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { type PresetVoiceDisplay } from "../types"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
/**
|
||||
* TTS 合成 Hook
|
||||
* 封装 AI 配音弹窗状态、合成请求、轮询、保存到素材库等逻辑
|
||||
*/
|
||||
interface UseTtsSynthesizeProps {
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
showToast: (message: string, type: "success" | "error") => void
|
||||
}
|
||||
|
||||
export function useTtsSynthesize({ presetVoices, showToast }: UseTtsSynthesizeProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [ttsOpen, setTtsOpen] = useState(false)
|
||||
const [ttsText, setTtsText] = useState("")
|
||||
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
|
||||
const [ttsSpeed, setTtsSpeed] = useState(1.0)
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
|
||||
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null)
|
||||
const [ttsError, setTtsError] = useState<string | null>(null)
|
||||
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
/** 开始 AI 配音合成 */
|
||||
const handleTtsSynthesize = useCallback(async () => {
|
||||
if (!ttsText.trim()) {
|
||||
message.warning("请输入要合成的文本")
|
||||
return
|
||||
}
|
||||
setTtsError(null)
|
||||
setTtsStatus("synthesizing")
|
||||
setTtsAudioUrl(null)
|
||||
setTtsJobId(null)
|
||||
|
||||
try {
|
||||
const resp = await synthesizeSpeech({
|
||||
text: ttsText.trim(),
|
||||
voice_id: ttsVoiceId || undefined,
|
||||
speed: ttsSpeed,
|
||||
})
|
||||
setTtsJobId(resp.job_id)
|
||||
|
||||
// 轮询任务状态
|
||||
ttsTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const job = await getTTSJobStatus(resp.job_id)
|
||||
if (job.status === "completed") {
|
||||
clearInterval(ttsTimerRef.current!)
|
||||
ttsTimerRef.current = null
|
||||
setTtsStatus("done")
|
||||
setTtsAudioUrl(job.output_audio_url)
|
||||
} else if (job.status === "failed") {
|
||||
clearInterval(ttsTimerRef.current!)
|
||||
ttsTimerRef.current = null
|
||||
setTtsStatus("error")
|
||||
setTtsError(job.error_message || "合成失败")
|
||||
}
|
||||
} catch {
|
||||
clearInterval(ttsTimerRef.current!)
|
||||
ttsTimerRef.current = null
|
||||
setTtsStatus("error")
|
||||
setTtsError("查询合成状态失败")
|
||||
}
|
||||
}, 2000)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "合成请求失败"
|
||||
setTtsStatus("error")
|
||||
setTtsError(msg)
|
||||
}
|
||||
}, [ttsText, ttsVoiceId, ttsSpeed])
|
||||
|
||||
/** 保存 TTS 结果到素材库 */
|
||||
const handleTtsSave = useCallback(async () => {
|
||||
if (!ttsJobId) return
|
||||
try {
|
||||
await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 50) })
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
|
||||
showToast("已保存到配音库", "success")
|
||||
setTtsOpen(false)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "保存失败"
|
||||
showToast(msg, "error")
|
||||
}
|
||||
}, [ttsJobId, ttsText, queryClient, showToast])
|
||||
|
||||
/** 关闭 TTS 弹窗并清理状态 */
|
||||
const handleTtsClose = useCallback(() => {
|
||||
setTtsOpen(false)
|
||||
setTtsText("")
|
||||
setTtsVoiceId("")
|
||||
setTtsSpeed(1.0)
|
||||
setTtsStatus("idle")
|
||||
setTtsAudioUrl(null)
|
||||
setTtsError(null)
|
||||
setTtsJobId(null)
|
||||
if (ttsTimerRef.current) {
|
||||
clearInterval(ttsTimerRef.current)
|
||||
ttsTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 打开 TTS 弹窗,可选指定音色 */
|
||||
const openTtsWithVoice = useCallback((voiceId?: string) => {
|
||||
setTtsOpen(true)
|
||||
if (voiceId) setTtsVoiceId(voiceId)
|
||||
}, [])
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
ttsOpen,
|
||||
ttsText,
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsJobId,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
// 可选音色列表
|
||||
ttsPresetVoices: presetVoices,
|
||||
// Setters
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
setTtsSpeed,
|
||||
setTtsOpen,
|
||||
// Actions
|
||||
handleTtsSynthesize,
|
||||
handleTtsSave,
|
||||
handleTtsClose,
|
||||
openTtsWithVoice,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type VoiceGender } from "../types"
|
||||
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
|
||||
import { getAudioDuration } from "../utils/audio"
|
||||
import { buildVoiceMetadata } from "../types"
|
||||
|
||||
/**
|
||||
* 配音上传 Hook
|
||||
* 封装上传音频弹窗状态、上传进度、上传 mutation 逻辑
|
||||
*/
|
||||
interface UseVoiceUploadProps {
|
||||
showToast: (message: string, type: "success" | "error") => void
|
||||
}
|
||||
|
||||
export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
||||
const [uploadName, setUploadName] = useState("")
|
||||
const [uploadGender, setUploadGender] = useState<VoiceGender>("female")
|
||||
const [uploadDesc, setUploadDesc] = useState("")
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
file: File
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
}) => {
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
/* 获取或创建默认配音库 */
|
||||
const libs = await queryClient.fetchQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
const lib = libs.find((l) => l.kind === "voice")
|
||||
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
|
||||
|
||||
/* 直传文件 */
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
/* 获取音频时长 */
|
||||
const duration = await getAudioDuration(data.file)
|
||||
|
||||
/* 创建素材记录 */
|
||||
await createAsset({
|
||||
library_id: lib.id,
|
||||
name: data.name,
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildVoiceMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
})
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
|
||||
showToast("上传成功", "success")
|
||||
handleUploadClose()
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showToast(err.message || "上传失败,请重试", "error")
|
||||
},
|
||||
})
|
||||
|
||||
const handleUploadClose = useCallback(() => {
|
||||
setUploadOpen(false)
|
||||
setUploadFile(null)
|
||||
setUploadName("")
|
||||
setUploadDesc("")
|
||||
setUploadProgress(null)
|
||||
}, [])
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(file: File) => {
|
||||
setUploadFile(file)
|
||||
if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, ""))
|
||||
},
|
||||
[uploadName],
|
||||
)
|
||||
|
||||
const handleFileRemove = useCallback(() => {
|
||||
setUploadFile(null)
|
||||
setUploadProgress(null)
|
||||
}, [])
|
||||
|
||||
const handleUpload = useCallback(() => {
|
||||
if (!uploadFile) return
|
||||
uploadMutation.mutate({
|
||||
file: uploadFile,
|
||||
name: uploadName.trim(),
|
||||
gender: uploadGender,
|
||||
description: uploadDesc.trim(),
|
||||
})
|
||||
}, [uploadFile, uploadName, uploadGender, uploadDesc, uploadMutation])
|
||||
|
||||
return {
|
||||
// 弹窗状态
|
||||
uploadOpen,
|
||||
setUploadOpen,
|
||||
// 表单状态
|
||||
uploadFile,
|
||||
uploadName,
|
||||
uploadGender,
|
||||
uploadDesc,
|
||||
uploadProgress,
|
||||
isUploading: uploadMutation.isPending,
|
||||
// Setters
|
||||
setUploadName,
|
||||
setUploadGender,
|
||||
setUploadDesc,
|
||||
// Handlers
|
||||
handleFileSelect,
|
||||
handleFileRemove,
|
||||
handleUpload,
|
||||
handleUploadClose,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { fetchPresetVoices, fetchVoices } from "@/api/voices"
|
||||
import { getVoiceClonesWithTotal, toVoiceClone } from "@/api/voice-clone"
|
||||
import { getAssetsByKind, type AssetItem } from "@/api/assets"
|
||||
import {
|
||||
type TabKey,
|
||||
type ClonedVoiceDisplay,
|
||||
type PresetVoiceDisplay,
|
||||
mapPresetToDisplay,
|
||||
mapCloneToDisplay,
|
||||
} from "../types"
|
||||
|
||||
/**
|
||||
* 配音库数据 Hook
|
||||
* 封装三个 Tab 的数据查询、筛选状态管理、数据映射逻辑
|
||||
*/
|
||||
export function useVoicesData() {
|
||||
// ── Tab & 筛选状态 ─────────────────────────────────────
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("preset")
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterGender, setFilterGender] = useState<string>("all")
|
||||
const [filterLang, setFilterLang] = useState<string>("all")
|
||||
|
||||
// ── 数据查询 ───────────────────────────────────────────
|
||||
|
||||
/** 预置音色列表 */
|
||||
const { data: presetData, isLoading: presetLoading } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
|
||||
/** 克隆音色列表 */
|
||||
const { data: cloneData, isLoading: cloneLoading } = useQuery({
|
||||
queryKey: ["voice-clones"],
|
||||
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
|
||||
})
|
||||
|
||||
/** 配音素材列表(用户上传音频) */
|
||||
const { data: materialData, isLoading: materialLoading } = useQuery({
|
||||
queryKey: ["voice-materials"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
/** 统一统计(preset_count / clone_count) */
|
||||
const { data: unifiedStats } = useQuery({
|
||||
queryKey: ["voices-unified"],
|
||||
queryFn: () => fetchVoices({ limit: 1 }),
|
||||
})
|
||||
|
||||
// ── 数据映射 ─────────────────────────────────────────
|
||||
|
||||
const presetVoices: PresetVoiceDisplay[] = useMemo(
|
||||
() => (presetData?.items ?? []).map(mapPresetToDisplay),
|
||||
[presetData],
|
||||
)
|
||||
|
||||
const clonedVoices: ClonedVoiceDisplay[] = useMemo(
|
||||
() => (cloneData?.items ?? []).map((p) => mapCloneToDisplay(toVoiceClone(p))),
|
||||
[cloneData],
|
||||
)
|
||||
|
||||
const materials: AssetItem[] = useMemo(() => materialData ?? [], [materialData])
|
||||
|
||||
// ── 计数 ──────────────────────────────────────────────
|
||||
|
||||
const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0
|
||||
const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0
|
||||
const materialCount = materialData?.length ?? 0
|
||||
|
||||
// ── 预置音色筛选 ─────────────────────────────────────
|
||||
|
||||
const filteredPreset = useMemo(() => {
|
||||
let list = presetVoices
|
||||
if (filterGender !== "all") {
|
||||
list = list.filter((v) => v.gender === filterGender)
|
||||
}
|
||||
if (filterLang !== "all") {
|
||||
list = list.filter((v) => v.language === filterLang)
|
||||
}
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter(
|
||||
(v) =>
|
||||
v.name.toLowerCase().includes(q) ||
|
||||
v.description.toLowerCase().includes(q) ||
|
||||
v.tags.some((tag) => tag.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [presetVoices, filterGender, filterLang, searchText])
|
||||
|
||||
return {
|
||||
// Tab 状态
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
// 筛选状态
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterGender,
|
||||
setFilterGender,
|
||||
filterLang,
|
||||
setFilterLang,
|
||||
// 加载状态
|
||||
presetLoading,
|
||||
cloneLoading,
|
||||
materialLoading,
|
||||
// 原始数据
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
materials,
|
||||
// 筛选后数据
|
||||
filteredPreset,
|
||||
// 计数
|
||||
presetCount,
|
||||
cloneCount,
|
||||
materialCount,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 配音库类型定义
|
||||
*/
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
export type VoiceGender = "male" | "female" | "child" | "elderly"
|
||||
export type VoiceLanguage = "zh" | "en" | "ja" | "ko"
|
||||
export type TabKey = "preset" | "cloned" | "material"
|
||||
|
||||
/** 前端展示用的预置音色(从 PresetVoiceItem 映射) */
|
||||
export interface PresetVoiceDisplay {
|
||||
id: string
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
language: VoiceLanguage
|
||||
duration: number
|
||||
tags: string[]
|
||||
description: string
|
||||
voiceId: string
|
||||
previewUrl: string
|
||||
starred: boolean
|
||||
}
|
||||
|
||||
/** 前端展示用的克隆音色(从 VoiceClone 映射) */
|
||||
export interface ClonedVoiceDisplay {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
sourceName: string
|
||||
status: "ready" | "processing" | "failed"
|
||||
createdAt: string
|
||||
duration: number
|
||||
tags: string[]
|
||||
voiceId: string
|
||||
language: string
|
||||
gender: string
|
||||
errorMessage: string | null
|
||||
sampleUrl?: string
|
||||
}
|
||||
|
||||
/** 音色上传元数据(传递给 createAsset 的 metadata) */
|
||||
export interface VoiceUploadMetadata {
|
||||
gender?: string
|
||||
description?: string
|
||||
duration?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 后端 PresetVoiceItem → 前端 PresetVoiceDisplay */
|
||||
export const mapPresetToDisplay = (item: PresetVoiceItem): PresetVoiceDisplay => ({
|
||||
id: item.voice_id,
|
||||
name: item.name,
|
||||
gender: (item.gender as VoiceGender) || "female",
|
||||
language: (item.language as VoiceLanguage) || "zh",
|
||||
duration: 0,
|
||||
tags: item.tags,
|
||||
description: item.description,
|
||||
voiceId: item.voice_id,
|
||||
previewUrl: item.preview_url || "",
|
||||
starred: false,
|
||||
})
|
||||
|
||||
/** 后端 VoiceClone → 前端 ClonedVoiceDisplay */
|
||||
export const mapCloneToDisplay = (clone: VoiceClone): ClonedVoiceDisplay => ({
|
||||
id: clone.id,
|
||||
name: clone.name,
|
||||
description: clone.description || "",
|
||||
sourceName: clone.sample_url || "未知来源",
|
||||
status: clone.status,
|
||||
createdAt: new Date(clone.created_at).toLocaleDateString("zh-CN"),
|
||||
duration: clone.duration_seconds,
|
||||
tags: [],
|
||||
voiceId: clone.id,
|
||||
language: clone.language || "",
|
||||
gender: clone.gender || "",
|
||||
errorMessage: clone.error_message || null,
|
||||
sampleUrl: clone.sample_url || undefined,
|
||||
})
|
||||
|
||||
/** 前端表单数据 → 后端 metadata */
|
||||
export const buildVoiceMetadata = (data: {
|
||||
gender?: string
|
||||
description?: string
|
||||
duration?: number
|
||||
}): VoiceUploadMetadata => {
|
||||
const metadata: VoiceUploadMetadata = {}
|
||||
if (data.gender) metadata.gender = data.gender
|
||||
if (data.description) metadata.description = data.description
|
||||
if (data.duration) metadata.duration = Math.round(data.duration)
|
||||
return metadata
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 音频相关工具函数
|
||||
*/
|
||||
|
||||
/** 获取音频文件时长(秒) */
|
||||
export const getAudioDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve) => {
|
||||
const audio = new Audio()
|
||||
const url = URL.createObjectURL(file)
|
||||
audio.addEventListener("loadedmetadata", () => {
|
||||
resolve(audio.duration)
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
audio.addEventListener("error", () => {
|
||||
resolve(0)
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
audio.src = url
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 格式化工具函数
|
||||
*/
|
||||
import { GENDER_OPTIONS, LANGUAGE_OPTIONS } from "../constants"
|
||||
import type { VoiceGender, VoiceLanguage } from "../types"
|
||||
|
||||
export const genderLabel = (g: VoiceGender) => GENDER_OPTIONS.find((o) => o.value === g)?.label ?? g
|
||||
|
||||
export const languageLabel = (l: VoiceLanguage) =>
|
||||
LANGUAGE_OPTIONS.find((o) => o.value === l)?.label ?? l
|
||||
|
||||
export const genderClass = (g: VoiceGender) => `xx-voice-gender--${g}`
|
||||
|
||||
export const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* GenerateHeader 组件单元测试
|
||||
* 同时 import GeneratePage 主组件,确保 vitest related 模式
|
||||
* 能匹配到 generate 目录下所有文件的改动
|
||||
*/
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, it, expect } from "vitest"
|
||||
import GenerateHeader from "@/pages/generate/components/GenerateHeader"
|
||||
// 引入主组件以建立依赖链,让 vitest related 覆盖整个 generate 目录
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("GenerateHeader", () => {
|
||||
it("should render title and description", () => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* AssetLibrary 模块 smoke test
|
||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||
* assets 目录下所有文件的改动(包括子组件和工具函数)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// 主组件
|
||||
import "@/pages/assets/AssetLibrary"
|
||||
|
||||
// 类型与常量
|
||||
import "@/pages/assets/types"
|
||||
import "@/pages/assets/constants"
|
||||
|
||||
// 工具函数
|
||||
import "@/pages/assets/utils/format"
|
||||
import "@/pages/assets/utils/asset"
|
||||
import "@/pages/assets/utils/kindIcon"
|
||||
|
||||
// UI 组件
|
||||
import "@/pages/assets/components/AssetCard"
|
||||
import "@/pages/assets/components/AssetSkeleton"
|
||||
import "@/pages/assets/components/LibrarySidebar"
|
||||
import "@/pages/assets/components/AssetFilterBar"
|
||||
import "@/pages/assets/components/BatchOperationBar"
|
||||
import "@/pages/assets/components/CreateLibraryModal"
|
||||
import "@/pages/assets/components/PlayModal"
|
||||
import "@/pages/assets/components/BatchTagModal"
|
||||
import "@/pages/assets/components/BatchClassifyModal"
|
||||
import "@/pages/assets/components/BatchMarkModal"
|
||||
import "@/pages/assets/components/ResultDrawer"
|
||||
import "@/pages/assets/components/UploadProgressModal"
|
||||
|
||||
describe("AssetLibrary module smoke test", () => {
|
||||
it("should load all asset modules", () => {
|
||||
// 纯模块加载测试,确保所有组件/工具函数能正常 import
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* GeneratePage 模块 smoke test
|
||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||
* generate 目录下所有文件的改动(包括 Phase 3 子组件)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// 直接引入所有 Step 组件,建立完整依赖链
|
||||
import "@/pages/generate/GeneratePage"
|
||||
import "@/pages/generate/components/Step2MaterialSelect"
|
||||
import "@/pages/generate/components/Step4TitleSettings"
|
||||
import "@/pages/generate/components/Step5VoiceSelect"
|
||||
import "@/pages/generate/components/voice/VoiceRecommendSection"
|
||||
import "@/pages/generate/components/voice/VoiceChoiceCard"
|
||||
import "@/pages/generate/components/voice/PresetVoiceDetail"
|
||||
import "@/pages/generate/components/voice/CustomVoicePanel"
|
||||
import "@/pages/generate/components/voice/SaveVoiceModal"
|
||||
import "@/pages/generate/components/voice/CloneVoiceSection"
|
||||
import "@/pages/generate/components/material/MaterialModeTabs"
|
||||
import "@/pages/generate/components/material/ManualMaterialList"
|
||||
import "@/pages/generate/components/material/SmartMatchInput"
|
||||
import "@/pages/generate/components/material/SmartMatchResults"
|
||||
import "@/pages/generate/components/material/SmartMatchCard"
|
||||
import "@/pages/generate/components/title/AiTitleGenerator"
|
||||
import "@/pages/generate/components/title/AiTitleCard"
|
||||
import "@/pages/generate/components/title/TitleStylePanel"
|
||||
import "@/pages/generate/components/title/TitlePresetsGrid"
|
||||
import "@/pages/generate/utils/formatDuration"
|
||||
|
||||
describe("GeneratePage module smoke test", () => {
|
||||
it("should load all generate modules", () => {
|
||||
// 纯模块加载测试,确保所有组件/工具函数能正常 import
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* TagSelector 组件单元测试
|
||||
* 同时 import VoiceMaterialLibrary 主组件,确保 vitest related 模式
|
||||
* 能匹配到 voice-materials 目录下所有文件的改动
|
||||
*/
|
||||
import { render, screen, fireEvent, within } from "@testing-library/react"
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import TagSelector from "@/pages/voice-materials/components/TagSelector"
|
||||
// 引入主组件以建立依赖链,让 vitest related 覆盖整个 voice-materials 目录
|
||||
import "@/pages/voice-materials/VoiceMaterialLibrary"
|
||||
import type { TagItem } from "@/api/tags"
|
||||
|
||||
const mockTags: TagItem[] = [
|
||||
{ id: "tag-1", name: "搞笑" },
|
||||
{ id: "tag-2", name: "情感" },
|
||||
{ id: "tag-3", name: "励志" },
|
||||
]
|
||||
|
||||
const mockTagMap = new Map(mockTags.map((t) => [t.id, t]))
|
||||
|
||||
describe("TagSelector", () => {
|
||||
const defaultProps = {
|
||||
value: [],
|
||||
onChange: vi.fn(),
|
||||
tags: mockTags,
|
||||
tagMap: mockTagMap,
|
||||
onCreateTag: vi.fn().mockResolvedValue({ id: "new-tag", name: "新标签" }),
|
||||
}
|
||||
|
||||
it("应渲染占位符文本", () => {
|
||||
render(<TagSelector {...defaultProps} />)
|
||||
expect(screen.getByPlaceholderText("输入标签后回车添加")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("应渲染已选标签", () => {
|
||||
const { container } = render(<TagSelector {...defaultProps} value={["tag-1", "tag-2"]} />)
|
||||
// 在标签选择器区域内查找已选标签
|
||||
const selectorArea = container.querySelector(".vmat-tag-selector")
|
||||
expect(selectorArea).not.toBeNull()
|
||||
expect(within(selectorArea as HTMLElement).getByText("搞笑")).toBeInTheDocument()
|
||||
expect(within(selectorArea as HTMLElement).getByText("情感")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("应渲染预设标签快捷选择区", () => {
|
||||
const { container } = render(<TagSelector {...defaultProps} />)
|
||||
const presetsArea = container.querySelector(".vmat-tag-selector-presets")
|
||||
expect(presetsArea).not.toBeNull()
|
||||
expect(within(presetsArea as HTMLElement).getByText("搞笑")).toBeInTheDocument()
|
||||
expect(within(presetsArea as HTMLElement).getByText("情感")).toBeInTheDocument()
|
||||
expect(within(presetsArea as HTMLElement).getByText("励志")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("点击预设标签应触发 onChange", () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<TagSelector {...defaultProps} onChange={onChange} />)
|
||||
const presetsArea = container.querySelector(".vmat-tag-selector-presets")
|
||||
fireEvent.click(within(presetsArea as HTMLElement).getByText("搞笑"))
|
||||
expect(onChange).toHaveBeenCalledWith(["tag-1"])
|
||||
})
|
||||
|
||||
it("点击已选预设标签应移除", () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(
|
||||
<TagSelector {...defaultProps} value={["tag-1"]} onChange={onChange} />,
|
||||
)
|
||||
const presetsArea = container.querySelector(".vmat-tag-selector-presets")
|
||||
// 点击预设区中已选中的标签按钮
|
||||
fireEvent.click(within(presetsArea as HTMLElement).getByText("搞笑"))
|
||||
expect(onChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
genderLabel,
|
||||
genderIcon,
|
||||
genderClass,
|
||||
formatDuration,
|
||||
formatFileSize,
|
||||
formatDate,
|
||||
} from "@/pages/voice-materials/utils/format"
|
||||
|
||||
describe("voice-materials format utils", () => {
|
||||
describe("genderLabel", () => {
|
||||
it("应返回正确的性别标签", () => {
|
||||
expect(genderLabel("male")).toBe("男声")
|
||||
expect(genderLabel("female")).toBe("女声")
|
||||
expect(genderLabel("child")).toBe("童声")
|
||||
expect(genderLabel("neutral")).toBe("中性")
|
||||
})
|
||||
|
||||
it("未知性别返回原值", () => {
|
||||
expect(genderLabel("unknown" as any)).toBe("unknown")
|
||||
})
|
||||
})
|
||||
|
||||
describe("genderIcon", () => {
|
||||
it("应返回图标组件", () => {
|
||||
expect(genderIcon("male")).toBeDefined()
|
||||
expect(genderIcon("female")).toBeDefined()
|
||||
expect(genderIcon("child")).toBeDefined()
|
||||
expect(genderIcon("neutral")).toBeDefined()
|
||||
})
|
||||
|
||||
it("未知性别返回 null", () => {
|
||||
expect(genderIcon("unknown" as any)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("genderClass", () => {
|
||||
it("应返回正确的 CSS 类名", () => {
|
||||
expect(genderClass("male")).toBe("vmat-gender--male")
|
||||
expect(genderClass("female")).toBe("vmat-gender--female")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("应正确格式化秒数", () => {
|
||||
expect(formatDuration(0)).toBe("0:00")
|
||||
expect(formatDuration(5)).toBe("0:05")
|
||||
expect(formatDuration(59)).toBe("0:59")
|
||||
expect(formatDuration(60)).toBe("1:00")
|
||||
expect(formatDuration(65)).toBe("1:05")
|
||||
expect(formatDuration(125)).toBe("2:05")
|
||||
expect(formatDuration(3600)).toBe("60:00")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatFileSize", () => {
|
||||
it("应正确格式化文件大小", () => {
|
||||
expect(formatFileSize(0)).toBe("0 B")
|
||||
expect(formatFileSize(512)).toBe("512 B")
|
||||
expect(formatFileSize(1024)).toBe("1.0 KB")
|
||||
expect(formatFileSize(1536)).toBe("1.5 KB")
|
||||
expect(formatFileSize(1024 * 1024)).toBe("1.0 MB")
|
||||
expect(formatFileSize(1024 * 1024 * 2.5)).toBe("2.5 MB")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("应格式化 ISO 日期字符串", () => {
|
||||
const result = formatDate("2026-07-24T10:30:00.000Z")
|
||||
// 格式应该是 YYYY/MM/DD 格式
|
||||
expect(result).toMatch(/^\d{4}\/\d{2}\/\d{2}$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* useAudioPlayer hook 测试
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
import { useAudioPlayer } from "@/pages/voice-materials/hooks/useAudioPlayer"
|
||||
import type { VoiceMaterial } from "@/pages/voice-materials/types"
|
||||
|
||||
// Mock Audio constructor
|
||||
const mockAudioPlay = vi.fn()
|
||||
const mockAudioPause = vi.fn()
|
||||
const mockAddEventListener = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAudioPlay.mockReset()
|
||||
mockAudioPause.mockReset()
|
||||
mockAddEventListener.mockReset()
|
||||
|
||||
// Mock HTMLAudioElement
|
||||
global.Audio = vi.fn().mockImplementation(() => ({
|
||||
play: mockAudioPlay.mockResolvedValue(undefined),
|
||||
pause: mockAudioPause,
|
||||
addEventListener: mockAddEventListener,
|
||||
currentTime: 0,
|
||||
volume: 0.7,
|
||||
paused: true,
|
||||
})) as unknown as typeof Audio
|
||||
})
|
||||
|
||||
const mockMaterial: VoiceMaterial = {
|
||||
id: "test-1",
|
||||
name: "测试素材",
|
||||
description: "测试描述",
|
||||
gender: "male",
|
||||
tagIds: ["tag-1"],
|
||||
fileName: "test.mp3",
|
||||
fileSize: 1024,
|
||||
duration: 30,
|
||||
mimeType: "audio/mpeg",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
fileUrl: "https://example.com/test.mp3",
|
||||
}
|
||||
|
||||
describe("useAudioPlayer", () => {
|
||||
it("应该使用初始状态初始化", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
expect(result.current.volume).toBe(0.7)
|
||||
expect(result.current.pausedMaterial).toBeNull()
|
||||
})
|
||||
|
||||
it("stopPlayback 应该重置播放状态", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.stopPlayback()
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
expect(result.current.pausedMaterial).toBeNull()
|
||||
})
|
||||
|
||||
it("handlePause 应该暂停播放并设置 pausedMaterial", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePause(mockMaterial)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.pausedMaterial).toEqual(mockMaterial)
|
||||
})
|
||||
|
||||
it("handlePause 不传参数时不设置 pausedMaterial", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePause()
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.pausedMaterial).toBeNull()
|
||||
})
|
||||
|
||||
it("toggleMute 应该切换静音状态", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
// 默认音量 0.7,静音后应为 0
|
||||
act(() => {
|
||||
result.current.toggleMute()
|
||||
})
|
||||
expect(result.current.volume).toBe(0)
|
||||
|
||||
// 再次切换,恢复到 0.7
|
||||
act(() => {
|
||||
result.current.toggleMute()
|
||||
})
|
||||
expect(result.current.volume).toBe(0.7)
|
||||
})
|
||||
|
||||
it("handlePlay 应该开始播放素材", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay(mockMaterial)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("test-1")
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
expect(global.Audio).toHaveBeenCalledWith("https://example.com/test.mp3")
|
||||
expect(mockAudioPlay).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handlePlay 对同一个素材不应重复播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay(mockMaterial)
|
||||
})
|
||||
|
||||
const playCallCount = mockAudioPlay.mock.calls.length
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay(mockMaterial)
|
||||
})
|
||||
|
||||
// 不应该再次调用 play
|
||||
expect(mockAudioPlay.mock.calls.length).toBe(playCallCount)
|
||||
})
|
||||
|
||||
it("返回值应该包含所有必要的方法和状态", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
expect(typeof result.current.handlePlay).toBe("function")
|
||||
expect(typeof result.current.handlePause).toBe("function")
|
||||
expect(typeof result.current.handleSeek).toBe("function")
|
||||
expect(typeof result.current.handleVolumeChange).toBe("function")
|
||||
expect(typeof result.current.toggleMute).toBe("function")
|
||||
expect(typeof result.current.stopPlayback).toBe("function")
|
||||
expect(typeof result.current.playingId).toBe("object") // string | null
|
||||
expect(typeof result.current.currentTime).toBe("number")
|
||||
expect(typeof result.current.volume).toBe("number")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* VoiceMaterialLibrary 模块 smoke test
|
||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||
* voice-materials 目录下所有文件的改动(包括子组件和工具函数)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// 主组件
|
||||
import "@/pages/voice-materials/VoiceMaterialLibrary"
|
||||
|
||||
// 子组件
|
||||
import "@/pages/voice-materials/components/TagSelector"
|
||||
import "@/pages/voice-materials/components/MaterialForm"
|
||||
import "@/pages/voice-materials/components/VoiceMaterialCard"
|
||||
import "@/pages/voice-materials/components/VoiceMaterialRow"
|
||||
|
||||
// 工具函数
|
||||
import "@/pages/voice-materials/utils/format"
|
||||
import "@/pages/voice-materials/utils/audio"
|
||||
|
||||
describe("VoiceMaterialLibrary module smoke test", () => {
|
||||
it("should load all voice-material modules", () => {
|
||||
// 纯模块加载测试,确保所有组件/工具函数能正常 import
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* useAudioPlayer hook 测试 — VoiceLibrary 版本
|
||||
*
|
||||
* 该 Hook 使用 setInterval 模拟音频播放进度,纯逻辑可测。
|
||||
* 参考 voice-materials/hooks/useAudioPlayer.test.ts 的测试结构。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
import { useAudioPlayer } from "@/pages/voices/hooks/useAudioPlayer"
|
||||
|
||||
describe("useAudioPlayer (voices)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("应该使用初始状态初始化", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("handlePlay 应该开始播放指定音色", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("handlePlay 对同一个音色不应重复启动播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
const initialTime = result.current.currentTime
|
||||
|
||||
// 推进一些时间让进度走动
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
const timeAfterAdvance = result.current.currentTime
|
||||
expect(timeAfterAdvance).toBeGreaterThan(initialTime)
|
||||
|
||||
// 对同一个音色再次调用 handlePlay 不应重置
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBe(timeAfterAdvance)
|
||||
})
|
||||
|
||||
it("handlePlay 切换音色时应停止上一个并从头开始", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBeGreaterThan(0)
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-2", 15)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-2")
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("播放进度应该随时间递增", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
// 每 100ms 增加 0.1
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(result.current.currentTime).toBeCloseTo(0.3, 1)
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
})
|
||||
|
||||
it("播放到结尾应自动停止并重置", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 0.5) // 0.5 秒的短音频
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600) // 超过 0.5 秒
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("handlePause 应该暂停播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
const timeBeforePause = result.current.currentTime
|
||||
|
||||
act(() => {
|
||||
result.current.handlePause()
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
|
||||
// 暂停后时间不应再变化
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(result.current.currentTime).toBe(timeBeforePause)
|
||||
})
|
||||
|
||||
it("handleSeek 应该跳转到指定时间", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSeek("voice-1", 5, 10)
|
||||
})
|
||||
|
||||
expect(result.current.currentTime).toBe(5)
|
||||
})
|
||||
|
||||
it("handleSeek 对不同音色应该开始播放该音色", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSeek("voice-2", 3, 15)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-2")
|
||||
expect(result.current.currentTime).toBe(3)
|
||||
})
|
||||
|
||||
it("handleTogglePlay 应该在播放和暂停之间切换", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
// 初始为暂停,调用应开始播放
|
||||
act(() => {
|
||||
result.current.handleTogglePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
|
||||
// 再次调用应暂停
|
||||
act(() => {
|
||||
result.current.handleTogglePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
})
|
||||
|
||||
it("stopPlayback 应该重置所有播放状态", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBeGreaterThan(0)
|
||||
|
||||
act(() => {
|
||||
result.current.stopPlayback()
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
|
||||
// 停止后定时器不应再触发
|
||||
const timeAfterStop = result.current.currentTime
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(result.current.currentTime).toBe(timeAfterStop)
|
||||
})
|
||||
|
||||
it("返回值应该包含所有必要的方法和状态", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
expect(typeof result.current.handlePlay).toBe("function")
|
||||
expect(typeof result.current.handlePause).toBe("function")
|
||||
expect(typeof result.current.handleSeek).toBe("function")
|
||||
expect(typeof result.current.handleTogglePlay).toBe("function")
|
||||
expect(typeof result.current.stopPlayback).toBe("function")
|
||||
expect(typeof result.current.playingId).toBe("object") // string | null
|
||||
expect(typeof result.current.currentTime).toBe("number")
|
||||
})
|
||||
})
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* VoiceLibrary 模块 smoke test
|
||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||
* voices 目录下所有文件的改动(包括工具函数)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// 主组件
|
||||
import "@/pages/voices/VoiceLibrary"
|
||||
|
||||
// 子组件
|
||||
import "@/pages/voices/components/VoiceCard"
|
||||
import "@/pages/voices/components/CloneVoiceCard"
|
||||
import "@/pages/voices/components/CloneDetailModal"
|
||||
import "@/pages/voices/components/CloneCardSkeleton"
|
||||
import "@/pages/voices/components/UploadVoiceModal"
|
||||
import "@/pages/voices/components/TtsModal"
|
||||
import "@/pages/voices/components/VoiceFilterBar"
|
||||
import "@/pages/voices/components/MaterialVoiceCard"
|
||||
|
||||
// 类型与常量
|
||||
import "@/pages/voices/types"
|
||||
import "@/pages/voices/constants"
|
||||
|
||||
// 工具函数
|
||||
import "@/pages/voices/utils/format"
|
||||
import "@/pages/voices/utils/audio"
|
||||
|
||||
// Hooks
|
||||
import "@/pages/voices/hooks/useVoicesData"
|
||||
import "@/pages/voices/hooks/useAudioPlayer"
|
||||
import "@/pages/voices/hooks/useCloneOperations"
|
||||
import "@/pages/voices/hooks/useTtsSynthesize"
|
||||
import "@/pages/voices/hooks/useVoiceUpload"
|
||||
|
||||
describe("VoiceLibrary module smoke test", () => {
|
||||
it("should load all voice-library modules", () => {
|
||||
// 纯模块加载测试,确保所有组件/工具函数能正常 import
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
# 端口分配清单
|
||||
|
||||
> 本文档梳理 xiaoxia-saas 项目中所有服务、容器及 CI 环境使用的端口,
|
||||
> 作为运维、排障和新功能开发时的统一参考。
|
||||
>
|
||||
> 最后更新:2026-07-24
|
||||
|
||||
---
|
||||
|
||||
## 一、应用服务端口
|
||||
|
||||
| 服务 | 容器内端口 | 环境变量名 | Staging 宿主机 | Production 宿主机 | 说明 |
|
||||
| -------- | ---------- | ---------------- | -------------- | ----------------- | ----------------------------------- |
|
||||
| API | 8000 | `API_PORT` | 8000 | 8001 | FastAPI 服务,Nginx 反代后端 |
|
||||
| Web | 80 | `WEB_PORT` | 3001 | 3002 | Nginx + 前端静态文件 |
|
||||
| Worker | — | — | — | — | Celery 任务队列,不暴露端口 |
|
||||
|
||||
### 补充说明
|
||||
- API 容器内部固定监听 8000(`API_HOST=0.0.0.0`,`API_PORT=8000`)
|
||||
- Web 容器内部 Nginx 固定监听 80
|
||||
- 所有端口均绑定 `127.0.0.1`,不直接暴露公网,由前置 Nginx/CDN 转发
|
||||
|
||||
---
|
||||
|
||||
## 二、基础设施端口
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
| 环境 | 容器内端口 | 宿主机映射 | 环境变量名 | 默认值 |
|
||||
| ------------ | ---------- | ---------- | --------------------- | -------- |
|
||||
| Production | 5432 | 5433 | `POSTGRES_PORT` | 5433 |
|
||||
| Staging | 5432 | 5434 | `POSTGRES_PORT` | 5434 |
|
||||
| 开发本地 | 5432 | 5432 | `DATABASE_URL` 中端口 | 5432 |
|
||||
| CI 共享 PG | 5432 | 5433 | `CI_SHARED_PG_PORT` | 5433 |
|
||||
| CI 本地 PG | 5432 | 5432 | `CI_LOCAL_PG_PORT` | 5432 |
|
||||
|
||||
### Redis
|
||||
|
||||
| 环境 | 容器内端口 | 宿主机映射 | 环境变量名 | 默认值 |
|
||||
| ------------ | ---------- | ---------- | ------------------- | -------- |
|
||||
| Production | 6379 | 6380 | `REDIS_URL` 中端口 | — |
|
||||
| Staging | 6379 | 6381 | `REDIS_URL` 中端口 | — |
|
||||
| 开发本地 | 6379 | 6379 | `REDIS_URL` | 6379 |
|
||||
| CI 动态创建 | 6379 | 随机 | 运行时 `REDIS_PORT` | — |
|
||||
|
||||
> CI Integration Tests 中 Redis 容器使用 `-P` 随机映射端口,
|
||||
> 通过 `docker port` 命令获取实际端口后写入 `REDIS_URL`。
|
||||
|
||||
### 容器镜像 Registry
|
||||
|
||||
| 服务 | 端口 | 地址 | 说明 |
|
||||
| ----------------- | ----- | ---------------------- | ------------------------------ |
|
||||
| Gitea Registry | 5000 | 172.30.18.198:5000 | CI 构建服务器内网 Registry |
|
||||
| ACR(生产镜像源) | 443 | crpi-xxx.aliyuncs.com | 阿里云容器镜像服务(HTTPS) |
|
||||
|
||||
---
|
||||
|
||||
## 三、CI / DevOps 端口
|
||||
|
||||
| 服务/用途 | 端口 | 环境变量名 | 默认值 | 说明 |
|
||||
| ------------------- | ----- | --------------------- | ------ | ------------------------------------- |
|
||||
| CI ChatOps Webhook | 8090 | `CHATOPS_WEBHOOK_PORT`| 8090 | Gitea webhook 接收服务(`scripts/ci/chatops/`) |
|
||||
| Staging SSH 部署 | 22222 | `STAGING_SSH_PORT` | 22222 | Staging 服务器 SSH 端口(secrets 配置) |
|
||||
| Preview SSH 部署 | 22222 | `PREVIEW_SSH_PORT` | 22222 | Preview 服务器 SSH 端口(secrets 配置) |
|
||||
| Preview 前端访问 | 80 | — | 80 | Nginx 子域名路由,`*.preview.xiaoxiajianji.com` |
|
||||
|
||||
---
|
||||
|
||||
## 四、开发环境默认端口(.env.example)
|
||||
|
||||
| 用途 | 端口 | 环境变量名 / 出处 |
|
||||
| ------------ | ----- | ------------------------------------------ |
|
||||
| API 服务 | 8000 | `API_PORT` |
|
||||
| 数据库 | 5432 | `DATABASE_URL`(`postgresql+psycopg://...:5432/...`) |
|
||||
| Redis | 6379 | `REDIS_URL` / `CELERY_BROKER_URL` / `CELERY_RESULT_BACKEND` |
|
||||
| SMTP | 587 | `SMTP_PORT` |
|
||||
| 前端开发服务 | 3000 | `APP_BASE_URL`(默认 localhost:3000) |
|
||||
| Vite Dev | 5173 | `CORS_ORIGINS_RAW` 中包含 |
|
||||
|
||||
---
|
||||
|
||||
## 五、CI Workflow 中的端口变量
|
||||
|
||||
### ci-pipeline.yml 顶层 env
|
||||
|
||||
| 变量名 | 默认值 | 用途 |
|
||||
| ------------------- | ------ | ------------------------ |
|
||||
| `CI_PG_PORT` | 5432 | CI PG 容器端口(本地) |
|
||||
| `CI_SHARED_PG_PORT` | 5433 | CI 共享常驻 PG 端口 |
|
||||
|
||||
### scripts/ci/ci_env.sh(统一常量)
|
||||
|
||||
| 变量名 | 默认值 | 说明 |
|
||||
| ------------------- | ----------- | ----------------------------- |
|
||||
| `CI_SHARED_PG_PORT` | 5433 | 共享常驻 PG 实例端口 |
|
||||
| `CI_LOCAL_PG_PORT` | 5432 | 本地 PG 容器默认端口 |
|
||||
| `CI_DEFAULT_DB` | xiaoxia_saas | 默认数据库名 |
|
||||
|
||||
---
|
||||
|
||||
## 六、命名规范
|
||||
|
||||
### 推荐命名格式
|
||||
|
||||
统一使用 `{服务/用途}_PORT` 格式:
|
||||
|
||||
```bash
|
||||
API_PORT # 应用服务
|
||||
WEB_PORT # 应用服务
|
||||
POSTGRES_PORT # 基础设施
|
||||
REDIS_PORT # 基础设施
|
||||
SMTP_PORT # 外部服务
|
||||
CI_SHARED_PG_PORT # CI 特定
|
||||
CI_LOCAL_PG_PORT # CI 特定
|
||||
CHATOPS_WEBHOOK_PORT # DevOps 服务
|
||||
```
|
||||
|
||||
### 历史命名不一致(待统一)
|
||||
|
||||
- `WEBHOOK_PORT`(chatops config.py 内部变量)→ 应与外部 env 名 `CHATOPS_WEBHOOK_PORT` 对齐
|
||||
- `STAGING_SSH_PORT` / `PREVIEW_SSH_PORT` → 符合规范,保留
|
||||
- `CI_PG_PORT`(workflow 中)→ 建议统一为 `CI_LOCAL_PG_PORT` 与 `ci_env.sh` 对齐
|
||||
|
||||
---
|
||||
|
||||
## 七、相关配置文件路径
|
||||
|
||||
| 文件路径 | 端口相关内容 |
|
||||
| ------------------------------------- | -------------------------------- |
|
||||
| `infra/docker/compose.yml` | API / Web / Worker 端口映射 |
|
||||
| `infra/docker/infra.yml` | Staging PG / Redis 端口 |
|
||||
| `infra/docker/infra-production.yml` | Production PG / Redis 端口 |
|
||||
| `.env.example` | 开发环境全部端口变量 |
|
||||
| `.gitea/workflows/ci-pipeline.yml` | CI PG 端口配置 |
|
||||
| `scripts/ci/ci_env.sh` | CI 端口统一常量 |
|
||||
| `scripts/ci/chatops/config.py` | ChatOps Webhook 端口 |
|
||||
| `scripts/ci/run_integration_tests.sh` | Redis 动态端口 + PG 端口 |
|
||||
| `scripts/ci/run_validate.sh` | PG 端口 |
|
||||
| `scripts/ci/validate_migration.sh` | PG 端口 |
|
||||
|
||||
Executable
+198
@@ -0,0 +1,198 @@
|
||||
# VoiceLibrary 页面重构方案
|
||||
|
||||
## 现状分析
|
||||
|
||||
**当前文件:** `apps/web/src/pages/voices/VoiceLibrary.tsx` — 1783 行
|
||||
|
||||
**代码质量:**
|
||||
- `any`: 0 处
|
||||
- `ts-ignore`: 0 处
|
||||
- `eslint-disable`: 0 处
|
||||
- `TODO`: 1 处
|
||||
- 整体质量良好,重构阻力小
|
||||
|
||||
## 目录结构(重构后)
|
||||
|
||||
```
|
||||
apps/web/src/pages/voices/
|
||||
├── VoiceLibrary.tsx # 主组件(目标:~550 行,-69%)
|
||||
├── types.ts # 类型定义
|
||||
├── constants.ts # 常量 + 配置
|
||||
├── utils/
|
||||
│ ├── format.ts # 格式化工具函数
|
||||
│ └── audio.ts # 音频工具函数
|
||||
├── components/
|
||||
│ ├── VoiceCard.tsx # 预设音色卡片
|
||||
│ ├── CloneVoiceCard.tsx # 克隆音色卡片
|
||||
│ ├── CloneDetailModal.tsx # 克隆详情弹窗
|
||||
│ ├── CloneCardSkeleton.tsx # 克隆卡片骨架屏
|
||||
│ ├── UploadModal.tsx # 上传音色弹窗
|
||||
│ ├── TTSModal.tsx # TTS合成弹窗
|
||||
│ ├── VoiceFilterBar.tsx # 筛选栏(搜索/性别/语言)
|
||||
│ └── VoiceTabBar.tsx # Tab切换栏
|
||||
└── hooks/
|
||||
├── useVoices.ts # 音色数据查询 + 筛选
|
||||
├── useAudioPlayer.ts # 音频播放控制
|
||||
├── useVoiceUpload.ts # 音色上传逻辑
|
||||
├── useTTS.ts # TTS合成逻辑
|
||||
└── useCloneVoice.ts # 克隆音色操作
|
||||
```
|
||||
|
||||
## 三阶段渐进式重构
|
||||
|
||||
### Phase 1:抽离类型、常量、工具函数
|
||||
|
||||
**目标:** 主文件 1783 → ~1550 行(-13%)
|
||||
|
||||
**抽出内容:**
|
||||
|
||||
1. **`types.ts`** — 类型定义(~60行)
|
||||
- `PresetVoiceDisplay` / `ClonedVoiceDisplay` / `VoiceCardProps` / `CloneVoiceCardProps`
|
||||
- `TabKey` / `Toast` / `VoiceUploadMetadata`
|
||||
- 现有 `mapPresetToDisplay` / `mapCloneToDisplay` 数据映射函数
|
||||
|
||||
2. **`constants.ts`** — 常量配置(~40行)
|
||||
- `CLONE_STATUS_CONFIG` 克隆状态配置
|
||||
- `GENDER_LABEL_MAP` / `LANGUAGE_LABEL_MAP` 性别/语言标签映射
|
||||
- Tab 配置项
|
||||
|
||||
3. **`utils/format.ts`** — 格式化工具(~25行)
|
||||
- `formatTime` 时长格式化
|
||||
- `formatFileSize` 文件大小格式化
|
||||
- `genderLabel` / `languageLabel` / `genderClass`
|
||||
|
||||
4. **`utils/audio.ts`** — 音频工具(~15行)
|
||||
- `getAudioDuration` 获取音频文件时长
|
||||
|
||||
5. **`format.test.ts`** — 工具函数单测
|
||||
- 覆盖 formatTime / formatFileSize 等纯函数
|
||||
|
||||
**Phase 1 交付:**
|
||||
- 新增文件:6 个(types.ts / constants.ts / utils/format.ts / utils/audio.ts / format.test.ts)
|
||||
- 主文件减少:~230 行
|
||||
- 纯机械抽离,无逻辑改动
|
||||
|
||||
---
|
||||
|
||||
### Phase 2:抽离子组件
|
||||
|
||||
**目标:** 主文件 1550 → ~950 行(-39%)
|
||||
|
||||
**抽出组件:**
|
||||
|
||||
1. **`components/VoiceCard.tsx`** — 预设音色卡片(~120行)
|
||||
- 卡片渲染:头像、名称、性别标签、播放按钮、进度条、收藏
|
||||
- Props:voice / playingId / currentTime / onPlay / onPause / onSeek / onToggleStar
|
||||
|
||||
2. **`components/CloneVoiceCard.tsx`** — 克隆音色卡片(~160行)
|
||||
- 三种状态:processing / success / failed
|
||||
- 操作:播放、详情、删除、重试、使用
|
||||
- Props:voice / playingId / currentTime / onPlayPause / onShowDetail / onDelete / onRetry / onUse
|
||||
|
||||
3. **`components/CloneDetailModal.tsx`** — 克隆详情弹窗(~90行)
|
||||
- 展示克隆音色详细信息
|
||||
- 状态展示、音频播放、操作按钮
|
||||
|
||||
4. **`components/CloneCardSkeleton.tsx`** — 克隆卡片骨架屏(~20行)
|
||||
- 加载状态占位
|
||||
|
||||
5. **`components/UploadModal.tsx`** — 上传音色弹窗(~180行)
|
||||
- 文件选择、名称/性别/描述填写
|
||||
- 上传进度展示
|
||||
- Props:open / onClose / onUpload
|
||||
|
||||
6. **`components/TTSModal.tsx`** — TTS合成弹窗(~170行)
|
||||
- 文本输入、音色选择、语速调节
|
||||
- 合成状态轮询(idle/synthesizing/done/error)
|
||||
- 保存到素材库
|
||||
- Props:open / onClose / onSave
|
||||
|
||||
7. **`components/VoiceFilterBar.tsx`** — 筛选栏(~80行)
|
||||
- 搜索框、性别筛选、语言筛选
|
||||
- Props:searchText / filterGender / filterLang / onChange handlers
|
||||
|
||||
**Phase 2 交付:**
|
||||
- 新增组件:7 个
|
||||
- 主文件减少:~600 行
|
||||
- 纯UI抽离,业务逻辑保留在主组件
|
||||
|
||||
---
|
||||
|
||||
### Phase 3:抽离业务逻辑 Hook
|
||||
|
||||
**目标:** 主文件 950 → ~550 行(-42%)
|
||||
|
||||
**抽出 Hook:**
|
||||
|
||||
1. **`hooks/useVoices.ts`** — 音色数据 Hook(~200行)
|
||||
- 三个 useQuery:presetVoices / cloneVoices / materialVoices / unifiedStats
|
||||
- 筛选逻辑:searchText / filterGender / filterLang → filteredPreset / filteredClone
|
||||
- 统计数据:presetCount / cloneCount / materialCount
|
||||
- 收藏切换 handleToggleStar
|
||||
- 返回:data / loading / filtered / counts / handlers
|
||||
|
||||
2. **`hooks/useAudioPlayer.ts`** — 音频播放控制 Hook(~120行)
|
||||
- playingId / currentTime / intervalRef 状态
|
||||
- handlePlay / handlePause / handleSeek
|
||||
- 自动停止(切换新音频时停止旧的)
|
||||
- 组件卸载清理
|
||||
- 注意:与 VoiceMaterialLibrary 的 useAudioPlayer 类似但有差异(一个操作DOM音频,一个操作audio元素),评估是否复用还是独立
|
||||
|
||||
3. **`hooks/useVoiceUpload.ts`** — 音色上传 Hook(~120行)
|
||||
- uploadFile / uploadName / uploadGender / uploadDesc / uploadProgress 状态
|
||||
- uploadMutation(上传文件 + 创建音色)
|
||||
- buildVoiceMetadata 元数据构建
|
||||
- getAudioDuration 时长获取
|
||||
- 成功后刷新列表 + toast 关闭弹窗
|
||||
|
||||
4. **`hooks/useTTS.ts`** — TTS合成 Hook(~110行)
|
||||
- ttsOpen / ttsText / ttsVoiceId / ttsSpeed 弹窗状态
|
||||
- ttsJobId / ttsStatus / ttsAudioUrl / ttsError 合成状态
|
||||
- handleTtsSynthesize 发起合成 + 轮询
|
||||
- handleTtsSave 保存到素材库
|
||||
- handleTtsClose 清理状态
|
||||
|
||||
5. **`hooks/useCloneVoice.ts`** — 克隆音色操作 Hook(~80行)
|
||||
- deleteMutation / retryMutation
|
||||
- handleCloneDelete / handleCloneRetry
|
||||
- detailVoice 详情状态
|
||||
- 操作后刷新列表
|
||||
|
||||
**Phase 3 交付:**
|
||||
- 新增 Hook:5 个
|
||||
- 主文件减少:~400 行
|
||||
- 主文件只剩:Tab切换逻辑 + JSX 组装 + 顶层状态编排
|
||||
|
||||
---
|
||||
|
||||
## 最终效果汇总
|
||||
|
||||
| 阶段 | 主文件行数 | 减少行数 | 减少比例 | 新增文件 |
|
||||
|------|-----------|---------|----------|----------|
|
||||
| 初始 | 1783 | — | — | — |
|
||||
| Phase 1 | ~1550 | -233 | -13.1% | 5 |
|
||||
| Phase 2 | ~950 | -600 | -38.7% | 7 |
|
||||
| Phase 3 | ~550 | -400 | -42.1% | 5 |
|
||||
| **总计** | **~550** | **-1233** | **-69.2%** | **17** |
|
||||
|
||||
## 与 GeneratePage / VoiceMaterialLibrary 的异同
|
||||
|
||||
**相同点:**
|
||||
- 三阶段渐进式重构(类型常量 → 组件 → Hook)
|
||||
- 每阶段独立PR,独立验证
|
||||
- 每阶段加 smoke test 保证 vitest related 模式可用
|
||||
- 代码质量基线高(0 any / 0 ts-ignore)
|
||||
|
||||
**不同点:**
|
||||
- VoiceLibrary 有两个数据源(预设音色 + 克隆音色),还有TTS和上传功能
|
||||
- 音频播放逻辑与 VoiceMaterialLibrary 类似但操作的音频类型不同
|
||||
- 有 CloneModal 是外部组件(已在 @/components/voice/CloneModal),不需要重写
|
||||
- 骨架屏组件比较简单
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
1. **vitest related 模式**:每阶段新增文件需建立测试覆盖,避免 "No test files found"
|
||||
2. **Preview Deploy**:环境问题导致失败,不影响代码质量
|
||||
3. **音频播放逻辑**:VoiceLibrary 和 VoiceMaterialLibrary 的播放控制类似但是两套独立实现,后续可考虑抽象为共享 Hook
|
||||
4. **克隆状态**:CLONE_STATUS_CONFIG 是核心常量,抽离时注意类型完整
|
||||
5. **上传逻辑**:uploadMutation 内部逻辑较复杂(上传文件 + 获取时长 + 创建音色),抽 Hook 时保持逻辑不变
|
||||
Executable
+198
@@ -0,0 +1,198 @@
|
||||
# VoiceMaterialLibrary 页面拆分方案
|
||||
|
||||
## 一、现状摸底
|
||||
|
||||
**文件:** `apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx`
|
||||
**总行数:** 1966 行
|
||||
**CSS 文件:** `apps/web/src/pages/voice-materials/voice-materials.css` (1262 行)
|
||||
|
||||
### 代码质量
|
||||
|
||||
| 指标 | 数量 | 评价 |
|
||||
|------|------|------|
|
||||
| `any` 类型 | 0 | ✅ 优秀 |
|
||||
| `@ts-ignore` | 0 | ✅ 优秀 |
|
||||
| `eslint-disable` | 0 | ✅ 优秀 |
|
||||
| TODO/FIXME | 0 | ✅ 干净 |
|
||||
|
||||
### 内联组件分布
|
||||
|
||||
| 组件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `TagSelector` | 148行 (197-345) | 标签选择器(输入+建议+选择) |
|
||||
| `MaterialForm` | 187行 (356-543) | 上传/编辑表单(名称/描述/性别/标签/文件) |
|
||||
| `VoiceMaterialCard` | 220行 (543-763) | 卡片视图 + 音频播放进度条 |
|
||||
| `VoiceMaterialRow` | 153行 (763-916) | 列表视图 + 音频播放进度条 |
|
||||
| **VoiceMaterialLibrary(主组件)** | **1051行 (916-1966)** | **页面主逻辑 + 渲染** |
|
||||
|
||||
### 主组件内部结构(1051行)
|
||||
|
||||
- **状态 & hooks**:约 230行
|
||||
- 数据查询:assets / libraries / tags / presetVoices
|
||||
- 视图状态:viewMode / searchText / filterGender / filterTagId
|
||||
- 播放状态:playingId / currentTime / audioRef / volume / pausedMaterial
|
||||
- 弹窗:uploadOpen / editingMaterial / ttsOpen
|
||||
- 批量:selectedIds / uploadProgress / batchCustomTag
|
||||
- TTS:ttsText / ttsVoiceId / ttsSpeed / ttsJobId / ttsStatus / ttsAudioUrl / ttsError
|
||||
- **播放控制函数**:约 100行(stopPlayback/startPlayback/handlePlay/handlePause/handleSeek/handleVolumeChange/toggleMute)
|
||||
- **数据操作函数**:约 60行(handleUpload/handleEdit/handleDelete)
|
||||
- **筛选 & 统计**:约 40行(filtered/tagCountMap)
|
||||
- **批量操作**:约 80行(handleToggleSelect/handleSelectAll/handleBatchDelete/handleBatchTag/handleBatchCustomTag)
|
||||
- **TTS 合成**:约 70行(handleTtsSynthesize/handleTtsSave)
|
||||
- **渲染 JSX**:约 473行
|
||||
|
||||
## 二、拆分策略
|
||||
|
||||
跟 GeneratePage 同样的思路:**按职责拆分,功能不变,纯结构优化。**
|
||||
|
||||
拆分四阶段,每个阶段独立 PR,逐步降低主文件复杂度。
|
||||
|
||||
---
|
||||
|
||||
## Phase 1:抽离常量、类型、工具函数
|
||||
|
||||
**目标:** 把纯数据定义和纯函数抽出去,主文件只留组件。
|
||||
|
||||
### 抽离内容
|
||||
|
||||
| 新文件 | 内容 | 行数估计 |
|
||||
|--------|------|---------|
|
||||
| `types.ts` | `VoiceGender` / `ViewMode` / `VoiceMaterial` / `VoiceAssetMetadata` 等类型定义 | ~40行 |
|
||||
| `constants.ts` | `MAX_CARD_TAGS` / `MAX_ROW_TAGS` / `TAG_VARIANTS` / `GENDER_OPTIONS` | ~25行 |
|
||||
| `utils/format.ts` | `formatDuration` / `formatFileSize` / `formatDate` / `genderLabel` / `genderIcon` / `genderClass` | ~35行 |
|
||||
| `utils/audio.ts` | `getAudioDuration`(纯工具,跟组件无关) | ~20行 |
|
||||
| `utils/mappers.ts` | `mapAssetToMaterial` / `buildMetadata`(数据映射) | ~35行 |
|
||||
|
||||
### 预期效果
|
||||
|
||||
- 主文件减少约 **155 行**(1966 → ~1811)
|
||||
- 工具函数可复用、可单测
|
||||
|
||||
---
|
||||
|
||||
## Phase 2:抽离已有内联子组件
|
||||
|
||||
**目标:** 把 4 个已经定义好的内联组件拆成独立文件,主文件只保留 VoiceMaterialLibrary 主组件。
|
||||
|
||||
### 抽离内容
|
||||
|
||||
| 新文件 | 原位置 | 行数 | 备注 |
|
||||
|--------|--------|------|------|
|
||||
| `components/TagSelector.tsx` | 197-345行 | ~148行 | 标签选择器 |
|
||||
| `components/MaterialForm.tsx` | 356-543行 | ~187行 | 上传/编辑表单 |
|
||||
| `components/VoiceMaterialCard.tsx` | 543-763行 | ~220行 | 卡片视图,带播放控制 |
|
||||
| `components/VoiceMaterialRow.tsx` | 763-916行 | ~153行 | 列表视图,带播放控制 |
|
||||
|
||||
### 播放状态共享方案
|
||||
|
||||
Card 和 Row 都有播放进度条(共 ~60行重复代码),但播放状态在主组件里。
|
||||
|
||||
**方案:** 播放状态提升到主组件,Card/Row 只接收 prop 并回调:
|
||||
- `isPlaying` / `currentTime` / `onPlay` / `onPause` / `onSeek`
|
||||
- 主组件统一管理 audioRef 和播放状态
|
||||
|
||||
这样 Card 和 Row 是纯展示组件,播放逻辑集中在主组件。
|
||||
|
||||
### 预期效果
|
||||
|
||||
- 主文件减少约 **700 行**(1811 → ~1111)
|
||||
- 主组件专注页面逻辑,子组件专注展示
|
||||
|
||||
---
|
||||
|
||||
## Phase 3:主组件拆分 — 业务逻辑抽 Hook
|
||||
|
||||
**目标:** 把主组件里的业务逻辑按领域拆成自定义 Hook,主组件只负责组装。
|
||||
|
||||
### 抽离 Hooks
|
||||
|
||||
| Hook 文件 | 职责 | 管理的状态 |
|
||||
|-----------|------|-----------|
|
||||
| `hooks/useVoiceMaterials.ts` | 素材列表查询 + 筛选 + 增删改 | assets / searchText / filterGender / filterTagId / uploadMutation / editMutation / deleteMutation |
|
||||
| `hooks/useAudioPlayer.ts` | 音频播放控制 | playingId / currentTime / volume / audioRef / pausedMaterial |
|
||||
| `hooks/useBatchOperations.ts` | 批量操作 | selectedIds / handleToggleSelect / handleSelectAll / handleBatchDelete / handleBatchTag |
|
||||
| `hooks/useTtsSynthesize.ts` | TTS 合成逻辑 | ttsText / ttsVoiceId / ttsSpeed / ttsJobId / ttsStatus / ttsAudioUrl / ttsError |
|
||||
|
||||
### 预期效果
|
||||
|
||||
- 主组件减少约 **500 行**(1111 → ~611)
|
||||
- 每个 Hook 职责单一,可独立测试
|
||||
- 主组件变成"组装器",可读性大幅提升
|
||||
|
||||
---
|
||||
|
||||
## Phase 4:UI 组件细化 — 工具栏 & 弹窗拆分
|
||||
|
||||
**目标:** 把主组件里的大块 JSX 拆成独立 UI 组件。
|
||||
|
||||
### 抽离 UI 组件
|
||||
|
||||
| 组件文件 | 内容 | 行数估计 |
|
||||
|----------|------|---------|
|
||||
| `components/LibraryToolbar.tsx` | 顶部工具栏:搜索框 + 性别筛选 + 视图切换 + 结果计数 | ~60行 |
|
||||
| `components/TagFilterBar.tsx` | 标签筛选药丸条(全部 + 各标签计数) | ~45行 |
|
||||
| `components/BatchActionBar.tsx` | 批量操作栏:全选 + 计数 + 批量打标签 + 批量删除 | ~70行 |
|
||||
| `components/UploadModal.tsx` | 上传弹窗(包裹 MaterialForm) | ~30行 |
|
||||
| `components/EditModal.tsx` | 编辑弹窗(包裹 MaterialForm) | ~30行 |
|
||||
| `components/TtsSynthesizeModal.tsx` | AI 配音合成弹窗:文本输入 + 音色选择 + 语速 + 合成结果预览 + 保存 | ~150行 |
|
||||
| `components/EmptyState.tsx` | 空状态展示 | ~20行 |
|
||||
| `components/VoiceMaterialGrid.tsx` | 卡片视图网格容器 | ~25行 |
|
||||
| `components/VoiceMaterialList.tsx` | 列表视图表格容器 | ~25行 |
|
||||
|
||||
### 预期效果
|
||||
|
||||
- 主组件 JSX 部分减少约 **400 行**(611 → ~211)
|
||||
- 每个 UI 组件职责清晰,方便后续迭代
|
||||
|
||||
---
|
||||
|
||||
## 三、拆分后文件结构
|
||||
|
||||
```
|
||||
apps/web/src/pages/voice-materials/
|
||||
├── VoiceMaterialLibrary.tsx # 主组件(~211行,组装器)
|
||||
├── voice-materials.css # 样式(保持不变,后续再拆)
|
||||
├── types.ts # 类型定义
|
||||
├── constants.ts # 常量
|
||||
├── utils/
|
||||
│ ├── format.ts # 格式化工具
|
||||
│ ├── audio.ts # 音频工具
|
||||
│ └── mappers.ts # 数据映射
|
||||
├── hooks/
|
||||
│ ├── useVoiceMaterials.ts # 素材数据 + 筛选 + 增删改
|
||||
│ ├── useAudioPlayer.ts # 音频播放控制
|
||||
│ ├── useBatchOperations.ts # 批量操作
|
||||
│ └── useTtsSynthesize.ts # TTS合成
|
||||
└── components/
|
||||
├── TagSelector.tsx # 标签选择器
|
||||
├── MaterialForm.tsx # 上传/编辑表单
|
||||
├── VoiceMaterialCard.tsx # 卡片视图
|
||||
├── VoiceMaterialRow.tsx # 列表视图
|
||||
├── VoiceMaterialGrid.tsx # 卡片网格容器
|
||||
├── VoiceMaterialList.tsx # 列表容器
|
||||
├── LibraryToolbar.tsx # 顶部工具栏
|
||||
├── TagFilterBar.tsx # 标签筛选条
|
||||
├── BatchActionBar.tsx # 批量操作栏
|
||||
├── UploadModal.tsx # 上传弹窗
|
||||
├── EditModal.tsx # 编辑弹窗
|
||||
├── TtsSynthesizeModal.tsx # TTS合成弹窗
|
||||
└── EmptyState.tsx # 空状态
|
||||
```
|
||||
|
||||
### 拆分前后对比
|
||||
|
||||
| 指标 | 拆分前 | 拆分后 | 变化 |
|
||||
|------|--------|--------|------|
|
||||
| 主文件行数 | 1966 | ~211 | **-89%** |
|
||||
| 文件数量 | 2 | 22 | +20 |
|
||||
| 最大文件 | 1966行 | ~220行 | -89% |
|
||||
| 可测试性 | 低 | 高 | 每个Hook/组件可单测 |
|
||||
|
||||
## 四、实施顺序 & 风险
|
||||
|
||||
1. **Phase 1**:最低风险,纯抽离,零逻辑变化
|
||||
2. **Phase 2**:低风险,组件本来就是独立定义的,只是挪位置
|
||||
3. **Phase 3**:中风险,Hook 拆分需要仔细梳理状态依赖
|
||||
4. **Phase 4**:低风险,JSX 拆分,纯结构调整
|
||||
|
||||
**每个 Phase 完成后提 PR,CI 全绿再合,跟 GeneratePage 同样节奏。**
|
||||
@@ -2,7 +2,6 @@
|
||||
# Worker Dockerfile - 分层缓存优化版
|
||||
# 优化:基础依赖 + Worker大包预构建为基础镜像,业务构建仅叠加业务依赖
|
||||
# 基础镜像:worker-base-builder / worker-base-runtime
|
||||
# 预计节省:依赖不变时构建时间从23min降至5min以内
|
||||
# ============================================================
|
||||
|
||||
# ==================== Builder 阶段 ====================
|
||||
@@ -24,8 +23,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
-r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
|
||||
# ---- 增量瘦身(只处理新增的业务依赖)----
|
||||
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
|
||||
# ---- 增量瘦身(清理新增业务依赖的冗余文件)----
|
||||
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
@@ -41,30 +39,33 @@ ARG APP_VERSION=dev
|
||||
# 从 builder 复制 Python 虚拟环境
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制应用代码
|
||||
COPY apps/worker/ /app/apps/worker/
|
||||
COPY apps/api/app/config.py /app/apps/api/app/config.py
|
||||
COPY apps/api/app/core/ /app/apps/api/app/core/
|
||||
COPY packages/ /app/packages/
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY migrations/ /app/migrations/
|
||||
|
||||
# 复制 Worker 启动脚本
|
||||
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
|
||||
|
||||
# 设置 Python 路径
|
||||
# 设置 Python 环境变量
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONPATH=/app:/app/packages
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# 创建非 root 用户运行 Worker
|
||||
RUN groupadd -r celery && useradd -r -g celery -d /app -s /sbin/nologin celery \
|
||||
&& mkdir -p /app/generated && chown celery:celery /app/generated
|
||||
# 创建非 root 用户(极少变化,放最前)
|
||||
RUN groupadd -r celery \
|
||||
&& useradd -r -g celery -d /app -s /sbin/nologin celery \
|
||||
&& mkdir -p /app/generated \
|
||||
&& chown celery:celery /app/generated
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制文件按变化频率从低到高排序,最大化层缓存命中
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY migrations/ /app/migrations/
|
||||
COPY packages/ /app/packages/
|
||||
COPY apps/api/app/config.py /app/apps/api/app/config.py
|
||||
COPY apps/api/app/core/ /app/apps/api/app/core/
|
||||
|
||||
# 复制 Worker 启动脚本
|
||||
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
|
||||
|
||||
# 业务代码(变化最频繁,放最后)
|
||||
COPY apps/worker/ /app/apps/worker/
|
||||
|
||||
USER celery
|
||||
|
||||
|
||||
@@ -28,6 +28,28 @@ class EditPlanStatus(StrEnum):
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: object) -> "EditPlanStatus":
|
||||
"""兼容历史脏数据,避免枚举转换失败导致500。
|
||||
|
||||
- success/done/finished/complete → COMPLETED
|
||||
- fail/error/err → FAILED
|
||||
- render/rendering → RENDERING
|
||||
- edit/editing → EDITING
|
||||
- 其他未知值 → DRAFT(兜底,不阻塞业务)
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in ("done", "success", "finished", "complete", "completed"):
|
||||
return cls.COMPLETED
|
||||
if normalized in ("fail", "failed", "error", "err"):
|
||||
return cls.FAILED
|
||||
if normalized in ("render", "rendering", "generating", "generating_video"):
|
||||
return cls.RENDERING
|
||||
if normalized in ("edit", "editing", "working"):
|
||||
return cls.EDITING
|
||||
return cls.DRAFT
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EditPlan:
|
||||
|
||||
@@ -31,6 +31,25 @@ class EditPlanClipStatus(StrEnum):
|
||||
RENDERED = "rendered" # 已渲染
|
||||
FAILED = "failed" # 渲染失败
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: object) -> "EditPlanClipStatus":
|
||||
"""兼容历史脏数据,避免枚举转换失败导致500。
|
||||
|
||||
- success/done/finished/complete/rendered → RENDERED
|
||||
- fail/error/err → FAILED
|
||||
- ready/available → READY
|
||||
- 其他未知值 → PENDING(兜底,不阻塞业务)
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in ("done", "success", "finished", "complete", "rendered", "render"):
|
||||
return cls.RENDERED
|
||||
if normalized in ("fail", "failed", "error", "err"):
|
||||
return cls.FAILED
|
||||
if normalized in ("ready", "available", "prepared"):
|
||||
return cls.READY
|
||||
return cls.PENDING
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EditPlanClip:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user