Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1da3ce95e2 | |||
| 683c0aba41 | |||
| a6636033f4 | |||
| a1c6074b3d | |||
| 783fe0bab3 | |||
| e6cf7548b1 | |||
| bc1c454fad | |||
| 9e2daf0022 | |||
| f61648bb0f | |||
| f4791e726e | |||
| 6dbbb5b71a | |||
| 38fda9aa9e | |||
| 77f9a908ad | |||
| 190d9e46f7 |
@@ -38,7 +38,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
- name: Check changed files
|
||||
id: check
|
||||
shell: bash
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -412,7 +412,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -458,15 +458,13 @@ jobs:
|
||||
name: Frontend Lint
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
needs: check-frontend-only
|
||||
if: needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -528,7 +526,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -578,12 +576,7 @@ jobs:
|
||||
name: PR Build ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs: check-frontend-only
|
||||
if: |
|
||||
github.event_name == 'pull_request' && (
|
||||
(matrix.service == 'web' && needs.check-frontend-only.outputs.skip_frontend != 'true') ||
|
||||
(matrix.service != 'web' && needs.check-frontend-only.outputs.skip_backend != 'true')
|
||||
)
|
||||
if: github.event_name == 'pull_request'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -612,7 +605,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -796,7 +789,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -928,7 +921,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -1080,7 +1073,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -1091,8 +1084,6 @@ jobs:
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-e2e-$$"
|
||||
# 强制清理可能残留的同名容器(上一次异常退出时未清理)
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
@@ -1146,7 +1137,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -1201,11 +1192,6 @@ jobs:
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs:
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- unit-tests
|
||||
- frontend-lint
|
||||
- frontend-unit-test
|
||||
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1235,7 +1221,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -1279,7 +1265,7 @@ jobs:
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Build and push production ${{ matrix.service_display }} image (with retry)
|
||||
shell: bash
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
@@ -1359,7 +1345,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -1490,7 +1476,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -1550,7 +1536,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -1609,7 +1595,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
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
|
||||
@@ -1698,8 +1684,9 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
|
||||
- name: Evaluate CI Gate
|
||||
id: gate
|
||||
@@ -1731,30 +1718,6 @@ jobs:
|
||||
echo " frontend-lint: $RESULT_FRONTEND_LINT"
|
||||
echo " frontend-unit-test: $RESULT_FRONTEND_UNIT"
|
||||
echo " build-pr: $RESULT_BUILD_PR"
|
||||
|
||||
# 查询 AI Code Review 状态(跨workflow,读commit status)
|
||||
AI_REVIEW_STATUS="pending"
|
||||
AI_REVIEW_DESC=""
|
||||
STATUS_JSON=$(curl -sfH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits/${PR_HEAD_SHA}/status" 2>/dev/null || true)
|
||||
if [ -n "$STATUS_JSON" ]; then
|
||||
AI_STATUS=$(echo "$STATUS_JSON" | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
for s in data.get('statuses',[]):
|
||||
if 'AI Code Review' in s.get('context',''):
|
||||
print(s['state']+'|'+s.get('description',''))
|
||||
break
|
||||
except: pass
|
||||
" 2>/dev/null)
|
||||
if [ -n "$AI_STATUS" ]; then
|
||||
AI_REVIEW_STATUS="${AI_STATUS%%|*}"
|
||||
AI_REVIEW_DESC="${AI_STATUS#*|}"
|
||||
fi
|
||||
fi
|
||||
echo " ai-code-review: $AI_REVIEW_STATUS ($AI_REVIEW_DESC)"
|
||||
|
||||
echo ""
|
||||
|
||||
# 判断PR类型
|
||||
@@ -1770,13 +1733,11 @@ jobs:
|
||||
"validate-migration:$RESULT_MIGRATION"
|
||||
"frontend-lint:$RESULT_FRONTEND_LINT"
|
||||
"build-pr:$RESULT_BUILD_PR"
|
||||
"ai-code-review:$AI_REVIEW_STATUS"
|
||||
)
|
||||
|
||||
# 后端检查
|
||||
REQUIRED_BACKEND=(
|
||||
"unit-tests:$RESULT_UNIT_TESTS"
|
||||
"integration-tests:$RESULT_INTEGRATION"
|
||||
)
|
||||
|
||||
# 前端检查
|
||||
@@ -1806,11 +1767,6 @@ jobs:
|
||||
for item in "${REQUIRED_GENERAL[@]}"; do
|
||||
name="${item%%:*}"
|
||||
result="${item##*:}"
|
||||
# AI Code Review pending时不阻塞(可能还在跑),等它跑完自然会重跑Gate
|
||||
if [ "$name" = "ai-code-review" ] && [ "$result" = "pending" ]; then
|
||||
echo " ⏳ $name: pending(审查中,暂不阻塞)"
|
||||
continue
|
||||
fi
|
||||
check_job "$name" "$result"
|
||||
done
|
||||
|
||||
@@ -1849,7 +1805,7 @@ jobs:
|
||||
echo "❌ CI Gate: FAILED"
|
||||
echo "失败项: ${FAILED_ITEMS[*]}"
|
||||
echo "gate_result=failure" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Report CI trace
|
||||
|
||||
@@ -48,7 +48,6 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
REPO_NAME: ${{ gitea.repository }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
|
||||
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
||||
LLM_PROVIDER: "coze"
|
||||
# 扣子模式配置(默认国内站 api.coze.cn)
|
||||
@@ -61,9 +60,8 @@ jobs:
|
||||
LLM_TIMEOUT: "120"
|
||||
run: |
|
||||
python3 scripts/ci_code_review.py
|
||||
# 注意:脚本退出码决定job状态
|
||||
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
|
||||
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open)
|
||||
# 审查脚本异常不影响 CI 通过
|
||||
continue-on-error: true
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
report: ${{ steps.report.outputs.report }}
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
report: ${{ steps.e2e.outputs.report }}
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
Regular → Executable
+1
-6
@@ -8,11 +8,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
concurrency:
|
||||
group: pr-automation-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
name: Auto Approve on CI Green
|
||||
@@ -61,7 +56,7 @@ jobs:
|
||||
name: Auto Merge on CI Green + Approved
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底
|
||||
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||
run: |
|
||||
set -eux
|
||||
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
|
||||
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
|
||||
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
||||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
# Install dependencies with retry
|
||||
for i in 1 2 3; do
|
||||
npm ci --no-audit --no-fund && break
|
||||
echo "npm install failed, retry $i/3..."
|
||||
echo "npm ci failed, retry $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
rm -rf node_modules
|
||||
sleep 5
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"""#1197 - 预览生成:generation_tasks 表新增 is_preview 字段
|
||||
|
||||
Revision ID: 053
|
||||
Revises: 052
|
||||
Create Date: 2026-08-15
|
||||
|
||||
Changes:
|
||||
1. generation_tasks 表新增 is_preview 字段,标记是否为预览生成任务(低清 480p)
|
||||
2. 默认 False,与现有正式生成任务兼容
|
||||
3. 加索引以支持按预览/正式任务筛选
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "053_generation_task_is_preview"
|
||||
down_revision = "052_generation_task_bgm_config"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if context.get_context().dialect.name == "postgresql":
|
||||
# 检查列是否已存在(幂等)
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'generation_tasks' AND column_name = 'is_preview'"
|
||||
)
|
||||
)
|
||||
if result.scalar() is not None:
|
||||
return
|
||||
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("is_preview", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
# 加索引
|
||||
op.create_index(
|
||||
"ix_generation_tasks_is_preview",
|
||||
"generation_tasks",
|
||||
["is_preview"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if context.get_context().dialect.name == "postgresql":
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'generation_tasks' AND column_name = 'is_preview'"
|
||||
)
|
||||
)
|
||||
if result.scalar() is None:
|
||||
return
|
||||
|
||||
op.drop_index("ix_generation_tasks_is_preview", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "is_preview")
|
||||
@@ -7,7 +7,6 @@ from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_preview import router as generation_preview_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
@@ -88,11 +87,6 @@ api_router.include_router(
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_preview_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
titles_router,
|
||||
prefix="/titles",
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
"""预览生成路由 — Phase 1:单版本预览接口(创建 + 查询)。
|
||||
|
||||
路径前缀:/api/v1/generation/preview(与 /generation/tasks 同体系)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.core.task_enqueue import (
|
||||
GLOBAL_PENDING_LIMIT,
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
CreatePreviewGenerationTaskRequest,
|
||||
PreviewGenerationTaskResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PREVIEW_RESOLUTION = "854x480"
|
||||
|
||||
|
||||
def _calc_preview_resolution(video_ratio: str = "") -> str:
|
||||
"""根据视频比例计算预览分辨率(短边 480,长边按比例)。
|
||||
|
||||
支持的比例:16:9, 9:16, 1:1, 4:3, 3:4, 其他默认 16:9。
|
||||
"""
|
||||
ratio_map = {
|
||||
"16:9": "854x480",
|
||||
"9:16": "480x854",
|
||||
"1:1": "480x480",
|
||||
"4:3": "640x480",
|
||||
"3:4": "480x640",
|
||||
}
|
||||
return ratio_map.get(video_ratio.strip(), PREVIEW_RESOLUTION)
|
||||
|
||||
|
||||
def _mark_task_failed(repo, task, reason: str) -> None:
|
||||
"""入队失败时将任务标记为 failed,避免产生僵尸 pending 数据。"""
|
||||
try:
|
||||
task.mark_failed(error_message=f"入队失败:{reason}")
|
||||
repo.update(task)
|
||||
except Exception:
|
||||
logger.exception("[预览生成] 标记任务失败时异常: task_id=%s", task.id)
|
||||
|
||||
|
||||
def _sign_video_url(raw_url: str) -> str:
|
||||
"""为私有 OSS bucket 的视频 URL 生成预签名下载链接。
|
||||
|
||||
有效期 2 小时,签名失败时降级返回原始 URL。
|
||||
"""
|
||||
if not raw_url:
|
||||
return ""
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
signed = storage.get_download_url(raw_url, expires_seconds=7200)
|
||||
# 如果返回的 URL 与原始 URL 完全不同且不是签名 URL(说明 bucket 未配置),
|
||||
# 降级返回原始 URL
|
||||
if signed and signed != raw_url:
|
||||
return signed
|
||||
if signed == raw_url:
|
||||
return raw_url
|
||||
# signed 为空或与 raw_url 无关,返回原始
|
||||
return raw_url
|
||||
except Exception:
|
||||
logger.warning("[预览] URL签名失败,降级返回原始URL: %s", raw_url[:100], exc_info=True)
|
||||
return raw_url
|
||||
|
||||
|
||||
def _to_preview_response(task, generated_videos: list | None = None) -> PreviewGenerationTaskResponse:
|
||||
"""将领域任务对象转换为预览响应 DTO。
|
||||
|
||||
Args:
|
||||
task: GenerationTask 领域对象
|
||||
generated_videos: 生成的视频列表(可选),取第一个作为 video_url
|
||||
|
||||
Returns:
|
||||
PreviewGenerationTaskResponse
|
||||
"""
|
||||
video_url = ""
|
||||
duration = 0.0
|
||||
file_size = 0
|
||||
if generated_videos:
|
||||
first_video = generated_videos[0]
|
||||
raw_url = getattr(first_video, "file_url", "") or ""
|
||||
# P0 修复:私有 bucket 需要预签名 URL,否则前端 403 → 黑屏
|
||||
video_url = _sign_video_url(raw_url)
|
||||
duration = float(getattr(first_video, "duration", 0.0) or 0.0)
|
||||
file_size = int(getattr(first_video, "file_size", 0) or 0)
|
||||
|
||||
# 从 extra_meta / metadata 中提取统计信息(如果有)
|
||||
extra_meta = getattr(task, "extra_meta", {}) or {}
|
||||
clip_count = int(extra_meta.get("clip_count", len(getattr(task, "asset_ids", [])) or 0))
|
||||
transition_count = int(extra_meta.get("transition_count", max(0, clip_count - 1)))
|
||||
material_usage = extra_meta.get("material_usage", {}) or {}
|
||||
|
||||
# 计算生成耗时
|
||||
generate_duration = 0.0
|
||||
started_at = getattr(task, "started_at", None)
|
||||
completed_at = getattr(task, "completed_at", None)
|
||||
if started_at and completed_at:
|
||||
generate_duration = (completed_at - started_at).total_seconds()
|
||||
|
||||
return PreviewGenerationTaskResponse(
|
||||
task_id=task.id,
|
||||
status=task.status.value if hasattr(task.status, "value") else str(task.status),
|
||||
progress=float(task.progress or 0.0),
|
||||
is_preview=bool(getattr(task, "is_preview", True)),
|
||||
resolution=getattr(task, "resolution", PREVIEW_RESOLUTION) or PREVIEW_RESOLUTION,
|
||||
video_url=video_url,
|
||||
duration=duration,
|
||||
file_size=file_size,
|
||||
clip_count=clip_count,
|
||||
transition_count=transition_count,
|
||||
material_usage=material_usage,
|
||||
error_message=task.error_message or "",
|
||||
created_at=task.created_at,
|
||||
started_at=started_at,
|
||||
finished_at=completed_at,
|
||||
generate_duration=generate_duration,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/preview", response_model=PreviewGenerationTaskResponse, status_code=201)
|
||||
def create_preview_generation_task(
|
||||
request: CreatePreviewGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository=Depends(get_generation_task_repository),
|
||||
) -> PreviewGenerationTaskResponse:
|
||||
"""创建预览生成任务。
|
||||
|
||||
预览为完整时长的低清版(480p + 低码率),效果与正式生成一致,仅清晰度降低。
|
||||
|
||||
Args:
|
||||
request: 预览任务创建请求(template_id + asset_ids 等)
|
||||
|
||||
Returns:
|
||||
201 + 预览任务详情
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
logger.info(
|
||||
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d",
|
||||
user_id,
|
||||
request.template_id,
|
||||
len(request.asset_ids),
|
||||
)
|
||||
|
||||
# 预检查队列限流
|
||||
try:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending + 1 > USER_PENDING_LIMIT:
|
||||
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending + 1, limit=USER_PENDING_LIMIT)
|
||||
if global_pending + 1 > GLOBAL_PENDING_LIMIT:
|
||||
raise GlobalQueueFull(pending_count=global_pending + 1, limit=GLOBAL_PENDING_LIMIT)
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待完成后再提交",
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from e
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
|
||||
try:
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id="",
|
||||
asset_library_id="",
|
||||
strategy_id="",
|
||||
voice_library_id="",
|
||||
template_id=request.template_id,
|
||||
asset_ids=list(request.asset_ids),
|
||||
title_ids=list(request.title_ids),
|
||||
voice_ids=list(request.voice_ids),
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id="",
|
||||
asset_select_mode="",
|
||||
batch_id="",
|
||||
video_title=request.video_title,
|
||||
resolution=_calc_preview_resolution(request.video_ratio),
|
||||
bgm_config=request.bgm_config or {},
|
||||
auto_retry_enabled=False,
|
||||
auto_retry_max=0,
|
||||
is_preview=True,
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning("[预览生成] 创建失败: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后重试") from e
|
||||
|
||||
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
task,
|
||||
generation_task_repository,
|
||||
user_id=user_id,
|
||||
log_prefix="[预览生成]",
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
|
||||
_mark_task_failed(generation_task_repository, task, "任务入队失败")
|
||||
raise HTTPException(status_code=500, detail="任务入队失败,请稍后重试")
|
||||
except UserPendingLimitExceeded:
|
||||
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
_mark_task_failed(generation_task_repository, task, "系统队列已满")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
|
||||
return _to_preview_response(task)
|
||||
|
||||
|
||||
@router.get("/preview/{task_id}", response_model=PreviewGenerationTaskResponse)
|
||||
def get_preview_generation_task(
|
||||
task_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository=Depends(get_generation_task_repository),
|
||||
generated_video_repository=Depends(get_generated_video_repository),
|
||||
) -> PreviewGenerationTaskResponse:
|
||||
"""查询预览生成任务状态。
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
|
||||
Returns:
|
||||
预览任务详情(含状态、进度、结果 URL 等)
|
||||
"""
|
||||
use_case = GetGenerationTaskUseCase(generation_task_repository)
|
||||
task = use_case.execute(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
|
||||
|
||||
# 权限校验:任务必须属于当前用户(统一转 str 比较,避免 UUID/str 类型差异)
|
||||
task_user_id = str(getattr(task, "created_by_user_id", "") or "")
|
||||
if not task_user_id or task_user_id != str(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该任务")
|
||||
|
||||
# 校验是否为预览任务
|
||||
if not getattr(task, "is_preview", False):
|
||||
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
|
||||
|
||||
# 查询生成的视频(取第一个)
|
||||
generated_videos = []
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if status_val == "completed":
|
||||
list_use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||
generated_videos = list_use_case.execute(task_id)
|
||||
|
||||
return _to_preview_response(task, generated_videos=generated_videos)
|
||||
@@ -24,94 +24,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
def _build_asset_analyses(
|
||||
asset_ids: list[str],
|
||||
db: Session,
|
||||
) -> dict[str, str]:
|
||||
"""调用 MediaKit 视频理解,返回 {asset_id: 分析文本}.
|
||||
|
||||
如果 MediaKit 不可用或分析失败,返回空 dict(调用方降级处理)。
|
||||
"""
|
||||
if not asset_ids:
|
||||
return {}
|
||||
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
client = get_mediakit_client()
|
||||
if not client.is_available:
|
||||
logger.info("MediaKit 未配置,跳过视频理解分析")
|
||||
return {}
|
||||
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
storage_svc = get_shared_storage_service()
|
||||
|
||||
# 查找素材并获取下载 URL(使用并行列表保持索引对应,避免 URL 重复导致映射覆盖)
|
||||
video_urls: list[str] = []
|
||||
valid_asset_ids: list[str] = []
|
||||
|
||||
for aid in asset_ids[:10]: # MediaKit 单次最多 10 个视频
|
||||
asset = asset_repo.get(aid)
|
||||
if not asset or not asset.storage_key:
|
||||
continue
|
||||
# 只处理视频素材
|
||||
mime = getattr(asset, "mime_type", "")
|
||||
if not mime.startswith("video/"):
|
||||
continue
|
||||
try:
|
||||
url = storage_svc.get_download_url(asset.storage_key)
|
||||
if url:
|
||||
video_urls.append(url)
|
||||
valid_asset_ids.append(aid)
|
||||
except Exception as e:
|
||||
logger.warning("获取素材URL失败: asset_id=%s error=%s", aid, str(e))
|
||||
|
||||
if not video_urls:
|
||||
logger.info("无可用视频素材,跳过视频理解分析")
|
||||
return {}
|
||||
|
||||
# 调用 MediaKit 视频理解
|
||||
prompt = (
|
||||
"请简要描述这段视频的主要内容,包括:场景(室内/室外/具体场所)、"
|
||||
"主体(人物/物体/动物)、动作/活动、氛围/情绪、主要色调。"
|
||||
"控制在100字以内。"
|
||||
)
|
||||
|
||||
# 限制轮询参数以适配 API 网关超时(nginx 60s)
|
||||
# 视频理解最多 30s(poll_interval=2s * max_poll_attempts=15)
|
||||
# 剩余 30s 留给 LLM 调用
|
||||
contents = client.analyze_videos(
|
||||
video_urls=video_urls,
|
||||
prompt=prompt,
|
||||
level="Economy",
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=15,
|
||||
)
|
||||
|
||||
if not contents:
|
||||
logger.warning("MediaKit 视频理解未返回结果")
|
||||
return {}
|
||||
|
||||
# 将结果映射回 asset_id(通过索引对应)
|
||||
analyses: dict[str, str] = {}
|
||||
for i, content in enumerate(contents):
|
||||
if i < len(valid_asset_ids) and content:
|
||||
analyses[valid_asset_ids[i]] = content
|
||||
|
||||
logger.info(
|
||||
"MediaKit 视频理解完成: total=%d analyzed=%d",
|
||||
len(video_urls),
|
||||
len(analyses),
|
||||
)
|
||||
return analyses
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("MediaKit 视频理解异常,将降级到无分析模式: %s", str(e))
|
||||
return {}
|
||||
|
||||
|
||||
@router.post("/ai-recommend", response_model=AIRecommendResponse)
|
||||
def editor_ai_recommend(
|
||||
template_id: str,
|
||||
@@ -134,16 +46,12 @@ def editor_ai_recommend(
|
||||
|
||||
from packages.shared.ai_service import run_ai_recommend
|
||||
|
||||
# 调用 MediaKit 视频理解,获取素材内容分析
|
||||
asset_analyses = _build_asset_analyses(body.asset_ids, db)
|
||||
|
||||
result = run_ai_recommend(
|
||||
plan_id=plan_id,
|
||||
template_id=plan.template_id,
|
||||
asset_ids=body.asset_ids,
|
||||
editing_mode=body.editing_mode,
|
||||
target_duration=body.target_duration,
|
||||
asset_analyses=asset_analyses,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -184,29 +184,6 @@ def editor_generate_cover(
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# 获取第一个视频的下载 URL(用于 MediaKit 抽帧)
|
||||
primary_video_url = None
|
||||
if body.asset_ids and body.cover_type in ("ai_frame", "ai_regenerate"):
|
||||
try:
|
||||
from app.database import get_db_session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
with get_db_session() as session:
|
||||
asset_repo = SQLAlchemyAssetRepository(session)
|
||||
first_asset = asset_repo.get(body.asset_ids[0])
|
||||
if first_asset and first_asset.storage_key:
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_download_url(first_asset.storage_key)
|
||||
logger.info(
|
||||
"获取视频URL用于封面生成: asset_id=%s url=%s",
|
||||
body.asset_ids[0],
|
||||
primary_video_url[:80] if primary_video_url else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("获取视频URL失败,将使用stub封面: %s", str(e))
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
cover_data = run_generate_cover(
|
||||
@@ -214,7 +191,6 @@ def editor_generate_cover(
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
primary_video_url=primary_video_url,
|
||||
)
|
||||
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
|
||||
@@ -11,7 +11,6 @@ from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repo
|
||||
from app.schemas.voice_clone import (
|
||||
CreateVoiceCloneRequest,
|
||||
ListVoiceCloneResponse,
|
||||
VoiceClonePreviewResponse,
|
||||
VoiceCloneProfileResponse,
|
||||
VoiceCloneStatusResponse,
|
||||
)
|
||||
@@ -20,7 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
|
||||
SQLAlchemyVoiceCloneProfileRepository,
|
||||
)
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
from packages.application.voice_clone.use_cases import (
|
||||
DeleteVoiceCloneUseCase,
|
||||
GetVoiceCloneStatusUseCase,
|
||||
@@ -37,13 +36,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 克隆音色试听缓存(减少重复TTS调用)
|
||||
# key: clone_id, value: (audio_url, duration, file_size, text, timestamp)
|
||||
_clone_preview_cache: dict[str, tuple[str, float, int, str, float]] = {}
|
||||
CLONE_PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
|
||||
# 默认试听文本
|
||||
CLONE_PREVIEW_TEMPLATE = "你好,这是我的克隆音色,很高兴能为你配音。"
|
||||
|
||||
|
||||
def _to_response(profile) -> VoiceCloneProfileResponse:
|
||||
# source_audio_url 是用户传入的原始 URL(可能是外部地址),不做预签名转换
|
||||
@@ -231,78 +223,3 @@ def retry_voice_clone(
|
||||
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
|
||||
|
||||
return _to_response(profile)
|
||||
|
||||
|
||||
@router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse)
|
||||
def get_voice_clone_preview(
|
||||
clone_id: str,
|
||||
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> VoiceClonePreviewResponse:
|
||||
"""获取克隆音色试听音频(实时 TTS 合成)。
|
||||
|
||||
- 克隆音色必须处于 ready 状态
|
||||
- 使用默认试听文本时,结果缓存 7 天
|
||||
- 可传入自定义 text 参数试听不同文本
|
||||
"""
|
||||
import time
|
||||
|
||||
use_case = GetVoiceCloneUseCase(repository)
|
||||
try:
|
||||
profile = use_case.execute(clone_id, authenticated_user.user.id)
|
||||
except VoiceCloneNotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
||||
|
||||
if not profile.is_ready:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Voice clone is not ready (current status: {profile.status})",
|
||||
)
|
||||
|
||||
# 有自定义文本时不缓存
|
||||
use_cache = not text.strip()
|
||||
|
||||
if use_cache and clone_id in _clone_preview_cache:
|
||||
audio_url, duration, file_size, cached_text, cached_at = _clone_preview_cache[clone_id]
|
||||
if time.time() - cached_at < CLONE_PREVIEW_CACHE_TTL:
|
||||
return VoiceClonePreviewResponse(
|
||||
clone_id=clone_id,
|
||||
voice_id=profile.voice_id,
|
||||
audio_url=audio_url,
|
||||
text=cached_text,
|
||||
duration=duration,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
# 合成试听音频
|
||||
preview_text = text.strip() or CLONE_PREVIEW_TEMPLATE
|
||||
try:
|
||||
result = cosyvoice.synthesize_speech(
|
||||
text=preview_text,
|
||||
voice_id=profile.voice_id,
|
||||
format="mp3",
|
||||
speed=1.0,
|
||||
)
|
||||
except CosyVoiceError as e:
|
||||
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
|
||||
|
||||
# 缓存(仅默认试听文本)
|
||||
if use_cache:
|
||||
_clone_preview_cache[clone_id] = (
|
||||
result.audio_url,
|
||||
result.duration,
|
||||
result.file_size,
|
||||
preview_text,
|
||||
time.time(),
|
||||
)
|
||||
|
||||
return VoiceClonePreviewResponse(
|
||||
clone_id=clone_id,
|
||||
voice_id=profile.voice_id,
|
||||
audio_url=result.audio_url,
|
||||
text=preview_text,
|
||||
duration=result.duration,
|
||||
file_size=result.file_size,
|
||||
)
|
||||
|
||||
Executable → Regular
+16
-129
@@ -5,8 +5,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Literal, Optional
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
@@ -44,7 +42,6 @@ from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 预置音色试听音频缓存(内存缓存,减少重复TTS调用)
|
||||
# key: voice_id, value: (audio_url, timestamp)
|
||||
@@ -54,65 +51,6 @@ PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
|
||||
PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。"
|
||||
|
||||
|
||||
def _resolve_preset_preview_url(
|
||||
voice_id: str,
|
||||
fallback_url: str,
|
||||
cosyvoice: CosyVoiceService,
|
||||
) -> str:
|
||||
"""为预置音色获取有效的 preview_url.
|
||||
|
||||
优先从内存缓存读取;缓存失效时调用 CosyVoice 重新合成;
|
||||
合成失败时降级返回硬编码 URL(可能已过期,但不会报错)。
|
||||
"""
|
||||
# 检查缓存
|
||||
if voice_id in _preset_preview_cache:
|
||||
audio_url, cached_at = _preset_preview_cache[voice_id]
|
||||
if time.time() - cached_at < PREVIEW_CACHE_TTL:
|
||||
return audio_url
|
||||
|
||||
# 缓存失效,调用 CosyVoice 合成
|
||||
preset = get_preset_voice_by_id(voice_id)
|
||||
if preset is None:
|
||||
return fallback_url
|
||||
|
||||
preview_text = PREVIEW_TEMPLATE.format(name=preset.name)
|
||||
try:
|
||||
result = cosyvoice.synthesize_speech(
|
||||
text=preview_text,
|
||||
voice_id=voice_id,
|
||||
format="mp3",
|
||||
speed=1.0,
|
||||
)
|
||||
audio_url = result.audio_url
|
||||
_preset_preview_cache[voice_id] = (audio_url, time.time())
|
||||
logger.info("Preset voice preview generated: %s", voice_id)
|
||||
return audio_url
|
||||
except Exception as e:
|
||||
logger.warning("Failed to generate preview for %s, using fallback: %s", voice_id, e)
|
||||
return fallback_url
|
||||
|
||||
|
||||
def _resolve_all_preset_preview_urls(
|
||||
presets: list,
|
||||
cosyvoice: CosyVoiceService,
|
||||
) -> dict[str, str]:
|
||||
"""顺序解析所有预置音色的 preview_url.
|
||||
|
||||
采用顺序调用(而非并行)以避免触发 DashScope API 速率限制。
|
||||
首次调用后结果缓存 7 天,后续请求直接命中缓存。
|
||||
|
||||
Returns:
|
||||
voice_id -> preview_url 映射
|
||||
"""
|
||||
result_map: dict[str, str] = {}
|
||||
for p in presets:
|
||||
try:
|
||||
result_map[p.voice_id] = _resolve_preset_preview_url(p.voice_id, p.preview_url, cosyvoice)
|
||||
except Exception:
|
||||
result_map[p.voice_id] = p.preview_url
|
||||
return result_map
|
||||
|
||||
|
||||
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
|
||||
return SQLAlchemyVoiceLibraryRepository(session)
|
||||
|
||||
@@ -180,16 +118,8 @@ def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None
|
||||
)
|
||||
|
||||
|
||||
def _preset_to_unified_response(preset, preview_url_map: dict[str, str] | None = None) -> UnifiedVoiceItemResponse:
|
||||
"""将预置音色转换为统一响应格式。
|
||||
|
||||
Args:
|
||||
preset: 预置音色对象
|
||||
preview_url_map: voice_id -> preview_url 动态映射,优先使用
|
||||
"""
|
||||
preview_url = preset.preview_url
|
||||
if preview_url_map and preset.voice_id in preview_url_map:
|
||||
preview_url = preview_url_map[preset.voice_id]
|
||||
def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
|
||||
"""将预置音色转换为统一响应格式。"""
|
||||
return UnifiedVoiceItemResponse(
|
||||
id=preset.voice_id,
|
||||
type="preset",
|
||||
@@ -199,40 +129,11 @@ def _preset_to_unified_response(preset, preview_url_map: dict[str, str] | None =
|
||||
language=preset.language,
|
||||
voice_id=preset.voice_id,
|
||||
voice_provider="cosyvoice",
|
||||
preview_url=preview_url,
|
||||
preview_url=preset.preview_url,
|
||||
tags=preset.tags or [],
|
||||
)
|
||||
|
||||
|
||||
def _clone_profile_to_unified_response(profile) -> UnifiedVoiceItemResponse:
|
||||
"""将克隆音色档案转换为统一响应格式。
|
||||
|
||||
注意:克隆音色是「音色模型」(可用于 TTS 合成任意文本),
|
||||
不同于配音库条目(具体的配音作品)。
|
||||
"""
|
||||
return UnifiedVoiceItemResponse(
|
||||
id=profile.id,
|
||||
type="clone",
|
||||
name=profile.name,
|
||||
description=profile.description or "",
|
||||
gender=profile.gender or "unknown",
|
||||
language=profile.language or "zh-CN",
|
||||
voice_id=profile.voice_id or "",
|
||||
voice_provider=profile.voice_model or "cosyvoice",
|
||||
audio_url="", # 克隆音色没有预合成音频,需通过 /voice-clones/{id}/preview 试听
|
||||
preview_url="", # 试听需实时合成,前端调用 preview 接口
|
||||
duration=0,
|
||||
file_size=0,
|
||||
status=profile.status.value if hasattr(profile.status, "value") else str(profile.status),
|
||||
tags=[],
|
||||
user_id=profile.user_id,
|
||||
project_id=None,
|
||||
voice_clone_profile_id=profile.id,
|
||||
created_at=profile.created_at,
|
||||
updated_at=profile.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 统一配音列表(预置 + 克隆)====================
|
||||
|
||||
|
||||
@@ -249,7 +150,6 @@ def list_voices_unified(
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> UnifiedVoiceListResponse:
|
||||
"""获取配音列表(预置音色 + 用户克隆音色)。
|
||||
|
||||
@@ -265,30 +165,19 @@ def list_voices_unified(
|
||||
has_preset = type is None or type == "preset"
|
||||
has_clone = type is None or type == "clone"
|
||||
|
||||
# 获取预置音色(动态生成 preview_url)
|
||||
# 获取预置音色
|
||||
if has_preset:
|
||||
preview_url_map = _resolve_all_preset_preview_urls(PRESET_VOICES, cosyvoice)
|
||||
preset_items = [_preset_to_unified_response(p, preview_url_map) for p in PRESET_VOICES]
|
||||
preset_items = [_preset_to_unified_response(p) for p in PRESET_VOICES]
|
||||
preset_count = len(preset_items)
|
||||
|
||||
# 获取克隆音色(从 voice_clone_profile 读取,ready 状态的克隆音色)
|
||||
# 获取克隆音色
|
||||
if has_clone:
|
||||
# status_filter 映射:不传则默认只返回 ready 状态(可用的克隆音色)
|
||||
# 前端可以传 status=all 获取所有状态,或传具体状态过滤
|
||||
filter_status = None
|
||||
if status_filter and status_filter != "all":
|
||||
filter_status = status_filter
|
||||
elif not status_filter:
|
||||
filter_status = "ready"
|
||||
|
||||
clone_profiles = clone_profile_repository.list_by_user(
|
||||
user_id,
|
||||
status=filter_status,
|
||||
limit=limit,
|
||||
offset=skip,
|
||||
)
|
||||
clone_count = clone_profile_repository.count_by_user(user_id, status=filter_status)
|
||||
clone_items = [_clone_profile_to_unified_response(p) for p in clone_profiles]
|
||||
use_case = ListVoiceLibraryUseCase(voice_repository)
|
||||
clone_items_raw, clone_count = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
|
||||
# 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id
|
||||
voice_ids = [i.voice_id for i in clone_items_raw if i.voice_id]
|
||||
profile_id_map = clone_profile_repository.find_profile_ids_by_voice_ids(voice_ids) if voice_ids else {}
|
||||
clone_items = [_to_unified_response(i, profile_id_map, sign_url) for i in clone_items_raw]
|
||||
|
||||
# 组装结果
|
||||
if type == "preset":
|
||||
@@ -315,15 +204,11 @@ def list_voices_unified(
|
||||
|
||||
|
||||
@router.get("/presets", response_model=PresetVoiceListResponse)
|
||||
def list_preset_voices(
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> PresetVoiceListResponse:
|
||||
def list_preset_voices() -> PresetVoiceListResponse:
|
||||
"""获取预置音色列表。
|
||||
|
||||
不需要认证,返回所有系统预置的 CosyVoice 音色。
|
||||
preview_url 通过 CosyVoice 动态生成,不依赖硬编码的过期 URL。
|
||||
"""
|
||||
preview_url_map = _resolve_all_preset_preview_urls(PRESET_VOICES, cosyvoice)
|
||||
items = [
|
||||
PresetVoiceItemResponse(
|
||||
voice_id=p.voice_id,
|
||||
@@ -331,7 +216,7 @@ def list_preset_voices(
|
||||
description=p.description,
|
||||
gender=p.gender,
|
||||
language=p.language,
|
||||
preview_url=preview_url_map.get(p.voice_id, p.preview_url),
|
||||
preview_url=p.preview_url,
|
||||
tags=p.tags or [],
|
||||
)
|
||||
for p in PRESET_VOICES
|
||||
@@ -351,6 +236,8 @@ def get_preset_voice_preview(
|
||||
- 相同 voice_id 重复调用直接返回缓存的音频URL
|
||||
- 可传入自定义 text 参数试听不同文本
|
||||
"""
|
||||
import time
|
||||
|
||||
preset = get_preset_voice_by_id(voice_id)
|
||||
if preset is None:
|
||||
raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
@@ -123,62 +122,3 @@ class ListGenerationTasksResponse(BaseModel):
|
||||
"""用户级生成任务列表响应(跨 project)。"""
|
||||
|
||||
items: list[GenerationTaskResponse]
|
||||
|
||||
|
||||
# ── 预览生成(Phase 1) ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
"""创建预览生成任务请求。
|
||||
|
||||
仅支持模板模式:template_id + asset_ids 等素材 ID 列表。
|
||||
预览为完整时长低清版(480p + 低码率)。
|
||||
"""
|
||||
|
||||
template_id: str
|
||||
asset_ids: list[str] = Field(default_factory=list)
|
||||
title_ids: list[str] = Field(default_factory=list)
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
||||
duration: float = Field(default=0.0, ge=0, description="期望视频时长(秒),0 表示由模板决定")
|
||||
video_ratio: str = Field(default="", description="视频比例,如 16:9 / 9:16,为空使用模板默认")
|
||||
bgm_config: dict = Field(
|
||||
default_factory=dict,
|
||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
if not self.template_id.strip():
|
||||
raise ValueError("template_id 不能为空")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_asset_ids(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
if not self.asset_ids and not self.title_ids and not self.voice_ids:
|
||||
raise ValueError("asset_ids/title_ids/voice_ids 至少需要提供一个")
|
||||
return self
|
||||
|
||||
|
||||
class PreviewGenerationTaskResponse(BaseModel):
|
||||
"""预览生成任务响应。
|
||||
|
||||
包含任务状态、进度、分辨率、生成结果 URL 等关键字段。
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
status: str
|
||||
progress: float
|
||||
is_preview: bool = True
|
||||
resolution: str = ""
|
||||
video_url: str = ""
|
||||
duration: float = 0.0
|
||||
file_size: int = 0
|
||||
clip_count: int = 0
|
||||
transition_count: int = 0
|
||||
material_usage: dict = Field(default_factory=dict)
|
||||
error_message: str = ""
|
||||
created_at: datetime | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
generate_duration: float = 0.0
|
||||
|
||||
Executable → Regular
-22
@@ -63,25 +63,3 @@ class ListVoiceCloneResponse(BaseModel):
|
||||
|
||||
items: List[VoiceCloneProfileResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class VoiceClonePreviewResponse(BaseModel):
|
||||
"""克隆音色试听响应。"""
|
||||
|
||||
clone_id: str
|
||||
"""音色克隆档案 ID"""
|
||||
|
||||
voice_id: str
|
||||
"""CosyVoice 音色 ID"""
|
||||
|
||||
audio_url: str
|
||||
"""试听音频 URL"""
|
||||
|
||||
text: str
|
||||
"""试听文本"""
|
||||
|
||||
duration: float = 0.0
|
||||
"""音频时长(秒)"""
|
||||
|
||||
file_size: int = 0
|
||||
"""文件大小(字节)"""
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
import axios from "axios"
|
||||
import apiClient from "./client"
|
||||
|
||||
// 类型定义
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 注册
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
|
||||
// 请求密码重置
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 验证邮箱
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ========== 微信登录 ========== */
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
|
||||
// 获取微信授权链接
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 微信回调登录
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 绑定联系方式
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
import type { SendVerificationCodeRequest, BindContactRequest, BindContactResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定联系方式
|
||||
*/
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
import type { User, UserResponse } from "./types"
|
||||
import { normalizeUser } from "./user"
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*/
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
* 保持向后兼容,从子模块 re-export
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
User,
|
||||
UserResponse,
|
||||
WechatAuthUrlResponse,
|
||||
WechatCallbackResponse,
|
||||
SendVerificationCodeRequest,
|
||||
BindContactRequest,
|
||||
BindContactResponse,
|
||||
} from "./types"
|
||||
|
||||
// 用户工具函数
|
||||
export { normalizeUser } from "./user"
|
||||
|
||||
// 登录/注册/登出/刷新
|
||||
export { login, refreshAccessToken, register, logout } from "./login"
|
||||
|
||||
// 当前用户
|
||||
export { getCurrentUser } from "./currentUser"
|
||||
|
||||
// 密码重置
|
||||
export { requestPasswordReset, resetPassword } from "./password"
|
||||
|
||||
// 邮箱验证
|
||||
export { verifyEmail } from "./email"
|
||||
|
||||
// 微信登录
|
||||
export { getWechatAuthUrl, wechatCallback } from "./wechat"
|
||||
|
||||
// 联系方式
|
||||
export { sendVerificationCode, bindContact } from "./contact"
|
||||
@@ -1,37 +0,0 @@
|
||||
import axios from "axios"
|
||||
import apiClient from "../client"
|
||||
import type { LoginRequest, LoginResponse, RegisterRequest } from "./types"
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
*/
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/**
|
||||
* 请求密码重置
|
||||
*/
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* 认证相关类型定义
|
||||
*/
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { User, UserResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 规范化用户数据,兼容不同后端返回格式
|
||||
*/
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 获取微信授权链接
|
||||
*/
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回调登录
|
||||
*/
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
@@ -57,21 +57,7 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
|
||||
// 401 → 尝试刷新 Token
|
||||
// 排除 auth 端点:登录/注册/找回密码的 401 是正常业务响应(如密码错误),
|
||||
// 不应触发 token 刷新或登出跳转,走后面的错误提示逻辑即可
|
||||
const requestUrl = originalRequest?.url || ""
|
||||
const isAuthEndpoint =
|
||||
requestUrl.includes("/auth/login") ||
|
||||
requestUrl.includes("/auth/register") ||
|
||||
requestUrl.includes("/auth/forgot-password") ||
|
||||
requestUrl.includes("/auth/reset-password")
|
||||
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
originalRequest &&
|
||||
!originalRequest._retry &&
|
||||
!isAuthEndpoint
|
||||
) {
|
||||
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||
const refreshToken = useAuthStore.getState().refreshToken
|
||||
|
||||
// 无 refresh_token → 直接登出
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export type {
|
||||
PreviewStatus,
|
||||
CreatePreviewRequest,
|
||||
CreatePreviewResponse,
|
||||
PreviewTaskResponse,
|
||||
} from "./types"
|
||||
|
||||
export { createPreview, getPreviewStatus } from "./preview"
|
||||
@@ -1,16 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
import type { CreatePreviewRequest, CreatePreviewResponse, PreviewTaskResponse } from "./types"
|
||||
|
||||
/** 创建预览生成任务(单版本) */
|
||||
export const createPreview = async (
|
||||
params: CreatePreviewRequest,
|
||||
): Promise<CreatePreviewResponse> => {
|
||||
const response = await apiClient.post<CreatePreviewResponse>("/generation/preview", params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 查询预览任务状态及结果 */
|
||||
export const getPreviewStatus = async (taskId: string): Promise<PreviewTaskResponse> => {
|
||||
const response = await apiClient.get<PreviewTaskResponse>(`/generation/preview/${taskId}`)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/** 预览任务状态 */
|
||||
export type PreviewStatus = "pending" | "generating" | "completed" | "failed" | "cancelled"
|
||||
|
||||
/** 创建预览任务请求 */
|
||||
export interface CreatePreviewRequest {
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids?: string[]
|
||||
voice_ids?: string[]
|
||||
video_title?: string
|
||||
duration?: number
|
||||
video_ratio?: string
|
||||
bgm_config?: {
|
||||
enabled: boolean
|
||||
preset_id?: string
|
||||
volume?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建预览任务响应 */
|
||||
export interface CreatePreviewResponse {
|
||||
task_id: string
|
||||
status: PreviewStatus
|
||||
is_preview: boolean
|
||||
resolution: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 预览任务详情响应 */
|
||||
export interface PreviewTaskResponse {
|
||||
task_id: string
|
||||
status: PreviewStatus
|
||||
progress: number
|
||||
is_preview: boolean
|
||||
resolution: string
|
||||
video_url?: string
|
||||
duration?: number
|
||||
file_size?: number
|
||||
clip_count?: number
|
||||
transition_count?: number
|
||||
material_usage?: number
|
||||
error_message?: string
|
||||
created_at: string
|
||||
started_at?: string
|
||||
finished_at?: string
|
||||
generate_duration?: number
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
VoiceCloneListParams,
|
||||
ListVoiceCloneResponse,
|
||||
VoiceCloneStatusResponse,
|
||||
VoiceClonePreviewResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 获取克隆音色列表(返回前端兼容数组) */
|
||||
@@ -86,17 +85,3 @@ export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> =>
|
||||
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取克隆音色试听音频(实时 TTS 合成) */
|
||||
export const getVoiceClonePreview = async (
|
||||
cloneId: string,
|
||||
text?: string,
|
||||
): Promise<VoiceClonePreviewResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (text) searchParams.set("text", text)
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<VoiceClonePreviewResponse>(
|
||||
`/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ export type {
|
||||
VoiceCloneStatusResponse,
|
||||
CreateVoiceCloneRequestFull,
|
||||
VoiceCloneListParams,
|
||||
VoiceClonePreviewResponse,
|
||||
} from "./types"
|
||||
|
||||
// 工具函数
|
||||
@@ -30,5 +29,4 @@ export {
|
||||
updateVoiceClone,
|
||||
getVoiceCloneStatus,
|
||||
retryVoiceClone,
|
||||
getVoiceClonePreview,
|
||||
} from "./clones"
|
||||
|
||||
@@ -90,24 +90,3 @@ export interface VoiceCloneListParams {
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/** 克隆音色试听响应 */
|
||||
export interface VoiceClonePreviewResponse {
|
||||
clone_id: string
|
||||
/** 音色克隆档案 ID */
|
||||
|
||||
voice_id: string
|
||||
/** CosyVoice 音色 ID */
|
||||
|
||||
audio_url: string
|
||||
/** 试听音频 URL */
|
||||
|
||||
text: string
|
||||
/** 试听文本 */
|
||||
|
||||
duration: number
|
||||
/** 音频时长(秒) */
|
||||
|
||||
file_size: number
|
||||
/** 文件大小(字节) */
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import React from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||
import { formatSize, formatDuration, getQualityLevel } from "./utils"
|
||||
|
||||
interface AssetCardProps {
|
||||
asset: MediaAsset
|
||||
isSelected: boolean
|
||||
isDragging: boolean
|
||||
isDragOver: boolean
|
||||
showCheckbox: boolean
|
||||
compact?: boolean
|
||||
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
|
||||
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
|
||||
onDragStart: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onDragEnd: () => void
|
||||
onMouseEnter: (e: React.MouseEvent) => void
|
||||
onMouseLeave: () => void
|
||||
}
|
||||
|
||||
const AssetCard: React.FC<AssetCardProps> = ({
|
||||
asset,
|
||||
isSelected,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
showCheckbox,
|
||||
onToggleSelect,
|
||||
onCardClick,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}) => {
|
||||
const qualityLevel = getQualityLevel(asset.quality_score)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"as-card",
|
||||
isSelected ? "selected" : "",
|
||||
isDragging ? "dragging" : "",
|
||||
isDragOver ? "drag-over" : "",
|
||||
showCheckbox ? "has-checkbox" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={(e) => onCardClick(asset, e)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
<div className="as-card-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
|
||||
) : (
|
||||
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
)}
|
||||
|
||||
{/* Checkbox */}
|
||||
{showCheckbox && (
|
||||
<span
|
||||
data-checkbox
|
||||
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(asset, e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 类型角标 */}
|
||||
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||
|
||||
{/* 时长角标 */}
|
||||
{asset.duration != null && (
|
||||
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
|
||||
)}
|
||||
|
||||
{/* 质量分角标 */}
|
||||
{asset.quality_score != null && (
|
||||
<span
|
||||
className={`as-card-quality ${qualityLevel}`}
|
||||
title={`质量分: ${asset.quality_score}`}
|
||||
>
|
||||
{asset.quality_score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-card-info">
|
||||
<p className="as-card-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetCard
|
||||
@@ -1,99 +0,0 @@
|
||||
import React from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||
import { formatSize, formatDuration, getQualityColor } from "./utils"
|
||||
|
||||
interface AssetListItemProps {
|
||||
asset: MediaAsset
|
||||
isSelected: boolean
|
||||
isDragging: boolean
|
||||
isDragOver: boolean
|
||||
showCheckbox: boolean
|
||||
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
|
||||
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
|
||||
onDragStart: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onDragEnd: () => void
|
||||
onMouseEnter: (e: React.MouseEvent) => void
|
||||
onMouseLeave: () => void
|
||||
}
|
||||
|
||||
const AssetListItem: React.FC<AssetListItemProps> = ({
|
||||
asset,
|
||||
isSelected,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
showCheckbox,
|
||||
onToggleSelect,
|
||||
onCardClick,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"as-list-item",
|
||||
isSelected ? "selected" : "",
|
||||
isDragging ? "dragging" : "",
|
||||
isDragOver ? "drag-over" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={(e) => onCardClick(asset, e)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<span className="as-list-item-drag" title="拖拽排序">
|
||||
⠿
|
||||
</span>
|
||||
|
||||
{/* Checkbox */}
|
||||
{showCheckbox && (
|
||||
<span
|
||||
data-checkbox
|
||||
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(asset, e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-list-item-info">
|
||||
<div className="as-list-item-name">{asset.name}</div>
|
||||
<div className="as-list-item-meta">
|
||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
||||
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 质量分 */}
|
||||
{asset.quality_score != null && (
|
||||
<span
|
||||
className="as-list-item-quality"
|
||||
style={{ color: getQualityColor(asset.quality_score) }}
|
||||
>
|
||||
{asset.quality_score}分
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetListItem
|
||||
@@ -9,20 +9,73 @@
|
||||
* - 筛选增强:类型筛选 + 质量分筛选
|
||||
* - 视图切换:网格视图 / 列表视图
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
|
||||
import "./AssetSelector.css"
|
||||
import type { AssetSelectorProps } from "./types"
|
||||
import useAssetFilter from "./hooks/useAssetFilter"
|
||||
import useAssetSelection from "./hooks/useAssetSelection"
|
||||
import useDragReorder from "./hooks/useDragReorder"
|
||||
import useAssetPreview from "./hooks/useAssetPreview"
|
||||
import SelectorToolbar from "./SelectorToolbar"
|
||||
import SelectorBatchBar from "./SelectorBatchBar"
|
||||
import AssetCard from "./AssetCard"
|
||||
import AssetListItem from "./AssetListItem"
|
||||
import PreviewOverlay from "./PreviewOverlay"
|
||||
import EmptyState from "./EmptyState"
|
||||
import { Input, Select, Button } from "@/components/ui"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/template-editor"
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export interface AssetSelectorProps {
|
||||
assets: MediaAsset[]
|
||||
selectedIds?: string[]
|
||||
onSelectionChange?: (ids: string[]) => void
|
||||
onAssetDragStart?: (asset: MediaAsset) => void
|
||||
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||
showQualityFilter?: boolean
|
||||
showBatchSelect?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
type ViewMode = "grid" | "list"
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatSize = (bytes?: number): string => {
|
||||
if (!bytes) return ""
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return ""
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
|
||||
}
|
||||
|
||||
/** 获取质量分等级 */
|
||||
const getQualityLevel = (score?: number): string => {
|
||||
if (score == null) return "none"
|
||||
if (score >= 90) return "excellent"
|
||||
if (score >= 70) return "good"
|
||||
if (score >= 50) return "fair"
|
||||
return "poor"
|
||||
}
|
||||
|
||||
/** 质量分颜色 */
|
||||
const getQualityColor = (score?: number): string => {
|
||||
if (score == null) return "var(--text-secondary)"
|
||||
if (score >= 90) return "var(--success-color, #10b981)"
|
||||
if (score >= 70) return "var(--primary-color, #6366f1)"
|
||||
if (score >= 50) return "var(--warning-color, #f59e0b)"
|
||||
return "var(--error-color, #ef4444)"
|
||||
}
|
||||
|
||||
/* ──────────── 类型筛选选项 ──────────── */
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: "", label: "全部类型" },
|
||||
{ value: "video", label: "🎬 视频" },
|
||||
{ value: "image", label: "🖼️ 图片" },
|
||||
{ value: "audio", label: "🎵 音频" },
|
||||
]
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
assets,
|
||||
@@ -34,41 +87,162 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
showBatchSelect = true,
|
||||
compact = false,
|
||||
}) => {
|
||||
// 搜索 & 筛选
|
||||
const {
|
||||
searchText,
|
||||
filterType,
|
||||
filterQuality,
|
||||
viewMode,
|
||||
filteredAssets,
|
||||
setSearchText,
|
||||
setFilterType,
|
||||
setFilterQuality,
|
||||
setViewMode,
|
||||
} = useAssetFilter(assets)
|
||||
/* ── 搜索 & 筛选 ── */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState("")
|
||||
const [filterQuality, setFilterQuality] = useState("")
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid")
|
||||
|
||||
// 选中操作
|
||||
const { selectedSet, toggleSelect, clearSelection } = useAssetSelection({
|
||||
filteredAssets,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
})
|
||||
/* ── 拖拽状态 ── */
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
|
||||
// 拖拽排序
|
||||
const { dragIdx, dragOverIdx, handleDragStart, handleDragOver, handleDrop, handleDragEnd } =
|
||||
useDragReorder({
|
||||
filteredAssets,
|
||||
selectedIds,
|
||||
onAssetDragStart,
|
||||
onReorder,
|
||||
})
|
||||
/* ── 悬浮预览 ── */
|
||||
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
|
||||
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
|
||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// 悬浮预览
|
||||
const { previewAsset, previewPos, handleMouseEnter, handleMouseLeave } = useAssetPreview()
|
||||
/* ── Shift 连选 ── */
|
||||
const lastClickedIdx = useRef<number | null>(null)
|
||||
|
||||
/* ── 过滤后的素材列表 ── */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets
|
||||
if (searchText) {
|
||||
const q = searchText.toLowerCase()
|
||||
list = list.filter(
|
||||
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
if (filterType) {
|
||||
list = list.filter((a) => a.type === filterType)
|
||||
}
|
||||
if (filterQuality) {
|
||||
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
|
||||
if (opt?.min != null && opt?.max != null) {
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
|
||||
)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}, [assets, searchText, filterType, filterQuality])
|
||||
|
||||
/* ── 选中状态 ── */
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
|
||||
/* ── 选择操作 ── */
|
||||
const toggleSelect = useCallback(
|
||||
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
|
||||
if (!onSelectionChange) return
|
||||
|
||||
if (shiftKey && lastClickedIdx.current !== null) {
|
||||
// Shift 连选
|
||||
const start = Math.min(lastClickedIdx.current, idx)
|
||||
const end = Math.max(lastClickedIdx.current, idx)
|
||||
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
|
||||
const newSet = new Set(selectedIds)
|
||||
rangeIds.forEach((id) => newSet.add(id))
|
||||
onSelectionChange(Array.from(newSet))
|
||||
} else {
|
||||
const newSet = new Set(selectedIds)
|
||||
if (newSet.has(asset.id)) {
|
||||
newSet.delete(asset.id)
|
||||
} else {
|
||||
newSet.add(asset.id)
|
||||
}
|
||||
onSelectionChange(Array.from(newSet))
|
||||
}
|
||||
lastClickedIdx.current = idx
|
||||
},
|
||||
[onSelectionChange, selectedIds, filteredAssets],
|
||||
)
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
onSelectionChange?.([])
|
||||
}, [onSelectionChange])
|
||||
|
||||
/* ── 拖拽排序 ── */
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
setDragIdx(idx)
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
e.dataTransfer.setData("text/plain", String(idx))
|
||||
// 设置素材数据,供 TimelinePanel 接收(P1-2 修复)
|
||||
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
|
||||
// 批量拖拽:如果有多个选中素材,一起携带
|
||||
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
|
||||
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
|
||||
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
|
||||
}
|
||||
// 通知父组件素材拖拽开始
|
||||
if (onAssetDragStart) {
|
||||
onAssetDragStart(filteredAssets[idx])
|
||||
}
|
||||
},
|
||||
[filteredAssets, selectedIds, onAssetDragStart],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
if (dragIdx === null || dragIdx === idx) return
|
||||
setDragOverIdx(idx)
|
||||
},
|
||||
[dragIdx],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault()
|
||||
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
|
||||
onReorder(dragIdx, toIdx)
|
||||
}
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
},
|
||||
[dragIdx, onReorder],
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
}, [])
|
||||
|
||||
/* ── 悬浮预览 ── */
|
||||
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||
previewTimer.current = setTimeout(() => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
setPreviewAsset(asset)
|
||||
setPreviewPos({
|
||||
x: rect.right + 12,
|
||||
y: Math.max(8, rect.top - 20),
|
||||
})
|
||||
}, 400)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (previewTimer.current) {
|
||||
clearTimeout(previewTimer.current)
|
||||
previewTimer.current = null
|
||||
}
|
||||
setPreviewAsset(null)
|
||||
}, [])
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 点击卡片 ── */
|
||||
const handleCardClick = useCallback(
|
||||
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
|
||||
// 如果点击的是 checkbox 区域,不触发卡片点击
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest("[data-checkbox]")) return
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
@@ -76,82 +250,253 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
[toggleSelect],
|
||||
)
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
|
||||
const hasSelection = selectedIds.length > 0
|
||||
|
||||
return (
|
||||
<div className="as-container">
|
||||
{/* 工具栏 */}
|
||||
<SelectorToolbar
|
||||
searchText={searchText}
|
||||
filterType={filterType}
|
||||
filterQuality={filterQuality}
|
||||
viewMode={viewMode}
|
||||
showQualityFilter={showQualityFilter}
|
||||
onSearchChange={setSearchText}
|
||||
onTypeChange={setFilterType}
|
||||
onQualityChange={setFilterQuality}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
{/* ═══ 工具栏 ═══ */}
|
||||
<div className="as-toolbar">
|
||||
<div className="as-toolbar-left">
|
||||
<div className="as-search">
|
||||
<Input
|
||||
placeholder="搜索素材..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
prefix="🔍"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={(v: string) => setFilterType(v)}
|
||||
options={TYPE_OPTIONS}
|
||||
/>
|
||||
{showQualityFilter && (
|
||||
<Select
|
||||
value={filterQuality}
|
||||
onChange={(v: string) => setFilterQuality(v)}
|
||||
options={QUALITY_OPTIONS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="as-toolbar-right">
|
||||
<button
|
||||
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
|
||||
onClick={() => setViewMode("grid")}
|
||||
title="网格视图"
|
||||
>
|
||||
⊞
|
||||
</button>
|
||||
<button
|
||||
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
|
||||
onClick={() => setViewMode("list")}
|
||||
title="列表视图"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{/* ═══ 批量操作栏 ═══ */}
|
||||
{showBatchSelect && hasSelection && (
|
||||
<SelectorBatchBar selectedCount={selectedIds.length} onClear={clearSelection} />
|
||||
<div className="as-batch-bar">
|
||||
<span className="as-batch-bar-count">已选 {selectedIds.length} 项</span>
|
||||
<div className="as-batch-bar-actions">
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 素材列表 */}
|
||||
{/* ═══ 素材列表 ═══ */}
|
||||
<div className="as-body">
|
||||
{filteredAssets.length === 0 ? (
|
||||
<EmptyState />
|
||||
<div className="as-empty">
|
||||
<div className="as-empty-icon">📂</div>
|
||||
<p>暂无素材</p>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
/* 网格视图 */
|
||||
/* ── 网格视图 ── */
|
||||
<div className={`as-grid${compact ? " compact" : ""}`}>
|
||||
{filteredAssets.map((asset, idx) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
isSelected={selectedSet.has(asset.id)}
|
||||
isDragging={dragIdx === idx}
|
||||
isDragOver={dragOverIdx === idx}
|
||||
showCheckbox={showBatchSelect}
|
||||
compact={compact}
|
||||
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
|
||||
onCardClick={(a, e) => handleCardClick(a, idx, e)}
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
))}
|
||||
{filteredAssets.map((asset, idx) => {
|
||||
const isSelected = selectedSet.has(asset.id)
|
||||
const isDragging = dragIdx === idx
|
||||
const isDragOver = dragOverIdx === idx
|
||||
const qualityLevel = getQualityLevel(asset.quality_score)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={[
|
||||
"as-card",
|
||||
isSelected ? "selected" : "",
|
||||
isDragging ? "dragging" : "",
|
||||
isDragOver ? "drag-over" : "",
|
||||
showBatchSelect ? "has-checkbox" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => handleCardClick(asset, idx, e)}
|
||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
<div className="as-card-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
|
||||
) : (
|
||||
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
)}
|
||||
|
||||
{/* Checkbox */}
|
||||
{showBatchSelect && (
|
||||
<span
|
||||
data-checkbox
|
||||
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 类型角标 */}
|
||||
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||
|
||||
{/* 时长角标 */}
|
||||
{asset.duration != null && (
|
||||
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
|
||||
)}
|
||||
|
||||
{/* 质量分角标 */}
|
||||
{asset.quality_score != null && (
|
||||
<span
|
||||
className={`as-card-quality ${qualityLevel}`}
|
||||
title={`质量分: ${asset.quality_score}`}
|
||||
>
|
||||
{asset.quality_score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-card-info">
|
||||
<p className="as-card-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* 列表视图 */
|
||||
/* ── 列表视图 ── */
|
||||
<div className="as-list">
|
||||
{filteredAssets.map((asset, idx) => (
|
||||
<AssetListItem
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
isSelected={selectedSet.has(asset.id)}
|
||||
isDragging={dragIdx === idx}
|
||||
isDragOver={dragOverIdx === idx}
|
||||
showCheckbox={showBatchSelect}
|
||||
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
|
||||
onCardClick={(a, e) => handleCardClick(a, idx, e)}
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
))}
|
||||
{filteredAssets.map((asset, idx) => {
|
||||
const isSelected = selectedSet.has(asset.id)
|
||||
const isDragging = dragIdx === idx
|
||||
const isDragOver = dragOverIdx === idx
|
||||
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={[
|
||||
"as-list-item",
|
||||
isSelected ? "selected" : "",
|
||||
isDragging ? "dragging" : "",
|
||||
isDragOver ? "drag-over" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => handleCardClick(asset, idx, e)}
|
||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<span className="as-list-item-drag" title="拖拽排序">
|
||||
⠿
|
||||
</span>
|
||||
|
||||
{/* Checkbox */}
|
||||
{showBatchSelect && (
|
||||
<span
|
||||
data-checkbox
|
||||
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-list-item-info">
|
||||
<div className="as-list-item-name">{asset.name}</div>
|
||||
<div className="as-list-item-meta">
|
||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
||||
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 质量分 */}
|
||||
{asset.quality_score != null && (
|
||||
<span
|
||||
className="as-list-item-quality"
|
||||
style={{ color: getQualityColor(asset.quality_score) }}
|
||||
>
|
||||
{asset.quality_score}分
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 悬浮预览 */}
|
||||
{previewAsset && <PreviewOverlay asset={previewAsset} position={previewPos} />}
|
||||
{/* ═══ 悬浮预览 ═══ */}
|
||||
{previewAsset && (
|
||||
<div className="as-preview-overlay" style={{ left: previewPos.x, top: previewPos.y }}>
|
||||
<div className="as-preview-overlay-thumb">
|
||||
{previewAsset.thumbnail_url ? (
|
||||
<img src={previewAsset.thumbnail_url} alt={previewAsset.name} />
|
||||
) : (
|
||||
<span className="as-preview-overlay-thumb-icon">
|
||||
{MATERIAL_TYPE_ICONS[previewAsset.type]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="as-preview-overlay-name">{previewAsset.name}</p>
|
||||
<div className="as-preview-overlay-meta">
|
||||
<span>类型: {MATERIAL_TYPE_LABELS[previewAsset.type]}</span>
|
||||
{previewAsset.duration != null && (
|
||||
<span>时长: {formatDuration(previewAsset.duration)}</span>
|
||||
)}
|
||||
{previewAsset.size != null && <span>大小: {formatSize(previewAsset.size)}</span>}
|
||||
{previewAsset.quality_score != null && (
|
||||
<span>质量分: {previewAsset.quality_score}</span>
|
||||
)}
|
||||
{previewAsset.tags.length > 0 && <span>标签: {previewAsset.tags.join(", ")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
const EmptyState: React.FC = () => {
|
||||
return (
|
||||
<div className="as-empty">
|
||||
<div className="as-empty-icon">📂</div>
|
||||
<p>暂无素材</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||
import { formatSize, formatDuration } from "./utils"
|
||||
|
||||
interface PreviewOverlayProps {
|
||||
asset: MediaAsset
|
||||
position: { x: number; y: number }
|
||||
}
|
||||
|
||||
const PreviewOverlay: React.FC<PreviewOverlayProps> = ({ asset, position }) => {
|
||||
return (
|
||||
<div className="as-preview-overlay" style={{ left: position.x, top: position.y }}>
|
||||
<div className="as-preview-overlay-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<span className="as-preview-overlay-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="as-preview-overlay-name">{asset.name}</p>
|
||||
<div className="as-preview-overlay-meta">
|
||||
<span>类型: {MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||
{asset.duration != null && <span>时长: {formatDuration(asset.duration)}</span>}
|
||||
{asset.size != null && <span>大小: {formatSize(asset.size)}</span>}
|
||||
{asset.quality_score != null && <span>质量分: {asset.quality_score}</span>}
|
||||
{asset.tags.length > 0 && <span>标签: {asset.tags.join(", ")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreviewOverlay
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
interface SelectorBatchBarProps {
|
||||
selectedCount: number
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
const SelectorBatchBar: React.FC<SelectorBatchBarProps> = ({ selectedCount, onClear }) => {
|
||||
return (
|
||||
<div className="as-batch-bar">
|
||||
<span className="as-batch-bar-count">已选 {selectedCount} 项</span>
|
||||
<div className="as-batch-bar-actions">
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={onClear}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectorBatchBar
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from "react"
|
||||
import { Input, Select } from "@/components/ui"
|
||||
import { TYPE_OPTIONS } from "./constants"
|
||||
import { QUALITY_OPTIONS } from "@/api/template-editor"
|
||||
import type { ViewMode } from "./types"
|
||||
|
||||
interface SelectorToolbarProps {
|
||||
searchText: string
|
||||
filterType: string
|
||||
filterQuality: string
|
||||
viewMode: ViewMode
|
||||
showQualityFilter: boolean
|
||||
onSearchChange: (value: string) => void
|
||||
onTypeChange: (value: string) => void
|
||||
onQualityChange: (value: string) => void
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
}
|
||||
|
||||
const SelectorToolbar: React.FC<SelectorToolbarProps> = ({
|
||||
searchText,
|
||||
filterType,
|
||||
filterQuality,
|
||||
viewMode,
|
||||
showQualityFilter,
|
||||
onSearchChange,
|
||||
onTypeChange,
|
||||
onQualityChange,
|
||||
onViewModeChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="as-toolbar">
|
||||
<div className="as-toolbar-left">
|
||||
<div className="as-search">
|
||||
<Input
|
||||
placeholder="搜索素材..."
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
prefix="🔍"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={(v: string) => onTypeChange(v)}
|
||||
options={TYPE_OPTIONS}
|
||||
/>
|
||||
{showQualityFilter && (
|
||||
<Select
|
||||
value={filterQuality}
|
||||
onChange={(v: string) => onQualityChange(v)}
|
||||
options={QUALITY_OPTIONS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="as-toolbar-right">
|
||||
<button
|
||||
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
|
||||
onClick={() => onViewModeChange("grid")}
|
||||
title="网格视图"
|
||||
>
|
||||
⊞
|
||||
</button>
|
||||
<button
|
||||
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
|
||||
onClick={() => onViewModeChange("list")}
|
||||
title="列表视图"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectorToolbar
|
||||
@@ -1,7 +0,0 @@
|
||||
/** 类型筛选选项 */
|
||||
export const TYPE_OPTIONS = [
|
||||
{ value: "", label: "全部类型" },
|
||||
{ value: "video", label: "🎬 视频" },
|
||||
{ value: "image", label: "🖼️ 图片" },
|
||||
{ value: "audio", label: "🎵 音频" },
|
||||
]
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { QUALITY_OPTIONS } from "@/api/template-editor"
|
||||
import type { ViewMode } from "../types"
|
||||
|
||||
interface UseAssetFilterReturn {
|
||||
searchText: string
|
||||
filterType: string
|
||||
filterQuality: string
|
||||
viewMode: ViewMode
|
||||
filteredAssets: MediaAsset[]
|
||||
setSearchText: (value: string) => void
|
||||
setFilterType: (value: string) => void
|
||||
setFilterQuality: (value: string) => void
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材筛选 Hook —— 搜索 + 类型 + 质量分 + 视图切换
|
||||
*/
|
||||
const useAssetFilter = (assets: MediaAsset[]): UseAssetFilterReturn => {
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState("")
|
||||
const [filterQuality, setFilterQuality] = useState("")
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid")
|
||||
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets
|
||||
if (searchText) {
|
||||
const q = searchText.toLowerCase()
|
||||
list = list.filter(
|
||||
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
if (filterType) {
|
||||
list = list.filter((a) => a.type === filterType)
|
||||
}
|
||||
if (filterQuality) {
|
||||
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
|
||||
if (opt?.min != null && opt?.max != null) {
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
|
||||
)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}, [assets, searchText, filterType, filterQuality])
|
||||
|
||||
return {
|
||||
searchText,
|
||||
filterType,
|
||||
filterQuality,
|
||||
viewMode,
|
||||
filteredAssets,
|
||||
setSearchText,
|
||||
setFilterType,
|
||||
setFilterQuality,
|
||||
setViewMode,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAssetFilter
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
interface UseAssetPreviewReturn {
|
||||
previewAsset: MediaAsset | null
|
||||
previewPos: { x: number; y: number }
|
||||
handleMouseEnter: (asset: MediaAsset, e: React.MouseEvent) => void
|
||||
handleMouseLeave: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬浮预览 Hook —— 延迟 400ms 显示预览浮层
|
||||
*/
|
||||
const useAssetPreview = (): UseAssetPreviewReturn => {
|
||||
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
|
||||
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
|
||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||
previewTimer.current = setTimeout(() => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
setPreviewAsset(asset)
|
||||
setPreviewPos({
|
||||
x: rect.right + 12,
|
||||
y: Math.max(8, rect.top - 20),
|
||||
})
|
||||
}, 400)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (previewTimer.current) {
|
||||
clearTimeout(previewTimer.current)
|
||||
previewTimer.current = null
|
||||
}
|
||||
setPreviewAsset(null)
|
||||
}, [])
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
previewAsset,
|
||||
previewPos,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAssetPreview
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useCallback, useRef, useMemo } from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
interface UseAssetSelectionOptions {
|
||||
filteredAssets: MediaAsset[]
|
||||
selectedIds: string[]
|
||||
onSelectionChange?: (ids: string[]) => void
|
||||
}
|
||||
|
||||
interface UseAssetSelectionReturn {
|
||||
selectedSet: Set<string>
|
||||
toggleSelect: (asset: MediaAsset, idx: number, shiftKey: boolean) => void
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材选择 Hook —— 单选/多选/Shift 连选
|
||||
*/
|
||||
const useAssetSelection = ({
|
||||
filteredAssets,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
}: UseAssetSelectionOptions): UseAssetSelectionReturn => {
|
||||
const lastClickedIdx = useRef<number | null>(null)
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
|
||||
const toggleSelect = useCallback(
|
||||
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
|
||||
if (!onSelectionChange) return
|
||||
|
||||
if (shiftKey && lastClickedIdx.current !== null) {
|
||||
// Shift 连选
|
||||
const start = Math.min(lastClickedIdx.current, idx)
|
||||
const end = Math.max(lastClickedIdx.current, idx)
|
||||
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
|
||||
const newSet = new Set(selectedIds)
|
||||
rangeIds.forEach((id) => newSet.add(id))
|
||||
onSelectionChange(Array.from(newSet))
|
||||
} else {
|
||||
const newSet = new Set(selectedIds)
|
||||
if (newSet.has(asset.id)) {
|
||||
newSet.delete(asset.id)
|
||||
} else {
|
||||
newSet.add(asset.id)
|
||||
}
|
||||
onSelectionChange(Array.from(newSet))
|
||||
}
|
||||
lastClickedIdx.current = idx
|
||||
},
|
||||
[onSelectionChange, selectedIds, filteredAssets],
|
||||
)
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
onSelectionChange?.([])
|
||||
}, [onSelectionChange])
|
||||
|
||||
return {
|
||||
selectedSet,
|
||||
toggleSelect,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAssetSelection
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
interface UseDragReorderOptions {
|
||||
filteredAssets: MediaAsset[]
|
||||
selectedIds: string[]
|
||||
onAssetDragStart?: (asset: MediaAsset) => void
|
||||
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||
}
|
||||
|
||||
interface UseDragReorderReturn {
|
||||
dragIdx: number | null
|
||||
dragOverIdx: number | null
|
||||
handleDragStart: (e: React.DragEvent, idx: number) => void
|
||||
handleDragOver: (e: React.DragEvent, idx: number) => void
|
||||
handleDrop: (e: React.DragEvent, toIdx: number) => void
|
||||
handleDragEnd: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽排序 Hook —— HTML5 DnD,支持批量拖拽携带数据
|
||||
*/
|
||||
const useDragReorder = ({
|
||||
filteredAssets,
|
||||
selectedIds,
|
||||
onAssetDragStart,
|
||||
onReorder,
|
||||
}: UseDragReorderOptions): UseDragReorderReturn => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
setDragIdx(idx)
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
e.dataTransfer.setData("text/plain", String(idx))
|
||||
// 设置素材数据,供 TimelinePanel 接收
|
||||
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
|
||||
// 批量拖拽:如果有多个选中素材,一起携带
|
||||
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
|
||||
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
|
||||
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
|
||||
}
|
||||
// 通知父组件素材拖拽开始
|
||||
if (onAssetDragStart) {
|
||||
onAssetDragStart(filteredAssets[idx])
|
||||
}
|
||||
},
|
||||
[filteredAssets, selectedIds, onAssetDragStart],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
if (dragIdx === null || dragIdx === idx) return
|
||||
setDragOverIdx(idx)
|
||||
},
|
||||
[dragIdx],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault()
|
||||
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
|
||||
onReorder(dragIdx, toIdx)
|
||||
}
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
},
|
||||
[dragIdx, onReorder],
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
dragIdx,
|
||||
dragOverIdx,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handleDragEnd,
|
||||
}
|
||||
}
|
||||
|
||||
export default useDragReorder
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as AssetSelector } from "./AssetSelector"
|
||||
export type { AssetSelectorProps, ViewMode } from "./types"
|
||||
export type { AssetSelectorProps } from "./AssetSelector"
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
export interface AssetSelectorProps {
|
||||
assets: MediaAsset[]
|
||||
selectedIds?: string[]
|
||||
onSelectionChange?: (ids: string[]) => void
|
||||
onAssetDragStart?: (asset: MediaAsset) => void
|
||||
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||
showQualityFilter?: boolean
|
||||
showBatchSelect?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export type ViewMode = "grid" | "list"
|
||||
@@ -1,33 +0,0 @@
|
||||
/** 格式化文件大小 */
|
||||
export const formatSize = (bytes?: number): string => {
|
||||
if (!bytes) return ""
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
/** 格式化时长 */
|
||||
export const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return ""
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
|
||||
}
|
||||
|
||||
/** 获取质量分等级 */
|
||||
export const getQualityLevel = (score?: number): string => {
|
||||
if (score == null) return "none"
|
||||
if (score >= 90) return "excellent"
|
||||
if (score >= 70) return "good"
|
||||
if (score >= 50) return "fair"
|
||||
return "poor"
|
||||
}
|
||||
|
||||
/** 质量分颜色 */
|
||||
export const getQualityColor = (score?: number): string => {
|
||||
if (score == null) return "var(--text-secondary)"
|
||||
if (score >= 90) return "var(--success-color, #10b981)"
|
||||
if (score >= 70) return "var(--primary-color, #6366f1)"
|
||||
if (score >= 50) return "var(--warning-color, #f59e0b)"
|
||||
return "var(--error-color, #ef4444)"
|
||||
}
|
||||
+83
-20
@@ -1,20 +1,84 @@
|
||||
import React from "react"
|
||||
import { Modal, Tabs, Form, Input, Button } from "antd"
|
||||
import { useBindContactForm } from "./hooks/useBindContactForm"
|
||||
import type { BindContactModalProps } from "./types"
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { Modal, Tabs, Form, Input, Button, message } from "antd"
|
||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
||||
|
||||
interface BindContactModalProps {
|
||||
open: boolean
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, onCancel }) => {
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
form,
|
||||
loading,
|
||||
codeLoading,
|
||||
countdown,
|
||||
codeDisabled,
|
||||
handleSendCode,
|
||||
handleSubmit,
|
||||
} = useBindContactForm({ open, onSuccess, onCancel })
|
||||
const [activeTab, setActiveTab] = useState<"email" | "phone">("email")
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeLoading, setCodeLoading] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1)
|
||||
}, 1000)
|
||||
} else if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields()
|
||||
setCountdown(0)
|
||||
}
|
||||
}, [open, form])
|
||||
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const value = form.getFieldValue(activeTab === "email" ? "email" : "phone")
|
||||
if (!value) {
|
||||
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
||||
return
|
||||
}
|
||||
setCodeLoading(true)
|
||||
await sendVerificationCode({
|
||||
target: activeTab,
|
||||
value,
|
||||
purpose: "bind",
|
||||
})
|
||||
message.success("验证码已发送")
|
||||
setCountdown(60)
|
||||
} catch (error) {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setCodeLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
|
||||
const payload =
|
||||
activeTab === "email"
|
||||
? { email: values.email, email_code: values.code }
|
||||
: { phone: values.phone, phone_code: values.code }
|
||||
|
||||
const result = await bindContact(payload)
|
||||
|
||||
message.success("绑定成功")
|
||||
onSuccess?.(result.user)
|
||||
} catch (error) {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -47,7 +111,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
<Input placeholder="请输入邮箱地址" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email_code"
|
||||
name="code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: "请输入验证码" }]}
|
||||
>
|
||||
@@ -57,7 +121,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
loading={codeLoading}
|
||||
disabled={codeDisabled}
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
@@ -82,7 +146,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
<Input placeholder="请输入手机号" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phone_code"
|
||||
name="code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: "请输入验证码" }]}
|
||||
>
|
||||
@@ -92,7 +156,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
loading={codeLoading}
|
||||
disabled={codeDisabled}
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
@@ -114,4 +178,3 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
}
|
||||
|
||||
export default BindContactModal
|
||||
export type { BindContactModalProps } from "./types"
|
||||
@@ -1,97 +0,0 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Form, message } from "antd"
|
||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
||||
import { useCountdown } from "./useCountdown"
|
||||
|
||||
type ContactType = "email" | "phone"
|
||||
|
||||
interface UseBindContactFormOptions {
|
||||
open: boolean
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定联系方式表单 Hook
|
||||
* 封装表单状态、验证码发送、提交绑定等业务逻辑
|
||||
*/
|
||||
export const useBindContactForm = ({ open, onSuccess }: UseBindContactFormOptions) => {
|
||||
const [activeTab, setActiveTab] = useState<ContactType>("email")
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeLoading, setCodeLoading] = useState(false)
|
||||
const { countdown, isRunning, start: startCountdown, reset: resetCountdown } = useCountdown(60)
|
||||
|
||||
/* ── 打开时重置表单 ── */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields()
|
||||
resetCountdown()
|
||||
}
|
||||
}, [open, form, resetCountdown])
|
||||
|
||||
/* ── 发送验证码 ── */
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const fieldName = activeTab === "email" ? "email" : "phone"
|
||||
const value = form.getFieldValue(fieldName)
|
||||
if (!value) {
|
||||
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
||||
return
|
||||
}
|
||||
setCodeLoading(true)
|
||||
await sendVerificationCode({
|
||||
target: activeTab,
|
||||
value,
|
||||
purpose: "bind",
|
||||
})
|
||||
message.success("验证码已发送")
|
||||
startCountdown()
|
||||
} catch {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setCodeLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 提交绑定 ── */
|
||||
const handleSubmit = async () => {
|
||||
// 1. 表单验证(失败时 Ant Design Form 会自动展示错误信息)
|
||||
let values
|
||||
try {
|
||||
values = await form.validateFields()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 调用绑定接口
|
||||
setLoading(true)
|
||||
try {
|
||||
const payload =
|
||||
activeTab === "email"
|
||||
? { email: values.email, email_code: values.email_code }
|
||||
: { phone: values.phone, phone_code: values.phone_code }
|
||||
|
||||
const result = await bindContact(payload)
|
||||
|
||||
message.success("绑定成功")
|
||||
onSuccess?.(result.user)
|
||||
} catch {
|
||||
// API 错误由全局拦截器统一处理提示
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
form,
|
||||
loading,
|
||||
codeLoading,
|
||||
countdown,
|
||||
codeDisabled: isRunning,
|
||||
handleSendCode,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* 倒计时 Hook
|
||||
* 用于验证码发送等场景的倒计时控制
|
||||
*/
|
||||
export const useCountdown = (initialSeconds = 60) => {
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1)
|
||||
}, 1000)
|
||||
} else if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
const start = useCallback(() => {
|
||||
setCountdown(initialSeconds)
|
||||
}, [initialSeconds])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCountdown(0)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
countdown,
|
||||
isRunning: countdown > 0,
|
||||
start,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { BindContactResponse } from "@/api/auth"
|
||||
|
||||
/** BindContactModal 组件属性 */
|
||||
export interface BindContactModalProps {
|
||||
/** 是否打开 */
|
||||
open: boolean
|
||||
/** 绑定成功回调 */
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
/** 取消回调 */
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/** 联系方式类型 */
|
||||
export type ContactType = "email" | "phone"
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* PageHead - 页面头部组件(Task 1.4)
|
||||
*
|
||||
* 功能:
|
||||
* - 页面标题展示
|
||||
* - 面包屑导航(自动根据路由生成,也支持手动传入)
|
||||
* - 右侧操作按钮区(slot,由页面自行填充)
|
||||
* - 响应式:移动端简化布局(隐藏面包屑,缩小标题)
|
||||
*
|
||||
* 复用 global.css 中已有的 .xx-page-head 基础样式,
|
||||
* 补充面包屑、操作区等扩展样式。
|
||||
*/
|
||||
import React from "react"
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom"
|
||||
import { RightOutlined, HomeOutlined } from "@ant-design/icons"
|
||||
import "./PageHead.css"
|
||||
|
||||
/* ── 类型定义 ─────────────────────────────────────────────── */
|
||||
|
||||
/** 面包屑项 */
|
||||
export interface BreadcrumbItem {
|
||||
/** 显示文字 */
|
||||
label: string
|
||||
/** 路由路径,不传则为当前页(不可点击) */
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** PageHead 组件属性 */
|
||||
export interface PageHeadProps {
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
/** 页面描述(可选,显示在标题下方) */
|
||||
description?: React.ReactNode
|
||||
/** 面包屑项(可选,不传则自动根据路由生成) */
|
||||
breadcrumb?: BreadcrumbItem[]
|
||||
/** 右侧操作区内容(按钮等) */
|
||||
actions?: React.ReactNode
|
||||
/** 是否隐藏面包屑 */
|
||||
hideBreadcrumb?: boolean
|
||||
}
|
||||
|
||||
/* ── 路由 → 标题映射(用于自动生成面包屑) ────────────────── */
|
||||
|
||||
const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/dashboard": "首页",
|
||||
"/app/generate": "智能剪辑",
|
||||
"/app/assets": "视频库",
|
||||
"/app/voices": "配音库",
|
||||
"/app/titles": "标题库",
|
||||
"/app/products": "成片库",
|
||||
"/app/templates": "模板库",
|
||||
"/app/history": "任务历史",
|
||||
"/app/admin": "控制台",
|
||||
"/app/admin/users": "用户管理",
|
||||
"/app/admin/analytics": "数据分析",
|
||||
"/app/admin/monitor": "系统监控",
|
||||
"/app/admin/logs": "系统日志",
|
||||
"/app/subscription": "订阅管理",
|
||||
"/app/subscription/upgrade": "升级订阅",
|
||||
"/app/subscription/billing": "账单管理",
|
||||
"/app/profile": "个人设置",
|
||||
"/app/editing-planner": "模板制作",
|
||||
"/app/my-templates": "我的模板",
|
||||
"/app/voice-clone": "我的音色",
|
||||
"/app/voice-materials": "配音库",
|
||||
"/app/accounts": "账号管理",
|
||||
"/app/duplication": "查重",
|
||||
"/app/duplication/results": "查重结果",
|
||||
}
|
||||
|
||||
/* ── 自动生成面包屑 ─────────────────────────────────────── */
|
||||
|
||||
/** 根据当前路径生成面包屑 */
|
||||
const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => {
|
||||
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }]
|
||||
|
||||
// 首页本身不需要面包屑
|
||||
if (pathname === "/app" || pathname === "/app/dashboard") {
|
||||
return items
|
||||
}
|
||||
|
||||
// 逐级拆分路径,生成中间层级
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
currentPath += `/${segments[i]}`
|
||||
const title = ROUTE_TITLE_MAP[currentPath]
|
||||
|
||||
if (title) {
|
||||
// 最后一级不带 path(当前页面,不可点击)
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: title,
|
||||
path: isLast ? undefined : currentPath,
|
||||
})
|
||||
} else {
|
||||
// 动态路由段(如 :id),用路径片段做 label
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: segments[i],
|
||||
path: isLast ? undefined : currentPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const PageHead: React.FC<PageHeadProps> = ({
|
||||
title,
|
||||
description,
|
||||
breadcrumb,
|
||||
actions,
|
||||
hideBreadcrumb = false,
|
||||
}) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 使用传入的面包屑或自动生成
|
||||
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname)
|
||||
|
||||
// 首页不显示面包屑
|
||||
const showBreadcrumb =
|
||||
!hideBreadcrumb &&
|
||||
breadcrumbItems.length > 1 &&
|
||||
location.pathname !== "/app" &&
|
||||
location.pathname !== "/app/dashboard"
|
||||
|
||||
return (
|
||||
<header className="xx-page-head">
|
||||
<div className="xx-page-head-left">
|
||||
{/* 面包屑导航 */}
|
||||
{showBreadcrumb && (
|
||||
<nav className="xx-page-breadcrumb" aria-label="面包屑导航">
|
||||
<ol>
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1
|
||||
return (
|
||||
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
|
||||
{index > 0 && <RightOutlined className="xx-page-breadcrumb-separator" />}
|
||||
{item.path && !isLast ? (
|
||||
<Link
|
||||
to={item.path}
|
||||
className="xx-page-breadcrumb-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate(item.path!)
|
||||
}}
|
||||
>
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="xx-page-breadcrumb-current" aria-current="page">
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* 标题 + 描述 */}
|
||||
<div className="xx-page-head-title">
|
||||
<h2>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧操作区 */}
|
||||
{actions && <div className="xx-page-head-actions">{actions}</div>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHead
|
||||
@@ -1,27 +0,0 @@
|
||||
/** 路由 → 标题映射(用于自动生成面包屑) */
|
||||
export const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/dashboard": "首页",
|
||||
"/app/generate": "智能剪辑",
|
||||
"/app/assets": "视频库",
|
||||
"/app/voices": "配音库",
|
||||
"/app/titles": "标题库",
|
||||
"/app/products": "成片库",
|
||||
"/app/templates": "模板库",
|
||||
"/app/history": "任务历史",
|
||||
"/app/admin": "控制台",
|
||||
"/app/admin/users": "用户管理",
|
||||
"/app/admin/analytics": "数据分析",
|
||||
"/app/admin/monitor": "系统监控",
|
||||
"/app/admin/logs": "系统日志",
|
||||
"/app/subscription": "订阅管理",
|
||||
"/app/subscription/upgrade": "升级订阅",
|
||||
"/app/subscription/billing": "账单管理",
|
||||
"/app/profile": "个人设置",
|
||||
"/app/editing-planner": "模板制作",
|
||||
"/app/my-templates": "我的模板",
|
||||
"/app/voice-clone": "我的音色",
|
||||
"/app/voice-materials": "配音库",
|
||||
"/app/accounts": "账号管理",
|
||||
"/app/duplication": "查重",
|
||||
"/app/duplication/results": "查重结果",
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* PageHead - 页面头部组件(Task 1.4)
|
||||
*
|
||||
* 功能:
|
||||
* - 页面标题展示
|
||||
* - 面包屑导航(自动根据路由生成,也支持手动传入)
|
||||
* - 右侧操作按钮区(slot,由页面自行填充)
|
||||
* - 响应式:移动端简化布局(隐藏面包屑,缩小标题)
|
||||
*
|
||||
* 复用 global.css 中已有的 .xx-page-head 基础样式,
|
||||
* 补充面包屑、操作区等扩展样式。
|
||||
*/
|
||||
import React from "react"
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom"
|
||||
import { RightOutlined, HomeOutlined } from "@ant-design/icons"
|
||||
import type { PageHeadProps } from "./types"
|
||||
import { generateBreadcrumb } from "./utils"
|
||||
import "./PageHead.css"
|
||||
|
||||
const PageHead: React.FC<PageHeadProps> = ({
|
||||
title,
|
||||
description,
|
||||
breadcrumb,
|
||||
actions,
|
||||
hideBreadcrumb = false,
|
||||
}) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 使用传入的面包屑或自动生成
|
||||
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname)
|
||||
|
||||
// 首页不显示面包屑
|
||||
const showBreadcrumb =
|
||||
!hideBreadcrumb &&
|
||||
breadcrumbItems.length > 1 &&
|
||||
location.pathname !== "/app" &&
|
||||
location.pathname !== "/app/dashboard"
|
||||
|
||||
return (
|
||||
<header className="xx-page-head">
|
||||
<div className="xx-page-head-left">
|
||||
{/* 面包屑导航 */}
|
||||
{showBreadcrumb && (
|
||||
<nav className="xx-page-breadcrumb" aria-label="面包屑导航">
|
||||
<ol>
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1
|
||||
return (
|
||||
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
|
||||
{index > 0 && <RightOutlined className="xx-page-breadcrumb-separator" />}
|
||||
{item.path && !isLast ? (
|
||||
<Link
|
||||
to={item.path}
|
||||
className="xx-page-breadcrumb-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate(item.path!)
|
||||
}}
|
||||
>
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="xx-page-breadcrumb-current" aria-current="page">
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* 标题 + 描述 */}
|
||||
<div className="xx-page-head-title">
|
||||
<h2>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧操作区 */}
|
||||
{actions && <div className="xx-page-head-actions">{actions}</div>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHead
|
||||
export type { BreadcrumbItem, PageHeadProps } from "./types"
|
||||
@@ -1,23 +0,0 @@
|
||||
import type React from "react"
|
||||
|
||||
/** 面包屑项 */
|
||||
export interface BreadcrumbItem {
|
||||
/** 显示文字 */
|
||||
label: string
|
||||
/** 路由路径,不传则为当前页(不可点击) */
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** PageHead 组件属性 */
|
||||
export interface PageHeadProps {
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
/** 页面描述(可选,显示在标题下方) */
|
||||
description?: React.ReactNode
|
||||
/** 面包屑项(可选,不传则自动根据路由生成) */
|
||||
breadcrumb?: BreadcrumbItem[]
|
||||
/** 右侧操作区内容(按钮等) */
|
||||
actions?: React.ReactNode
|
||||
/** 是否隐藏面包屑 */
|
||||
hideBreadcrumb?: boolean
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { ROUTE_TITLE_MAP } from "./constants"
|
||||
import type { BreadcrumbItem } from "./types"
|
||||
|
||||
/** 根据当前路径生成面包屑 */
|
||||
export const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => {
|
||||
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }]
|
||||
|
||||
// 首页本身不需要面包屑
|
||||
if (pathname === "/app" || pathname === "/app/dashboard") {
|
||||
return items
|
||||
}
|
||||
|
||||
// 逐级拆分路径,生成中间层级
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
currentPath += `/${segments[i]}`
|
||||
const title = ROUTE_TITLE_MAP[currentPath]
|
||||
|
||||
if (title) {
|
||||
// 最后一级不带 path(当前页面,不可点击)
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: title,
|
||||
path: isLast ? undefined : currentPath,
|
||||
})
|
||||
} else {
|
||||
// 动态路由段(如 :id),用路径片段做 label
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: segments[i],
|
||||
path: isLast ? undefined : currentPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
+226
-84
@@ -1,22 +1,54 @@
|
||||
/**
|
||||
* CloneModal — 音色克隆弹窗(共享组件)
|
||||
*
|
||||
* 功能:
|
||||
* - 步骤引导:上传/录制音频 → 填写信息 → 提交克隆
|
||||
* - 两种输入方式:上传文件(wav/mp3/m4a,≤10MB)或浏览器原生录音(MediaRecorder)
|
||||
* - 提交:先 uploadAsset 获取真实 URL,再 createVoiceClone
|
||||
* - 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
|
||||
* - 错误处理:上传失败、格式错误、大小超限、录音权限拒绝等提示
|
||||
*
|
||||
* V21 Design System — 零 antd 直接导入
|
||||
*/
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset } from "@/api/assets"
|
||||
import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants"
|
||||
import { validateFile } from "./utils"
|
||||
import { useAudioRecorder } from "./hooks/useAudioRecorder"
|
||||
import type { CloneModalProps, ModalPhase } from "./types"
|
||||
import "./clone-modal.css"
|
||||
|
||||
/** 根据 MIME 类型推断文件扩展名 */
|
||||
const getExtensionFromMime = (mime: string): string => {
|
||||
if (mime.includes("webm")) return "webm"
|
||||
if (mime.includes("mp4") || mime.includes("m4a")) return "m4a"
|
||||
if (mime.includes("ogg")) return "ogg"
|
||||
if (mime.includes("wav")) return "wav"
|
||||
return "webm"
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
||||
|
||||
export interface CloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void
|
||||
}
|
||||
|
||||
/* ── 进度阶段配置 ─────────────────────────────────────────── */
|
||||
|
||||
const PROGRESS_STEPS: { key: string; label: string; icon: string }[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
]
|
||||
|
||||
/* ── 常量 ───────────────────────────────────────────────── */
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
|
||||
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
/** 最长录制时长:5 分钟(秒) */
|
||||
const MAX_RECORD_SECONDS = 5 * 60
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||
const [voiceName, setVoiceName] = useState("")
|
||||
@@ -25,22 +57,18 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
// 录音状态
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordTime, setRecordTime] = useState(0)
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
||||
const cloneCounterRef = useRef(1)
|
||||
/** 组件挂载状态标记,防止卸载后更新 state */
|
||||
const isMountedRef = useRef(true)
|
||||
const isSubmittingRef = useRef(false)
|
||||
|
||||
/* ── 录音 Hook ──────────────────────────────────── */
|
||||
const {
|
||||
isRecording,
|
||||
recordedBlob,
|
||||
formattedTime,
|
||||
toggleRecord,
|
||||
reset: resetRecorder,
|
||||
} = useAudioRecorder(setErrorMessage)
|
||||
|
||||
/** 生成下一个默认音色名称 */
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
@@ -49,6 +77,17 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
return name
|
||||
}, [])
|
||||
|
||||
/** 组件卸载时清理定时器和 MediaRecorder */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input")
|
||||
@@ -56,11 +95,20 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
setErrorMessage("")
|
||||
resetRecorder()
|
||||
}, [getNextDefaultName, resetRecorder])
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
audioChunksRef.current = []
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
mediaRecorderRef.current = null
|
||||
}, [getNextDefaultName])
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -75,16 +123,20 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
}
|
||||
}, [open, resetState])
|
||||
|
||||
/** 组件挂载/卸载标记 + 清理定时器 */
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
/* ── 文件验证 ──────────────────────────────────────── */
|
||||
|
||||
/* ── 文件上传 ──────────────────────────────────── */
|
||||
const validateFile = (file: File): string | null => {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return "文件大小超过 10MB,请压缩后重试"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/* ── 文件上传 ──────────────────────────────────────── */
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
@@ -98,17 +150,18 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
resetRecorder()
|
||||
// 清除录音
|
||||
setRecordedBlob(null)
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
}
|
||||
}
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/* ── 拖拽 ──────────────────────────────────────── */
|
||||
/* ── 拖拽 ──────────────────────────────────────────── */
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -131,38 +184,120 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
resetRecorder()
|
||||
setRecordedBlob(null)
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 计算属性 ──────────────────────────────────── */
|
||||
/* ── 录音(真实 MediaRecorder) ───────────────────── */
|
||||
|
||||
const handleRecord = async () => {
|
||||
if (isRecording) {
|
||||
// 停止录制
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
} else {
|
||||
// 开始录制
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
})
|
||||
const mediaRecorder = new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
audioChunksRef.current = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
|
||||
setRecordedBlob(blob)
|
||||
// 清除上传的文件
|
||||
setSelectedFile(null)
|
||||
// 停止音轨
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
setErrorMessage("")
|
||||
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => {
|
||||
const next = prev + 1
|
||||
if (next >= MAX_RECORD_SECONDS) {
|
||||
// 达到 5 分钟上限,自动停止录制
|
||||
setTimeout(() => {
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
setErrorMessage("已达最长录制时长(5分钟),已自动停止")
|
||||
}, 0)
|
||||
return MAX_RECORD_SECONDS
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, 1000)
|
||||
} catch {
|
||||
setErrorMessage("无法访问麦克风,请检查浏览器权限设置")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化录制时间 mm:ss */
|
||||
const formatRecordTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/* ── 表单验证 ──────────────────────────────────────── */
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
const canSubmit = hasAudio && !isProcessing
|
||||
|
||||
const progressIndex = PROGRESS_STEPS.findIndex((s) => s.key === phase)
|
||||
|
||||
/* ── 提交 ──────────────────────────────────────── */
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const validateForm = (): string | null => {
|
||||
const name = voiceName.trim()
|
||||
if (!name || name.length < 2 || name.length > 20) {
|
||||
setErrorMessage("音色名称需在 2-20 个字符之间")
|
||||
return
|
||||
if (!name) {
|
||||
return "请输入音色名称"
|
||||
}
|
||||
if (name.length < 2 || name.length > 20) {
|
||||
return "音色名称需在 2-20 个字符之间"
|
||||
}
|
||||
if (!hasAudio) {
|
||||
setErrorMessage("请上传音频文件或录制一段声音")
|
||||
return "请上传音频文件或录制一段声音"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/* ── 提交克隆 ──────────────────────────────────────── */
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const formError = validateForm()
|
||||
if (formError) {
|
||||
setErrorMessage(formError)
|
||||
return
|
||||
}
|
||||
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
@@ -173,11 +308,9 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile
|
||||
} else {
|
||||
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配
|
||||
const mimeType = recordedBlob?.type || "audio/webm"
|
||||
const ext = getExtensionFromMime(mimeType)
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, {
|
||||
type: mimeType,
|
||||
// 将录音 Blob 转为 File
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -185,40 +318,50 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
formData.append("file", fileToUpload)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
|
||||
// 组件已卸载则中止后续操作
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
name: voiceName.trim(),
|
||||
description: voiceDescription.trim() || undefined,
|
||||
audio_url: uploadResult.url,
|
||||
})
|
||||
|
||||
// 组件已卸载则中止后续操作
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
isSubmittingRef.current = false
|
||||
// 阶段 3:完成
|
||||
setPhase("done")
|
||||
|
||||
// 2秒后自动关闭
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
handleClose()
|
||||
}
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
handleClose()
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
isSubmittingRef.current = false
|
||||
// 组件已卸载则不更新 state
|
||||
if (!isMountedRef.current) return
|
||||
setPhase("input")
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 计算属性 ──────────────────────────────────────── */
|
||||
|
||||
const canSubmit = voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio
|
||||
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
|
||||
/** 当前进度索引 */
|
||||
const getProgressIndex = (): number => {
|
||||
switch (phase) {
|
||||
case "uploading":
|
||||
return 0
|
||||
case "cloning":
|
||||
return 1
|
||||
case "done":
|
||||
return 2
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
const progressIndex = getProgressIndex()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -230,7 +373,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
maskClosable={!isProcessing}
|
||||
keyboard={!isProcessing}
|
||||
>
|
||||
{/* ── 输入阶段 ────────────────────────────────── */}
|
||||
{/* ── 输入阶段 ──────────────────────────────────── */}
|
||||
{phase === "input" && (
|
||||
<div className="xx-clonemodal-body">
|
||||
{/* 步骤引导 */}
|
||||
@@ -307,9 +450,9 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
<div className="xx-clonemodal-record-info">
|
||||
<p className="xx-clonemodal-record-hint">
|
||||
{isRecording
|
||||
? `录制中 ${formattedTime}`
|
||||
? `录制中 ${formatRecordTime(recordTime)}`
|
||||
: recordedBlob
|
||||
? `已录制 ${formattedTime}`
|
||||
? `已录制 ${formatRecordTime(recordTime)}`
|
||||
: "点击按钮开始录制(最长 5 分钟)"}
|
||||
</p>
|
||||
{isRecording && (
|
||||
@@ -325,7 +468,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
||||
onClick={toggleRecord}
|
||||
onClick={handleRecord}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
@@ -373,7 +516,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 进度阶段(上传中 / 克隆中) ────────────── */}
|
||||
{/* ── 进度阶段(上传中 / 克隆中) ──────────────── */}
|
||||
{isProcessing && (
|
||||
<div className="xx-clonemodal-progress-body">
|
||||
{/* 步骤指示器 */}
|
||||
@@ -425,7 +568,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 完成阶段 ────────────────────────────────── */}
|
||||
{/* ── 完成阶段 ──────────────────────────────────── */}
|
||||
{phase === "done" && (
|
||||
<div className="xx-clonemodal-progress-body">
|
||||
{/* 步骤指示器(全部完成) */}
|
||||
@@ -457,4 +600,3 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
}
|
||||
|
||||
export default CloneModal
|
||||
export type { CloneModalProps, ModalPhase } from "./types"
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ProgressStep } from "./types"
|
||||
|
||||
/** 进度阶段配置 */
|
||||
export const PROGRESS_STEPS: ProgressStep[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
]
|
||||
|
||||
/** 支持的音频扩展名 */
|
||||
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "webm"]
|
||||
|
||||
/** 文件选择器 accept 属性 */
|
||||
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm"
|
||||
|
||||
/** 最大文件大小:10MB */
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** 最长录制时长:5 分钟(秒) */
|
||||
export const MAX_RECORD_SECONDS = 5 * 60
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { MAX_RECORD_SECONDS } from "../constants"
|
||||
import { formatRecordTime } from "../utils"
|
||||
|
||||
/**
|
||||
* 录音 Hook
|
||||
* 封装浏览器 MediaRecorder 录音逻辑,含计时、自动停止、时长上限
|
||||
*/
|
||||
export const useAudioRecorder = (onError?: (msg: string) => void) => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordTime, setRecordTime] = useState(0)
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
||||
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
const isMountedRef = useRef(true)
|
||||
const isStartingRef = useRef(false)
|
||||
|
||||
/** 重置录音状态 */
|
||||
const reset = useCallback(() => {
|
||||
if (!isMountedRef.current) return
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
audioChunksRef.current = []
|
||||
isStartingRef.current = false
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
mediaRecorderRef.current = null
|
||||
}, [])
|
||||
|
||||
/** 开始/停止录制 */
|
||||
const toggleRecord = useCallback(async () => {
|
||||
if (isRecording) {
|
||||
// 停止录制
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 防止快速双击导致重复启动
|
||||
if (isStartingRef.current) return
|
||||
isStartingRef.current = true
|
||||
|
||||
// 开始录制
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
})
|
||||
if (!isMountedRef.current) {
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
isStartingRef.current = false
|
||||
return
|
||||
}
|
||||
const mediaRecorder = new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
audioChunksRef.current = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (!isMountedRef.current) return
|
||||
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器兼容性问题
|
||||
const mimeType = mediaRecorder.mimeType || "audio/webm"
|
||||
const blob = new Blob(audioChunksRef.current, { type: mimeType })
|
||||
setRecordedBlob(blob)
|
||||
// 停止音轨
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
isStartingRef.current = false
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => {
|
||||
const next = prev + 1
|
||||
if (next >= MAX_RECORD_SECONDS) {
|
||||
// 达到时长上限,自动停止
|
||||
setTimeout(() => {
|
||||
if (!isMountedRef.current) return
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
onError?.("已达最长录制时长(5分钟),已自动停止")
|
||||
}, 0)
|
||||
return MAX_RECORD_SECONDS
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, 1000)
|
||||
} catch {
|
||||
isStartingRef.current = false
|
||||
onError?.("无法访问麦克风,请检查浏览器权限设置")
|
||||
}
|
||||
}, [isRecording, onError])
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
isStartingRef.current = false
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
formattedTime: formatRecordTime(recordTime),
|
||||
toggleRecord,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
/** 弹窗阶段 */
|
||||
export type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
||||
|
||||
/** CloneModal 组件属性 */
|
||||
export interface CloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void
|
||||
}
|
||||
|
||||
/** 进度步骤配置 */
|
||||
export interface ProgressStep {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "./constants"
|
||||
|
||||
/**
|
||||
* 验证音频文件
|
||||
* @returns 错误信息,null 表示验证通过
|
||||
*/
|
||||
export const validateFile = (file: File): string | null => {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return "文件大小超过 10MB,请压缩后重试"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 格式化录制时间 mm:ss */
|
||||
export const formatRecordTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import UploadZone from "./UploadZone"
|
||||
import RecordArea from "./RecordArea"
|
||||
import StepIndicator from "./StepIndicator"
|
||||
import { MAX_VOICE_NAME_LENGTH, MAX_VOICE_DESC_LENGTH } from "../constants/cloneModal"
|
||||
|
||||
interface InputViewProps {
|
||||
voiceName: string
|
||||
voiceDescription: string
|
||||
selectedFile: File | null
|
||||
dragActive: boolean
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
errorMessage: string
|
||||
canSubmit: boolean
|
||||
onVoiceNameChange: (value: string) => void
|
||||
onVoiceDescChange: (value: string) => void
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
onFileSelect: (file: File | null, error: string) => void
|
||||
onRecordToggle: () => void
|
||||
onClose: () => void
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
const INPUT_STEPS = ["上传/录制音频", "填写信息", "提交克隆"]
|
||||
|
||||
const InputView: React.FC<InputViewProps> = ({
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
errorMessage,
|
||||
canSubmit,
|
||||
onVoiceNameChange,
|
||||
onVoiceDescChange,
|
||||
onDragActiveChange,
|
||||
onFileSelect,
|
||||
onRecordToggle,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-clonemodal-body">
|
||||
{/* 步骤引导 */}
|
||||
<StepIndicator currentStep={0} steps={INPUT_STEPS} />
|
||||
|
||||
{/* 音色名称 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">
|
||||
音色名称 <span className="xx-clonemodal-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="xx-clonemodal-input"
|
||||
value={voiceName}
|
||||
onChange={(e) => onVoiceNameChange(e.target.value)}
|
||||
placeholder="输入音色名称(2-20字符)"
|
||||
maxLength={MAX_VOICE_NAME_LENGTH}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceName.length}/{MAX_VOICE_NAME_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">上传音频</label>
|
||||
<UploadZone
|
||||
selectedFile={selectedFile}
|
||||
dragActive={dragActive}
|
||||
onDragActiveChange={onDragActiveChange}
|
||||
onFileSelect={onFileSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 或分隔 */}
|
||||
<div className="xx-clonemodal-divider">
|
||||
<div className="xx-clonemodal-divider-line" />
|
||||
<span className="xx-clonemodal-divider-text">或</span>
|
||||
<div className="xx-clonemodal-divider-line" />
|
||||
</div>
|
||||
|
||||
{/* 录制区域 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">直接录制</label>
|
||||
<RecordArea
|
||||
isRecording={isRecording}
|
||||
recordTime={recordTime}
|
||||
recordedBlob={recordedBlob}
|
||||
onRecordToggle={onRecordToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色描述 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">音色描述</label>
|
||||
<textarea
|
||||
className="xx-clonemodal-textarea"
|
||||
value={voiceDescription}
|
||||
onChange={(e) => onVoiceDescChange(e.target.value)}
|
||||
placeholder="可选,描述这个音色的特点(最多100字符)"
|
||||
maxLength={MAX_VOICE_DESC_LENGTH}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceDescription.length}/{MAX_VOICE_DESC_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{errorMessage && (
|
||||
<div className="xx-clonemodal-error">
|
||||
<span className="xx-clonemodal-error-icon">⚠️</span>
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示 */}
|
||||
<div className="xx-clonemodal-tip">
|
||||
<span className="xx-clonemodal-tip-icon">💡</span>
|
||||
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="xx-clonemodal-footer">
|
||||
<Button buttonType="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" disabled={!canSubmit} onClick={onSubmit}>
|
||||
🎤 开始克隆
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputView
|
||||
@@ -1,92 +0,0 @@
|
||||
import React from "react"
|
||||
import { PROGRESS_STEPS } from "../constants/cloneModal"
|
||||
import type { ProgressStep } from "../types/cloneModal"
|
||||
import type { ModalPhase } from "../types/cloneModal"
|
||||
|
||||
interface ProgressViewProps {
|
||||
phase: ModalPhase
|
||||
}
|
||||
|
||||
const getProgressIndex = (phase: ModalPhase): number => {
|
||||
switch (phase) {
|
||||
case "uploading":
|
||||
return 0
|
||||
case "cloning":
|
||||
return 1
|
||||
case "done":
|
||||
return 2
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
const ProgressView: React.FC<ProgressViewProps> = ({ phase }) => {
|
||||
const progressIndex = getProgressIndex(phase)
|
||||
const isDone = phase === "done"
|
||||
|
||||
return (
|
||||
<div className="xx-clonemodal-progress-body">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="xx-clonemodal-steps-progress">
|
||||
{PROGRESS_STEPS.map((step: ProgressStep, idx: number) => {
|
||||
const isActive = idx === progressIndex && !isDone
|
||||
const stepDone = idx < progressIndex || isDone
|
||||
const stepClass = [
|
||||
"xx-clonemodal-step-progress",
|
||||
isActive ? "xx-clonemodal-step-progress--active" : "",
|
||||
stepDone ? "xx-clonemodal-step-progress--done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div
|
||||
className={`xx-clonemodal-step-connector${stepDone ? " xx-clonemodal-step-connector--done" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<div className={stepClass}>
|
||||
<div className="xx-clonemodal-step-icon">{stepDone ? "✓" : step.icon}</div>
|
||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 完成阶段 */}
|
||||
{isDone && (
|
||||
<div className="xx-clonemodal-success">
|
||||
<div className="xx-clonemodal-success-icon">🎉</div>
|
||||
<h3 className="xx-clonemodal-success-title">克隆已提交</h3>
|
||||
<p className="xx-clonemodal-success-desc">
|
||||
音色正在生成中,完成后将出现在「我的克隆」列表中
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 进行中阶段 */}
|
||||
{!isDone && (
|
||||
<div className="xx-clonemodal-progress-info">
|
||||
{phase === "uploading" && (
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner" />
|
||||
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
||||
</>
|
||||
)}
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
||||
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
||||
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressView
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from "react"
|
||||
import { formatRecordTime } from "../utils/cloneModal"
|
||||
|
||||
interface RecordAreaProps {
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
onRecordToggle: () => void
|
||||
}
|
||||
|
||||
const RecordArea: React.FC<RecordAreaProps> = ({
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
onRecordToggle,
|
||||
}) => {
|
||||
const getHintText = () => {
|
||||
if (isRecording) return `录制中 ${formatRecordTime(recordTime)}`
|
||||
if (recordedBlob) return `已录制 ${formatRecordTime(recordTime)}`
|
||||
return "点击按钮开始录制(最长 5 分钟)"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-clonemodal-record-area">
|
||||
<div className="xx-clonemodal-record-info">
|
||||
<p className="xx-clonemodal-record-hint">{getHintText()}</p>
|
||||
{isRecording && (
|
||||
<div className="xx-clonemodal-record-wave">
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
||||
onClick={onRecordToggle}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RecordArea
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入阶段顶部的步骤引导(数字步骤)
|
||||
*/
|
||||
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, steps }) => {
|
||||
return (
|
||||
<div className="xx-clonemodal-steps">
|
||||
{steps.map((label, idx) => {
|
||||
const isActive = idx <= currentStep
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
{idx > 0 && <div className="xx-clonemodal-step-connector" />}
|
||||
<div className={`xx-clonemodal-step${isActive ? " xx-clonemodal-step--active" : ""}`}>
|
||||
<div className="xx-clonemodal-step-number">{idx + 1}</div>
|
||||
<span className="xx-clonemodal-step-label">{label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StepIndicator
|
||||
@@ -1,79 +0,0 @@
|
||||
import React, { useRef } from "react"
|
||||
import { ACCEPTED_MIME } from "../constants/cloneModal"
|
||||
import { validateFile } from "../utils/cloneModal"
|
||||
|
||||
interface UploadZoneProps {
|
||||
selectedFile: File | null
|
||||
dragActive: boolean
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
onFileSelect: (file: File | null, error: string) => void
|
||||
}
|
||||
|
||||
const UploadZone: React.FC<UploadZoneProps> = ({
|
||||
selectedFile,
|
||||
dragActive,
|
||||
onDragActiveChange,
|
||||
onFileSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file)
|
||||
onFileSelect(error ? null : file, error || "")
|
||||
}
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
onDragActiveChange(true)
|
||||
} else if (e.type === "dragleave") {
|
||||
onDragActiveChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDragActiveChange(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file)
|
||||
onFileSelect(error ? null : file, error || "")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
|
||||
<p className="xx-clonemodal-upload-title">
|
||||
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
|
||||
</p>
|
||||
<p className="xx-clonemodal-upload-hint">支持 MP3、WAV、M4A 格式,最大 10MB</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_MIME}
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadZone
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { ProgressStep } from "../types/cloneModal"
|
||||
|
||||
/** 进度阶段配置 */
|
||||
export const PROGRESS_STEPS: ProgressStep[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
]
|
||||
|
||||
/** 支持的音频扩展名 */
|
||||
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
|
||||
|
||||
/** input accept 属性值 */
|
||||
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
|
||||
|
||||
/** 最大文件大小:10MB */
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** 最长录制时长(秒):5 分钟 */
|
||||
export const MAX_RECORD_SECONDS = 5 * 60
|
||||
|
||||
/** 音色名称最小长度 */
|
||||
export const MIN_VOICE_NAME_LENGTH = 2
|
||||
|
||||
/** 音色名称最大长度 */
|
||||
export const MAX_VOICE_NAME_LENGTH = 20
|
||||
|
||||
/** 音色描述最大长度 */
|
||||
export const MAX_VOICE_DESC_LENGTH = 100
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { MAX_RECORD_SECONDS } from "../constants/cloneModal"
|
||||
|
||||
interface UseAudioRecorderReturn {
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
toggleRecording: () => void
|
||||
resetRecording: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 录音 Hook —— 封装 MediaRecorder 录音逻辑
|
||||
*/
|
||||
const useAudioRecorder = (): UseAudioRecorderReturn => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordTime, setRecordTime] = useState(0)
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
||||
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
const mediaRecorder = new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
audioChunksRef.current = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
|
||||
setRecordedBlob(blob)
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => {
|
||||
const next = prev + 1
|
||||
if (next >= MAX_RECORD_SECONDS) {
|
||||
setTimeout(() => {
|
||||
stopRecording()
|
||||
}, 0)
|
||||
return MAX_RECORD_SECONDS
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, 1000)
|
||||
} catch {
|
||||
// 错误由调用方通过其他机制提示
|
||||
setIsRecording(false)
|
||||
}
|
||||
}, [stopRecording])
|
||||
|
||||
const toggleRecording = useCallback(() => {
|
||||
if (isRecording) {
|
||||
stopRecording()
|
||||
} else {
|
||||
startRecording()
|
||||
}
|
||||
}, [isRecording, startRecording, stopRecording])
|
||||
|
||||
const resetRecording = useCallback(() => {
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
audioChunksRef.current = []
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
mediaRecorderRef.current = null
|
||||
}, [])
|
||||
|
||||
// 卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
toggleRecording,
|
||||
resetRecording,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAudioRecorder
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { ModalPhase } from "../types/cloneModal"
|
||||
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
||||
import useAudioRecorder from "./useAudioRecorder"
|
||||
|
||||
/**
|
||||
* 克隆弹窗表单状态 Hook
|
||||
* 管理表单字段、录音、文件选择、验证逻辑
|
||||
*/
|
||||
export function useCloneFormState({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||
const [voiceName, setVoiceName] = useState("")
|
||||
const [voiceDescription, setVoiceDescription] = useState("")
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
|
||||
useAudioRecorder()
|
||||
|
||||
/** 默认音色名称计数器 */
|
||||
const cloneCounterRef = useRef(1)
|
||||
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
const name = `我的声音 ${cloneCounterRef.current}`
|
||||
cloneCounterRef.current += 1
|
||||
return name
|
||||
}, [])
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
|
||||
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
|
||||
hasAudio
|
||||
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
setErrorMessage("")
|
||||
resetRecording()
|
||||
}, [getNextDefaultName, resetRecording])
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [resetState, onClose])
|
||||
|
||||
/** 弹窗打开时重置状态 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState()
|
||||
}
|
||||
}, [open, resetState])
|
||||
|
||||
/** 选择文件(来自上传或拖拽) */
|
||||
const handleFileSelect = useCallback(
|
||||
(file: File | null, error: string) => {
|
||||
if (error) {
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
// 清除录音
|
||||
resetRecording()
|
||||
}
|
||||
},
|
||||
[resetRecording],
|
||||
)
|
||||
|
||||
/** 录音切换 */
|
||||
const handleRecordToggle = useCallback(() => {
|
||||
setErrorMessage("")
|
||||
if (isRecording) {
|
||||
toggleRecording()
|
||||
} else {
|
||||
// 开始录制前清除已选文件
|
||||
setSelectedFile(null)
|
||||
toggleRecording()
|
||||
}
|
||||
}, [isRecording, toggleRecording])
|
||||
|
||||
/** 表单验证 */
|
||||
const validateForm = useCallback((): string | null => {
|
||||
const name = voiceName.trim()
|
||||
if (!name) {
|
||||
return "请输入音色名称"
|
||||
}
|
||||
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
|
||||
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
|
||||
}
|
||||
if (!hasAudio) {
|
||||
return "请上传音频文件或录制一段声音"
|
||||
}
|
||||
return null
|
||||
}, [voiceName, hasAudio])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
phase,
|
||||
setPhase,
|
||||
voiceName,
|
||||
setVoiceName,
|
||||
voiceDescription,
|
||||
setVoiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
setDragActive,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
// 录音
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
// 计算属性
|
||||
hasAudio,
|
||||
canSubmit,
|
||||
isProcessing,
|
||||
// handlers
|
||||
handleFileSelect,
|
||||
handleRecordToggle,
|
||||
handleClose,
|
||||
validateForm,
|
||||
resetState,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { CloneModalProps } from "../types/cloneModal"
|
||||
import { useCloneFormState } from "./useCloneFormState"
|
||||
import { useCloneSubmit } from "./useCloneSubmit"
|
||||
|
||||
/**
|
||||
* 音色克隆弹窗主业务 Hook
|
||||
* 组合表单状态 + 提交流程两个子 Hook
|
||||
*/
|
||||
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => {
|
||||
const formState = useCloneFormState({ open, onClose })
|
||||
|
||||
const { handleSubmit } = useCloneSubmit({
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
setPhase: formState.setPhase,
|
||||
setErrorMessage: formState.setErrorMessage,
|
||||
validateForm: formState.validateForm,
|
||||
onSuccess,
|
||||
onClose: formState.handleClose,
|
||||
})
|
||||
|
||||
return {
|
||||
phase: formState.phase,
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
dragActive: formState.dragActive,
|
||||
errorMessage: formState.errorMessage,
|
||||
isRecording: formState.isRecording,
|
||||
recordTime: formState.recordTime,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
canSubmit: formState.canSubmit,
|
||||
isProcessing: formState.isProcessing,
|
||||
setVoiceName: formState.setVoiceName,
|
||||
setVoiceDescription: formState.setVoiceDescription,
|
||||
setDragActive: formState.setDragActive,
|
||||
handleFileSelect: formState.handleFileSelect,
|
||||
handleRecordToggle: formState.handleRecordToggle,
|
||||
handleClose: formState.handleClose,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
export default useCloneModal
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
interface UseCloneSubmitOptions {
|
||||
voiceName: string
|
||||
voiceDescription: string
|
||||
selectedFile: File | null
|
||||
recordedBlob: Blob | null
|
||||
setPhase: (phase: "input" | "uploading" | "cloning" | "done") => void
|
||||
setErrorMessage: (msg: string) => void
|
||||
validateForm: () => string | null
|
||||
onSuccess?: (clone: VoiceClone) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆提交流程 Hook
|
||||
* 封装上传 + 克隆 + 完成的三阶段流程
|
||||
*/
|
||||
export function useCloneSubmit({
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
setPhase,
|
||||
setErrorMessage,
|
||||
validateForm,
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: UseCloneSubmitOptions) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
/** 组件卸载时清理定时器 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const formError = validateForm()
|
||||
if (formError) {
|
||||
setErrorMessage(formError)
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
setPhase("uploading")
|
||||
|
||||
let fileToUpload: File
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile
|
||||
} else {
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
})
|
||||
}
|
||||
|
||||
// 获取默认项目和素材库(后端 /upload 接口必填)
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
formData.append("project_id", project.id)
|
||||
formData.append("library_id", library.id)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name: voiceName.trim(),
|
||||
description: voiceDescription.trim() || undefined,
|
||||
audio_url: uploadResult.url,
|
||||
})
|
||||
|
||||
// 阶段 3:完成
|
||||
setPhase("done")
|
||||
|
||||
// 2秒后自动关闭
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
onClose()
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
setPhase("input")
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||
}
|
||||
}, [
|
||||
validateForm,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
setPhase,
|
||||
setErrorMessage,
|
||||
onSuccess,
|
||||
onClose,
|
||||
])
|
||||
|
||||
return { handleSubmit }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
/** 弹窗阶段 */
|
||||
export type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
||||
|
||||
export interface CloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void
|
||||
}
|
||||
|
||||
/** 进度步骤项 */
|
||||
export interface ProgressStep {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "../constants/cloneModal"
|
||||
|
||||
/**
|
||||
* 格式化录制时间 mm:ss
|
||||
*/
|
||||
export const formatRecordTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证上传的音频文件
|
||||
* @returns 错误信息,null 表示验证通过
|
||||
*/
|
||||
export const validateFile = (file: File): string | null => {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return "文件大小超过 10MB,请压缩后重试"
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -31,8 +31,8 @@ export const useLogin = () => {
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, data.access_token, refreshToken)
|
||||
|
||||
// 跳转到登录前页面或仪表盘(与 Login.tsx onFinish 保持一致)
|
||||
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
|
||||
// 跳转到登录前页面或首页
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
return data
|
||||
|
||||
@@ -11,7 +11,10 @@ export interface AssetUploadZoneProps {
|
||||
onUpload: (file: File) => void
|
||||
}
|
||||
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onUpload }) => {
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
uploading,
|
||||
onUpload,
|
||||
}) => {
|
||||
return (
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export { useBatchDelete } from "./useBatchDelete"
|
||||
export { useBatchTag } from "./useBatchTag"
|
||||
export { useBatchClassify } from "./useBatchClassify"
|
||||
export { useBatchMark } from "./useBatchMark"
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchClassifyOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchClassify = ({
|
||||
selectedIds,
|
||||
queryClient,
|
||||
showResult,
|
||||
}: UseBatchClassifyOptions) => {
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
batchLoading,
|
||||
handleBatchClassify,
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchDeleteOptions {
|
||||
selectedIds: Set<string>
|
||||
invalidateAssets: () => void
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchDelete = ({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
}: UseBatchDeleteOptions) => {
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showResult])
|
||||
|
||||
return { batchLoading, handleBatchDelete }
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../../../components/BatchMarkModal"
|
||||
import { SMART_VIEW_LABELS } from "../constants"
|
||||
|
||||
interface UseBatchMarkOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
batchLoading,
|
||||
handleBatchMark,
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchTagOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
||||
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
return {
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
batchLoading,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { useBatchDelete } from "./useBatchDelete"
|
||||
export { useBatchTag } from "./useBatchTag"
|
||||
export { useBatchClassify } from "./useBatchClassify"
|
||||
export { useBatchMark } from "./useBatchMark"
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchClassifyOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchClassify = ({
|
||||
selectedIds,
|
||||
queryClient,
|
||||
showResult,
|
||||
}: UseBatchClassifyOptions) => {
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
batchLoading,
|
||||
handleBatchClassify,
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchDeleteOptions {
|
||||
selectedIds: Set<string>
|
||||
invalidateAssets: () => void
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchDelete = ({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
}: UseBatchDeleteOptions) => {
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showResult])
|
||||
|
||||
return { batchLoading, handleBatchDelete }
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../../../components/BatchMarkModal"
|
||||
import { SMART_VIEW_LABELS } from "../constants"
|
||||
|
||||
interface UseBatchMarkOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
batchLoading,
|
||||
handleBatchMark,
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
|
||||
|
||||
interface UseBatchTagOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
||||
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
return {
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
batchLoading,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @deprecated 请从 ./batch/ 目录导入子模块
|
||||
* 保持向后兼容,re-export 所有批量操作 Hook
|
||||
*/
|
||||
export { useBatchDelete } from "./batch/useBatchDelete"
|
||||
export { useBatchTag } from "./batch/useBatchTag"
|
||||
export { useBatchClassify } from "./batch/useBatchClassify"
|
||||
export { useBatchMark } from "./batch/useBatchMark"
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { SmartViewType } from "../../components/BatchMarkModal"
|
||||
|
||||
export const SMART_VIEW_LABELS: Record<SmartViewType, string> = {
|
||||
recommended: "推荐",
|
||||
caution: "慎用",
|
||||
high_risk: "高风险",
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useCallback } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { getAssetDiagnosis, deleteAsset, type BatchOperationResult } from "@/api/assets"
|
||||
import type { AssetItem } from "../../types"
|
||||
|
||||
interface UseSingleOperationsOptions {
|
||||
selectedIds: Set<string>
|
||||
setSelectedIds: (ids: Set<string>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个素材操作 Hook
|
||||
* 诊断、单个删除
|
||||
*/
|
||||
export const useSingleOperations = ({
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
}: UseSingleOperationsOptions) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 诊断 ── */
|
||||
const handleDiagnose = useCallback(
|
||||
async (asset: AssetItem) => {
|
||||
// 模拟 loading 状态
|
||||
try {
|
||||
const result = await getAssetDiagnosis(asset.id)
|
||||
const score = result.readiness_score ?? "-"
|
||||
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
} catch {
|
||||
message.error(`"${asset.name}" 诊断失败`)
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
)
|
||||
|
||||
/* ── 单个素材删除 ── */
|
||||
const handleSingleDelete = useCallback(
|
||||
async (assetId: string) => {
|
||||
try {
|
||||
await deleteAsset(assetId)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
// 从选中集合中移除
|
||||
setSelectedIds(
|
||||
(() => {
|
||||
const next = new Set(selectedIds)
|
||||
next.delete(assetId)
|
||||
return next
|
||||
})(),
|
||||
)
|
||||
message.success("素材已删除")
|
||||
} catch {
|
||||
message.error("删除失败,请重试")
|
||||
}
|
||||
},
|
||||
[queryClient, selectedIds, setSelectedIds],
|
||||
)
|
||||
|
||||
return {
|
||||
handleDiagnose,
|
||||
handleSingleDelete,
|
||||
}
|
||||
}
|
||||
|
||||
interface UseBatchHelpersOptions {
|
||||
queryClient: ReturnType<typeof useQueryClient>
|
||||
setSelectedIds: (ids: Set<string>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作辅助函数
|
||||
* 刷新数据、显示操作结果
|
||||
*/
|
||||
export const useBatchHelpers = ({ queryClient, setSelectedIds }: UseBatchHelpersOptions) => {
|
||||
const invalidateAssets = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
}, [queryClient])
|
||||
|
||||
const showOperationResult = useCallback(
|
||||
(
|
||||
setResult: (r: BatchOperationResult | null) => void,
|
||||
setTitle: (t: string) => void,
|
||||
setDrawerOpen: (v: boolean) => void,
|
||||
result: BatchOperationResult,
|
||||
title: string,
|
||||
clearSelection = true,
|
||||
) => {
|
||||
setResult(result)
|
||||
setTitle(title)
|
||||
setDrawerOpen(true)
|
||||
if (clearSelection) setSelectedIds(new Set())
|
||||
},
|
||||
[setSelectedIds],
|
||||
)
|
||||
|
||||
return {
|
||||
invalidateAssets,
|
||||
showOperationResult,
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
/**
|
||||
* 素材操作 Hook(入口)
|
||||
* 组合各子模块,保持导出不变
|
||||
*
|
||||
* 子模块位于 ./asset-operations/
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useSingleOperations, useBatchHelpers } from "./asset-operations/useSingleOperations"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
useBatchDelete,
|
||||
useBatchTag,
|
||||
useBatchClassify,
|
||||
useBatchMark,
|
||||
} from "./asset-operations/batch-operations"
|
||||
import type { BatchOperationResult } from "@/api/assets"
|
||||
deleteAsset,
|
||||
getAssetDiagnosis,
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets"
|
||||
import type { AssetItem } from "../types"
|
||||
import type { SmartViewType } from "../components/BatchMarkModal"
|
||||
|
||||
/**
|
||||
* 素材操作 Hook
|
||||
* 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作,
|
||||
* 以及相关弹窗和结果展示的状态管理
|
||||
*/
|
||||
interface UseAssetOperationsProps {
|
||||
selectedIds: Set<string>
|
||||
setSelectedIds: (ids: Set<string>) => void
|
||||
@@ -27,35 +29,78 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
||||
/* ── 诊断状态 ── */
|
||||
const [diagnosingId, setDiagnosingId] = useState<string | null>(null)
|
||||
|
||||
/* ── 操作结果 ── */
|
||||
/* ── 批量操作弹窗状态 ── */
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 批量打标签表单 ── */
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
|
||||
/* ── 批量改分类表单 ── */
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
|
||||
/* ── 批量智能标记表单 ── */
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
|
||||
/* ── 操作结果 ── */
|
||||
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
|
||||
const [operationTitle, setOperationTitle] = useState("")
|
||||
|
||||
/* ── 单个操作 ── */
|
||||
const { handleDiagnose: handleDiagnoseRaw, handleSingleDelete } = useSingleOperations({
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
})
|
||||
/* ── 批量操作 loading ── */
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
// 包装一下,加上 diagnosingId 状态
|
||||
/* ── 刷新数据辅助函数 ── */
|
||||
const invalidateAssets = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
}, [queryClient])
|
||||
|
||||
/* ── 诊断 ── */
|
||||
const handleDiagnose = useCallback(
|
||||
async (asset: Parameters<typeof handleDiagnoseRaw>[0]) => {
|
||||
async (asset: AssetItem) => {
|
||||
setDiagnosingId(asset.id)
|
||||
try {
|
||||
await handleDiagnoseRaw(asset)
|
||||
const result = await getAssetDiagnosis(asset.id)
|
||||
const score = result.readiness_score ?? "-"
|
||||
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
} catch {
|
||||
message.error(`"${asset.name}" 诊断失败`)
|
||||
} finally {
|
||||
setDiagnosingId(null)
|
||||
}
|
||||
},
|
||||
[handleDiagnoseRaw],
|
||||
[queryClient],
|
||||
)
|
||||
|
||||
/* ── 批量操作辅助 ── */
|
||||
const { invalidateAssets } = useBatchHelpers({ queryClient, setSelectedIds })
|
||||
/* ── 单个素材删除 ── */
|
||||
const handleSingleDelete = useCallback(
|
||||
async (assetId: string) => {
|
||||
try {
|
||||
await deleteAsset(assetId)
|
||||
invalidateAssets()
|
||||
// 从选中集合中移除
|
||||
setSelectedIds(
|
||||
(() => {
|
||||
const next = new Set(selectedIds)
|
||||
next.delete(assetId)
|
||||
return next
|
||||
})(),
|
||||
)
|
||||
message.success("素材已删除")
|
||||
} catch {
|
||||
message.error("删除失败,请重试")
|
||||
}
|
||||
},
|
||||
[invalidateAssets, selectedIds, setSelectedIds],
|
||||
)
|
||||
|
||||
// 包装 showResult 适配子模块的接口
|
||||
const showResult = useCallback(
|
||||
/* ── 显示操作结果 ── */
|
||||
const showOperationResult = useCallback(
|
||||
(result: BatchOperationResult, title: string, clearSelection = true) => {
|
||||
setOperationResult(result)
|
||||
setOperationTitle(title)
|
||||
@@ -65,23 +110,147 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
||||
[setSelectedIds],
|
||||
)
|
||||
|
||||
/* ── 批量操作 ── */
|
||||
const { batchLoading: deleteLoading, handleBatchDelete } = useBatchDelete({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
})
|
||||
/* ── 批量删除 ── */
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showOperationResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showOperationResult])
|
||||
|
||||
const tagResult = useBatchTag({ selectedIds, queryClient, showResult })
|
||||
const classifyResult = useBatchClassify({ selectedIds, queryClient, showResult })
|
||||
const markResult = useBatchMark({ selectedIds, queryClient, showResult })
|
||||
/* ── 批量打标签 ── */
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showOperationResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showOperationResult])
|
||||
|
||||
// 取任一批量操作的 loading 状态(任意一个在加载都算加载中)
|
||||
const batchLoading =
|
||||
deleteLoading ||
|
||||
tagResult.batchLoading ||
|
||||
classifyResult.batchLoading ||
|
||||
markResult.batchLoading
|
||||
/* ── 标签输入处理 ── */
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
/* ── 批量改分类 ── */
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showOperationResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showOperationResult])
|
||||
|
||||
/* ── 批量智能标记 ── */
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showOperationResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
const labelMap: Record<SmartViewType, string> = {
|
||||
recommended: "推荐",
|
||||
caution: "慎用",
|
||||
high_risk: "高风险",
|
||||
}
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showOperationResult])
|
||||
|
||||
/* ── 关闭结果 Drawer ── */
|
||||
const handleResultDrawerClose = useCallback(() => {
|
||||
@@ -98,29 +267,29 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
||||
// 批量操作 loading
|
||||
batchLoading,
|
||||
// 批量打标签
|
||||
tagModalOpen: tagResult.tagModalOpen,
|
||||
setTagModalOpen: tagResult.setTagModalOpen,
|
||||
batchTagInput: tagResult.batchTagInput,
|
||||
setBatchTagInput: tagResult.setBatchTagInput,
|
||||
batchTags: tagResult.batchTags,
|
||||
setBatchTags: tagResult.setBatchTags,
|
||||
tagMode: tagResult.tagMode,
|
||||
setTagMode: tagResult.setTagMode,
|
||||
handleBatchTag: tagResult.handleBatchTag,
|
||||
handleTagInputKeyDown: tagResult.handleTagInputKeyDown,
|
||||
removeBatchTag: tagResult.removeBatchTag,
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
// 批量改分类
|
||||
classifyModalOpen: classifyResult.classifyModalOpen,
|
||||
setClassifyModalOpen: classifyResult.setClassifyModalOpen,
|
||||
batchCategory: classifyResult.batchCategory,
|
||||
setBatchCategory: classifyResult.setBatchCategory,
|
||||
handleBatchClassify: classifyResult.handleBatchClassify,
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
handleBatchClassify,
|
||||
// 批量智能标记
|
||||
markModalOpen: markResult.markModalOpen,
|
||||
setMarkModalOpen: markResult.setMarkModalOpen,
|
||||
batchSmartView: markResult.batchSmartView as SmartViewType,
|
||||
setBatchSmartView: markResult.setBatchSmartView,
|
||||
handleBatchMark: markResult.handleBatchMark,
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
handleBatchMark,
|
||||
// 批量删除
|
||||
handleBatchDelete,
|
||||
// 操作结果
|
||||
|
||||
Executable → Regular
+239
-23
@@ -3,29 +3,137 @@
|
||||
* 风险评估 + 基本信息 + 检测项列表 + 匹配片段
|
||||
* 零 antd 依赖
|
||||
*/
|
||||
import React from "react"
|
||||
import { Button, Tag } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { RiskCard } from "./components/RiskCard"
|
||||
import { InfoCard } from "./components/InfoCard"
|
||||
import { SegmentsSection } from "./components/SegmentsSection"
|
||||
import { useDuplicationDetail } from "./hooks/useDuplicationDetail"
|
||||
import { RISK_TAG_VARIANT, RISK_LABEL } from "./constants"
|
||||
import { formatSize, formatDuration } from "./utils"
|
||||
import React, { useState, useCallback } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Button, Tag, Tooltip } from "@/components/ui"
|
||||
import { useParams, useNavigate } from "react-router-dom"
|
||||
import { getDuplicationDetail, retryDuplication, type DuplicateSegment } from "@/api/duplication"
|
||||
import "./duplication.css"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
|
||||
/** 格式化时间(秒 → mm:ss) */
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return "-"
|
||||
const totalSec = Math.round(seconds)
|
||||
const m = Math.floor(totalSec / 60)
|
||||
const s = totalSec % 60
|
||||
return m > 0 ? `${m}分${s}秒` : `${s}秒`
|
||||
}
|
||||
|
||||
/** 根据查重率获取风险等级 */
|
||||
const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
|
||||
if (rate === undefined) return "low"
|
||||
if (rate <= 10) return "low"
|
||||
if (rate <= 30) return "medium"
|
||||
return "high"
|
||||
}
|
||||
|
||||
/** 风险等级描述 */
|
||||
const RISK_DESC: Record<string, string> = {
|
||||
low: "查重率较低,内容原创度高",
|
||||
medium: "存在一定重复,建议修改部分片段",
|
||||
high: "重复率较高,建议大幅修改或替换",
|
||||
}
|
||||
|
||||
/** 风险等级标签变体 */
|
||||
const RISK_TAG_VARIANT: Record<string, "success" | "warning" | "error"> = {
|
||||
low: "success",
|
||||
medium: "warning",
|
||||
high: "error",
|
||||
}
|
||||
|
||||
/** 风险等级文字 */
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
low: "低风险",
|
||||
medium: "中风险",
|
||||
high: "高风险",
|
||||
}
|
||||
|
||||
/** 简易 toast */
|
||||
interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
|
||||
/** 单个重复片段卡片 */
|
||||
const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
|
||||
segment,
|
||||
index,
|
||||
}) => {
|
||||
const sourceDuration = segment.source_end - segment.source_start
|
||||
const matchedDuration = segment.matched_end - segment.matched_start
|
||||
const riskLevel = segment.similarity >= 90 ? "high" : segment.similarity >= 70 ? "medium" : "low"
|
||||
|
||||
return (
|
||||
<div className="dup-check-item">
|
||||
<div className="dup-check-icon">🎬</div>
|
||||
<div className="dup-check-body">
|
||||
<h4>
|
||||
片段 {index + 1}:{segment.matched_video_name}
|
||||
</h4>
|
||||
<p>
|
||||
原始 {formatTime(segment.source_start)} - {formatTime(segment.source_end)}(
|
||||
{sourceDuration.toFixed(0)}s)→ 匹配 {formatTime(segment.matched_start)} -{" "}
|
||||
{formatTime(segment.matched_end)}({matchedDuration.toFixed(0)}s)
|
||||
</p>
|
||||
</div>
|
||||
<div className={`dup-check-bar`}>
|
||||
<div
|
||||
className={`dup-check-bar-fill ${riskLevel}`}
|
||||
style={{ width: `${Math.min(segment.similarity, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`dup-check-value ${riskLevel}`}>{segment.similarity.toFixed(1)}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DuplicationDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
detail,
|
||||
data: detail,
|
||||
isLoading,
|
||||
isError,
|
||||
toast,
|
||||
riskLevel,
|
||||
similarityPercent,
|
||||
handleRetry,
|
||||
handleDownloadReport,
|
||||
handleBack,
|
||||
} = useDuplicationDetail()
|
||||
} = useQuery({
|
||||
queryKey: ["duplication-detail", id],
|
||||
queryFn: () => getDuplicationDetail(id!),
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
// 重新查重
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryDuplication,
|
||||
onSuccess: () => {
|
||||
showToast("已重新提交查重", "success")
|
||||
queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] })
|
||||
},
|
||||
onError: () => {
|
||||
showToast("重新查重失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -47,7 +155,7 @@ const DuplicationDetail: React.FC = () => {
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={handleBack}
|
||||
onClick={() => navigate("/app/duplication/results")}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
返回列表
|
||||
@@ -57,6 +165,10 @@ const DuplicationDetail: React.FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const riskLevel = getRiskLevel(detail.duplicate_rate)
|
||||
const similarityPercent =
|
||||
detail.duplicate_rate !== undefined ? detail.duplicate_rate.toFixed(1) : "—"
|
||||
|
||||
return (
|
||||
<div className="dup-page">
|
||||
{/* Toast */}
|
||||
@@ -82,11 +194,21 @@ const DuplicationDetail: React.FC = () => {
|
||||
}
|
||||
actions={
|
||||
<div className="dup-detail-actions" style={{ display: "flex", gap: 8 }}>
|
||||
<Button buttonType="secondary" buttonSize="md" onClick={handleDownloadReport}>
|
||||
<Button
|
||||
buttonType="secondary"
|
||||
buttonSize="md"
|
||||
onClick={() => {
|
||||
showToast("报告下载功能开发中", "warning")
|
||||
}}
|
||||
>
|
||||
📥 下载报告
|
||||
</Button>
|
||||
{detail.status === "failed" && (
|
||||
<Button buttonType="primary" buttonSize="md" onClick={handleRetry}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => retryMutation.mutate(detail.id)}
|
||||
>
|
||||
🔄 重新查重
|
||||
</Button>
|
||||
)}
|
||||
@@ -96,9 +218,103 @@ const DuplicationDetail: React.FC = () => {
|
||||
|
||||
{/* 内容网格 */}
|
||||
<div className="dup-detail-grid">
|
||||
<RiskCard riskLevel={riskLevel} similarityPercent={similarityPercent} />
|
||||
<InfoCard detail={detail} />
|
||||
<SegmentsSection segments={detail.segments} />
|
||||
{/* 风险评估卡片 */}
|
||||
<div className="dup-risk-card">
|
||||
<h3>📊 风险评估</h3>
|
||||
<div className={`dup-risk-circle ${riskLevel}`}>
|
||||
<span className="dup-risk-value">{similarityPercent}%</span>
|
||||
<span className="dup-risk-label">查重率</span>
|
||||
</div>
|
||||
<p className="dup-risk-desc">{RISK_DESC[riskLevel]}</p>
|
||||
</div>
|
||||
|
||||
{/* 基本信息卡片 */}
|
||||
<div className="dup-info-detail-card">
|
||||
<h3>📋 基本信息</h3>
|
||||
<div className="dup-info-rows">
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">文件名</span>
|
||||
<Tooltip title={detail.filename}>
|
||||
<span
|
||||
className="dup-info-row-value"
|
||||
style={{
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{detail.filename}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">文件大小</span>
|
||||
<span className="dup-info-row-value">{formatSize(detail.file_size)}</span>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">视频时长</span>
|
||||
<span className="dup-info-row-value">{formatDuration(detail.duration_seconds)}</span>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">查重状态</span>
|
||||
<span className="dup-info-row-value">
|
||||
<Tag
|
||||
variant={
|
||||
detail.status === "completed"
|
||||
? "success"
|
||||
: detail.status === "failed"
|
||||
? "error"
|
||||
: detail.status === "processing"
|
||||
? "warning"
|
||||
: "info"
|
||||
}
|
||||
>
|
||||
{detail.status === "completed"
|
||||
? "✅ 已完成"
|
||||
: detail.status === "failed"
|
||||
? "❌ 失败"
|
||||
: detail.status === "processing"
|
||||
? "🔄 查重中"
|
||||
: "⏳ 等待中"}
|
||||
</Tag>
|
||||
</span>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">重复片段数</span>
|
||||
<span className="dup-info-row-value">{detail.duplicate_count ?? 0} 个</span>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">提交时间</span>
|
||||
<span className="dup-info-row-value">
|
||||
{new Date(detail.created_at).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 检测项列表 */}
|
||||
<div className="dup-checks-section">
|
||||
<h3>
|
||||
🔍 重复片段详情
|
||||
<Tag variant="primary" style={{ marginLeft: 8 }}>
|
||||
{detail.segments?.length ?? 0} 个片段
|
||||
</Tag>
|
||||
</h3>
|
||||
|
||||
{detail.segments && detail.segments.length > 0 ? (
|
||||
<div className="dup-checks-list">
|
||||
{detail.segments.map((segment, index) => (
|
||||
<SegmentCard key={segment.id} segment={segment} index={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
|
||||
<div className="dup-results-empty-icon">🎉</div>
|
||||
<p>未发现重复片段,内容原创度很高</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Executable → Regular
+184
-36
@@ -1,35 +1,116 @@
|
||||
/**
|
||||
* 查重上传页面 — V21 设计系统
|
||||
* 左右分栏:拖拽上传区 + 格式说明
|
||||
* 零 antd 依赖
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useState, useRef, useCallback } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { Button, Card, Tag } from "@/components/ui"
|
||||
import { uploadForDuplication } from "@/api/duplication"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Card } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import UploadZone from "./duplication-upload/UploadZone"
|
||||
import UploadActions from "./duplication-upload/UploadActions"
|
||||
import UploadProgress from "./duplication-upload/UploadProgress"
|
||||
import UploadResultPanel from "./duplication-upload/UploadResultPanel"
|
||||
import InfoSidebar from "./duplication-upload/InfoSidebar"
|
||||
import { useDuplicationUpload } from "./duplication-upload/useDuplicationUpload"
|
||||
import { ACCEPT_FORMATS } from "./duplication-upload/constants"
|
||||
import "./duplication.css"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
|
||||
/** 支持的视频格式 */
|
||||
const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||
const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||
/** 最大文件大小:2GB */
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
/** 简易 toast */
|
||||
interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
|
||||
const DuplicationUpload: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
fileInputRef,
|
||||
dragging,
|
||||
uploading,
|
||||
uploadResult,
|
||||
toast,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleSelectFile,
|
||||
handleFileChange,
|
||||
handleReset,
|
||||
} = useDuplicationUpload()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResult, setUploadResult] = useState<{
|
||||
id: string
|
||||
message: string
|
||||
} | null>(null)
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
// 上传查重 mutation
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadForDuplication(file),
|
||||
onSuccess: (data) => {
|
||||
setUploading(false)
|
||||
setUploadResult({ id: data.id, message: data.message })
|
||||
showToast("查重任务已提交", "success")
|
||||
},
|
||||
onError: () => {
|
||||
setUploading(false)
|
||||
showToast("上传失败,请重试", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 校验并上传文件 */
|
||||
const handleFile = useCallback(
|
||||
(file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast("文件大小不能超过 2GB", "error")
|
||||
return
|
||||
}
|
||||
const ext = file.name.toLowerCase().split(".").pop()
|
||||
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
|
||||
if (!allowedExts.includes(ext || "")) {
|
||||
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
setUploadResult(null)
|
||||
uploadMutation.mutate(file)
|
||||
},
|
||||
[uploadMutation, showToast],
|
||||
)
|
||||
|
||||
/** 拖拽事件 */
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
},
|
||||
[handleFile],
|
||||
)
|
||||
|
||||
/** 点击选择文件 */
|
||||
const handleSelectFile = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
// 重置 input 以便重复选择同一文件
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 重置状态 */
|
||||
const handleReset = () => {
|
||||
setUploadResult(null)
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dup-page">
|
||||
@@ -44,14 +125,25 @@ const DuplicationUpload: React.FC = () => {
|
||||
<div className="dup-upload-grid">
|
||||
{/* 左侧:上传区域 */}
|
||||
<Card>
|
||||
<UploadZone
|
||||
dragging={dragging}
|
||||
uploading={uploading}
|
||||
{/* 拖拽上传区 */}
|
||||
<div
|
||||
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleSelectFile}
|
||||
/>
|
||||
onClick={uploading ? undefined : handleSelectFile}
|
||||
>
|
||||
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
||||
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
||||
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
||||
<div className="dup-upload-formats">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 隐藏的文件 input */}
|
||||
<input
|
||||
@@ -62,21 +154,77 @@ const DuplicationUpload: React.FC = () => {
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<UploadActions uploading={uploading} onSelectFile={handleSelectFile} />
|
||||
{/* 上传按钮 */}
|
||||
<div className="dup-upload-actions">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={handleSelectFile}
|
||||
disabled={uploading}
|
||||
>
|
||||
📂 选择文件
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{uploading && <UploadProgress />}
|
||||
{/* 上传进度 */}
|
||||
{uploading && (
|
||||
<div className="dup-progress">
|
||||
<div className="dup-progress-circle">
|
||||
<span className="dup-progress-icon">⏳</span>
|
||||
<span className="dup-progress-text">查重中...</span>
|
||||
</div>
|
||||
<p>正在分析视频内容,请稍候...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传结果 */}
|
||||
{uploadResult && !uploading && (
|
||||
<UploadResultPanel
|
||||
result={uploadResult}
|
||||
onViewResult={() => navigate("/app/duplication/results")}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
<div className="dup-result">
|
||||
<div className="dup-result-icon">✅</div>
|
||||
<h3>查重任务已提交</h3>
|
||||
<p>{uploadResult.message}</p>
|
||||
<div className="dup-result-actions">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/app/duplication/results")}
|
||||
>
|
||||
查看结果
|
||||
</Button>
|
||||
<Button buttonType="secondary" buttonSize="md" onClick={handleReset}>
|
||||
继续上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 右侧:格式说明 + 提示 */}
|
||||
<InfoSidebar />
|
||||
<div className="dup-info-card">
|
||||
<h3>📋 查重说明</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
||||
<li>查重完成后,可查看重复片段的具体位置</li>
|
||||
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
||||
<li>高相似度片段建议进行替换或裁剪</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>🎬 支持格式</h3>
|
||||
<div className="dup-format-tags">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>💡 温馨提示</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>单个文件不超过 2GB</li>
|
||||
<li>视频时长建议不超过 60 分钟</li>
|
||||
<li>查重结果可在「查重记录」中随时查看</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import React from "react"
|
||||
import { Tag, Tooltip } from "@/components/ui"
|
||||
import { formatSize, formatDuration } from "../utils"
|
||||
import type { DuplicationDetail } from "@/api/duplication"
|
||||
|
||||
interface InfoCardProps {
|
||||
detail: DuplicationDetail
|
||||
}
|
||||
|
||||
/**
|
||||
* 基本信息卡片
|
||||
*/
|
||||
export const InfoCard: React.FC<InfoCardProps> = ({ detail }) => {
|
||||
const statusMap: Record<string, { text: string; variant: string }> = {
|
||||
completed: { text: "✅ 已完成", variant: "success" },
|
||||
failed: { text: "❌ 失败", variant: "error" },
|
||||
processing: { text: "🔄 查重中", variant: "warning" },
|
||||
pending: { text: "⏳ 等待中", variant: "info" },
|
||||
}
|
||||
const status = statusMap[detail.status] || {
|
||||
text: detail.status,
|
||||
variant: "info",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dup-info-detail-card">
|
||||
<h3>📋 基本信息</h3>
|
||||
<div className="dup-info-rows">
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">文件名</span>
|
||||
<Tooltip title={detail.filename}>
|
||||
<span
|
||||
className="dup-info-row-value"
|
||||
style={{
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{detail.filename}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">文件大小</span>
|
||||
<span className="dup-info-row-value">{formatSize(detail.file_size)}</span>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">视频时长</span>
|
||||
<span className="dup-info-row-value">{formatDuration(detail.duration_seconds)}</span>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">查重状态</span>
|
||||
<span className="dup-info-row-value">
|
||||
<Tag variant={status.variant as "success"}>{status.text}</Tag>
|
||||
</span>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">重复片段数</span>
|
||||
<span className="dup-info-row-value">{detail.duplicate_count ?? 0} 个</span>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">提交时间</span>
|
||||
<span className="dup-info-row-value">
|
||||
{new Date(detail.created_at).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from "react"
|
||||
import { RISK_DESC } from "../constants"
|
||||
|
||||
interface RiskCardProps {
|
||||
riskLevel: string
|
||||
similarityPercent: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险评估卡片
|
||||
*/
|
||||
export const RiskCard: React.FC<RiskCardProps> = ({ riskLevel, similarityPercent }) => (
|
||||
<div className="dup-risk-card">
|
||||
<h3>📊 风险评估</h3>
|
||||
<div className={`dup-risk-circle ${riskLevel}`}>
|
||||
<span className="dup-risk-value">{similarityPercent}%</span>
|
||||
<span className="dup-risk-label">查重率</span>
|
||||
</div>
|
||||
<p className="dup-risk-desc">{RISK_DESC[riskLevel]}</p>
|
||||
</div>
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from "react"
|
||||
import type { DuplicateSegment } from "@/api/duplication"
|
||||
import { formatTime } from "../utils"
|
||||
|
||||
interface SegmentCardProps {
|
||||
segment: DuplicateSegment
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个重复片段卡片
|
||||
*/
|
||||
export const SegmentCard: React.FC<SegmentCardProps> = ({ segment, index }) => {
|
||||
const sourceDuration = segment.source_end - segment.source_start
|
||||
const matchedDuration = segment.matched_end - segment.matched_start
|
||||
const riskLevel = segment.similarity >= 90 ? "high" : segment.similarity >= 70 ? "medium" : "low"
|
||||
|
||||
return (
|
||||
<div className="dup-check-item">
|
||||
<div className="dup-check-icon">🎬</div>
|
||||
<div className="dup-check-body">
|
||||
<h4>
|
||||
片段 {index + 1}:{segment.matched_video_name}
|
||||
</h4>
|
||||
<p>
|
||||
原始 {formatTime(segment.source_start)} - {formatTime(segment.source_end)}(
|
||||
{sourceDuration.toFixed(0)}s)→ 匹配 {formatTime(segment.matched_start)} -{" "}
|
||||
{formatTime(segment.matched_end)}({matchedDuration.toFixed(0)}s)
|
||||
</p>
|
||||
</div>
|
||||
<div className={`dup-check-bar`}>
|
||||
<div
|
||||
className={`dup-check-bar-fill ${riskLevel}`}
|
||||
style={{ width: `${Math.min(segment.similarity, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`dup-check-value ${riskLevel}`}>{segment.similarity.toFixed(1)}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from "react"
|
||||
import { Tag } from "@/components/ui"
|
||||
import type { DuplicateSegment } from "@/api/duplication"
|
||||
import { SegmentCard } from "./SegmentCard"
|
||||
|
||||
interface SegmentsSectionProps {
|
||||
segments?: DuplicateSegment[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复片段列表区域
|
||||
*/
|
||||
export const SegmentsSection: React.FC<SegmentsSectionProps> = ({ segments = [] }) => (
|
||||
<div className="dup-checks-section">
|
||||
<h3>
|
||||
🔍 重复片段详情
|
||||
<Tag variant="primary" style={{ marginLeft: 8 }}>
|
||||
{segments.length} 个片段
|
||||
</Tag>
|
||||
</h3>
|
||||
|
||||
{segments.length > 0 ? (
|
||||
<div className="dup-checks-list">
|
||||
{segments.map((segment, index) => (
|
||||
<SegmentCard key={segment.id} segment={segment} index={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
|
||||
<div className="dup-results-empty-icon">🎉</div>
|
||||
<p>未发现重复片段,内容原创度很高</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
Executable → Regular
+1
-34
@@ -16,28 +16,7 @@ export const STATUS_CONFIG: Record<
|
||||
failed: { variant: "error", text: "失败", icon: "❌" },
|
||||
}
|
||||
|
||||
/** 风险等级描述 */
|
||||
export const RISK_DESC: Record<string, string> = {
|
||||
low: "查重率较低,内容原创度高",
|
||||
medium: "存在一定重复,建议修改部分片段",
|
||||
high: "重复率较高,建议大幅修改或替换",
|
||||
}
|
||||
|
||||
/** 风险等级标签变体 */
|
||||
export const RISK_TAG_VARIANT: Record<string, "success" | "warning" | "error"> = {
|
||||
low: "success",
|
||||
medium: "warning",
|
||||
high: "error",
|
||||
}
|
||||
|
||||
/** 风险等级文字(详情页用) */
|
||||
export const RISK_LABEL: Record<string, string> = {
|
||||
low: "低风险",
|
||||
medium: "中风险",
|
||||
high: "高风险",
|
||||
}
|
||||
|
||||
/** 风险等级标签(列表页用) */
|
||||
/** 风险等级标签 */
|
||||
export const RISK_LABELS: Record<string, string> = {
|
||||
low: "低风险",
|
||||
medium: "中风险",
|
||||
@@ -50,15 +29,3 @@ export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
|
||||
{ key: "medium", label: "中风险" },
|
||||
{ key: "high", label: "高风险" },
|
||||
]
|
||||
|
||||
/** 支持的视频格式 */
|
||||
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||
/** 最大文件大小:2GB */
|
||||
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
/** 简易 toast */
|
||||
export interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from "react"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { FORMAT_LIST } from "./constants"
|
||||
|
||||
/** 右侧说明卡 */
|
||||
const InfoSidebar: React.FC = () => {
|
||||
return (
|
||||
<div className="dup-info-card">
|
||||
<h3>📋 查重说明</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
||||
<li>查重完成后,可查看重复片段的具体位置</li>
|
||||
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
||||
<li>高相似度片段建议进行替换或裁剪</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>🎬 支持格式</h3>
|
||||
<div className="dup-format-tags">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>💡 温馨提示</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>单个文件不超过 2GB</li>
|
||||
<li>视频时长建议不超过 60 分钟</li>
|
||||
<li>查重结果可在「查重记录」中随时查看</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfoSidebar
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user