diff --git a/.gitea/workflows/ci-pipeline.yml b/.gitea/workflows/ci-pipeline.yml index e834fb87d..4d4e5b212 100755 --- a/.gitea/workflows/ci-pipeline.yml +++ b/.gitea/workflows/ci-pipeline.yml @@ -24,17 +24,6 @@ concurrency: group: ci-pipeline-${{ gitea.event_name }}-${{ gitea.ref }} # PR事件取消进行中的旧run,push事件不取消(确保完整CI跑完) cancel-in-progress: ${{ gitea.event_name == 'pull_request' }} -env: - CI_PG_HOST: host.docker.internal - CI_LOCAL_PG_PORT: "5432" - CI_PG_USER: postgres - CI_PG_PASSWORD: postgres - CI_PG_DB: xiaoxia_saas - CI_SHARED_PG_PORT: "5433" - CI_SHARED_PG_USER: postgres - CI_SHARED_PG_PASSWORD: ci_pg_2026! - CI_DEFAULT_DB: xiaoxia_saas - jobs: check-frontend-only: name: Check if frontend-only change @@ -58,56 +47,24 @@ jobs: run: | set -eu PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') - - # 分页获取所有变更文件(修复>300文件时漏判) - ALL_FILES="" - PAGE=1 - while true; do - API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300&page=${PAGE}" - PAGE_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; files=json.load(sys.stdin); [print(f['filename']) for f in files]; sys.exit(0 if len(files)==300 else 1)" 2>/dev/null || true) - PAGE_COUNT=$(echo "$PAGE_FILES" | wc -l) - if [ "$PAGE_COUNT" -lt 300 ]; then HAS_MORE=1; else HAS_MORE=0; fi - ALL_FILES="${ALL_FILES}${PAGE_FILES}"$'\n' - if [ "$HAS_MORE" != "0" ]; then - break - fi - PAGE=$((PAGE + 1)) - done - - FRONTEND_COUNT=$(echo "$ALL_FILES" | grep -c '^apps/web/' || true) - TOTAL=$(echo "$ALL_FILES" | grep -cv '^$' || true) - BACKEND_COUNT=$(( TOTAL - FRONTEND_COUNT )) - - # 基础设施文件:改了就强制全量CI(不跳过任何检查) - INFRA_COUNT=$(echo "$ALL_FILES" | grep -cE '^(infra/|Dockerfile|\.gitea/workflows/|scripts/ci/|docker/)' || true) - - echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT}, 基础设施: ${INFRA_COUNT})" - - # 判定是否纯前端/纯后端 - PURE_FRONTEND=false - PURE_BACKEND=false - if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ] && [ "$INFRA_COUNT" = "0" ]; then - PURE_FRONTEND=true - elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ] && [ "$INFRA_COUNT" = "0" ]; then - PURE_BACKEND=true - fi - - if [ "$PURE_FRONTEND" = "true" ]; then + API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" + FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]") + FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true) + BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true) + TOTAL=$(echo "$FILES" | grep -cv '^$' || true) + echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})" + if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then echo "skip_backend=true" >> $GITHUB_OUTPUT echo "skip_frontend=false" >> $GITHUB_OUTPUT echo "✅ 纯前端改动,跳过后端检查" - elif [ "$PURE_BACKEND" = "true" ]; then + elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ]; then echo "skip_backend=false" >> $GITHUB_OUTPUT echo "skip_frontend=true" >> $GITHUB_OUTPUT echo "🔧 纯后端改动,跳过前端检查" else echo "skip_backend=false" >> $GITHUB_OUTPUT echo "skip_frontend=false" >> $GITHUB_OUTPUT - if [ "$INFRA_COUNT" -gt "0" ]; then - echo "🏗️ 包含基础设施变更,强制运行完整CI" - else - echo "🔧 包含全栈变更,运行完整CI" - fi + echo "🔧 包含全栈变更,运行完整CI" fi - name: Report CI trace @@ -294,7 +251,7 @@ jobs: permissions: contents: read env: - DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }} + DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas USE_IN_MEMORY_DB: 'false' CI_USE_SHARED_PG: 'true' steps: @@ -378,7 +335,6 @@ jobs: OSS_ACCESS_KEY_SECRET: placeholder OSS_BUCKET_NAME: xiaoxia-autocut OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com - JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026 steps: - name: Checkout code shell: sh @@ -443,14 +399,13 @@ jobs: - validate-type-check - validate-migration env: - DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }} + DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas USE_IN_MEMORY_DB: 'false' CI_USE_SHARED_PG: 'true' OSS_ACCESS_KEY_ID: placeholder OSS_ACCESS_KEY_SECRET: placeholder OSS_BUCKET_NAME: xiaoxia-autocut OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com - JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026 steps: - name: Checkout code shell: sh @@ -672,83 +627,60 @@ jobs: echo "Docker login failed ($i/3), retrying in 5s..." sleep 5 done - - name: Pre-build worker base images (3-level cache) + - name: Pre-build worker base images (fallback if not exist) if: matrix.service == 'worker' id: prebuild - shell: bash + shell: sh run: | set -eu - GITEA_REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas" - ACR_REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji" - GITEA_BUILDER="${GITEA_REGISTRY}/worker-base-builder:latest" - GITEA_RUNTIME="${GITEA_REGISTRY}/worker-base-runtime:latest" - ACR_BUILDER="${ACR_REGISTRY}/worker-base-builder:latest" - ACR_RUNTIME="${ACR_REGISTRY}/worker-base-runtime:latest" - - # L1: 本地daemon缓存(DooD模式8runner共享宿主机daemon) - echo "=== L1 本地缓存 ===" - if docker image inspect "$ACR_BUILDER" > /dev/null 2>&1 \ - && docker image inspect "$ACR_RUNTIME" > /dev/null 2>&1; then - echo "本地缓存命中" - echo "has_local_base=true" >> $GITHUB_OUTPUT - exit 0 - fi - echo "本地无缓存" - - # L2: Gitea registry缓存(内网快) - echo "=== L2 Registry拉取 ===" - if docker pull "$GITEA_BUILDER" 2>/dev/null && docker pull "$GITEA_RUNTIME" 2>/dev/null; then - echo "Registry拉取成功,重tag供Dockerfile使用" - docker tag "$GITEA_BUILDER" "$ACR_BUILDER" - docker tag "$GITEA_RUNTIME" "$ACR_RUNTIME" - echo "has_local_base=true" >> $GITHUB_OUTPUT - exit 0 - fi - echo "Registry无缓存,需本地构建" - - # L3: 本地构建 - echo "=== L3 本地构建 ===" - BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}" - if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then - docker buildx create --use --name "$BUILDER_NAME" --driver docker-container + REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas" + BASE_BUILDER="${REGISTRY}/worker-base-builder:latest" + BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest" + + # 尝试拉取基础镜像 + echo "检查基础镜像..." + if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then + echo "基础镜像已存在,使用远程镜像" + echo "fallback=false" >> $GITHUB_OUTPUT else - docker buildx use "$BUILDER_NAME" + echo "基础镜像不存在,本地构建(fallback模式)..." + + # 尝试用buildx构建,失败则回退到普通docker build(DooD模式下buildx builder偶发崩溃) + BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}" + BUILDX_AVAILABLE=true + if ! docker buildx create --use --name "$BUILDER_NAME" --driver docker-container > /dev/null 2>&1; then + BUILDX_AVAILABLE=false + fi + if [ "$BUILDX_AVAILABLE" = true ] && ! docker buildx inspect --bootstrap > /dev/null 2>&1; then + BUILDX_AVAILABLE=false + docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true + fi + + build_base() { + local df="$1" + local tag="$2" + local name="$3" + if [ "$BUILDX_AVAILABLE" = true ]; then + echo "构建 $name(buildx)..." + if docker buildx build --load -f "$df" -t "$tag" . > /dev/null 2>&1; then + echo "$name 构建成功" + return 0 + fi + echo "buildx失败,回退到普通docker build" + BUILDX_AVAILABLE=false + docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true + fi + echo "构建 $name(docker build)..." + docker build -f "$df" -t "$tag" . + } + + build_base infra/docker/worker-base-builder.Dockerfile "$BASE_BUILDER" "worker-base-builder" + build_base infra/docker/worker-base-runtime.Dockerfile "$BASE_RUNTIME" "worker-base-runtime" + + echo "fallback=true" >> $GITHUB_OUTPUT + echo "基础镜像本地构建完成" fi - docker buildx inspect --bootstrap > /dev/null 2>&1 - echo "构建 worker-base-builder..." - for attempt in 1 2 3; do - if docker buildx build --load -f infra/docker/worker-base-builder.Dockerfile -t "$ACR_BUILDER" .; then - echo "worker-base-builder 构建成功" - break - fi - echo "worker-base-builder 失败,重试 $attempt/3..." - docker buildx rm "$BUILDER_NAME" 2>/dev/null || true - docker buildx create --use --name "$BUILDER_NAME" --driver docker-container - sleep 3 - done - - echo "构建 worker-base-runtime..." - for attempt in 1 2 3; do - if docker buildx build --load -f infra/docker/worker-base-runtime.Dockerfile -t "$ACR_RUNTIME" .; then - echo "worker-base-runtime 构建成功" - break - fi - echo "worker-base-runtime 失败,重试 $attempt/3..." - docker buildx rm "$BUILDER_NAME" 2>/dev/null || true - docker buildx create --use --name "$BUILDER_NAME" --driver docker-container - sleep 3 - done - - # 推送到Gitea registry供后续复用 - echo "=== 推送缓存到Registry ===" - docker tag "$ACR_BUILDER" "$GITEA_BUILDER" - docker tag "$ACR_RUNTIME" "$GITEA_RUNTIME" - docker push "$GITEA_BUILDER" 2>/dev/null || echo "push builder失败(不影响)" - docker push "$GITEA_RUNTIME" 2>/dev/null || echo "push runtime失败(不影响)" - - echo "has_local_base=true" >> $GITHUB_OUTPUT - echo "基础镜像构建完成" - name: Build PR image (verify only, no push) shell: sh run: | @@ -762,15 +694,15 @@ jobs: EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf" fi - # Worker有本地base镜像时:用BuildKit直接构建(快,无需起buildx容器) - if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.has_local_base }}" = "true" ]; then - echo "本地base镜像已就绪,BuildKit快速构建" + # Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx + if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then + echo "Fallback模式:用普通docker build(基础镜像本地已构建)" BUILD_ARG_STR="" for arg in $EXTRA_BUILD_ARGS; do BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg" done - DOCKER_BUILDKIT=1 docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR . - echo "快速构建成功" + docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR . + echo "Fallback PR Build successful" exit 0 fi @@ -1151,8 +1083,7 @@ jobs: set -eu # DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致) # 改用 docker create + docker cp 方式把代码拷进容器 - CONTAINER_NAME="staging-e2e-${GITHUB_SHA::8}" - docker rm -f "$CONTAINER_NAME" 2>/dev/null || true + CONTAINER_NAME="staging-e2e-$$" 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 \ @@ -1216,8 +1147,7 @@ jobs: set -eu # DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致) # 改用 docker create + docker cp 方式把代码拷进容器 - CONTAINER_NAME="staging-api-tests-${GITHUB_SHA::8}" - docker rm -f "$CONTAINER_NAME" 2>/dev/null || true + CONTAINER_NAME="staging-api-tests-$$" docker create --name "$CONTAINER_NAME" \ -e E2E_BASE_URL=https://staging.xiaoxiajianji.com \ -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \ @@ -1620,6 +1550,7 @@ jobs: set -eu python3 scripts/ci/acr_cleanup.py \ --keep 20 \ + --pr-days 7 \ --execute - name: Job duration summary @@ -1731,3 +1662,160 @@ jobs: START_TIME="" [ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time) python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true + + ci-gate: + name: CI Gate + runs-on: ci-l2 + if: always() && github.event_name == 'pull_request' + needs: + - check-frontend-only + - validate-code-quality + - validate-type-check + - validate-migration + - unit-tests + - integration-tests + - frontend-lint + - frontend-unit-test + - build-pr + timeout-minutes: 3 + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \ + | bash + + - name: Evaluate CI Gate + id: gate + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RESULT_CHECK_FRONTEND: ${{ needs.check-frontend-only.result }} + RESULT_CODE_QUALITY: ${{ needs.validate-code-quality.result }} + RESULT_TYPE_CHECK: ${{ needs.validate-type-check.result }} + RESULT_MIGRATION: ${{ needs.validate-migration.result }} + RESULT_UNIT_TESTS: ${{ needs.unit-tests.result }} + RESULT_INTEGRATION: ${{ needs.integration-tests.result }} + RESULT_FRONTEND_LINT: ${{ needs.frontend-lint.result }} + RESULT_FRONTEND_UNIT: ${{ needs.frontend-unit-test.result }} + RESULT_BUILD_PR: ${{ needs.build-pr.result }} + run: | + set -eu + echo "=== CI Gate 评估 ===" + echo "" + echo "各job结果:" + echo " check-frontend-only: $RESULT_CHECK_FRONTEND" + echo " validate-code-quality: $RESULT_CODE_QUALITY" + echo " validate-type-check: $RESULT_TYPE_CHECK" + echo " validate-migration: $RESULT_MIGRATION" + echo " unit-tests: $RESULT_UNIT_TESTS" + echo " integration-tests: $RESULT_INTEGRATION" + echo " frontend-lint: $RESULT_FRONTEND_LINT" + echo " frontend-unit-test: $RESULT_FRONTEND_UNIT" + echo " build-pr: $RESULT_BUILD_PR" + echo "" + + # 判断PR类型 + SKIP_BACKEND="${{ needs.check-frontend-only.outputs.skip_backend }}" + SKIP_FRONTEND="${{ needs.check-frontend-only.outputs.skip_frontend }}" + echo "PR类型: skip_backend=$SKIP_BACKEND, skip_frontend=$SKIP_FRONTEND" + + # 必填检查项(根据PR类型决定) + # 通用检查(所有PR都必须过) + REQUIRED_GENERAL=( + "validate-code-quality:$RESULT_CODE_QUALITY" + "validate-type-check:$RESULT_TYPE_CHECK" + "validate-migration:$RESULT_MIGRATION" + "frontend-lint:$RESULT_FRONTEND_LINT" + "build-pr:$RESULT_BUILD_PR" + ) + + # 后端检查 + REQUIRED_BACKEND=( + "unit-tests:$RESULT_UNIT_TESTS" + ) + + # 前端检查 + REQUIRED_FRONTEND=( + "frontend-unit-test:$RESULT_FRONTEND_UNIT" + ) + + ALL_PASSED=true + FAILED_ITEMS=() + + check_job() { + local name=$1 + local result=$2 + if [ "$result" = "success" ]; then + echo " ✅ $name: success" + elif [ "$result" = "skipped" ]; then + echo " ⏭️ $name: skipped(跳过,不影响)" + else + echo " ❌ $name: $result" + ALL_PASSED=false + FAILED_ITEMS+=("$name=$result") + fi + } + + echo "" + echo "=== 通用检查(所有PR必填)===" + for item in "${REQUIRED_GENERAL[@]}"; do + name="${item%%:*}" + result="${item##*:}" + check_job "$name" "$result" + done + + if [ "$SKIP_BACKEND" != "true" ]; then + echo "" + echo "=== 后端检查 ===" + for item in "${REQUIRED_BACKEND[@]}"; do + name="${item%%:*}" + result="${item##*:}" + check_job "$name" "$result" + done + else + echo "" + echo "=== 后端检查(纯前端PR,跳过)===" + fi + + if [ "$SKIP_FRONTEND" != "true" ]; then + echo "" + echo "=== 前端检查 ===" + for item in "${REQUIRED_FRONTEND[@]}"; do + name="${item%%:*}" + result="${item##*:}" + check_job "$name" "$result" + done + else + echo "" + echo "=== 前端检查(纯后端PR,跳过)===" + fi + + echo "" + if [ "$ALL_PASSED" = "true" ]; then + echo "✅ CI Gate: PASSED" + echo "gate_result=success" >> $GITHUB_OUTPUT + exit 0 + else + echo "❌ CI Gate: FAILED" + echo "失败项: ${FAILED_ITEMS[*]}" + echo "gate_result=failure" >> $GITHUB_OUTPUT + exit 0 + fi + + - name: Report CI trace + if: always() + shell: sh + env: + AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }} + run: | + STATUS="ok" + [ "${{ steps.gate.outputs.gate_result }}" = "success" ] || STATUS="error" + START_TIME="" + [ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time) + python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true diff --git a/apps/api/app/services/ai_service.py b/apps/api/app/services/ai_service.py index 13e465b2f..6e1291a11 100755 --- a/apps/api/app/services/ai_service.py +++ b/apps/api/app/services/ai_service.py @@ -12,7 +12,6 @@ from __future__ import annotations -import json import logging from typing import Any, Dict, List, Optional diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts deleted file mode 100644 index 8cb2aa49b..000000000 --- a/apps/web/src/api/assets.ts +++ /dev/null @@ -1,443 +0,0 @@ -/** - * 素材相关 API - * Phase 1 重构:去掉 project_id,素材直接归属用户 - */ -import apiClient from "./client" -import { getOrCreateDefaultProject } from "./projects" - -/** 素材元数据 */ -export interface AssetMetadata { - /** 时长(秒) */ - duration?: number - /** 宽度(像素) */ - width?: number - /** 高度(像素) */ - height?: number - /** 比特率(bps) */ - bitrate?: number - /** 编码格式 */ - codec?: string - /** 帧率 */ - fps?: number - /** 采样率(Hz) */ - sample_rate?: number - /** 声道数 */ - channels?: number - /** 其他扩展字段 */ - [key: string]: unknown -} - -/** 素材分类状态 */ -export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed" - -/** 素材条目 */ -export interface AssetItem { - id: string - library_id: string - name: string - storage_key: string - mime_type: string - metadata: AssetMetadata - file_size?: number - file_url?: string - thumbnail_url?: string - /** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */ - duration?: number - status?: string - classification_status?: AssetClassificationStatus | null - quality_score?: number | null - tag_ids?: string[] - created_at?: string -} - -/** 素材库 */ -export interface AssetLibraryItem { - id: string - name: string - kind: "video" | "voice" | "image" - asset_count?: number - total_size?: number - created_at?: string -} - -/** 入库任务 */ -export interface IngestJob { - id: string - library_id: string - storage_key: string - status: "pending" | "processing" | "completed" | "failed" - error_message: string - result_asset_id: string -} - -/** 分类任务 */ -export interface ClassificationJob { - id: string - asset_id: string - status: "pending" | "processing" | "completed" | "failed" - classification: string - confidence: number - error_message: string -} - -/** 素材诊断信息 */ -export interface AssetDiagnosis { - readiness_score: number - readiness_label: string - total_assets: number - ready_assets: number - video_assets: number - image_assets: number - voice_assets: number - total_duration_seconds: number - estimated_video_count: number - used_assets: number - unused_assets: number - pending_review_assets: number - smart_views: Array<{ - key: string - label: string - count: number - description: string - }> - gaps: Array<{ - key: string - severity: "critical" | "warning" | "info" - message: string - recommendation: string - }> -} - -// ─── 素材诊断 ────────────────────────────────────────────── - -/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */ -export const getAssetDiagnosis = async (assetId?: string): Promise => { - const params: Record = {} - if (assetId) params.asset_id = assetId - const response = await apiClient.get("/asset-diagnosis", { params }) - return response.data -} - -// ─── 素材库 ──────────────────────────────────────────────── - -/** 获取当前用户的所有素材库 */ -export const getAssetLibraries = async (): Promise => { - const response = await apiClient.get("/asset-libraries") - return response.data.items || [] -} - -/** 创建素材库(自动获取或创建默认项目以提供 project_id) */ -export const createAssetLibrary = async (data: { - name: string - kind: "video" | "voice" | "image" -}): Promise => { - // 后端要求 project_id,前端自动管理默认项目 - const project = await getOrCreateDefaultProject() - const response = await apiClient.post("/asset-libraries", { - project_id: project.id, - ...data, - }) - return response.data -} - -/** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */ -export const ensureDefaultLibrary = async (data: { - project_id: string - kind: "video" | "voice" | "image" -}): Promise => { - const response = await apiClient.post("/asset-libraries/ensure-default", data) - return response.data -} - -/** 删除素材库 */ -export const deleteAssetLibrary = async (libraryId: string): Promise => { - await apiClient.delete(`/asset-libraries/${libraryId}`) -} - -// ─── 素材 ────────────────────────────────────────────────── - -/** 获取素材库下的所有素材 */ -export const getAssets = async ( - libraryId: string, - options?: { status?: string; page?: number; page_size?: number }, -): Promise<{ items: AssetItem[]; total: number }> => { - const params: Record = { library_id: libraryId } - // 默认拉取所有非删除状态的素材(ready/ingesting/processing/uploading/error/failed) - // 让用户能看到"处理中"的素材,不会以为上传失败了 - if (options?.status) { - params.status = options.status - } - if (options?.page) params.page = options.page - if (options?.page_size) params.page_size = options.page_size - const response = await apiClient.get("/assets", { params }) - const data = response.data || {} - const items: AssetItem[] = data.items || [] - const total: number = typeof data.total === "number" ? data.total : items.length - return { items, total } -} - -/** 按类型获取素材(如 voice/video/image),支持可选筛选 */ -export const getAssetsByKind = async ( - kind: string, - filters?: { - keyword?: string - gender?: string - style?: string - tag_ids?: string[] - limit?: number - page?: number - page_size?: number - }, -): Promise => { - const params: Record = { kind } - if (filters?.keyword) params.keyword = filters.keyword - if (filters?.gender) params.gender = filters.gender - if (filters?.style) params.style = filters.style - if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",") - if (filters?.limit) params.limit = filters.limit - if (filters?.page) params.page = filters.page - if (filters?.page_size) params.page_size = filters.page_size - const response = await apiClient.get("/assets", { params }) - return response.data.items || [] -} - -/** 创建素材(上传文件后调用,附带 metadata) */ -export const createAsset = async (data: { - library_id: string - name: string - storage_key: string - mime_type: string - metadata?: AssetMetadata -}): Promise => { - const response = await apiClient.post("/assets", data) - return response.data -} - -/** 更新素材(名称、metadata 等) */ -export const updateAsset = async ( - assetId: string, - data: { name?: string; metadata?: AssetMetadata }, -): Promise => { - const response = await apiClient.put(`/assets/${assetId}`, data) - return response.data -} - -/** 更新素材审核状态 */ -export const updateAssetReviewStatus = async ( - assetId: string, - reviewStatus: "pending_review" | "approved" | "rejected", -): Promise => { - const response = await apiClient.patch(`/assets/${assetId}/review`, { - review_status: reviewStatus, - }) - return response.data -} - -/** 删除素材 */ -export const deleteAsset = async (assetId: string): Promise => { - await apiClient.delete(`/assets/${assetId}`) -} - -// ─── 上传 ────────────────────────────────────────────────── - -/** 表单上传素材(小文件) */ -export const uploadAsset = async ( - formData: FormData, -): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => { - const response = await apiClient.post("/upload", formData, { - headers: { "Content-Type": "multipart/form-data" }, - timeout: 30 * 60 * 1000, - }) - return response.data -} - -/** 预签名直传准备 */ -export const prepareDirectUpload = async (data: { - project_id: string - library_id: string - filename: string - content_type: string - file_size: number -}): Promise<{ - upload_url: string - method: string - storage_key: string - expires_at: string - fields: Record - max_size_bytes: number -}> => { - const response = await apiClient.post("/upload/direct/prepare", data) - return response.data -} - -/** 直传完成确认 */ -export const completeDirectUpload = async (data: { - project_id: string - library_id: string - storage_key: string -}): Promise<{ storage_key: string; ingest_job_id: string }> => { - const response = await apiClient.post("/upload/direct/complete", data) - return response.data -} - -/** 直传上传(大文件推荐),支持可选进度回调 */ -export const uploadAssetDirect = async (data: { - file: File - library_id: string - onProgress?: (percent: number) => void -}): Promise<{ storage_key: string; ingest_job_id: string }> => { - // 后端要求 project_id,前端自动获取默认项目 - const project = await getOrCreateDefaultProject() - - const prepared = await prepareDirectUpload({ - project_id: project.id, - library_id: data.library_id, - filename: data.file.name, - content_type: data.file.type || "application/octet-stream", - file_size: data.file.size, - }) - - const directForm = new FormData() - Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value)) - directForm.append("file", data.file) - - // 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断 - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - xhr.open(prepared.method, prepared.upload_url) - - // 超时 10 分钟 - xhr.timeout = 10 * 60 * 1000 - - xhr.upload.onprogress = (e) => { - if (e.lengthComputable && data.onProgress) { - data.onProgress(Math.round((e.loaded / e.total) * 100)) - } - } - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve() - } else { - // 解析 OSS 返回的 XML 错误信息 - let ossError = "" - try { - const codeMatch = xhr.responseText.match(/([^<]+)<\/Code>/) - const msgMatch = xhr.responseText.match(/([^<]+)<\/Message>/) - if (codeMatch || msgMatch) { - ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]` - } - } catch { - // 无法解析响应体 - } - const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}` - console.error("[OSS Upload] 直传失败:", { - url: prepared.upload_url, - storage_key: prepared.storage_key, - status: xhr.status, - statusText: xhr.statusText, - }) - reject(new Error(detail)) - } - } - xhr.onerror = () => { - console.error("[OSS Upload] 网络错误:", { - url: prepared.upload_url, - storage_key: prepared.storage_key, - }) - reject(new Error("OSS 上传网络错误,请检查网络连接")) - } - xhr.ontimeout = () => { - console.error("[OSS Upload] 上传超时:", { - url: prepared.upload_url, - storage_key: prepared.storage_key, - }) - reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件")) - } - xhr.send(directForm) - }) - - return completeDirectUpload({ - project_id: project.id, - library_id: data.library_id, - storage_key: prepared.storage_key, - }) -} - -// ─── 入库 / 分类任务 ─────────────────────────────────────── - -/** 查询入库任务状态 */ -export const getIngestJob = async (jobId: string): Promise => { - const response = await apiClient.get(`/ingest-jobs/${jobId}`) - return response.data -} - -/** 提交素材分类任务 */ -export const submitClassificationJob = async (data: { - asset_id: string -}): Promise => { - const response = await apiClient.post("/classification-jobs", data) - return response.data -} - -/** 查询分类任务状态 */ -export const getClassificationJob = async (jobId: string): Promise => { - const response = await apiClient.get(`/classification-jobs/${jobId}`) - return response.data -} - -// ─── 批量操作 ─────────────────────────────────────────────── - -/** 批量操作结果 */ -export interface BatchOperationResult { - succeeded: string[] - failed: string[] - total: number - success_count: number - failure_count: number -} - -/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */ -const normalizeBatchResult = (raw: Record): BatchOperationResult => { - const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : [] - const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : [] - const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length - const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length - const total = typeof raw.total === "number" ? raw.total : success_count + failure_count - return { succeeded, failed, total, success_count, failure_count } -} - -/** 批量删除素材 */ -export const batchDeleteAssets = async (assetIds: string[]): Promise => { - const response = await apiClient.post("/assets/batch-delete", { - asset_ids: assetIds, - }) - return normalizeBatchResult((response.data || {}) as Record) -} - -/** 批量打标签 */ -export const batchTagAssets = async (data: { - asset_ids: string[] - tags: string[] - mode: "add" | "replace" -}): Promise => { - const response = await apiClient.post("/assets/batch-tag", data) - return normalizeBatchResult((response.data || {}) as Record) -} - -/** 批量改分类 */ -export const batchClassifyAssets = async (data: { - asset_ids: string[] - category: string -}): Promise => { - const response = await apiClient.post("/assets/batch-classify", data) - return normalizeBatchResult((response.data || {}) as Record) -} - -/** 批量智能标记 */ -export const batchMarkAssets = async (data: { - asset_ids: string[] - smart_view: "recommended" | "caution" | "high_risk" -}): Promise => { - const response = await apiClient.post("/assets/batch-mark", data) - return normalizeBatchResult((response.data || {}) as Record) -} diff --git a/apps/web/src/api/assets/assets.ts b/apps/web/src/api/assets/assets.ts new file mode 100644 index 000000000..902aface1 --- /dev/null +++ b/apps/web/src/api/assets/assets.ts @@ -0,0 +1,87 @@ +/** + * 素材 CRUD API + */ +import apiClient from "../client" +import type { AssetItem, AssetMetadata } from "./types" + +/** 获取素材库下的所有素材 */ +export const getAssets = async ( + libraryId: string, + options?: { + status?: string + page?: number + page_size?: number + }, +): Promise<{ items: AssetItem[]; total: number }> => { + const params: Record = { library_id: libraryId } + if (options?.status) params.status = options.status + if (options?.page) params.page = options.page + if (options?.page_size) params.page_size = options.page_size + const response = await apiClient.get("/assets", { params }) + const data = response.data || {} + const items: AssetItem[] = data.items || [] + const total: number = typeof data.total === "number" ? data.total : items.length + return { items, total } +} + +/** 按类型获取素材(如 voice/video/image),支持可选筛选 */ +export const getAssetsByKind = async ( + kind: string, + filters?: { + keyword?: string + gender?: string + style?: string + tag_ids?: string[] + limit?: number + page?: number + page_size?: number + }, +): Promise => { + const params: Record = { kind } + if (filters?.keyword) params.keyword = filters.keyword + if (filters?.gender) params.gender = filters.gender + if (filters?.style) params.style = filters.style + if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",") + if (filters?.limit) params.limit = filters.limit + if (filters?.page) params.page = filters.page + if (filters?.page_size) params.page_size = filters.page_size + const response = await apiClient.get("/assets", { params }) + return response.data.items || [] +} + +/** 创建素材(上传文件后调用,附带 metadata) */ +export const createAsset = async (data: { + library_id: string + name: string + storage_key: string + mime_type: string + metadata?: AssetMetadata +}): Promise => { + const response = await apiClient.post("/assets", data) + return response.data +} + +/** 更新素材(名称、metadata 等) */ +export const updateAsset = async ( + assetId: string, + data: { name?: string; metadata?: AssetMetadata }, +): Promise => { + const response = await apiClient.put(`/assets/${assetId}`, data) + return response.data +} + +/** 更新素材审核状态 */ +export const updateAssetReviewStatus = async ( + assetId: string, + reviewStatus: "pending_review" | "approved" | "rejected", +): Promise => { + const response = await apiClient.patch(`/assets/${assetId}/review`, { + review_status: reviewStatus, + }) + return response.data +} + +/** 删除素材 */ +export const deleteAsset = async (assetId: string): Promise => { + await apiClient.delete(`/assets/${assetId}`) +} diff --git a/apps/web/src/api/assets/batch.ts b/apps/web/src/api/assets/batch.ts new file mode 100644 index 000000000..cd52c5728 --- /dev/null +++ b/apps/web/src/api/assets/batch.ts @@ -0,0 +1,51 @@ +/** + * 素材批量操作 API + */ +import apiClient from "../client" +import type { BatchOperationResult } from "./types" + +/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */ +export const normalizeBatchResult = (raw: Record): BatchOperationResult => { + const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : [] + const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : [] + const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length + const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length + const total = typeof raw.total === "number" ? raw.total : success_count + failure_count + return { succeeded, failed, total, success_count, failure_count } +} + +/** 批量删除素材 */ +export const batchDeleteAssets = async (assetIds: string[]): Promise => { + const response = await apiClient.post("/assets/batch-delete", { + asset_ids: assetIds, + }) + return normalizeBatchResult((response.data || {}) as Record) +} + +/** 批量打标签 */ +export const batchTagAssets = async (data: { + asset_ids: string[] + tags: string[] + mode: "add" | "replace" +}): Promise => { + const response = await apiClient.post("/assets/batch-tag", data) + return normalizeBatchResult((response.data || {}) as Record) +} + +/** 批量改分类 */ +export const batchClassifyAssets = async (data: { + asset_ids: string[] + category: string +}): Promise => { + const response = await apiClient.post("/assets/batch-classify", data) + return normalizeBatchResult((response.data || {}) as Record) +} + +/** 批量智能标记 */ +export const batchMarkAssets = async (data: { + asset_ids: string[] + smart_view: "recommended" | "caution" | "high_risk" +}): Promise => { + const response = await apiClient.post("/assets/batch-mark", data) + return normalizeBatchResult((response.data || {}) as Record) +} diff --git a/apps/web/src/api/assets/diagnosis.ts b/apps/web/src/api/assets/diagnosis.ts new file mode 100644 index 000000000..f3628530e --- /dev/null +++ b/apps/web/src/api/assets/diagnosis.ts @@ -0,0 +1,13 @@ +/** + * 素材诊断 API + */ +import apiClient from "../client" +import type { AssetDiagnosis } from "./types" + +/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */ +export const getAssetDiagnosis = async (assetId?: string): Promise => { + const params: Record = {} + if (assetId) params.asset_id = assetId + const response = await apiClient.get("/asset-diagnosis", { params }) + return response.data +} diff --git a/apps/web/src/api/assets/index.ts b/apps/web/src/api/assets/index.ts new file mode 100644 index 000000000..a69db9a6c --- /dev/null +++ b/apps/web/src/api/assets/index.ts @@ -0,0 +1,55 @@ +/** + * 素材相关 API — 按模块拆分后的统一入口 + * 保持与原 assets.ts 相同的导出结构,向后兼容 + */ + +// 类型 +export type { + AssetMetadata, + AssetClassificationStatus, + AssetItem, + AssetLibraryItem, + IngestJob, + ClassificationJob, + AssetDiagnosis, + BatchOperationResult, + UploadResult, + DirectUploadPrepareResult, + DirectUploadCompleteResult, +} from "./types" + +// 素材诊断 +export { getAssetDiagnosis } from "./diagnosis" + +// 素材库 +export { + getAssetLibraries, + createAssetLibrary, + ensureDefaultLibrary, + deleteAssetLibrary, +} from "./libraries" + +// 素材 CRUD +export { + getAssets, + getAssetsByKind, + createAsset, + updateAsset, + updateAssetReviewStatus, + deleteAsset, +} from "./assets" + +// 上传 +export { uploadAsset, prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload" + +// 任务 +export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs" + +// 批量操作 +export { + normalizeBatchResult, + batchDeleteAssets, + batchTagAssets, + batchClassifyAssets, + batchMarkAssets, +} from "./batch" diff --git a/apps/web/src/api/assets/jobs.ts b/apps/web/src/api/assets/jobs.ts new file mode 100644 index 000000000..e994cdcfc --- /dev/null +++ b/apps/web/src/api/assets/jobs.ts @@ -0,0 +1,25 @@ +/** + * 入库任务 & 分类任务 API + */ +import apiClient from "../client" +import type { IngestJob, ClassificationJob } from "./types" + +/** 查询入库任务状态 */ +export const getIngestJob = async (jobId: string): Promise => { + const response = await apiClient.get(`/ingest-jobs/${jobId}`) + return response.data +} + +/** 提交素材分类任务 */ +export const submitClassificationJob = async (data: { + asset_id: string +}): Promise => { + const response = await apiClient.post("/classification-jobs", data) + return response.data +} + +/** 查询分类任务状态 */ +export const getClassificationJob = async (jobId: string): Promise => { + const response = await apiClient.get(`/classification-jobs/${jobId}`) + return response.data +} diff --git a/apps/web/src/api/assets/libraries.ts b/apps/web/src/api/assets/libraries.ts new file mode 100644 index 000000000..4d3327116 --- /dev/null +++ b/apps/web/src/api/assets/libraries.ts @@ -0,0 +1,39 @@ +/** + * 素材库 API + */ +import apiClient from "../client" +import { getOrCreateDefaultProject } from "../projects" +import type { AssetLibraryItem } from "./types" + +/** 获取当前用户的所有素材库 */ +export const getAssetLibraries = async (): Promise => { + const response = await apiClient.get("/asset-libraries") + return response.data.items || [] +} + +/** 创建素材库(自动获取或创建默认项目以提供 project_id) */ +export const createAssetLibrary = async (data: { + name: string + kind: "video" | "voice" | "image" +}): Promise => { + const project = await getOrCreateDefaultProject() + const response = await apiClient.post("/asset-libraries", { + project_id: project.id, + ...data, + }) + return response.data +} + +/** 确保项目下指定 kind 的默认素材库存在 */ +export const ensureDefaultLibrary = async (data: { + project_id: string + kind: "video" | "voice" | "image" +}): Promise => { + const response = await apiClient.post("/asset-libraries/ensure-default", data) + return response.data +} + +/** 删除素材库 */ +export const deleteAssetLibrary = async (libraryId: string): Promise => { + await apiClient.delete(`/asset-libraries/${libraryId}`) +} diff --git a/apps/web/src/api/assets/types.ts b/apps/web/src/api/assets/types.ts new file mode 100644 index 000000000..9e64a942e --- /dev/null +++ b/apps/web/src/api/assets/types.ts @@ -0,0 +1,138 @@ +/** + * 素材相关类型定义 + */ + +/** 素材元数据 */ +export interface AssetMetadata { + /** 时长(秒) */ + duration?: number + /** 宽度(像素) */ + width?: number + /** 高度(像素) */ + height?: number + /** 比特率(bps) */ + bitrate?: number + /** 编码格式 */ + codec?: string + /** 帧率 */ + fps?: number + /** 采样率(Hz) */ + sample_rate?: number + /** 声道数 */ + channels?: number + /** 其他扩展字段 */ + [key: string]: unknown +} + +/** 素材分类状态 */ +export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed" + +/** 素材条目 */ +export interface AssetItem { + id: string + library_id: string + name: string + storage_key: string + mime_type: string + metadata: AssetMetadata + file_size?: number + file_url?: string + thumbnail_url?: string + /** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */ + duration?: number + status?: string + classification_status?: AssetClassificationStatus | null + quality_score?: number | null + tag_ids?: string[] + created_at?: string +} + +/** 素材库 */ +export interface AssetLibraryItem { + id: string + name: string + kind: "video" | "voice" | "image" + asset_count?: number + total_size?: number + created_at?: string +} + +/** 入库任务 */ +export interface IngestJob { + id: string + library_id: string + storage_key: string + status: "pending" | "processing" | "completed" | "failed" + error_message: string + result_asset_id: string +} + +/** 分类任务 */ +export interface ClassificationJob { + id: string + asset_id: string + status: "pending" | "processing" | "completed" | "failed" + classification: string + confidence: number + error_message: string +} + +/** 素材诊断信息 */ +export interface AssetDiagnosis { + readiness_score: number + readiness_label: string + total_assets: number + ready_assets: number + video_assets: number + image_assets: number + voice_assets: number + total_duration_seconds: number + estimated_video_count: number + used_assets: number + unused_assets: number + pending_review_assets: number + smart_views: Array<{ + key: string + label: string + count: number + description: string + }> + gaps: Array<{ + key: string + severity: "critical" | "warning" | "info" + message: string + recommendation: string + }> +} + +/** 批量操作结果 */ +export interface BatchOperationResult { + succeeded: string[] + failed: string[] + total: number + success_count: number + failure_count: number +} + +/** 上传返回 */ +export interface UploadResult { + storage_key: string + ingest_job_id: string + url: string +} + +/** 预签名直传准备返回 */ +export interface DirectUploadPrepareResult { + upload_url: string + method: string + storage_key: string + expires_at: string + fields: Record + max_size_bytes: number +} + +/** 直传完成确认返回 */ +export interface DirectUploadCompleteResult { + storage_key: string + ingest_job_id: string +} diff --git a/apps/web/src/api/assets/upload.ts b/apps/web/src/api/assets/upload.ts new file mode 100644 index 000000000..874861f1f --- /dev/null +++ b/apps/web/src/api/assets/upload.ts @@ -0,0 +1,119 @@ +/** + * 上传相关 API(表单上传 + OSS 直传) + */ +import apiClient from "../client" +import { getOrCreateDefaultProject } from "../projects" +import type { UploadResult, DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types" + +/** 表单上传素材(小文件) */ +export const uploadAsset = async (formData: FormData): Promise => { + const response = await apiClient.post("/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, + timeout: 30 * 60 * 1000, + }) + return response.data +} + +/** 预签名直传准备 */ +export const prepareDirectUpload = async (data: { + project_id: string + library_id: string + filename: string + content_type: string + file_size: number +}): Promise => { + const response = await apiClient.post("/upload/direct/prepare", data) + return response.data +} + +/** 直传完成确认 */ +export const completeDirectUpload = async (data: { + project_id: string + library_id: string + storage_key: string +}): Promise => { + const response = await apiClient.post("/upload/direct/complete", data) + return response.data +} + +/** 直传上传(大文件推荐),支持可选进度回调 */ +export const uploadAssetDirect = async (data: { + file: File + library_id: string + onProgress?: (percent: number) => void +}): Promise => { + const project = await getOrCreateDefaultProject() + + const prepared = await prepareDirectUpload({ + project_id: project.id, + library_id: data.library_id, + filename: data.file.name, + content_type: data.file.type || "application/octet-stream", + file_size: data.file.size, + }) + + const directForm = new FormData() + Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value)) + directForm.append("file", data.file) + + // 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断 + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open(prepared.method, prepared.upload_url) + + // 超时 10 分钟 + xhr.timeout = 10 * 60 * 1000 + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && data.onProgress) { + data.onProgress(Math.round((e.loaded / e.total) * 100)) + } + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve() + } else { + // 解析 OSS 返回的 XML 错误信息 + let ossError = "" + try { + const codeMatch = xhr.responseText.match(/([^<]+)<\/Code>/) + const msgMatch = xhr.responseText.match(/([^<]+)<\/Message>/) + if (codeMatch || msgMatch) { + ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]` + } + } catch { + // 无法解析响应体 + } + const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}` + console.error("[OSS Upload] 直传失败:", { + url: prepared.upload_url, + storage_key: prepared.storage_key, + status: xhr.status, + statusText: xhr.statusText, + }) + reject(new Error(detail)) + } + } + xhr.onerror = () => { + console.error("[OSS Upload] 网络错误:", { + url: prepared.upload_url, + storage_key: prepared.storage_key, + }) + reject(new Error("OSS 上传网络错误,请检查网络连接")) + } + xhr.ontimeout = () => { + console.error("[OSS Upload] 上传超时:", { + url: prepared.upload_url, + storage_key: prepared.storage_key, + }) + reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件")) + } + xhr.send(directForm) + }) + + return completeDirectUpload({ + project_id: project.id, + library_id: data.library_id, + storage_key: prepared.storage_key, + }) +} diff --git a/apps/web/src/api/bgm.ts b/apps/web/src/api/bgm.ts deleted file mode 100644 index f56af3a54..000000000 --- a/apps/web/src/api/bgm.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * BGM 预设音乐 API - * 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索) - */ -import apiClient from "./client" - -/* ──────────── 类型 ──────────── */ - -/** BGM 风格分类 */ -export type BgmCategory = "轻快" | "治愈" | "科技" | "电商" - -/** BGM 预设项 */ -export interface BgmPreset { - id: string - name: string - category: BgmCategory - /** 音频文件 URL */ - url: string - /** 时长(秒) */ - duration: number - /** 关键词标签 */ - tags: string[] - /** 封面图 URL */ - cover_url?: string -} - -/** BGM 预设列表查询参数 */ -export interface BgmPresetsQuery { - category?: BgmCategory | string - keyword?: string -} - -/** BGM 混音配置(嵌入模板) */ -export interface BgmMixConfig { - /** 是否启用 BGM */ - enabled: boolean - /** 选中的 BGM ID */ - music_id: string - /** BGM 音量 0-100 */ - volume: number - /** 淡入时长(秒) 0-3 */ - fade_in: number - /** 淡出时长(秒) 0-3 */ - fade_out: number - /** 人声闪避(sidechain) */ - voice_dodge: boolean -} - -/** 默认 BGM 混音配置 */ -export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = { - enabled: false, - music_id: "", - volume: 50, - fade_in: 0.5, - fade_out: 0.5, - voice_dodge: true, -} - -/* ──────────── API ──────────── */ - -/** 获取 BGM 预设列表 */ -export const getBgmPresets = async (params?: BgmPresetsQuery): Promise => { - const searchParams: Record = {} - if (params?.category) searchParams.category = params.category - if (params?.keyword) searchParams.keyword = params.keyword - const res = await apiClient.get("/bgm/presets", { params: searchParams }) - return res.data?.data ?? res.data ?? [] -} diff --git a/apps/web/src/api/bgm/bgm.ts b/apps/web/src/api/bgm/bgm.ts new file mode 100644 index 000000000..d9dc58e62 --- /dev/null +++ b/apps/web/src/api/bgm/bgm.ts @@ -0,0 +1,14 @@ +/** + * BGM 预设音乐 API 函数 + */ +import apiClient from "../client" +import type { BgmPreset, BgmPresetsQuery } from "./types" + +/** 获取 BGM 预设列表 */ +export const getBgmPresets = async (params?: BgmPresetsQuery): Promise => { + const searchParams: Record = {} + if (params?.category) searchParams.category = params.category + if (params?.keyword) searchParams.keyword = params.keyword + const res = await apiClient.get("/bgm/presets", { params: searchParams }) + return res.data?.data ?? res.data ?? [] +} diff --git a/apps/web/src/api/bgm/constants.ts b/apps/web/src/api/bgm/constants.ts new file mode 100644 index 000000000..8b3c8cb55 --- /dev/null +++ b/apps/web/src/api/bgm/constants.ts @@ -0,0 +1,14 @@ +/** + * BGM 相关常量 + */ +import type { BgmMixConfig } from "./types" + +/** 默认 BGM 混音配置 */ +export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = { + enabled: false, + music_id: "", + volume: 50, + fade_in: 0.5, + fade_out: 0.5, + voice_dodge: true, +} diff --git a/apps/web/src/api/bgm/index.ts b/apps/web/src/api/bgm/index.ts new file mode 100644 index 000000000..ad23ca565 --- /dev/null +++ b/apps/web/src/api/bgm/index.ts @@ -0,0 +1,13 @@ +/** + * BGM API — 目录化入口 + * 保持与原 bgm.ts 相同导出,向后兼容 + */ + +// 类型 +export type { BgmCategory, BgmPreset, BgmPresetsQuery, BgmMixConfig } from "./types" + +// 常量 +export { DEFAULT_BGM_MIX_CONFIG } from "./constants" + +// API 函数 +export { getBgmPresets } from "./bgm" diff --git a/apps/web/src/api/bgm/types.ts b/apps/web/src/api/bgm/types.ts new file mode 100644 index 000000000..4a14bac17 --- /dev/null +++ b/apps/web/src/api/bgm/types.ts @@ -0,0 +1,43 @@ +/** + * BGM 相关类型定义 + */ + +/** BGM 风格分类 */ +export type BgmCategory = "轻快" | "治愈" | "科技" | "电商" + +/** BGM 预设项 */ +export interface BgmPreset { + id: string + name: string + category: BgmCategory + /** 音频文件 URL */ + url: string + /** 时长(秒) */ + duration: number + /** 关键词标签 */ + tags: string[] + /** 封面图 URL */ + cover_url?: string +} + +/** BGM 预设列表查询参数 */ +export interface BgmPresetsQuery { + category?: BgmCategory | string + keyword?: string +} + +/** BGM 混音配置(嵌入模板) */ +export interface BgmMixConfig { + /** 是否启用 BGM */ + enabled: boolean + /** 选中的 BGM ID */ + music_id: string + /** BGM 音量 0-100 */ + volume: number + /** 淡入时长(秒) 0-3 */ + fade_in: number + /** 淡出时长(秒) 0-3 */ + fade_out: number + /** 人声闪避(sidechain) */ + voice_dodge: boolean +} diff --git a/apps/web/src/api/duplication/duplication.ts b/apps/web/src/api/duplication/duplication.ts new file mode 100644 index 000000000..7cd9be74e --- /dev/null +++ b/apps/web/src/api/duplication/duplication.ts @@ -0,0 +1,38 @@ +/** + * 查重 API 函数 + */ +import apiClient from "../client" +import type { DuplicationDetail, DuplicationRecord, DuplicationUploadResponse } from "./types" + +/** 上传视频进行查重 */ +export const uploadForDuplication = async (file: File): Promise => { + const formData = new FormData() + formData.append("file", file) + const response = await apiClient.post("/duplication/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }) + return response.data +} + +/** 获取查重记录列表 */ +export const getDuplicationRecords = async (): Promise => { + const response = await apiClient.get("/duplication/records") + return response.data +} + +/** 获取查重详情 */ +export const getDuplicationDetail = async (recordId: string): Promise => { + const response = await apiClient.get(`/duplication/records/${recordId}`) + return response.data +} + +/** 删除查重记录 */ +export const deleteDuplicationRecord = async (recordId: string): Promise => { + await apiClient.delete(`/duplication/records/${recordId}`) +} + +/** 重新查重 */ +export const retryDuplication = async (recordId: string): Promise => { + const response = await apiClient.post(`/duplication/records/${recordId}/retry`) + return response.data +} diff --git a/apps/web/src/api/duplication/index.ts b/apps/web/src/api/duplication/index.ts new file mode 100644 index 000000000..21564a8ba --- /dev/null +++ b/apps/web/src/api/duplication/index.ts @@ -0,0 +1,22 @@ +/** + * 查重 API — 目录化入口 + * 保持与原 duplication.ts 相同导出,向后兼容 + */ + +// 类型 +export type { + DuplicationStatus, + DuplicationRecord, + DuplicateSegment, + DuplicationDetail, + DuplicationUploadResponse, +} from "./types" + +// API 函数 +export { + uploadForDuplication, + getDuplicationRecords, + getDuplicationDetail, + deleteDuplicationRecord, + retryDuplication, +} from "./duplication" diff --git a/apps/web/src/api/duplication.ts b/apps/web/src/api/duplication/types.ts similarity index 53% rename from apps/web/src/api/duplication.ts rename to apps/web/src/api/duplication/types.ts index 1135b8163..1566c79a1 100644 --- a/apps/web/src/api/duplication.ts +++ b/apps/web/src/api/duplication/types.ts @@ -1,8 +1,6 @@ /** - * 查重 API 模块 - * 提供视频查重相关接口 + * 查重相关类型定义 */ -import apiClient from "./client" /** 查重记录状态 */ export type DuplicationStatus = "pending" | "processing" | "completed" | "failed" @@ -62,38 +60,3 @@ export interface DuplicationUploadResponse { /** 消息 */ message: string } - -// ============ API 函数 ============ - -/** 上传视频进行查重 */ -export const uploadForDuplication = async (file: File): Promise => { - const formData = new FormData() - formData.append("file", file) - const response = await apiClient.post("/duplication/upload", formData, { - headers: { "Content-Type": "multipart/form-data" }, - }) - return response.data -} - -/** 获取查重记录列表 */ -export const getDuplicationRecords = async (): Promise => { - const response = await apiClient.get("/duplication/records") - return response.data -} - -/** 获取查重详情 */ -export const getDuplicationDetail = async (recordId: string): Promise => { - const response = await apiClient.get(`/duplication/records/${recordId}`) - return response.data -} - -/** 删除查重记录 */ -export const deleteDuplicationRecord = async (recordId: string): Promise => { - await apiClient.delete(`/duplication/records/${recordId}`) -} - -/** 重新查重 */ -export const retryDuplication = async (recordId: string): Promise => { - const response = await apiClient.post(`/duplication/records/${recordId}/retry`) - return response.data -} diff --git a/apps/web/src/api/editing-planner.ts b/apps/web/src/api/editing-planner.ts deleted file mode 100644 index 6794bed24..000000000 --- a/apps/web/src/api/editing-planner.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * 模板编辑器 API - * 对接后端 /api/v1/templates 路由 - */ -import apiClient from "./client" -import type { - WatermarkConfig, - IntroOutroConfig, - PipConfig, - FilterConfig, - ChromaKeyConfig, - StickerConfig, - CoverConfig, -} from "@/pages/editing-planner/types" - -/* ──────────── 类型定义 ──────────── */ - -/** 模板模式(后端枚举值) */ -export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip" - -/** 模式显示名称映射 */ -export const MODE_LABELS: Record = { - pip: "混剪", - voice_over: "人物口播", - one_take: "一镜到底", - voice_pip: "口播+混剪", -} - -/** 模式颜色映射 */ -export const MODE_COLORS: Record = { - pip: "blue", - voice_over: "green", - one_take: "orange", - voice_pip: "purple", -} - -/** 标题配置 */ -export interface TitleConfig { - ai_auto_select: boolean - content: string - font_preset: string - font_color: string - font_size: number - position: string -} - -/** 字幕配置 */ -export interface SubtitleConfig { - enabled: boolean - position: string - font: string - color: string - size: number - animation: string -} - -/** BGM 配置 */ -export interface BgmConfig { - enabled: boolean - music_id: string -} - -/** 模板片段 */ -export interface TemplateSegment { - id?: string - segment_order: number - duration_min: number - duration_max: number - material_type: string | null -} - -/** 剪辑模板 */ -export interface EditingTemplate { - id: string - name: string - mode: TemplateMode - category: string - tags: string[] - title_config: TitleConfig - subtitle_config: SubtitleConfig - bgm_config: BgmConfig - estimated_duration: number - segments: TemplateSegment[] - /** 水印配置(后端就绪后启用) */ - watermark_config?: WatermarkConfig - /** 片头片尾配置(后端就绪后启用) */ - intro_outro_config?: IntroOutroConfig - /** 混剪配置 */ - pip_config?: PipConfig - /** 滤镜调色配置 */ - filter_config?: FilterConfig - /** 绿幕抠像配置 */ - green_screen_config?: ChromaKeyConfig - /** 贴纸配置 */ - sticker_config?: StickerConfig - /** 封面配置 */ - cover_config?: CoverConfig - is_active?: boolean - created_at: string - updated_at: string -} - -/** 模板分类 */ -export interface TemplateCategory { - id: string - name: string - created_at?: string -} - -/** 创建/更新模板请求体 */ -export interface SaveTemplatePayload { - name: string - mode: TemplateMode - category: string - tags: string[] - title_config: TitleConfig - subtitle_config: SubtitleConfig - bgm_config: BgmConfig - estimated_duration: number - segments: Omit[] - /** 水印配置(后端就绪后启用) */ - watermark_config?: WatermarkConfig - /** 片头片尾配置(后端就绪后启用) */ - intro_outro_config?: IntroOutroConfig - /** 混剪配置 */ - pip_config?: PipConfig - /** 滤镜调色配置 */ - filter_config?: FilterConfig - /** 绿幕抠像配置 */ - green_screen_config?: ChromaKeyConfig - /** 贴纸配置 */ - sticker_config?: StickerConfig - /** 封面配置 */ - cover_config?: CoverConfig -} - -/** 使用模板生成请求体 */ -export interface GenerateFromTemplatePayload { - voiceover_duration: number -} - -/** 验证警告详情 */ -export interface ValidationWarningDetails { - /** 相关字段名 */ - field?: string - /** 期望值 */ - expected?: string | number - /** 实际值 */ - actual?: string | number - /** 建议值 */ - suggested?: string | number -} - -/** 验证/生成响应 */ -export interface ValidateWarning { - code: string - message: string - details?: ValidationWarningDetails -} - -/** 使用模板生成响应 */ -export interface GenerateFromTemplateResponse { - template: EditingTemplate - warnings: ValidateWarning[] -} - -/** 列表响应(带分页) */ -export interface ListTemplatesResponse { - items: EditingTemplate[] - total: number -} - -/** 分类列表响应 */ -export interface ListCategoriesResponse { - items: TemplateCategory[] -} - -// ============ API 函数 ============ - -/** 获取模板列表 */ -export const getEditingTemplates = async (params?: { - category?: string - tag?: string - skip?: number - limit?: number -}): Promise => { - const response = await apiClient.get("/templates", { - params: { - skip: params?.skip ?? 0, - limit: params?.limit ?? 50, - }, - }) - let list = response.data.items - if (params?.category) list = list.filter((t) => t.category === params.category) - if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!)) - return list -} - -/** 获取模板详情 */ -export const getEditingTemplate = async (id: string): Promise => { - const response = await apiClient.get(`/templates/${id}`) - return response.data -} - -/** 创建模板 */ -export const createEditingTemplate = async ( - data: SaveTemplatePayload, -): Promise => { - const response = await apiClient.post("/templates", data) - return response.data -} - -/** 更新模板 */ -export const updateEditingTemplate = async ( - id: string, - data: SaveTemplatePayload, -): Promise => { - const response = await apiClient.patch(`/templates/${id}`, data) - return response.data -} - -/** 删除模板 */ -export const deleteEditingTemplate = async (id: string): Promise => { - await apiClient.delete(`/templates/${id}`) -} - -/** 获取模板分类列表 */ -export const getTemplateCategories = async (): Promise => { - const response = await apiClient.get("/templates/categories/list") - return response.data.items -} - -/** 使用模板生成视频(调用 validate 端点) */ -export const generateFromTemplate = async ( - templateId: string, - data: GenerateFromTemplatePayload, -): Promise => { - const response = await apiClient.post( - `/templates/${templateId}/validate`, - data, - ) - return response.data -} diff --git a/apps/web/src/api/editing-planner/constants.ts b/apps/web/src/api/editing-planner/constants.ts new file mode 100644 index 000000000..5e23f900a --- /dev/null +++ b/apps/web/src/api/editing-planner/constants.ts @@ -0,0 +1,20 @@ +/** + * 模板编辑器常量 + */ +import type { TemplateMode } from "./types" + +/** 模式显示名称映射 */ +export const MODE_LABELS: Record = { + pip: "混剪", + voice_over: "人物口播", + one_take: "一镜到底", + voice_pip: "口播+混剪", +} + +/** 模式颜色映射 */ +export const MODE_COLORS: Record = { + pip: "blue", + voice_over: "green", + one_take: "orange", + voice_pip: "purple", +} diff --git a/apps/web/src/api/editing-planner/index.ts b/apps/web/src/api/editing-planner/index.ts new file mode 100644 index 000000000..117be0f94 --- /dev/null +++ b/apps/web/src/api/editing-planner/index.ts @@ -0,0 +1,36 @@ +/** + * 模板编辑器 API — 目录化入口 + * 保持与原 editing-planner.ts 相同导出,向后兼容 + */ + +// 类型 +export type { + TemplateMode, + TitleConfig, + SubtitleConfig, + BgmConfig, + TemplateSegment, + EditingTemplate, + TemplateCategory, + SaveTemplatePayload, + GenerateFromTemplatePayload, + ValidationWarningDetails, + ValidateWarning, + GenerateFromTemplateResponse, + ListTemplatesResponse, + ListCategoriesResponse, +} from "./types" + +// 常量 +export { MODE_LABELS, MODE_COLORS } from "./constants" + +// API 函数 +export { + getEditingTemplates, + getEditingTemplate, + createEditingTemplate, + updateEditingTemplate, + deleteEditingTemplate, + getTemplateCategories, + generateFromTemplate, +} from "./templates" diff --git a/apps/web/src/api/editing-planner/templates.ts b/apps/web/src/api/editing-planner/templates.ts new file mode 100644 index 000000000..2458de451 --- /dev/null +++ b/apps/web/src/api/editing-planner/templates.ts @@ -0,0 +1,78 @@ +/** + * 模板编辑器 API 函数 + */ +import apiClient from "../client" +import type { + EditingTemplate, + TemplateCategory, + SaveTemplatePayload, + GenerateFromTemplatePayload, + GenerateFromTemplateResponse, + ListTemplatesResponse, + ListCategoriesResponse, +} from "./types" + +/** 获取模板列表 */ +export const getEditingTemplates = async (params?: { + category?: string + tag?: string + skip?: number + limit?: number +}): Promise => { + const response = await apiClient.get("/templates", { + params: { + skip: params?.skip ?? 0, + limit: params?.limit ?? 50, + }, + }) + let list = response.data.items + if (params?.category) list = list.filter((t) => t.category === params.category) + if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!)) + return list +} + +/** 获取模板详情 */ +export const getEditingTemplate = async (id: string): Promise => { + const response = await apiClient.get(`/templates/${id}`) + return response.data +} + +/** 创建模板 */ +export const createEditingTemplate = async ( + data: SaveTemplatePayload, +): Promise => { + const response = await apiClient.post("/templates", data) + return response.data +} + +/** 更新模板 */ +export const updateEditingTemplate = async ( + id: string, + data: SaveTemplatePayload, +): Promise => { + const response = await apiClient.patch(`/templates/${id}`, data) + return response.data +} + +/** 删除模板 */ +export const deleteEditingTemplate = async (id: string): Promise => { + await apiClient.delete(`/templates/${id}`) +} + +/** 获取模板分类列表 */ +export const getTemplateCategories = async (): Promise => { + const response = await apiClient.get("/templates/categories/list") + return response.data.items +} + +/** 使用模板生成视频(调用 validate 端点) */ +export const generateFromTemplate = async ( + templateId: string, + data: GenerateFromTemplatePayload, +): Promise => { + const response = await apiClient.post( + `/templates/${templateId}/validate`, + data, + ) + return response.data +} diff --git a/apps/web/src/api/editing-planner/types.ts b/apps/web/src/api/editing-planner/types.ts new file mode 100644 index 000000000..a866de307 --- /dev/null +++ b/apps/web/src/api/editing-planner/types.ts @@ -0,0 +1,138 @@ +/** + * 模板编辑器类型定义 + */ +import type { + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, + CoverConfig, +} from "@/pages/editing-planner/types" + +/** 模板模式(后端枚举值) */ +export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip" + +/** 标题配置 */ +export interface TitleConfig { + ai_auto_select: boolean + content: string + font_preset: string + font_color: string + font_size: number + position: string +} + +/** 字幕配置 */ +export interface SubtitleConfig { + enabled: boolean + position: string + font: string + color: string + size: number + animation: string +} + +/** BGM 配置 */ +export interface BgmConfig { + enabled: boolean + music_id: string +} + +/** 模板片段 */ +export interface TemplateSegment { + id?: string + segment_order: number + duration_min: number + duration_max: number + material_type: string | null +} + +/** 剪辑模板 */ +export interface EditingTemplate { + id: string + name: string + mode: TemplateMode + category: string + tags: string[] + title_config: TitleConfig + subtitle_config: SubtitleConfig + bgm_config: BgmConfig + estimated_duration: number + segments: TemplateSegment[] + watermark_config?: WatermarkConfig + intro_outro_config?: IntroOutroConfig + pip_config?: PipConfig + filter_config?: FilterConfig + green_screen_config?: ChromaKeyConfig + sticker_config?: StickerConfig + cover_config?: CoverConfig + is_active?: boolean + created_at: string + updated_at: string +} + +/** 模板分类 */ +export interface TemplateCategory { + id: string + name: string + created_at?: string +} + +/** 创建/更新模板请求体 */ +export interface SaveTemplatePayload { + name: string + mode: TemplateMode + category: string + tags: string[] + title_config: TitleConfig + subtitle_config: SubtitleConfig + bgm_config: BgmConfig + estimated_duration: number + segments: Omit[] + watermark_config?: WatermarkConfig + intro_outro_config?: IntroOutroConfig + pip_config?: PipConfig + filter_config?: FilterConfig + green_screen_config?: ChromaKeyConfig + sticker_config?: StickerConfig + cover_config?: CoverConfig +} + +/** 使用模板生成请求体 */ +export interface GenerateFromTemplatePayload { + voiceover_duration: number +} + +/** 验证警告详情 */ +export interface ValidationWarningDetails { + field?: string + expected?: string | number + actual?: string | number + suggested?: string | number +} + +/** 验证警告 */ +export interface ValidateWarning { + code: string + message: string + details?: ValidationWarningDetails +} + +/** 使用模板生成响应 */ +export interface GenerateFromTemplateResponse { + template: EditingTemplate + warnings: ValidateWarning[] +} + +/** 列表响应(带分页) */ +export interface ListTemplatesResponse { + items: EditingTemplate[] + total: number +} + +/** 分类列表响应 */ +export interface ListCategoriesResponse { + items: TemplateCategory[] +} diff --git a/apps/web/src/api/products.ts b/apps/web/src/api/products.ts deleted file mode 100644 index 615a5f45c..000000000 --- a/apps/web/src/api/products.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * 成品 / 视频相关 API - * 后端实际接口:/videos - */ -import apiClient from "./client" - -/** 复核状态 */ -export type ReviewStatus = "pending_review" | "approved" | "rejected" - -/** 成品条目 */ -export interface ProductItem { - id: string - title: string - video_url?: string - thumbnail_url?: string - duration_seconds?: number - file_size?: number - resolution?: string - status: "processing" | "completed" | "failed" - /** 复核状态 */ - review_status?: ReviewStatus - /** 所属项目 ID */ - project_id?: string - /** 所属项目名称 */ - project_name?: string - /** 查重率(百分比) */ - duplicate_rate?: number - created_at?: string - updated_at?: string -} - -/** 列表查询参数 */ -export interface ProductListParams { - page?: number - page_size?: number - project_id?: string - review_status?: ReviewStatus | "all" -} - -/** 分页响应 */ -export interface ProductListResponse { - items: ProductItem[] - total: number - page: number - page_size: number -} - -/** 批量下载任务状态 */ -export interface BatchDownloadStatus { - job_id: string - status: "processing" | "completed" | "failed" - /** 完成后返回的下载 URL */ - download_url?: string - /** 进度百分比 */ - progress?: number -} - -/** 后端 /videos 接口返回的原始视频条目 */ -interface VideoItem { - id: string - project_id: string - generation_task_id: string - name: string - file_url: string - file_size: number - duration: number - thumbnail_url: string | null - width: number - height: number - fps: number - status: string - review_status: ReviewStatus - generation_params: Record - download_url: string - generated_at: string -} - -/** - * 将后端 VideoItem 映射为 ProductItem 格式 - */ -function mapVideoToProductItem(video: VideoItem): ProductItem { - return { - id: video.id, - title: video.name || "未命名视频", - // 优先用 download_url(带签名)播放,file_url 无签名无法访问 - video_url: video.download_url || video.file_url, - thumbnail_url: video.thumbnail_url || undefined, - duration_seconds: video.duration, - file_size: video.file_size, - resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined, - status: - video.status === "completed" - ? "completed" - : video.status === "failed" - ? "failed" - : "processing", - review_status: video.review_status, - project_id: video.project_id, - // 后端 /videos 接口暂无 project_name 字段 - project_name: undefined, - // 后端字段名为 generated_at,映射为 created_at 供前端统一使用 - created_at: video.generated_at, - updated_at: video.generated_at, - // 后端 /videos 接口暂无 duplicate_rate 字段 - duplicate_rate: undefined, - } -} - -/** 获取成品列表(支持分页和筛选) */ -export const getProducts = async (params?: ProductListParams): Promise => { - const response = await apiClient.get("/videos", { params }) - const data = response.data - const videos: VideoItem[] = Array.isArray(data?.items) - ? data.items - : Array.isArray(data) - ? data - : [] - return videos.map(mapVideoToProductItem) -} - -/** 获取单个成品详情 */ -export const getProduct = async (productId: string): Promise => { - const response = await apiClient.get(`/videos/${productId}`) - return mapVideoToProductItem(response.data as VideoItem) -} - -/** - * 删除成品 - * 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405 - * 待后端实现后自动生效 - */ -export const deleteProduct = async (productId: string): Promise => { - await apiClient.delete(`/videos/${productId}`) -} - -/** - * 获取成品下载链接 - * 直接使用列表返回的 download_url(带OSS签名) - */ -export const getProductDownloadUrl = async ( - productId: string, -): Promise<{ url: string; expires_at: string }> => { - // 优先从列表缓存取;如果没有则调详情接口 - const product = await getProduct(productId) - if (!product.video_url) throw new Error("下载链接不可用") - return { url: product.video_url, expires_at: "" } -} - -/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */ -export const updateReviewStatus = async ( - productId: string, - status: ReviewStatus, -): Promise => { - // 后端暂无 /videos/{id}/review 端点 - // 暂时返回当前状态,后续可扩展 - const product = await getProduct(productId) - return { ...product, review_status: status } -} - -/** 发起批量下载 — TODO: 后端暂无对应端点 */ -export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => { - // 后端暂无 /videos/batch-download 端点 - // 暂时返回模拟 job_id,后续可扩展 - console.warn("[batchDownload] 后端暂无批量下载端点", videoIds) - return { job_id: `mock-${Date.now()}` } -} - -/** 查询批量下载状态 — TODO: 后端暂无对应端点 */ -export const getBatchDownloadStatus = async (jobId: string): Promise => { - // 后端暂无 /videos/batch-download/{jobId} 端点 - // 暂时返回模拟状态,后续可扩展 - console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId) - return { job_id: jobId, status: "processing", progress: 0 } -} diff --git a/apps/web/src/api/products/index.ts b/apps/web/src/api/products/index.ts new file mode 100644 index 000000000..b4bf23a95 --- /dev/null +++ b/apps/web/src/api/products/index.ts @@ -0,0 +1,28 @@ +/** + * 成品 / 视频相关 API — 目录化入口 + * 保持与原 products.ts 相同导出,向后兼容 + */ + +// 类型 +export type { + ReviewStatus, + ProductItem, + ProductListParams, + ProductListResponse, + BatchDownloadStatus, + VideoItem, +} from "./types" + +// 工具函数 +export { mapVideoToProductItem } from "./utils" + +// API 函数 +export { + getProducts, + getProduct, + deleteProduct, + getProductDownloadUrl, + updateReviewStatus, + batchDownload, + getBatchDownloadStatus, +} from "./products" diff --git a/apps/web/src/api/products/products.ts b/apps/web/src/api/products/products.ts new file mode 100644 index 000000000..7c0fcb19f --- /dev/null +++ b/apps/web/src/api/products/products.ts @@ -0,0 +1,80 @@ +/** + * 成品 / 视频相关 API 函数 + * 后端实际接口:/videos + */ +import apiClient from "../client" +import type { + BatchDownloadStatus, + ProductItem, + ProductListParams, + VideoItem, + ReviewStatus, +} from "./types" +import { mapVideoToProductItem } from "./utils" + +/** 获取成品列表(支持分页和筛选) */ +export const getProducts = async (params?: ProductListParams): Promise => { + const response = await apiClient.get("/videos", { params }) + const data = response.data + const videos: VideoItem[] = Array.isArray(data?.items) + ? data.items + : Array.isArray(data) + ? data + : [] + return videos.map(mapVideoToProductItem) +} + +/** 获取单个成品详情 */ +export const getProduct = async (productId: string): Promise => { + const response = await apiClient.get(`/videos/${productId}`) + return mapVideoToProductItem(response.data as VideoItem) +} + +/** + * 删除成品 + * 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405 + * 待后端实现后自动生效 + */ +export const deleteProduct = async (productId: string): Promise => { + await apiClient.delete(`/videos/${productId}`) +} + +/** + * 获取成品下载链接 + * 直接使用列表返回的 download_url(带OSS签名) + */ +export const getProductDownloadUrl = async ( + productId: string, +): Promise<{ url: string; expires_at: string }> => { + // 优先从列表缓存取;如果没有则调详情接口 + const product = await getProduct(productId) + if (!product.video_url) throw new Error("下载链接不可用") + return { url: product.video_url, expires_at: "" } +} + +/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */ +export const updateReviewStatus = async ( + productId: string, + status: ReviewStatus, +): Promise => { + // 后端暂无 /videos/{id}/review 端点 + // 暂时返回当前状态,后续可扩展 + const product = await getProduct(productId) + return { ...product, review_status: status } +} + +/** 发起批量下载 — TODO: 后端暂无对应端点 */ +export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => { + // 后端暂无 /videos/batch-download 端点 + // 暂时返回模拟 job_id,后续可扩展 + console.warn("[batchDownload] 后端暂无批量下载端点", videoIds) + return { job_id: `mock-${Date.now()}` } +} + +/** 查询批量下载状态 — TODO: 后端暂无对应端点 */ +export const getBatchDownloadStatus = async (jobId: string): Promise => { + // 后端暂无 /videos/batch-download/{jobId} 端点 + // 暂时返回模拟状态,后续可扩展 + console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId) + return { job_id: jobId, status: "processing", progress: 0 } +} diff --git a/apps/web/src/api/products/types.ts b/apps/web/src/api/products/types.ts new file mode 100644 index 000000000..83a8af804 --- /dev/null +++ b/apps/web/src/api/products/types.ts @@ -0,0 +1,74 @@ +/** + * 成品 / 视频相关类型定义 + */ + +/** 复核状态 */ +export type ReviewStatus = "pending_review" | "approved" | "rejected" + +/** 成品条目 */ +export interface ProductItem { + id: string + title: string + video_url?: string + thumbnail_url?: string + duration_seconds?: number + file_size?: number + resolution?: string + status: "processing" | "completed" | "failed" + /** 复核状态 */ + review_status?: ReviewStatus + /** 所属项目 ID */ + project_id?: string + /** 所属项目名称 */ + project_name?: string + /** 查重率(百分比) */ + duplicate_rate?: number + created_at?: string + updated_at?: string +} + +/** 列表查询参数 */ +export interface ProductListParams { + page?: number + page_size?: number + project_id?: string + review_status?: ReviewStatus | "all" +} + +/** 分页响应 */ +export interface ProductListResponse { + items: ProductItem[] + total: number + page: number + page_size: number +} + +/** 批量下载任务状态 */ +export interface BatchDownloadStatus { + job_id: string + status: "processing" | "completed" | "failed" + /** 完成后返回的下载 URL */ + download_url?: string + /** 进度百分比 */ + progress?: number +} + +/** 后端 /videos 接口返回的原始视频条目 */ +export interface VideoItem { + id: string + project_id: string + generation_task_id: string + name: string + file_url: string + file_size: number + duration: number + thumbnail_url: string | null + width: number + height: number + fps: number + status: string + review_status: ReviewStatus + generation_params: Record + download_url: string + generated_at: string +} diff --git a/apps/web/src/api/products/utils.ts b/apps/web/src/api/products/utils.ts new file mode 100644 index 000000000..00fd89ef3 --- /dev/null +++ b/apps/web/src/api/products/utils.ts @@ -0,0 +1,35 @@ +/** + * 成品数据转换工具函数 + */ +import type { ProductItem, VideoItem } from "./types" + +/** + * 将后端 VideoItem 映射为 ProductItem 格式 + */ +export function mapVideoToProductItem(video: VideoItem): ProductItem { + return { + id: video.id, + title: video.name || "未命名视频", + // 优先用 download_url(带签名)播放,file_url 无签名无法访问 + video_url: video.download_url || video.file_url, + thumbnail_url: video.thumbnail_url || undefined, + duration_seconds: video.duration, + file_size: video.file_size, + resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined, + status: + video.status === "completed" + ? "completed" + : video.status === "failed" + ? "failed" + : "processing", + review_status: video.review_status, + project_id: video.project_id, + // 后端 /videos 接口暂无 project_name 字段 + project_name: undefined, + // 后端字段名为 generated_at,映射为 created_at 供前端统一使用 + created_at: video.generated_at, + updated_at: video.generated_at, + // 后端 /videos 接口暂无 duplicate_rate 字段 + duplicate_rate: undefined, + } +} diff --git a/apps/web/src/api/projects/index.ts b/apps/web/src/api/projects/index.ts new file mode 100644 index 000000000..83c85af3f --- /dev/null +++ b/apps/web/src/api/projects/index.ts @@ -0,0 +1,13 @@ +/** + * 项目 API — 目录化入口 + * 保持与原 projects.ts 相同导出,向后兼容 + */ + +// 类型 +export type { ProjectItem, BackendProjectResponse, BackendListProjectsResponse } from "./types" + +// 工具函数 +export { toProjectItem } from "./utils" + +// API 函数 +export { getProjects, createProject, getOrCreateDefaultProject } from "./projects" diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects/projects.ts similarity index 64% rename from apps/web/src/api/projects.ts rename to apps/web/src/api/projects/projects.ts index 45fca2901..a5fd66568 100644 --- a/apps/web/src/api/projects.ts +++ b/apps/web/src/api/projects/projects.ts @@ -1,32 +1,10 @@ /** - * 项目相关 API + * 项目相关 API 函数 * 素材库需要 project_id,前端自动管理默认项目 */ -import apiClient from "./client" - -export interface ProjectItem { - id: string - name: string - description: string -} - -/** 后端 ProjectResponse 只返回 id, name, description */ -interface BackendProjectResponse { - id: string - name: string - description: string -} - -/** 后端 ListProjectsResponse 返回 { items: [...] } */ -interface BackendListProjectsResponse { - items: BackendProjectResponse[] -} - -const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({ - id: item.id, - name: item.name, - description: item.description, -}) +import apiClient from "../client" +import type { BackendListProjectsResponse, BackendProjectResponse, ProjectItem } from "./types" +import { toProjectItem } from "./utils" /** 获取当前用户的项目列表 */ export const getProjects = async (): Promise => { diff --git a/apps/web/src/api/projects/types.ts b/apps/web/src/api/projects/types.ts new file mode 100644 index 000000000..f2236016b --- /dev/null +++ b/apps/web/src/api/projects/types.ts @@ -0,0 +1,21 @@ +/** + * 项目相关类型定义 + */ + +export interface ProjectItem { + id: string + name: string + description: string +} + +/** 后端 ProjectResponse 只返回 id, name, description */ +export interface BackendProjectResponse { + id: string + name: string + description: string +} + +/** 后端 ListProjectsResponse 返回 { items: [...] } */ +export interface BackendListProjectsResponse { + items: BackendProjectResponse[] +} diff --git a/apps/web/src/api/projects/utils.ts b/apps/web/src/api/projects/utils.ts new file mode 100644 index 000000000..342e88637 --- /dev/null +++ b/apps/web/src/api/projects/utils.ts @@ -0,0 +1,10 @@ +/** + * 项目相关工具函数 + */ +import type { BackendProjectResponse, ProjectItem } from "./types" + +export const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({ + id: item.id, + name: item.name, + description: item.description, +}) diff --git a/apps/web/src/api/subscription/index.ts b/apps/web/src/api/subscription/index.ts new file mode 100644 index 000000000..74bdd8f84 --- /dev/null +++ b/apps/web/src/api/subscription/index.ts @@ -0,0 +1,26 @@ +/** + * 订阅 API — 目录化入口 + * 保持与原 subscription.ts 相同导出,向后兼容 + */ + +// 类型 +export type { + PlanType, + SubscriptionStatus, + BillingStatus, + BillingCycle, + Plan, + SubscriptionInfo, + BillingRecord, + ChangePlanRequest, + ChangePlanResponse, +} from "./types" + +// API 函数 +export { + getCurrentSubscription, + getBillingRecords, + changePlan, + cancelSubscription, + toggleAutoRenew, +} from "./subscription" diff --git a/apps/web/src/api/subscription/subscription.ts b/apps/web/src/api/subscription/subscription.ts new file mode 100644 index 000000000..3a6a0010f --- /dev/null +++ b/apps/web/src/api/subscription/subscription.ts @@ -0,0 +1,47 @@ +/** + * 订阅相关 API 函数 + */ +import apiClient from "../client" +import type { + BillingRecord, + ChangePlanRequest, + ChangePlanResponse, + SubscriptionInfo, +} from "./types" + +/** 获取当前订阅信息 */ +export const getCurrentSubscription = async (): Promise => { + const response = await apiClient.get("/subscription/current") + return response.data +} + +/** 获取账单记录列表 */ +export const getBillingRecords = async (): Promise => { + const response = await apiClient.get("/subscription/billing-records") + return response.data +} + +/** 升级/降级套餐 */ +export const changePlan = async (request: ChangePlanRequest): Promise => { + const response = await apiClient.post("/subscription/change-plan", request) + return response.data +} + +/** 取消订阅 */ +export const cancelSubscription = async (): Promise<{ + success: boolean + message: string +}> => { + const response = await apiClient.post("/subscription/cancel") + return response.data +} + +/** 切换自动续费 */ +export const toggleAutoRenew = async ( + enabled: boolean, +): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post("/subscription/toggle-auto-renew", { + enabled, + }) + return response.data +} diff --git a/apps/web/src/api/subscription.ts b/apps/web/src/api/subscription/types.ts similarity index 52% rename from apps/web/src/api/subscription.ts rename to apps/web/src/api/subscription/types.ts index 2d4303801..fefabf5bc 100644 --- a/apps/web/src/api/subscription.ts +++ b/apps/web/src/api/subscription/types.ts @@ -1,8 +1,6 @@ /** - * 订阅 API 模块 - * 对接后端订阅管理接口 + * 订阅相关类型定义 */ -import apiClient from "./client" /** 套餐类型 */ export type PlanType = "free" | "standard" | "pro" | "enterprise" @@ -65,42 +63,3 @@ export interface ChangePlanResponse { message: string new_subscription?: SubscriptionInfo } - -// ============ API 函数 ============ - -/** 获取当前订阅信息 */ -export const getCurrentSubscription = async (): Promise => { - const response = await apiClient.get("/subscription/current") - return response.data -} - -/** 获取账单记录列表 */ -export const getBillingRecords = async (): Promise => { - const response = await apiClient.get("/subscription/billing-records") - return response.data -} - -/** 升级/降级套餐 */ -export const changePlan = async (request: ChangePlanRequest): Promise => { - const response = await apiClient.post("/subscription/change-plan", request) - return response.data -} - -/** 取消订阅 */ -export const cancelSubscription = async (): Promise<{ - success: boolean - message: string -}> => { - const response = await apiClient.post("/subscription/cancel") - return response.data -} - -/** 切换自动续费 */ -export const toggleAutoRenew = async ( - enabled: boolean, -): Promise<{ success: boolean; message: string }> => { - const response = await apiClient.post("/subscription/toggle-auto-renew", { - enabled, - }) - return response.data -} diff --git a/apps/web/src/api/tags/index.ts b/apps/web/src/api/tags/index.ts new file mode 100644 index 000000000..cb84c7b0d --- /dev/null +++ b/apps/web/src/api/tags/index.ts @@ -0,0 +1,10 @@ +/** + * 标签 API — 目录化入口 + * 保持与原 tags.ts 相同导出,向后兼容 + */ + +// 类型 +export type { TagItem } from "./types" + +// API 函数 +export { getTags, createTag, deleteTag, tagAsset, untagAsset } from "./tags" diff --git a/apps/web/src/api/tags.ts b/apps/web/src/api/tags/tags.ts similarity index 86% rename from apps/web/src/api/tags.ts rename to apps/web/src/api/tags/tags.ts index 4e6c812d0..b5e1f442f 100644 --- a/apps/web/src/api/tags.ts +++ b/apps/web/src/api/tags/tags.ts @@ -1,15 +1,9 @@ /** - * 标签 CRUD API + * 标签 CRUD API 函数 * P3 标签体系:对接后端标签表 */ -import apiClient from "./client" - -export interface TagItem { - id: string - name: string - created_at?: string - usage_count?: number -} +import apiClient from "../client" +import type { TagItem } from "./types" /** 获取当前用户所有标签 */ export const getTags = async (): Promise => { diff --git a/apps/web/src/api/tags/types.ts b/apps/web/src/api/tags/types.ts new file mode 100644 index 000000000..7dc38bdf0 --- /dev/null +++ b/apps/web/src/api/tags/types.ts @@ -0,0 +1,10 @@ +/** + * 标签相关类型定义 + */ + +export interface TagItem { + id: string + name: string + created_at?: string + usage_count?: number +} diff --git a/apps/web/src/api/tasks/index.ts b/apps/web/src/api/tasks/index.ts new file mode 100644 index 000000000..e4ec234e4 --- /dev/null +++ b/apps/web/src/api/tasks/index.ts @@ -0,0 +1,19 @@ +/** + * 任务相关 API — 目录化入口 + * 保持与原 tasks.ts 相同导出,向后兼容 + */ + +// 类型 +export type { + TaskStatus, + TaskType, + TaskErrorInfo, + TaskItem, + TaskListParams, + TaskListResponse, + CreateGenerationTaskRequest, + CreateGenerationTaskResponse, +} from "./types" + +// API 函数 +export { createGenerationTask, getTasks, getUserTasks, getTask, retryTask } from "./tasks" diff --git a/apps/web/src/api/tasks/tasks.ts b/apps/web/src/api/tasks/tasks.ts new file mode 100644 index 000000000..6b0ad6cc3 --- /dev/null +++ b/apps/web/src/api/tasks/tasks.ts @@ -0,0 +1,46 @@ +/** + * 任务相关 API 函数 + * 对接后端任务中心 API + */ +import apiClient from "../client" +import type { + CreateGenerationTaskRequest, + CreateGenerationTaskResponse, + TaskItem, + TaskListParams, + TaskListResponse, +} from "./types" + +/** 创建生成任务(智能剪辑) */ +export const createGenerationTask = async ( + params: CreateGenerationTaskRequest, +): Promise => { + const { data } = await apiClient.post("/generation/tasks", params) + return data +} + +/** 获取任务列表(支持分页和筛选) */ +export const getTasks = async (params?: TaskListParams): Promise => { + const { data } = await apiClient.get("/tasks", { + params, + }) + return data +} + +/** 获取当前用户的所有任务(兼容旧接口,跨 project) */ +export const getUserTasks = async (): Promise => { + const { data } = await apiClient.get("/tasks") + return data.items || data || [] +} + +/** 获取单个任务详情(含 error_info) */ +export const getTask = async (taskId: string): Promise => { + const { data } = await apiClient.get(`/tasks/${taskId}`) + return data +} + +/** 重试失败的任务 */ +export const retryTask = async (taskId: string): Promise => { + const { data } = await apiClient.post(`/tasks/${taskId}/retry`) + return data +} diff --git a/apps/web/src/api/tasks.ts b/apps/web/src/api/tasks/types.ts similarity index 50% rename from apps/web/src/api/tasks.ts rename to apps/web/src/api/tasks/types.ts index f3b14a8c0..6be490f96 100644 --- a/apps/web/src/api/tasks.ts +++ b/apps/web/src/api/tasks/types.ts @@ -1,14 +1,6 @@ /** - * 任务相关 API - * 对接后端任务中心 API: - * - POST /api/v1/generation/tasks — 创建生成任务 - * - GET /api/v1/tasks — 用户级任务列表(支持分页/筛选) - * - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info) - * - POST /api/v1/tasks/{task_id}/retry — 重试失败任务 + * 任务相关类型定义 */ -import apiClient from "./client" - -/* ──────────── 类型定义 ──────────── */ /** 任务状态 */ export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled" @@ -85,39 +77,3 @@ export interface CreateGenerationTaskResponse { result_count: number error_message: string } - -/* ──────────── API 函数 ──────────── */ - -/** 创建生成任务(智能剪辑) */ -export const createGenerationTask = async ( - params: CreateGenerationTaskRequest, -): Promise => { - const { data } = await apiClient.post("/generation/tasks", params) - return data -} - -/** 获取任务列表(支持分页和筛选) */ -export const getTasks = async (params?: TaskListParams): Promise => { - const { data } = await apiClient.get("/tasks", { - params, - }) - return data -} - -/** 获取当前用户的所有任务(兼容旧接口,跨 project) */ -export const getUserTasks = async (): Promise => { - const { data } = await apiClient.get("/tasks") - return data.items || data || [] -} - -/** 获取单个任务详情(含 error_info) */ -export const getTask = async (taskId: string): Promise => { - const { data } = await apiClient.get(`/tasks/${taskId}`) - return data -} - -/** 重试失败的任务 */ -export const retryTask = async (taskId: string): Promise => { - const { data } = await apiClient.post(`/tasks/${taskId}/retry`) - return data -} diff --git a/apps/web/src/api/template-editor.ts b/apps/web/src/api/template-editor.ts deleted file mode 100644 index d9409a596..000000000 --- a/apps/web/src/api/template-editor.ts +++ /dev/null @@ -1,744 +0,0 @@ -/** - * 模板草稿 API — 对接后端 Template Editor Schema - * 字段名严格匹配后端 API 响应 - */ -import apiClient from "./client" -import type { AssetItem } from "./assets" -import type { - WatermarkConfig, - IntroOutroConfig, - PipConfig, - FilterConfig, - ChromaKeyConfig, - StickerConfig, - CoverConfig, -} from "@/pages/editing-planner/types" - -/* ============================================================ - * 后端 API 类型(严格匹配后端 Schema) - * ============================================================ */ - -/** 模板草稿状态枚举 */ -export type EditPlanStatus = - "draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled" - -/** 标题配置(对齐后端 title_config) */ -export interface TitleConfig { - ai_auto_select: boolean - content: string - font_preset: string - font_color: string - font_size: number - position: string -} - -/** 字幕配置 */ -export interface SubtitleConfig { - enabled: boolean - position: string - font: string - color: string - size: number - animation: string -} - -/** BGM 配置 */ -export interface BgmConfig { - enabled: boolean - music_id: string -} - -/** 片段 TTS 配置 */ -export interface SegmentTtsConfig { - mode: string - text: string - voice_id: string - speed: number - pitch: number - volume: number - subtitle_sync: boolean -} - -/** 片段裁剪配置 */ -export interface SegmentTrimConfig { - start_time: number - end_time: number -} - -/** 片段转场配置 */ -export interface SegmentTransitionConfig { - type: string - duration: number -} - -/** 模板草稿中的单个片段(config 内部 segments 项) */ -export interface EditPlanSegment { - segment_order: number - duration_min: number - duration_max: number - material_type: string - transition?: SegmentTransitionConfig - playback_speed?: number - tts_config?: SegmentTtsConfig - trim_config?: SegmentTrimConfig -} - -/** 模板草稿 config 完整类型(对齐后端 config JSON 结构) */ -export interface EditPlanConfig { - title_config?: TitleConfig - subtitle_config?: SubtitleConfig - bgm_config?: BgmConfig - estimated_duration?: number - segments?: EditPlanSegment[] - watermark_config?: WatermarkConfig - intro_outro_config?: IntroOutroConfig - pip_config?: PipConfig - filter_config?: FilterConfig - green_screen_config?: ChromaKeyConfig - sticker_config?: StickerConfig - cover_config?: CoverConfig - /** 前端扩展:关联的素材 ID 列表 */ - asset_ids?: string[] - /** 配音 ID */ - voice_id?: string - /** 克隆音色档案 ID */ - voice_clone_profile_id?: string - /** 自定义配音音频 URL */ - custom_audio_url?: string - /** 自定义配音文本 */ - custom_text?: string - /** 视频比例 */ - ratio?: string - /** 视频风格 */ - style?: string - /** 目标时长(秒) */ - duration?: number - /** 是否自动生成字幕 */ - auto_subtitles?: boolean - /** 是否启用 BGM */ - bgm?: boolean - /** 生成数量 */ - generate_count?: number - /** 素材模式 */ - material_mode?: string -} - -/** 模板草稿(后端响应) */ -export interface EditPlan { - id: string - template_id: string - name: string - status: EditPlanStatus - total_duration: number - /** 生成视频数量(后端 EditPlanResponse.result_count) */ - result_count: number - config: EditPlanConfig - created_at: string - updated_at: string -} - -/** 创建模板草稿请求(后端要求 template_id + name 必填) */ -export interface CreateEditPlanRequest { - template_id: string - name: string - config?: EditPlanConfig - total_duration?: number - /** 来源模板草稿 ID(从模板编辑器跳转到智能剪辑时关联) */ - source_edit_plan_id?: string -} - -/** 更新模板草稿请求 */ -export interface UpdateEditPlanRequest { - name?: string - config?: EditPlanConfig - total_duration?: number - status?: EditPlanStatus -} - -/** 生成响应 */ -export interface GenerateResponse { - plan_id: string - plan_status: EditPlanStatus - generation_task_id: string - clip_count: number -} - -/** 模板草稿关联的生成记录(实际是 GenerationTask 对象) */ -export interface EditPlanGeneration { - id: string // 即 generation_task_id - source_edit_plan_id: string - template_id: string - asset_ids: string[] - status: EditPlanStatus - progress: number - result_count: number - error_message: string - error_info: Record - logs: Array> - retry_count: number - created_at?: string - updated_at?: string -} - -/** 片段生成状态 */ -export interface ClipStatusItem { - clip_id: string - clip_type: string - order: number - status: string - asset_id?: string - text_content?: string - duration?: number - error_message?: string -} - -/** 生成状态轮询响应 */ -export interface GenerationStatusResponse { - plan_id: string - plan_status: EditPlanStatus - generation_task_id?: string - error_message?: string - clips: ClipStatusItem[] - error?: string - message?: string -} - -/** 生成视频详情(对应后端 GeneratedVideoResponse) */ -export interface GeneratedVideo { - id: string - project_id?: string - generation_task_id?: string - name: string - file_url: string - file_size?: number - duration?: number - thumbnail_url?: string - width?: number - height?: number - fps?: number - status: string - review_status?: string - download_url?: string - created_at?: string - updated_at?: string -} - -/* ============================================================ - * AI 推荐 & 封面生成(任务 3.09) - * ============================================================ */ - -/** AI 推荐请求 */ -export interface AIRecommendRequest { - asset_ids: string[] - editing_mode?: string - target_duration?: number -} - -/** AI 推荐单个片段 */ -export interface AIRecommendClipItem { - clip_type: string - order: number - text_content: string - duration: number - transition_effect: string - asset_id: string - start_time: number - config: EditPlanConfig -} - -/** AI 推荐响应 */ -export interface AIRecommendResponse { - plan_id: string - clips: AIRecommendClipItem[] - config: EditPlanConfig - total_duration: number - confidence: number -} - -/** AI 封面生成请求 */ -export interface GenerateCoverRequest { - asset_ids: string[] - cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate" - frame_time?: number -} - -/** AI 封面生成响应 */ -export interface GenerateCoverResponse { - plan_id: string - cover: CoverResult -} - -/** 封面生成结果 */ -export interface CoverResult { - scheme?: string - asset_id?: string - frame_time?: number - thumbnail_url?: string -} - -/* ============================================================ - * 前端 UI 类型(EditingPlanner 组件依赖,保留兼容) - * ============================================================ */ - -/** 转场效果(14 种预设) */ -export interface TransitionEffect { - type: - | "none" - | "cut" - | "fade" - | "dissolve" - | "zoom" - | "slide_left" - | "slide_right" - | "slide_up" - | "slide_down" - | "wipe_left" - | "wipe_right" - | "wipe_up" - | "wipe_down" - | "circlecrop" - | "rectcrop" - duration: number // 转场时长(秒) - /** 播放速度倍率 */ - playback_speed?: number -} - -/** 素材库资产(UI 层类型,映射自后端 AssetResponse) */ -export interface MediaAsset { - id: string - name: string - type: "video" | "image" | "audio" - /** 缩略图 URL */ - thumbnail_url?: string - /** 时长(秒),仅 video/audio */ - duration?: number - /** 文件大小(字节) */ - size?: number - /** 标签 */ - tags: string[] - created_at: string - /** 质量分 0-100 */ - quality_score?: number - /** 分类状态 */ - classification_status?: "pending" | "processing" | "completed" | "failed" -} - -/* ============================================================ - * API 函数 — 严格对接后端 - * ============================================================ */ - -/** 模板草稿列表查询参数 */ -export interface EditPlanListParams { - page?: number - page_size?: number - template_id?: string - status?: string -} - -/** 模板草稿列表分页响应 */ -export interface EditPlanListResponse { - items: EditPlan[] - total: number - page: number - page_size: number -} - -/** 获取模板草稿列表(支持分页和筛选) */ -export async function getEditPlans(params?: EditPlanListParams): Promise { - const response = await apiClient.get("/templates/drafts", { - params, - }) - return response.data -} - -/** 获取单个模板草稿 */ -export async function getEditPlan(templateId: string): Promise { - const response = await apiClient.get(`/templates/${templateId}/editor`) - return response.data -} - -/** 创建模板草稿 */ -export async function createEditPlan(data: CreateEditPlanRequest): Promise { - const response = await apiClient.post("/templates/drafts", data) - return response.data -} - -/** 更新模板草稿 */ -export async function updateEditPlan( - templateId: string, - data: UpdateEditPlanRequest, -): Promise { - const response = await apiClient.put(`/templates/${templateId}/editor`, data) - return response.data -} - -/** 删除模板草稿 */ -export async function deleteEditPlan(templateId: string): Promise { - await apiClient.delete(`/templates/${templateId}/editor`) -} - -/** 触发生成 */ -export async function generateEditPlan(templateId: string): Promise { - const response = await apiClient.post(`/templates/${templateId}/editor/generate`) - return response.data -} - -/** 获取生成状态(轮询用) */ -export async function getGenerationStatus(templateId: string): Promise { - const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`) - return response.data -} - -/** AI 推荐片段方案 */ -export async function aiRecommendClips( - templateId: string, - data: AIRecommendRequest, -): Promise { - const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data) - return response.data -} - -/** AI 生成封面 */ -export async function generateCover( - templateId: string, - data: GenerateCoverRequest, -): Promise { - const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data) - return response.data -} - -/** 获取模板草稿关联的生成记录 */ -export async function getEditPlanGenerations(templateId: string): Promise { - const response = await apiClient.get(`/templates/${templateId}/editor/generations`) - return response.data.items || [] -} - -/** 获取生成任务的视频结果列表 */ -export async function getGenerationTaskResults(taskId: string): Promise { - const response = await apiClient.get(`/generation/tasks/${taskId}/results`) - return response.data.items || response.data || [] -} - -/** 取消生成任务 */ -export async function cancelGeneration(templateId: string): Promise { - await apiClient.post(`/templates/${templateId}/editor/cancel`) -} - -/* ============================================================ - * 片段 CRUD(后端 EditPlanClip 独立表) - * ============================================================ */ - -/** 片段状态 */ -export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed" - -/** 剪辑片段(后端响应) */ -export interface EditPlanClip { - id: string - plan_id: string - clip_type: string // main / intro / outro / overlay / background / b_roll 等 - order: number - asset_id: string - text_content: string - start_time: number - duration: number - transition_effect: string - transition_duration: number - playback_speed: number - status: EditPlanClipStatus - config: Record - created_at?: string - updated_at?: string -} - -/** 创建片段请求 */ -export interface CreateEditPlanClipRequest { - clip_type: string - order: number - asset_id?: string - text_content?: string - start_time?: number - duration?: number - transition_effect?: string - transition_duration?: number - playback_speed?: number - config?: Record -} - -/** 更新片段请求 */ -export interface UpdateEditPlanClipRequest { - clip_type?: string - order?: number - asset_id?: string - text_content?: string - start_time?: number - duration?: number - transition_effect?: string - transition_duration?: number - playback_speed?: number - config?: Record -} - -/** 片段列表响应 */ -export interface EditPlanClipListResponse { - items: EditPlanClip[] - total: number -} - -/** 片段列表查询参数 */ -export interface EditPlanClipListParams { - status?: string - skip?: number - limit?: number -} - -/** 获取片段列表 */ -export async function getEditPlanClips( - templateId: string, - params?: EditPlanClipListParams, -): Promise { - const response = await apiClient.get( - `/templates/${templateId}/editor/clips`, - { - params, - }, - ) - return response.data -} - -/** 获取单个片段详情 */ -export async function getEditPlanClip(templateId: string, clipId: string): Promise { - const response = await apiClient.get( - `/templates/${templateId}/editor/clips/${clipId}`, - ) - return response.data -} - -/** 创建片段 */ -export async function createEditPlanClip( - templateId: string, - data: CreateEditPlanClipRequest, -): Promise { - const response = await apiClient.post(`/templates/${templateId}/editor/clips`, data) - return response.data -} - -/** 更新片段 */ -export async function updateEditPlanClip( - templateId: string, - clipId: string, - data: UpdateEditPlanClipRequest, -): Promise { - const response = await apiClient.put( - `/templates/${templateId}/editor/clips/${clipId}`, - data, - ) - return response.data -} - -/** 删除片段 */ -export async function deleteEditPlanClip(templateId: string, clipId: string): Promise { - await apiClient.delete(`/templates/${templateId}/editor/clips/${clipId}`) -} - -/* ============================================================ - * 片段批量操作 - * ============================================================ */ - -/** 重排序条目 */ -export interface ClipReorderItem { - clip_id: string - new_order: number -} - -/** 重排序响应 */ -export interface ClipReorderResponse { - success: boolean - updated_count: number - message: string -} - -/** 批量删除响应 */ -export interface ClipBatchDeleteResponse { - success: boolean - deleted_count: number - message: string -} - -/** 从素材批量创建响应 */ -export interface ClipsFromAssetsResponse { - success: boolean - created_count: number - message: string - clip_ids: string[] -} - -/** 片段重排序(拖拽排序后一次性提交) */ -export async function reorderEditPlanClips( - templateId: string, - items: ClipReorderItem[], -): Promise { - const response = await apiClient.post( - `/templates/${templateId}/editor/clips/reorder`, - { items }, - ) - return response.data -} - -/** 批量删除片段 */ -export async function batchDeleteEditPlanClips( - templateId: string, - clipIds: string[], -): Promise { - const response = await apiClient.post( - `/templates/${templateId}/editor/clips/batch-delete`, - { clip_ids: clipIds }, - ) - return response.data -} - -/** 从素材批量创建片段(追加到时间线末尾) */ -export async function createClipsFromAssets( - templateId: string, - assetIds: string[], - clipType = "main", -): Promise { - const response = await apiClient.post( - `/templates/${templateId}/editor/clips/from-assets`, - { asset_ids: assetIds, clip_type: clipType }, - ) - return response.data -} - -/* ============================================================ - * 复制计划 - * ============================================================ */ - -/** 复制计划请求 */ -export interface CopyEditPlanRequest { - name?: string - project_id?: string -} - -/** 复制模板草稿(含所有片段配置) */ -export async function copyEditPlan( - templateId: string, - data?: CopyEditPlanRequest, -): Promise { - const response = await apiClient.post( - `/templates/${templateId}/editor/copy`, - data || {}, - ) - return response.data -} - -/** - * 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx - * 将后端 AssetResponse 映射为前端 MediaAsset 类型 - */ -export async function getMediaAssets(libraryId?: string): Promise { - const response = await apiClient.get("/assets", { - params: libraryId ? { library_id: libraryId } : undefined, - }) - const items: AssetItem[] = response.data.items || [] - return items.map(mapAssetToMediaAsset) -} - -/** 获取单个素材 */ -export async function getMediaAsset(id: string): Promise { - const response = await apiClient.get(`/assets/${id}`) - return mapAssetToMediaAsset(response.data) -} - -/* ============================================================ - * 映射函数:AssetResponse → MediaAsset - * ============================================================ */ - -function inferMediaType(mimeType: string): "video" | "image" | "audio" { - if (mimeType.startsWith("video/")) return "video" - if (mimeType.startsWith("image/")) return "image" - return "audio" -} - -function mapAssetToMediaAsset(asset: AssetItem): MediaAsset { - // 优先取顶层 duration,其次从 metadata 回退 - const metaDuration = - typeof asset.metadata?.duration === "number" ? asset.metadata.duration : undefined - return { - id: asset.id, - name: asset.name, - type: inferMediaType(asset.mime_type || ""), - thumbnail_url: asset.thumbnail_url, - duration: asset.duration ?? metaDuration, - size: asset.file_size ?? undefined, - tags: [], - created_at: asset.created_at ?? "", - quality_score: asset.quality_score ?? undefined, - classification_status: asset.classification_status ?? undefined, - } -} - -/* ============================================================ - * 常量 - * ============================================================ */ - -/** 转场效果选项(14 种预设) */ -export const TRANSITION_OPTIONS: { - value: TransitionEffect["type"] - label: string - icon: string -}[] = [ - { value: "none", label: "无转场", icon: "⊘" }, - { value: "cut", label: "硬切", icon: "✂" }, - { value: "fade", label: "淡入淡出", icon: "◐" }, - { value: "dissolve", label: "溶解", icon: "◈" }, - { value: "zoom", label: "缩放", icon: "⊕" }, - { value: "slide_left", label: "左滑", icon: "←" }, - { value: "slide_right", label: "右滑", icon: "→" }, - { value: "slide_up", label: "上滑", icon: "↑" }, - { value: "slide_down", label: "下滑", icon: "↓" }, - { value: "wipe_left", label: "左擦除", icon: "▸|" }, - { value: "wipe_right", label: "右擦除", icon: "|◂" }, - { value: "wipe_up", label: "上擦除", icon: "▴̄" }, - { value: "wipe_down", label: "下擦除", icon: "▾̄" }, - { value: "circlecrop", label: "圆形裁切", icon: "●" }, - { value: "rectcrop", label: "矩形裁切", icon: "■" }, -] - -/** 素材类型标签 */ -export const MATERIAL_TYPE_LABELS: Record = { - video: "视频", - image: "图片", - audio: "音频", - voiceover: "配音", -} - -/** 素材类型图标 */ -export const MATERIAL_TYPE_ICONS: Record = { - video: "🎬", - image: "🖼️", - audio: "🎵", - voiceover: "🎙️", -} - -/** 计划状态标签 */ -export const PLAN_STATUS_LABELS: Record = { - draft: "草稿", - editing: "编辑中", - rendering: "渲染中", - completed: "已完成", - failed: "失败", - cancelled: "已取消", -} - -/** 质量分筛选选项 */ -export const QUALITY_OPTIONS: { - value: string - label: string - min?: number - max?: number -}[] = [ - { value: "all", label: "全部质量" }, - { value: "high", label: "高质量 (80-100)", min: 80, max: 100 }, - { value: "medium", label: "中质量 (50-79)", min: 50, max: 79 }, - { value: "low", label: "低质量 (0-49)", min: 0, max: 49 }, -] diff --git a/apps/web/src/api/template-editor/aiFeatures.ts b/apps/web/src/api/template-editor/aiFeatures.ts new file mode 100644 index 000000000..4ae9d76e4 --- /dev/null +++ b/apps/web/src/api/template-editor/aiFeatures.ts @@ -0,0 +1,28 @@ +/** + * AI 推荐 + 封面生成 API + */ +import apiClient from "../client" +import type { + AIRecommendRequest, + AIRecommendResponse, + GenerateCoverRequest, + GenerateCoverResponse, +} from "./types" + +/** AI 推荐片段方案 */ +export async function aiRecommendClips( + templateId: string, + data: AIRecommendRequest, +): Promise { + const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data) + return response.data +} + +/** AI 生成封面 */ +export async function generateCover( + templateId: string, + data: GenerateCoverRequest, +): Promise { + const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data) + return response.data +} diff --git a/apps/web/src/api/template-editor/clips.ts b/apps/web/src/api/template-editor/clips.ts new file mode 100644 index 000000000..cbee754e0 --- /dev/null +++ b/apps/web/src/api/template-editor/clips.ts @@ -0,0 +1,99 @@ +/** + * 片段 CRUD + 批量操作 API + */ +import apiClient from "../client" +import type { + EditPlanClip, + EditPlanClipListParams, + EditPlanClipListResponse, + CreateEditPlanClipRequest, + UpdateEditPlanClipRequest, + ClipReorderItem, + ClipReorderResponse, + ClipBatchDeleteResponse, + ClipsFromAssetsResponse, +} from "./types" + +/** 获取片段列表 */ +export async function getEditPlanClips( + templateId: string, + params?: EditPlanClipListParams, +): Promise { + const response = await apiClient.get( + `/templates/${templateId}/editor/clips`, + { params }, + ) + return response.data +} + +/** 获取单个片段详情 */ +export async function getEditPlanClip(templateId: string, clipId: string): Promise { + const response = await apiClient.get( + `/templates/${templateId}/editor/clips/${clipId}`, + ) + return response.data +} + +/** 创建片段 */ +export async function createEditPlanClip( + templateId: string, + data: CreateEditPlanClipRequest, +): Promise { + const response = await apiClient.post(`/templates/${templateId}/editor/clips`, data) + return response.data +} + +/** 更新片段 */ +export async function updateEditPlanClip( + templateId: string, + clipId: string, + data: UpdateEditPlanClipRequest, +): Promise { + const response = await apiClient.put( + `/templates/${templateId}/editor/clips/${clipId}`, + data, + ) + return response.data +} + +/** 删除片段 */ +export async function deleteEditPlanClip(templateId: string, clipId: string): Promise { + await apiClient.delete(`/templates/${templateId}/editor/clips/${clipId}`) +} + +/** 片段重排序(拖拽排序后一次性提交) */ +export async function reorderEditPlanClips( + templateId: string, + items: ClipReorderItem[], +): Promise { + const response = await apiClient.post( + `/templates/${templateId}/editor/clips/reorder`, + { items }, + ) + return response.data +} + +/** 批量删除片段 */ +export async function batchDeleteEditPlanClips( + templateId: string, + clipIds: string[], +): Promise { + const response = await apiClient.post( + `/templates/${templateId}/editor/clips/batch-delete`, + { clip_ids: clipIds }, + ) + return response.data +} + +/** 从素材批量创建片段(追加到时间线末尾) */ +export async function createClipsFromAssets( + templateId: string, + assetIds: string[], + clipType = "main", +): Promise { + const response = await apiClient.post( + `/templates/${templateId}/editor/clips/from-assets`, + { asset_ids: assetIds, clip_type: clipType }, + ) + return response.data +} diff --git a/apps/web/src/api/template-editor/constants.ts b/apps/web/src/api/template-editor/constants.ts new file mode 100644 index 000000000..bce60cd0f --- /dev/null +++ b/apps/web/src/api/template-editor/constants.ts @@ -0,0 +1,66 @@ +/** + * 模板编辑器常量 + */ +import type { TransitionEffect, EditPlanStatus } from "./types" + +/** 转场效果选项(14 种预设) */ +export const TRANSITION_OPTIONS: { + value: TransitionEffect["type"] + label: string + icon: string +}[] = [ + { value: "none", label: "无转场", icon: "⊘" }, + { value: "cut", label: "硬切", icon: "✂" }, + { value: "fade", label: "淡入淡出", icon: "◐" }, + { value: "dissolve", label: "溶解", icon: "◈" }, + { value: "zoom", label: "缩放", icon: "⊕" }, + { value: "slide_left", label: "左滑", icon: "←" }, + { value: "slide_right", label: "右滑", icon: "→" }, + { value: "slide_up", label: "上滑", icon: "↑" }, + { value: "slide_down", label: "下滑", icon: "↓" }, + { value: "wipe_left", label: "左擦除", icon: "▸|" }, + { value: "wipe_right", label: "右擦除", icon: "|◂" }, + { value: "wipe_up", label: "上擦除", icon: "▴̄" }, + { value: "wipe_down", label: "下擦除", icon: "▾̄" }, + { value: "circlecrop", label: "圆形裁切", icon: "●" }, + { value: "rectcrop", label: "矩形裁切", icon: "■" }, +] + +/** 素材类型标签 */ +export const MATERIAL_TYPE_LABELS: Record = { + video: "视频", + image: "图片", + audio: "音频", + voiceover: "配音", +} + +/** 素材类型图标 */ +export const MATERIAL_TYPE_ICONS: Record = { + video: "🎬", + image: "🖼️", + audio: "🎵", + voiceover: "🎙️", +} + +/** 计划状态标签 */ +export const PLAN_STATUS_LABELS: Record = { + draft: "草稿", + editing: "编辑中", + rendering: "渲染中", + completed: "已完成", + failed: "失败", + cancelled: "已取消", +} + +/** 质量分筛选选项 */ +export const QUALITY_OPTIONS: { + value: string + label: string + min?: number + max?: number +}[] = [ + { value: "all", label: "全部质量" }, + { value: "high", label: "高质量 (80-100)", min: 80, max: 100 }, + { value: "medium", label: "中质量 (50-79)", min: 50, max: 79 }, + { value: "low", label: "低质量 (0-49)", min: 0, max: 49 }, +] diff --git a/apps/web/src/api/template-editor/editPlans.ts b/apps/web/src/api/template-editor/editPlans.ts new file mode 100644 index 000000000..c4d0785f1 --- /dev/null +++ b/apps/web/src/api/template-editor/editPlans.ts @@ -0,0 +1,91 @@ +/** + * 模板草稿 CRUD + 生成相关 API + */ +import apiClient from "../client" +import type { + EditPlan, + EditPlanListParams, + EditPlanListResponse, + CreateEditPlanRequest, + UpdateEditPlanRequest, + GenerateResponse, + GenerationStatusResponse, + EditPlanGeneration, + GeneratedVideo, + CopyEditPlanRequest, +} from "./types" + +/** 获取模板草稿列表(支持分页和筛选) */ +export async function getEditPlans(params?: EditPlanListParams): Promise { + const response = await apiClient.get("/templates/drafts", { + params, + }) + return response.data +} + +/** 获取单个模板草稿 */ +export async function getEditPlan(templateId: string): Promise { + const response = await apiClient.get(`/templates/${templateId}/editor`) + return response.data +} + +/** 创建模板草稿 */ +export async function createEditPlan(data: CreateEditPlanRequest): Promise { + const response = await apiClient.post("/templates/drafts", data) + return response.data +} + +/** 更新模板草稿 */ +export async function updateEditPlan( + templateId: string, + data: UpdateEditPlanRequest, +): Promise { + const response = await apiClient.put(`/templates/${templateId}/editor`, data) + return response.data +} + +/** 删除模板草稿 */ +export async function deleteEditPlan(templateId: string): Promise { + await apiClient.delete(`/templates/${templateId}/editor`) +} + +/** 触发生成 */ +export async function generateEditPlan(templateId: string): Promise { + const response = await apiClient.post(`/templates/${templateId}/editor/generate`) + return response.data +} + +/** 获取生成状态(轮询用) */ +export async function getGenerationStatus(templateId: string): Promise { + const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`) + return response.data +} + +/** 获取模板草稿关联的生成记录 */ +export async function getEditPlanGenerations(templateId: string): Promise { + const response = await apiClient.get(`/templates/${templateId}/editor/generations`) + return response.data.items || [] +} + +/** 获取生成任务的视频结果列表 */ +export async function getGenerationTaskResults(taskId: string): Promise { + const response = await apiClient.get(`/generation/tasks/${taskId}/results`) + return response.data.items || response.data || [] +} + +/** 取消生成任务 */ +export async function cancelGeneration(templateId: string): Promise { + await apiClient.post(`/templates/${templateId}/editor/cancel`) +} + +/** 复制模板草稿(含所有片段配置) */ +export async function copyEditPlan( + templateId: string, + data?: CopyEditPlanRequest, +): Promise { + const response = await apiClient.post( + `/templates/${templateId}/editor/copy`, + data || {}, + ) + return response.data +} diff --git a/apps/web/src/api/template-editor/index.ts b/apps/web/src/api/template-editor/index.ts new file mode 100644 index 000000000..751f9024b --- /dev/null +++ b/apps/web/src/api/template-editor/index.ts @@ -0,0 +1,88 @@ +/** + * 模板编辑器 API — 按模块拆分后的统一入口 + * 保持与原 template-editor.ts 相同的导出结构,向后兼容 + */ + +// 类型 +export type { + EditPlanStatus, + TitleConfig, + SubtitleConfig, + BgmConfig, + SegmentTtsConfig, + SegmentTrimConfig, + SegmentTransitionConfig, + EditPlanSegment, + EditPlanConfig, + EditPlan, + CreateEditPlanRequest, + UpdateEditPlanRequest, + EditPlanListParams, + EditPlanListResponse, + GenerateResponse, + EditPlanGeneration, + ClipStatusItem, + GenerationStatusResponse, + GeneratedVideo, + AIRecommendRequest, + AIRecommendClipItem, + AIRecommendResponse, + GenerateCoverRequest, + GenerateCoverResponse, + CoverResult, + EditPlanClipStatus, + EditPlanClip, + CreateEditPlanClipRequest, + UpdateEditPlanClipRequest, + EditPlanClipListResponse, + EditPlanClipListParams, + ClipReorderItem, + ClipReorderResponse, + ClipBatchDeleteResponse, + ClipsFromAssetsResponse, + CopyEditPlanRequest, + TransitionEffect, + MediaAsset, +} from "./types" + +// 常量 +export { + TRANSITION_OPTIONS, + MATERIAL_TYPE_LABELS, + MATERIAL_TYPE_ICONS, + PLAN_STATUS_LABELS, + QUALITY_OPTIONS, +} from "./constants" + +// 模板草稿 CRUD + 生成 +export { + getEditPlans, + getEditPlan, + createEditPlan, + updateEditPlan, + deleteEditPlan, + generateEditPlan, + getGenerationStatus, + getEditPlanGenerations, + getGenerationTaskResults, + cancelGeneration, + copyEditPlan, +} from "./editPlans" + +// 片段 CRUD + 批量操作 +export { + getEditPlanClips, + getEditPlanClip, + createEditPlanClip, + updateEditPlanClip, + deleteEditPlanClip, + reorderEditPlanClips, + batchDeleteEditPlanClips, + createClipsFromAssets, +} from "./clips" + +// AI 推荐 + 封面生成 +export { aiRecommendClips, generateCover } from "./aiFeatures" + +// 素材库 +export { getMediaAssets, getMediaAsset } from "./mediaAssets" diff --git a/apps/web/src/api/template-editor/mediaAssets.ts b/apps/web/src/api/template-editor/mediaAssets.ts new file mode 100644 index 000000000..9e13b7680 --- /dev/null +++ b/apps/web/src/api/template-editor/mediaAssets.ts @@ -0,0 +1,44 @@ +/** + * 素材库 API(对接 /assets 接口,映射为 MediaAsset 类型) + */ +import apiClient from "../client" +import type { AssetItem } from "../assets" +import type { MediaAsset } from "./types" + +const inferMediaType = (mimeType: string): "video" | "image" | "audio" => { + if (mimeType.startsWith("video/")) return "video" + if (mimeType.startsWith("image/")) return "image" + return "audio" +} + +const mapAssetToMediaAsset = (asset: AssetItem): MediaAsset => { + const metaDuration = + typeof asset.metadata?.duration === "number" ? asset.metadata.duration : undefined + return { + id: asset.id, + name: asset.name, + type: inferMediaType(asset.mime_type || ""), + thumbnail_url: asset.thumbnail_url, + duration: asset.duration ?? metaDuration, + size: asset.file_size ?? undefined, + tags: [], + created_at: asset.created_at ?? "", + quality_score: asset.quality_score ?? undefined, + classification_status: asset.classification_status ?? undefined, + } +} + +/** 获取素材库列表 */ +export async function getMediaAssets(libraryId?: string): Promise { + const response = await apiClient.get("/assets", { + params: libraryId ? { library_id: libraryId } : undefined, + }) + const items: AssetItem[] = response.data.items || [] + return items.map(mapAssetToMediaAsset) +} + +/** 获取单个素材 */ +export async function getMediaAsset(id: string): Promise { + const response = await apiClient.get(`/assets/${id}`) + return mapAssetToMediaAsset(response.data) +} diff --git a/apps/web/src/api/template-editor/types.ts b/apps/web/src/api/template-editor/types.ts new file mode 100644 index 000000000..b9bc8bf89 --- /dev/null +++ b/apps/web/src/api/template-editor/types.ts @@ -0,0 +1,440 @@ +/** + * 模板编辑器 API 类型定义 + * 字段名严格匹配后端 API 响应 + */ +import type { + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, + CoverConfig, +} from "@/pages/editing-planner/types" + +/* ── 模板草稿状态 ── */ + +export type EditPlanStatus = + "draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled" + +/* ── 配置相关类型 ── */ + +/** 标题配置(对齐后端 title_config) */ +export interface TitleConfig { + ai_auto_select: boolean + content: string + font_preset: string + font_color: string + font_size: number + position: string +} + +/** 字幕配置 */ +export interface SubtitleConfig { + enabled: boolean + position: string + font: string + color: string + size: number + animation: string +} + +/** BGM 配置 */ +export interface BgmConfig { + enabled: boolean + music_id: string +} + +/** 片段 TTS 配置 */ +export interface SegmentTtsConfig { + mode: string + text: string + voice_id: string + speed: number + pitch: number + volume: number + subtitle_sync: boolean +} + +/** 片段裁剪配置 */ +export interface SegmentTrimConfig { + start_time: number + end_time: number +} + +/** 片段转场配置 */ +export interface SegmentTransitionConfig { + type: string + duration: number +} + +/** 模板草稿中的单个片段 */ +export interface EditPlanSegment { + segment_order: number + duration_min: number + duration_max: number + material_type: string + transition?: SegmentTransitionConfig + playback_speed?: number + tts_config?: SegmentTtsConfig + trim_config?: SegmentTrimConfig +} + +/** 模板草稿 config 完整类型 */ +export interface EditPlanConfig { + title_config?: TitleConfig + subtitle_config?: SubtitleConfig + bgm_config?: BgmConfig + estimated_duration?: number + segments?: EditPlanSegment[] + watermark_config?: WatermarkConfig + intro_outro_config?: IntroOutroConfig + pip_config?: PipConfig + filter_config?: FilterConfig + green_screen_config?: ChromaKeyConfig + sticker_config?: StickerConfig + cover_config?: CoverConfig + /** 前端扩展:关联的素材 ID 列表 */ + asset_ids?: string[] + /** 配音 ID */ + voice_id?: string + /** 克隆音色档案 ID */ + voice_clone_profile_id?: string + /** 自定义配音音频 URL */ + custom_audio_url?: string + /** 自定义配音文本 */ + custom_text?: string + /** 视频比例 */ + ratio?: string + /** 视频风格 */ + style?: string + /** 目标时长(秒) */ + duration?: number + /** 是否自动生成字幕 */ + auto_subtitles?: boolean + /** 是否启用 BGM */ + bgm?: boolean + /** 生成数量 */ + generate_count?: number + /** 素材模式 */ + material_mode?: string +} + +/* ── 模板草稿主体 ── */ + +/** 模板草稿(后端响应) */ +export interface EditPlan { + id: string + template_id: string + name: string + status: EditPlanStatus + total_duration: number + /** 生成视频数量 */ + result_count: number + config: EditPlanConfig + created_at: string + updated_at: string +} + +/** 创建模板草稿请求 */ +export interface CreateEditPlanRequest { + template_id: string + name: string + config?: EditPlanConfig + total_duration?: number + /** 来源模板草稿 ID */ + source_edit_plan_id?: string +} + +/** 更新模板草稿请求 */ +export interface UpdateEditPlanRequest { + name?: string + config?: EditPlanConfig + total_duration?: number + status?: EditPlanStatus +} + +/** 模板草稿列表查询参数 */ +export interface EditPlanListParams { + page?: number + page_size?: number + template_id?: string + status?: string +} + +/** 模板草稿列表分页响应 */ +export interface EditPlanListResponse { + items: EditPlan[] + total: number + page: number + page_size: number +} + +/* ── 生成相关 ── */ + +/** 生成响应 */ +export interface GenerateResponse { + plan_id: string + plan_status: EditPlanStatus + generation_task_id: string + clip_count: number +} + +/** 模板草稿关联的生成记录 */ +export interface EditPlanGeneration { + id: string + source_edit_plan_id: string + template_id: string + asset_ids: string[] + status: EditPlanStatus + progress: number + result_count: number + error_message: string + error_info: Record + logs: Array> + retry_count: number + created_at?: string + updated_at?: string +} + +/** 片段生成状态 */ +export interface ClipStatusItem { + clip_id: string + clip_type: string + order: number + status: string + asset_id?: string + text_content?: string + duration?: number + error_message?: string +} + +/** 生成状态轮询响应 */ +export interface GenerationStatusResponse { + plan_id: string + plan_status: EditPlanStatus + generation_task_id?: string + error_message?: string + clips: ClipStatusItem[] + error?: string + message?: string +} + +/** 生成视频详情 */ +export interface GeneratedVideo { + id: string + project_id?: string + generation_task_id?: string + name: string + file_url: string + file_size?: number + duration?: number + thumbnail_url?: string + width?: number + height?: number + fps?: number + status: string + review_status?: string + download_url?: string + created_at?: string + updated_at?: string +} + +/* ── AI 推荐 & 封面生成 ── */ + +/** AI 推荐请求 */ +export interface AIRecommendRequest { + asset_ids: string[] + editing_mode?: string + target_duration?: number +} + +/** AI 推荐单个片段 */ +export interface AIRecommendClipItem { + clip_type: string + order: number + text_content: string + duration: number + transition_effect: string + asset_id: string + start_time: number + config: EditPlanConfig +} + +/** AI 推荐响应 */ +export interface AIRecommendResponse { + plan_id: string + clips: AIRecommendClipItem[] + config: EditPlanConfig + total_duration: number + confidence: number +} + +/** AI 封面生成请求 */ +export interface GenerateCoverRequest { + asset_ids: string[] + cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate" + frame_time?: number +} + +/** AI 封面生成响应 */ +export interface GenerateCoverResponse { + plan_id: string + cover: CoverResult +} + +/** 封面生成结果 */ +export interface CoverResult { + scheme?: string + asset_id?: string + frame_time?: number + thumbnail_url?: string +} + +/* ── 片段 CRUD 相关 ── */ + +/** 片段状态 */ +export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed" + +/** 剪辑片段(后端响应) */ +export interface EditPlanClip { + id: string + plan_id: string + clip_type: string + order: number + asset_id: string + text_content: string + start_time: number + duration: number + transition_effect: string + transition_duration: number + playback_speed: number + status: EditPlanClipStatus + config: Record + created_at?: string + updated_at?: string +} + +/** 创建片段请求 */ +export interface CreateEditPlanClipRequest { + clip_type: string + order: number + asset_id?: string + text_content?: string + start_time?: number + duration?: number + transition_effect?: string + transition_duration?: number + playback_speed?: number + config?: Record +} + +/** 更新片段请求 */ +export interface UpdateEditPlanClipRequest { + clip_type?: string + order?: number + asset_id?: string + text_content?: string + start_time?: number + duration?: number + transition_effect?: string + transition_duration?: number + playback_speed?: number + config?: Record +} + +/** 片段列表响应 */ +export interface EditPlanClipListResponse { + items: EditPlanClip[] + total: number +} + +/** 片段列表查询参数 */ +export interface EditPlanClipListParams { + status?: string + skip?: number + limit?: number +} + +/* ── 片段批量操作 ── */ + +/** 重排序条目 */ +export interface ClipReorderItem { + clip_id: string + new_order: number +} + +/** 重排序响应 */ +export interface ClipReorderResponse { + success: boolean + updated_count: number + message: string +} + +/** 批量删除响应 */ +export interface ClipBatchDeleteResponse { + success: boolean + deleted_count: number + message: string +} + +/** 从素材批量创建响应 */ +export interface ClipsFromAssetsResponse { + success: boolean + created_count: number + message: string + clip_ids: string[] +} + +/* ── 复制计划 ── */ + +/** 复制计划请求 */ +export interface CopyEditPlanRequest { + name?: string + project_id?: string +} + +/* ── 前端 UI 类型 ── */ + +/** 转场效果(14 种预设) */ +export interface TransitionEffect { + type: + | "none" + | "cut" + | "fade" + | "dissolve" + | "zoom" + | "slide_left" + | "slide_right" + | "slide_up" + | "slide_down" + | "wipe_left" + | "wipe_right" + | "wipe_up" + | "wipe_down" + | "circlecrop" + | "rectcrop" + duration: number + /** 播放速度倍率 */ + playback_speed?: number +} + +/** 素材库资产(UI 层类型) */ +export interface MediaAsset { + id: string + name: string + type: "video" | "image" | "audio" + /** 缩略图 URL */ + thumbnail_url?: string + /** 时长(秒),仅 video/audio */ + duration?: number + /** 文件大小(字节) */ + size?: number + /** 标签 */ + tags: string[] + created_at: string + /** 质量分 0-100 */ + quality_score?: number + /** 分类状态 */ + classification_status?: "pending" | "processing" | "completed" | "failed" +} diff --git a/apps/web/src/api/templates.ts b/apps/web/src/api/templates.ts deleted file mode 100644 index a7a5587f0..000000000 --- a/apps/web/src/api/templates.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * 模板相关 API - * 对接后端模板管理接口: - * - GET /api/v1/templates — 模板列表(分页/筛选) - * - GET /api/v1/templates/{id} — 模板详情 - * - POST /api/v1/templates/{id}/copy — 复制模板 - * - POST /api/v1/templates/{id}/generate — 从模板生成 - * - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏 - */ -import apiClient from "./client" -import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editing-planner" -import type { EditPlanConfig } from "./template-editor" - -/* ──────────── 类型定义 ──────────── */ - -/** 模板条目(后端 TemplateResponse) */ -export interface TemplateItem { - id: string - user_id?: string - name: string - description?: string - mode?: string - category: string - tags?: string[] - /** 预估时长(后端字段名 estimated_duration) */ - estimated_duration?: number - /** @deprecated 后端已改名为 estimated_duration,保留兼容 */ - target_duration?: number - clip_count?: number - /** 使用次数 */ - usage_count?: number - thumbnail_url?: string - preview_url?: string - is_active?: boolean - is_favorite?: boolean - /** 素材规则(片段配置) */ - segments?: TemplateSegment[] - /** 字幕样式 */ - subtitle_config?: SubtitleConfig - /** BGM 配置 */ - bgm_config?: BgmConfig - /** 标题配置 */ - title_config?: TitleConfig - /** 视频比例 */ - aspect_ratio?: string - created_at?: string - updated_at?: string -} - -/** 模板片段(素材规则) */ -export interface TemplateSegment { - id?: string - segment_order: number - duration_min: number - duration_max: number - material_type: string | null - description?: string -} - -/** 模板列表查询参数 */ -export interface TemplateListParams { - page?: number - page_size?: number - category?: string - tags?: string - keyword?: string - /** 时长筛选(秒):short < 30, medium 30-120, long > 120 */ - duration_range?: "short" | "medium" | "long" -} - -/** 模板列表分页响应 */ -export interface TemplateListResponse { - items: TemplateItem[] - total: number - page: number - page_size: number -} - -/** 从模板生成请求 */ -export interface GenerateFromTemplateRequest { - asset_ids?: string[] - name?: string - config?: EditPlanConfig -} - -/** 从模板生成响应 */ -export interface GenerateFromTemplateResponse { - plan_id: string - template_id: string - status: string - name: string -} - -/** 复制模板响应 */ -export interface CopyTemplateResponse { - id: string - name: string - source_template_id: string -} - -/* ──────────── API 函数 ──────────── */ - -/** 获取模板列表(支持分页和筛选) */ -export const getTemplates = async (params?: TemplateListParams): Promise => { - const { data } = await apiClient.get("/templates", { - params, - }) - return data -} - -/** 获取模板列表(兼容旧接口,返回数组) */ -export const getTemplatesList = async (): Promise => { - const response = await apiClient.get("/templates") - return response.data.items || response.data || [] -} - -/** 获取单个模板详情 */ -export const getTemplate = async (templateId: string): Promise => { - const response = await apiClient.get(`/templates/${templateId}`) - return response.data -} - -/** 收藏 / 取消收藏模板 */ -export const toggleFavoriteTemplate = async ( - templateId: string, -): Promise<{ is_favorite: boolean }> => { - const response = await apiClient.post(`/templates/${templateId}/toggle-favorite`) - return response.data -} - -/** 复制模板(创建副本到我的模板) */ -export const copyTemplate = async (templateId: string): Promise => { - const response = await apiClient.post(`/templates/${templateId}/copy`) - return response.data -} - -/** 从模板生成 */ -export const generateFromTemplate = async ( - templateId: string, - data?: GenerateFromTemplateRequest, -): Promise => { - const response = await apiClient.post( - `/templates/${templateId}/generate`, - data, - ) - return response.data -} - -/* ──────────── 常量 ──────────── */ - -/** 模板分类选项 */ -export const TEMPLATE_CATEGORY_OPTIONS = [ - { value: "", label: "全部分类" }, - { value: "口播", label: "口播" }, - { value: "种草", label: "种草" }, - { value: "产品", label: "产品" }, - { value: "品牌", label: "品牌" }, - { value: "混剪", label: "混剪" }, - { value: "Vlog", label: "Vlog" }, -] - -/** 时长筛选选项 */ -export const TEMPLATE_DURATION_OPTIONS = [ - { value: "", label: "全部时长" }, - { value: "short", label: "30秒以内" }, - { value: "medium", label: "30秒-2分钟" }, - { value: "long", label: "2分钟以上" }, -] diff --git a/apps/web/src/api/templates/constants.ts b/apps/web/src/api/templates/constants.ts new file mode 100644 index 000000000..e0952e079 --- /dev/null +++ b/apps/web/src/api/templates/constants.ts @@ -0,0 +1,22 @@ +/** + * 模板相关常量 + */ + +/** 模板分类选项 */ +export const TEMPLATE_CATEGORY_OPTIONS = [ + { value: "", label: "全部分类" }, + { value: "口播", label: "口播" }, + { value: "种草", label: "种草" }, + { value: "产品", label: "产品" }, + { value: "品牌", label: "品牌" }, + { value: "混剪", label: "混剪" }, + { value: "Vlog", label: "Vlog" }, +] + +/** 时长筛选选项 */ +export const TEMPLATE_DURATION_OPTIONS = [ + { value: "", label: "全部时长" }, + { value: "short", label: "30秒以内" }, + { value: "medium", label: "30秒-2分钟" }, + { value: "long", label: "2分钟以上" }, +] diff --git a/apps/web/src/api/templates/index.ts b/apps/web/src/api/templates/index.ts new file mode 100644 index 000000000..bece1cbb4 --- /dev/null +++ b/apps/web/src/api/templates/index.ts @@ -0,0 +1,28 @@ +/** + * 模板相关 API — 目录化入口 + * 保持与原 templates.ts 相同导出,向后兼容 + */ + +// 类型 +export type { + TemplateItem, + TemplateSegment, + TemplateListParams, + TemplateListResponse, + GenerateFromTemplateRequest, + GenerateFromTemplateResponse, + CopyTemplateResponse, +} from "./types" + +// 常量 +export { TEMPLATE_CATEGORY_OPTIONS, TEMPLATE_DURATION_OPTIONS } from "./constants" + +// API 函数 +export { + getTemplates, + getTemplatesList, + getTemplate, + toggleFavoriteTemplate, + copyTemplate, + generateFromTemplate, +} from "./templates" diff --git a/apps/web/src/api/templates/templates.ts b/apps/web/src/api/templates/templates.ts new file mode 100644 index 000000000..d388f932a --- /dev/null +++ b/apps/web/src/api/templates/templates.ts @@ -0,0 +1,59 @@ +/** + * 模板相关 API 函数 + * 对接后端模板管理接口 + */ +import apiClient from "../client" +import type { + CopyTemplateResponse, + GenerateFromTemplateRequest, + GenerateFromTemplateResponse, + TemplateItem, + TemplateListParams, + TemplateListResponse, +} from "./types" + +/** 获取模板列表(支持分页和筛选) */ +export const getTemplates = async (params?: TemplateListParams): Promise => { + const { data } = await apiClient.get("/templates", { + params, + }) + return data +} + +/** 获取模板列表(兼容旧接口,返回数组) */ +export const getTemplatesList = async (): Promise => { + const response = await apiClient.get("/templates") + return response.data.items || response.data || [] +} + +/** 获取单个模板详情 */ +export const getTemplate = async (templateId: string): Promise => { + const response = await apiClient.get(`/templates/${templateId}`) + return response.data +} + +/** 收藏 / 取消收藏模板 */ +export const toggleFavoriteTemplate = async ( + templateId: string, +): Promise<{ is_favorite: boolean }> => { + const response = await apiClient.post(`/templates/${templateId}/toggle-favorite`) + return response.data +} + +/** 复制模板(创建副本到我的模板) */ +export const copyTemplate = async (templateId: string): Promise => { + const response = await apiClient.post(`/templates/${templateId}/copy`) + return response.data +} + +/** 从模板生成 */ +export const generateFromTemplate = async ( + templateId: string, + data?: GenerateFromTemplateRequest, +): Promise => { + const response = await apiClient.post( + `/templates/${templateId}/generate`, + data, + ) + return response.data +} diff --git a/apps/web/src/api/templates/types.ts b/apps/web/src/api/templates/types.ts new file mode 100644 index 000000000..8b0575dd7 --- /dev/null +++ b/apps/web/src/api/templates/types.ts @@ -0,0 +1,90 @@ +/** + * 模板相关类型定义 + */ +import type { TitleConfig, SubtitleConfig, BgmConfig } from "../editing-planner" +import type { EditPlanConfig } from "../template-editor" + +/** 模板条目(后端 TemplateResponse) */ +export interface TemplateItem { + id: string + user_id?: string + name: string + description?: string + mode?: string + category: string + tags?: string[] + /** 预估时长(后端字段名 estimated_duration) */ + estimated_duration?: number + /** @deprecated 后端已改名为 estimated_duration,保留兼容 */ + target_duration?: number + clip_count?: number + /** 使用次数 */ + usage_count?: number + thumbnail_url?: string + preview_url?: string + is_active?: boolean + is_favorite?: boolean + /** 素材规则(片段配置) */ + segments?: TemplateSegment[] + /** 字幕样式 */ + subtitle_config?: SubtitleConfig + /** BGM 配置 */ + bgm_config?: BgmConfig + /** 标题配置 */ + title_config?: TitleConfig + /** 视频比例 */ + aspect_ratio?: string + created_at?: string + updated_at?: string +} + +/** 模板片段(素材规则) */ +export interface TemplateSegment { + id?: string + segment_order: number + duration_min: number + duration_max: number + material_type: string | null + description?: string +} + +/** 模板列表查询参数 */ +export interface TemplateListParams { + page?: number + page_size?: number + category?: string + tags?: string + keyword?: string + /** 时长筛选(秒):short < 30, medium 30-120, long > 120 */ + duration_range?: "short" | "medium" | "long" +} + +/** 模板列表分页响应 */ +export interface TemplateListResponse { + items: TemplateItem[] + total: number + page: number + page_size: number +} + +/** 从模板生成请求 */ +export interface GenerateFromTemplateRequest { + asset_ids?: string[] + name?: string + config?: EditPlanConfig +} + +/** 从模板生成响应 */ +export interface GenerateFromTemplateResponse { + plan_id: string + template_id: string + status: string + name: string +} + +/** 复制模板响应 */ +export interface CopyTemplateResponse { + id: string + name: string + source_template_id: string +} diff --git a/apps/web/src/api/titles/index.ts b/apps/web/src/api/titles/index.ts new file mode 100644 index 000000000..9900272d0 --- /dev/null +++ b/apps/web/src/api/titles/index.ts @@ -0,0 +1,19 @@ +/** + * 标题相关 API — 目录化入口 + * 保持与原 titles.ts 相同导出,向后兼容 + */ + +// 类型 +export type { + TitleItem, + BackendTitleResponse, + BackendCreateTitleRequest, + BackendUpdateTitleRequest, + CreateTitleRequest, +} from "./types" + +// 工具函数 +export { toTitleItem } from "./utils" + +// API 函数 +export { getTitles, createTitle, updateTitle, deleteTitle, batchImportTitles } from "./titles" diff --git a/apps/web/src/api/titles.ts b/apps/web/src/api/titles/titles.ts similarity index 59% rename from apps/web/src/api/titles.ts rename to apps/web/src/api/titles/titles.ts index 23ba5ea17..b4174f820 100644 --- a/apps/web/src/api/titles.ts +++ b/apps/web/src/api/titles/titles.ts @@ -1,70 +1,17 @@ /** - * 标题相关 API + * 标题相关 API 函数 * Phase 1 新增:全局标题库 * 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示 */ -import apiClient from "./client" - -/** 标题条目(前端展示用) */ -export interface TitleItem { - id: string - content: string - category?: string - source?: string - word_count?: number - is_favorite?: boolean - created_at?: string - updated_at?: string -} - -/** 后端标题响应格式 */ -interface BackendTitleResponse { - id: string - user_id: string - name: string - text: string - category: string - description: string - tags: string[] - usage_count: number - is_active: boolean - created_at: string - updated_at: string -} - -/** 后端创建标题请求格式 */ -interface BackendCreateTitleRequest { - name: string - text: string - category: string - description?: string - tags?: string[] -} - -/** 后端更新标题请求格式 */ -interface BackendUpdateTitleRequest { - name?: string - text?: string - category?: string - description?: string - tags?: string[] -} - -/** 将后端响应映射为前端 TitleItem */ -const toTitleItem = (item: BackendTitleResponse): TitleItem => ({ - id: item.id, - content: item.text, - category: item.category, - word_count: item.text?.length || 0, - created_at: item.created_at, - updated_at: item.updated_at, -}) - -/** 创建标题请求(前端接口,保持向后兼容) */ -export interface CreateTitleRequest { - content: string - category?: string -} +import apiClient from "../client" +import type { + BackendCreateTitleRequest, + BackendTitleResponse, + BackendUpdateTitleRequest, + CreateTitleRequest, + TitleItem, +} from "./types" +import { toTitleItem } from "./utils" /** 获取当前用户的所有标题 */ export const getTitles = async (): Promise => { diff --git a/apps/web/src/api/titles/types.ts b/apps/web/src/api/titles/types.ts new file mode 100644 index 000000000..37dc715d9 --- /dev/null +++ b/apps/web/src/api/titles/types.ts @@ -0,0 +1,54 @@ +/** + * 标题相关类型定义 + */ + +/** 标题条目(前端展示用) */ +export interface TitleItem { + id: string + content: string + category?: string + source?: string + word_count?: number + is_favorite?: boolean + created_at?: string + updated_at?: string +} + +/** 后端标题响应格式 */ +export interface BackendTitleResponse { + id: string + user_id: string + name: string + text: string + category: string + description: string + tags: string[] + usage_count: number + is_active: boolean + created_at: string + updated_at: string +} + +/** 后端创建标题请求格式 */ +export interface BackendCreateTitleRequest { + name: string + text: string + category: string + description?: string + tags?: string[] +} + +/** 后端更新标题请求格式 */ +export interface BackendUpdateTitleRequest { + name?: string + text?: string + category?: string + description?: string + tags?: string[] +} + +/** 创建标题请求(前端接口,保持向后兼容) */ +export interface CreateTitleRequest { + content: string + category?: string +} diff --git a/apps/web/src/api/titles/utils.ts b/apps/web/src/api/titles/utils.ts new file mode 100644 index 000000000..64b88fbfd --- /dev/null +++ b/apps/web/src/api/titles/utils.ts @@ -0,0 +1,14 @@ +/** + * 标题数据转换工具函数 + */ +import type { BackendTitleResponse, TitleItem } from "./types" + +/** 将后端响应映射为前端 TitleItem */ +export const toTitleItem = (item: BackendTitleResponse): TitleItem => ({ + id: item.id, + content: item.text, + category: item.category, + word_count: item.text?.length || 0, + created_at: item.created_at, + updated_at: item.updated_at, +}) diff --git a/apps/web/src/api/tts.ts b/apps/web/src/api/tts.ts deleted file mode 100644 index 5e8d854fd..000000000 --- a/apps/web/src/api/tts.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * TTS 语音合成 API - * 对接后端 /api/v1/tts/* 端点 - * - * 任务 3.14 新增 - */ -import apiClient from "./client" - -/* ── 类型定义 ──────────────────────────────────── */ - -/** TTS 元数据(合成时附带的扩展信息) */ -export interface TTSMetadata { - /** 语音时长(秒) */ - duration?: number - /** 采样率(Hz) */ - sample_rate?: number - /** 语言 */ - language?: string - /** 其他扩展字段 */ - [key: string]: unknown -} - -/** TTS 合成请求参数 */ -export interface TTSSynthesizeRequest { - text: string - voice_id?: string - output_name?: string - language?: string - speed?: number - voice_model?: string - voice_clone_profile_id?: string - format?: string - metadata?: TTSMetadata -} - -/** TTS 合成创建响应 */ -export interface TTSSynthesizeResponse { - job_id: string - status: string - message: string -} - -/** TTS 任务详情 */ -export interface TTSJob { - id: string - user_id: string - project_id: string | null - text: string - voice_id: string | null - voice_model: string | null - voice_clone_profile_id: string | null - language: string - speed: number - output_name: string | null - output_audio_url: string | null - output_format: string - duration_seconds: number | null - file_size_bytes: number | null - sample_rate: number | null - status: string - error_message: string | null - retry_count: number - max_retries: number - metadata_: TTSMetadata | null - created_at: string - updated_at: string -} - -/** TTS 任务状态(轻量轮询用) */ -export interface TTSJobStatus { - id: string - status: string - output_audio_url: string | null - error_message: string | null - duration_seconds: number | null - retry_count: number -} - -/** TTS 任务列表响应 */ -export interface TTSJobListResponse { - items: TTSJob[] - total: number - skip: number - limit: number -} - -/** TTS 任务列表查询参数 */ -export interface TTSJobListParams { - status?: string - skip?: number - limit?: number -} - -/* ── API 函数 ──────────────────────────────────── */ - -/** 创建 TTS 合成任务 */ -export const synthesizeSpeech = async ( - data: TTSSynthesizeRequest, -): Promise => { - const response = await apiClient.post("/tts/synthesize", data) - return response.data -} - -/** 获取 TTS 任务详情 */ -export const getTTSJob = async (jobId: string): Promise => { - const response = await apiClient.get(`/tts/jobs/${jobId}`) - return response.data -} - -/** 获取 TTS 任务状态(轻量轮询) */ -export const getTTSJobStatus = async (jobId: string): Promise => { - const response = await apiClient.get(`/tts/jobs/${jobId}/status`) - return response.data -} - -/** 获取 TTS 任务列表 */ -export const getTTSJobs = async (params?: TTSJobListParams): Promise => { - const searchParams = new URLSearchParams() - if (params?.status) searchParams.set("status", params.status) - if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)) - if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)) - const qs = searchParams.toString() - const response = await apiClient.get(`/tts/jobs${qs ? `?${qs}` : ""}`) - return response.data -} - -/** 存为素材请求参数 */ -export interface SaveTtsToLibraryRequest { - name?: string - tag_ids?: string[] -} - -/** 将 TTS 合成结果保存到配音库 */ -export const saveTtsToLibrary = async ( - jobId: string, - data?: SaveTtsToLibraryRequest, -): Promise => { - await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {}) -} - -/** 删除 TTS 任务 */ -export const deleteTTSJob = async (jobId: string): Promise => { - await apiClient.delete(`/tts/jobs/${jobId}`) -} - -/* ── 音色列表 ──────────────────────────────────── */ - -/** TTS 音色 */ -export interface TTSVoice { - id: string - name: string - /** 音色分类标签:male/female/young/service/news/emotion */ - category?: string - /** 语言 */ - language?: string - /** 试听 URL */ - preview_url?: string - /** 描述 */ - description?: string -} - -/** 获取 TTS 音色列表 */ -export const getTtsVoices = async (): Promise => { - const response = await apiClient.get("/tts/voices") - return response.data -} - -/* ── TTS 试听 ──────────────────────────────────── */ - -/** TTS 试听请求参数 */ -export interface TTSPreviewRequest { - text: string - voice_id: string - speed?: number - pitch?: number -} - -/** TTS 试听响应 */ -export interface TTSPreviewResponse { - audio_url: string - duration?: number -} - -/** TTS 试听 */ -export const previewTts = async (data: TTSPreviewRequest): Promise => { - const response = await apiClient.post("/tts/preview", data) - return response.data -} diff --git a/apps/web/src/api/tts/index.ts b/apps/web/src/api/tts/index.ts new file mode 100644 index 000000000..4cadefa41 --- /dev/null +++ b/apps/web/src/api/tts/index.ts @@ -0,0 +1,31 @@ +/** + * TTS 语音合成 API — 目录化入口 + * 保持与原 tts.ts 相同导出,向后兼容 + */ + +// 类型 +export type { + TTSMetadata, + TTSSynthesizeRequest, + TTSSynthesizeResponse, + TTSJob, + TTSJobStatus, + TTSJobListResponse, + TTSJobListParams, + SaveTtsToLibraryRequest, + TTSVoice, + TTSPreviewRequest, + TTSPreviewResponse, +} from "./types" + +// API 函数 +export { + synthesizeSpeech, + getTTSJob, + getTTSJobStatus, + getTTSJobs, + saveTtsToLibrary, + deleteTTSJob, + getTtsVoices, + previewTts, +} from "./jobs" diff --git a/apps/web/src/api/tts/jobs.ts b/apps/web/src/api/tts/jobs.ts new file mode 100644 index 000000000..69a5860ce --- /dev/null +++ b/apps/web/src/api/tts/jobs.ts @@ -0,0 +1,72 @@ +/** + * TTS 语音合成 API 函数 + */ +import apiClient from "../client" +import type { + TTSSynthesizeRequest, + TTSSynthesizeResponse, + TTSJob, + TTSJobStatus, + TTSJobListResponse, + TTSJobListParams, + SaveTtsToLibraryRequest, + TTSVoice, + TTSPreviewRequest, + TTSPreviewResponse, +} from "./types" + +/** 创建 TTS 合成任务 */ +export const synthesizeSpeech = async ( + data: TTSSynthesizeRequest, +): Promise => { + const response = await apiClient.post("/tts/synthesize", data) + return response.data +} + +/** 获取 TTS 任务详情 */ +export const getTTSJob = async (jobId: string): Promise => { + const response = await apiClient.get(`/tts/jobs/${jobId}`) + return response.data +} + +/** 获取 TTS 任务状态(轻量轮询) */ +export const getTTSJobStatus = async (jobId: string): Promise => { + const response = await apiClient.get(`/tts/jobs/${jobId}/status`) + return response.data +} + +/** 获取 TTS 任务列表 */ +export const getTTSJobs = async (params?: TTSJobListParams): Promise => { + const searchParams = new URLSearchParams() + if (params?.status) searchParams.set("status", params.status) + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)) + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)) + const qs = searchParams.toString() + const response = await apiClient.get(`/tts/jobs${qs ? `?${qs}` : ""}`) + return response.data +} + +/** 将 TTS 合成结果保存到配音库 */ +export const saveTtsToLibrary = async ( + jobId: string, + data?: SaveTtsToLibraryRequest, +): Promise => { + await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {}) +} + +/** 删除 TTS 任务 */ +export const deleteTTSJob = async (jobId: string): Promise => { + await apiClient.delete(`/tts/jobs/${jobId}`) +} + +/** 获取 TTS 音色列表 */ +export const getTtsVoices = async (): Promise => { + const response = await apiClient.get("/tts/voices") + return response.data +} + +/** TTS 试听 */ +export const previewTts = async (data: TTSPreviewRequest): Promise => { + const response = await apiClient.post("/tts/preview", data) + return response.data +} diff --git a/apps/web/src/api/tts/types.ts b/apps/web/src/api/tts/types.ts new file mode 100644 index 000000000..39e1046d5 --- /dev/null +++ b/apps/web/src/api/tts/types.ts @@ -0,0 +1,112 @@ +/** + * TTS 语音合成类型定义 + */ + +/** TTS 元数据 */ +export interface TTSMetadata { + duration?: number + sample_rate?: number + language?: string + [key: string]: unknown +} + +/** TTS 合成请求参数 */ +export interface TTSSynthesizeRequest { + text: string + voice_id?: string + output_name?: string + language?: string + speed?: number + voice_model?: string + voice_clone_profile_id?: string + format?: string + metadata?: TTSMetadata +} + +/** TTS 合成创建响应 */ +export interface TTSSynthesizeResponse { + job_id: string + status: string + message: string +} + +/** TTS 任务详情 */ +export interface TTSJob { + id: string + user_id: string + project_id: string | null + text: string + voice_id: string | null + voice_model: string | null + voice_clone_profile_id: string | null + language: string + speed: number + output_name: string | null + output_audio_url: string | null + output_format: string + duration_seconds: number | null + file_size_bytes: number | null + sample_rate: number | null + status: string + error_message: string | null + retry_count: number + max_retries: number + metadata_: TTSMetadata | null + created_at: string + updated_at: string +} + +/** TTS 任务状态(轻量轮询用) */ +export interface TTSJobStatus { + id: string + status: string + output_audio_url: string | null + error_message: string | null + duration_seconds: number | null + retry_count: number +} + +/** TTS 任务列表响应 */ +export interface TTSJobListResponse { + items: TTSJob[] + total: number + skip: number + limit: number +} + +/** TTS 任务列表查询参数 */ +export interface TTSJobListParams { + status?: string + skip?: number + limit?: number +} + +/** 存为素材请求参数 */ +export interface SaveTtsToLibraryRequest { + name?: string + tag_ids?: string[] +} + +/** TTS 音色 */ +export interface TTSVoice { + id: string + name: string + category?: string + language?: string + preview_url?: string + description?: string +} + +/** TTS 试听请求参数 */ +export interface TTSPreviewRequest { + text: string + voice_id: string + speed?: number + pitch?: number +} + +/** TTS 试听响应 */ +export interface TTSPreviewResponse { + audio_url: string + duration?: number +} diff --git a/apps/web/src/api/voice-clone.ts b/apps/web/src/api/voice-clone.ts deleted file mode 100644 index 7054ab164..000000000 --- a/apps/web/src/api/voice-clone.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * 音色克隆 API - * 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05) - * 任务 3.15:新增 progress 字段用于进度展示 - */ -import apiClient from "./client" - -/* ── 前端兼容类型 ─────────────────────────────────────── */ - -/** 克隆音色状态(前端展示用) */ -export type VoiceCloneStatus = "ready" | "processing" | "failed" - -/** 克隆音色条目(前端展示用) */ -export interface VoiceClone { - id: string - name: string - description: string - duration_seconds: number - status: VoiceCloneStatus - /** 克隆进度 0-100,仅 processing 状态时有值 */ - progress: number - sample_url?: string - language: string - gender: string - error_message: string | null - created_at: string - updated_at: string -} - -/** 创建克隆请求(前端简化版) */ -export interface CreateVoiceCloneRequest { - name: string - audio_url: string - description?: string -} - -/* ── 后端 API 类型 ────────────────────────────────────── */ - -/** 音色克隆元数据(克隆时附带的扩展信息) */ -export interface VoiceCloneMetadata { - /** 语音时长(秒) */ - duration?: number - /** 采样率(Hz) */ - sample_rate?: number - /** 音色 ID(克隆完成后分配) */ - voice_id?: string - /** 其他扩展字段 */ - [key: string]: unknown -} - -/** 后端克隆档案响应 */ -export interface VoiceCloneProfile { - id: string - user_id: string - name: string - description: string - source_audio_url: string - voice_id: string | null - voice_model: string - language: string - gender: string - status: "pending" | "processing" | "ready" | "failed" - error_message: string | null - retry_count: number - max_retries: number - metadata_: VoiceCloneMetadata | null - created_at: string - updated_at: string -} - -/** 后端克隆列表响应 */ -export interface ListVoiceCloneResponse { - items: VoiceCloneProfile[] - total: number -} - -/** 后端克隆状态响应 */ -export interface VoiceCloneStatusResponse { - id: string - status: "pending" | "processing" | "ready" | "failed" - error_message: string | null - voice_id: string | null - retry_count: number -} - -/** 后端创建克隆请求(完整版) */ -export interface CreateVoiceCloneRequestFull { - name: string - description?: string - source_audio_url: string - voice_model?: string - language?: string - gender?: string - max_retries?: number - metadata_?: VoiceCloneMetadata -} - -/* ── 辅助函数 ─────────────────────────────────────────── */ - -/** - * 将后端 VoiceCloneProfile 转换为前端 VoiceClone - * 后端 status "pending" 映射为前端 "processing" - */ -export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({ - id: profile.id, - name: profile.name, - description: profile.description || "", - duration_seconds: 0, - status: profile.status === "pending" ? "processing" : profile.status, - progress: 0, - sample_url: profile.source_audio_url || undefined, - language: profile.language || "", - gender: profile.gender || "", - error_message: profile.error_message || null, - created_at: profile.created_at, - updated_at: profile.updated_at, -}) - -/** 格式化时长 */ -export const formatDuration = (seconds: number): string => { - const m = Math.floor(seconds / 60) - const s = seconds % 60 - return `${m}:${String(s).padStart(2, "0")}` -} - -/* ── 查询参数 ─────────────────────────────────────────── */ - -export interface VoiceCloneListParams { - status?: string - skip?: number - limit?: number -} - -/* ── API 函数 ─────────────────────────────────────────── */ - -/** 获取克隆音色列表(返回前端兼容数组) */ -export const getVoiceClones = async (params?: VoiceCloneListParams): Promise => { - const searchParams = new URLSearchParams() - if (params?.status) searchParams.set("status", params.status) - if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)) - if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)) - const qs = searchParams.toString() - const response = await apiClient.get(`/voice-clones${qs ? `?${qs}` : ""}`) - return response.data.items.map(toVoiceClone) -} - -/** 获取克隆音色列表(返回完整响应含 total) */ -export const getVoiceClonesWithTotal = async ( - params?: VoiceCloneListParams, -): Promise => { - const searchParams = new URLSearchParams() - if (params?.status) searchParams.set("status", params.status) - if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)) - if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)) - const qs = searchParams.toString() - const response = await apiClient.get(`/voice-clones${qs ? `?${qs}` : ""}`) - return response.data -} - -/** 获取单个克隆音色详情 */ -export const getVoiceCloneDetail = async (id: string): Promise => { - const response = await apiClient.get(`/voice-clones/${id}`) - return response.data -} - -/** 创建克隆音色 */ -export const createVoiceClone = async ( - data: CreateVoiceCloneRequest, -): Promise => { - const payload: CreateVoiceCloneRequestFull = { - name: data.name, - description: data.description, - source_audio_url: data.audio_url, - } - const response = await apiClient.post("/voice-clones", payload) - return response.data -} - -/** 删除克隆音色 */ -export const deleteVoiceClone = async (id: string): Promise => { - await apiClient.delete(`/voice-clones/${id}`) -} - -/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */ -export const updateVoiceClone = async ( - id: string, - data: Partial>, -): Promise => { - // 后端暂未提供更新端点,暂用详情接口模拟 - const response = await apiClient.get(`/voice-clones/${id}`) - return toVoiceClone({ - ...response.data, - ...data, - updated_at: new Date().toISOString(), - }) -} - -/** 获取克隆状态 */ -export const getVoiceCloneStatus = async (id: string): Promise => { - const response = await apiClient.get(`/voice-clones/${id}/status`) - return response.data -} - -/** 重试克隆 */ -export const retryVoiceClone = async (id: string): Promise => { - const response = await apiClient.post(`/voice-clones/${id}/retry`) - return response.data -} diff --git a/apps/web/src/api/voice-clone/clones.ts b/apps/web/src/api/voice-clone/clones.ts new file mode 100644 index 000000000..45714b5fd --- /dev/null +++ b/apps/web/src/api/voice-clone/clones.ts @@ -0,0 +1,87 @@ +/** + * 音色克隆 API 函数 + */ +import apiClient from "../client" +import { toVoiceClone } from "./utils" +import type { + VoiceClone, + VoiceCloneProfile, + CreateVoiceCloneRequest, + CreateVoiceCloneRequestFull, + VoiceCloneListParams, + ListVoiceCloneResponse, + VoiceCloneStatusResponse, +} from "./types" + +/** 获取克隆音色列表(返回前端兼容数组) */ +export const getVoiceClones = async (params?: VoiceCloneListParams): Promise => { + const searchParams = new URLSearchParams() + if (params?.status) searchParams.set("status", params.status) + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)) + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)) + const qs = searchParams.toString() + const response = await apiClient.get(`/voice-clones${qs ? `?${qs}` : ""}`) + return response.data.items.map(toVoiceClone) +} + +/** 获取克隆音色列表(返回完整响应含 total) */ +export const getVoiceClonesWithTotal = async ( + params?: VoiceCloneListParams, +): Promise => { + const searchParams = new URLSearchParams() + if (params?.status) searchParams.set("status", params.status) + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)) + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)) + const qs = searchParams.toString() + const response = await apiClient.get(`/voice-clones${qs ? `?${qs}` : ""}`) + return response.data +} + +/** 获取单个克隆音色详情 */ +export const getVoiceCloneDetail = async (id: string): Promise => { + const response = await apiClient.get(`/voice-clones/${id}`) + return response.data +} + +/** 创建克隆音色 */ +export const createVoiceClone = async ( + data: CreateVoiceCloneRequest, +): Promise => { + const payload: CreateVoiceCloneRequestFull = { + name: data.name, + description: data.description, + source_audio_url: data.audio_url, + } + const response = await apiClient.post("/voice-clones", payload) + return response.data +} + +/** 删除克隆音色 */ +export const deleteVoiceClone = async (id: string): Promise => { + await apiClient.delete(`/voice-clones/${id}`) +} + +/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */ +export const updateVoiceClone = async ( + id: string, + data: Partial>, +): Promise => { + const response = await apiClient.get(`/voice-clones/${id}`) + return toVoiceClone({ + ...response.data, + ...data, + updated_at: new Date().toISOString(), + }) +} + +/** 获取克隆状态 */ +export const getVoiceCloneStatus = async (id: string): Promise => { + const response = await apiClient.get(`/voice-clones/${id}/status`) + return response.data +} + +/** 重试克隆 */ +export const retryVoiceClone = async (id: string): Promise => { + const response = await apiClient.post(`/voice-clones/${id}/retry`) + return response.data +} diff --git a/apps/web/src/api/voice-clone/index.ts b/apps/web/src/api/voice-clone/index.ts new file mode 100644 index 000000000..cd5e1b23c --- /dev/null +++ b/apps/web/src/api/voice-clone/index.ts @@ -0,0 +1,32 @@ +/** + * 音色克隆 API — 目录化入口 + * 保持与原 voice-clone.ts 相同导出,向后兼容 + */ + +// 类型 +export type { + VoiceCloneStatus, + VoiceClone, + CreateVoiceCloneRequest, + VoiceCloneMetadata, + VoiceCloneProfile, + ListVoiceCloneResponse, + VoiceCloneStatusResponse, + CreateVoiceCloneRequestFull, + VoiceCloneListParams, +} from "./types" + +// 工具函数 +export { toVoiceClone, formatDuration } from "./utils" + +// API 函数 +export { + getVoiceClones, + getVoiceClonesWithTotal, + getVoiceCloneDetail, + createVoiceClone, + deleteVoiceClone, + updateVoiceClone, + getVoiceCloneStatus, + retryVoiceClone, +} from "./clones" diff --git a/apps/web/src/api/voice-clone/types.ts b/apps/web/src/api/voice-clone/types.ts new file mode 100644 index 000000000..56b780cfa --- /dev/null +++ b/apps/web/src/api/voice-clone/types.ts @@ -0,0 +1,92 @@ +/** + * 音色克隆类型定义 + */ + +/** 克隆音色状态(前端展示用) */ +export type VoiceCloneStatus = "ready" | "processing" | "failed" + +/** 克隆音色条目(前端展示用) */ +export interface VoiceClone { + id: string + name: string + description: string + duration_seconds: number + status: VoiceCloneStatus + /** 克隆进度 0-100,仅 processing 状态时有值 */ + progress: number + sample_url?: string + language: string + gender: string + error_message: string | null + created_at: string + updated_at: string +} + +/** 创建克隆请求(前端简化版) */ +export interface CreateVoiceCloneRequest { + name: string + audio_url: string + description?: string +} + +/** 音色克隆元数据 */ +export interface VoiceCloneMetadata { + duration?: number + sample_rate?: number + voice_id?: string + [key: string]: unknown +} + +/** 后端克隆档案响应 */ +export interface VoiceCloneProfile { + id: string + user_id: string + name: string + description: string + source_audio_url: string + voice_id: string | null + voice_model: string + language: string + gender: string + status: "pending" | "processing" | "ready" | "failed" + error_message: string | null + retry_count: number + max_retries: number + metadata_: VoiceCloneMetadata | null + created_at: string + updated_at: string +} + +/** 后端克隆列表响应 */ +export interface ListVoiceCloneResponse { + items: VoiceCloneProfile[] + total: number +} + +/** 后端克隆状态响应 */ +export interface VoiceCloneStatusResponse { + id: string + status: "pending" | "processing" | "ready" | "failed" + error_message: string | null + voice_id: string | null + retry_count: number +} + +/** 后端创建克隆请求(完整版) */ +export interface CreateVoiceCloneRequestFull { + name: string + description?: string + source_audio_url: string + voice_model?: string + language?: string + gender?: string + max_retries?: number + metadata_?: VoiceCloneMetadata +} + +/** 查询参数 */ +export interface VoiceCloneListParams { + status?: string + skip?: number + limit?: number +} diff --git a/apps/web/src/api/voice-clone/utils.ts b/apps/web/src/api/voice-clone/utils.ts new file mode 100644 index 000000000..43a42a5b1 --- /dev/null +++ b/apps/web/src/api/voice-clone/utils.ts @@ -0,0 +1,30 @@ +/** + * 音色克隆工具函数 + */ +import type { VoiceCloneProfile, VoiceClone } from "./types" + +/** + * 将后端 VoiceCloneProfile 转换为前端 VoiceClone + * 后端 status "pending" 映射为前端 "processing" + */ +export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({ + id: profile.id, + name: profile.name, + description: profile.description || "", + duration_seconds: 0, + status: profile.status === "pending" ? "processing" : profile.status, + progress: 0, + sample_url: profile.source_audio_url || undefined, + language: profile.language || "", + gender: profile.gender || "", + error_message: profile.error_message || null, + created_at: profile.created_at, + updated_at: profile.updated_at, +}) + +/** 格式化时长 */ +export const formatDuration = (seconds: number): string => { + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return `${m}:${String(s).padStart(2, "0")}` +} diff --git a/apps/web/src/api/voices/index.ts b/apps/web/src/api/voices/index.ts new file mode 100644 index 000000000..182aa7d31 --- /dev/null +++ b/apps/web/src/api/voices/index.ts @@ -0,0 +1,26 @@ +/** + * 配音相关 API — 目录化入口 + * 保持与原 voices.ts 相同导出,向后兼容 + */ + +// 类型 +export type { + UnifiedVoiceItem, + UnifiedVoiceListResponse, + PresetVoiceItem, + PresetVoiceListResponse, + UnifiedVoiceListParams, + VoiceItem, + CreateVoiceRequest, +} from "./types" + +// API 函数 +export { + fetchVoices, + fetchPresetVoices, + getVoices, + createVoice, + updateVoice, + deleteVoice, + generateAIVoice, +} from "./voices" diff --git a/apps/web/src/api/voices/types.ts b/apps/web/src/api/voices/types.ts new file mode 100644 index 000000000..25c10b94b --- /dev/null +++ b/apps/web/src/api/voices/types.ts @@ -0,0 +1,81 @@ +/** + * 配音相关类型定义 + */ + +/** 统一音色条目(preset + clone 混合) */ +export interface UnifiedVoiceItem { + id: string + type: "preset" | "clone" + name: string + description: string + gender: string + language: string + voice_id: string + voice_provider: string + audio_url: string | null + preview_url: string | null + duration: number | null + file_size: number | null + status: string + tags: string[] + user_id: string | null + project_id: string | null + voice_clone_profile_id: string | null + created_at: string | null + updated_at: string | null +} + +/** 统一音色列表响应 */ +export interface UnifiedVoiceListResponse { + items: UnifiedVoiceItem[] + total: number + preset_count: number + clone_count: number +} + +/** 预设音色条目 */ +export interface PresetVoiceItem { + voice_id: string + name: string + description: string + gender: string + language: string + preview_url: string | null + tags: string[] +} + +/** 预设音色列表响应 */ +export interface PresetVoiceListResponse { + items: PresetVoiceItem[] + total: number +} + +/** 统一列表查询参数 */ +export interface UnifiedVoiceListParams { + type?: "preset" | "clone" + status?: string + skip?: number + limit?: number +} + +/** 配音条目(旧) */ +export interface VoiceItem { + id: string + name: string + text: string + voice_type?: string + duration_seconds?: number + storage_key?: string + audio_url?: string + status?: string + is_favorite?: boolean + created_at?: string + updated_at?: string +} + +/** 创建配音请求(旧) */ +export interface CreateVoiceRequest { + name: string + text: string + voice_type?: string +} diff --git a/apps/web/src/api/voices.ts b/apps/web/src/api/voices/voices.ts similarity index 54% rename from apps/web/src/api/voices.ts rename to apps/web/src/api/voices/voices.ts index 7d777606b..9401645c4 100644 --- a/apps/web/src/api/voices.ts +++ b/apps/web/src/api/voices/voices.ts @@ -1,68 +1,15 @@ /** - * 配音相关 API - * Phase 1 新增:全局配音库 - * + * 配音相关 API 函数 * 任务 3.11:新增统一音色 API(对接后端 3.04),保留旧接口向后兼容 */ -import apiClient from "./client" - -/* ── 统一音色 API(后端 3.04) ─────────────────────────── */ - -/** 统一音色条目(preset + clone 混合) */ -export interface UnifiedVoiceItem { - id: string - type: "preset" | "clone" - name: string - description: string - gender: string - language: string - voice_id: string - voice_provider: string - audio_url: string | null - preview_url: string | null - duration: number | null - file_size: number | null - status: string - tags: string[] - user_id: string | null - project_id: string | null - voice_clone_profile_id: string | null - created_at: string | null - updated_at: string | null -} - -/** 统一音色列表响应 */ -export interface UnifiedVoiceListResponse { - items: UnifiedVoiceItem[] - total: number - preset_count: number - clone_count: number -} - -/** 预设音色条目 */ -export interface PresetVoiceItem { - voice_id: string - name: string - description: string - gender: string - language: string - preview_url: string | null - tags: string[] -} - -/** 预设音色列表响应 */ -export interface PresetVoiceListResponse { - items: PresetVoiceItem[] - total: number -} - -/** 统一列表查询参数 */ -export interface UnifiedVoiceListParams { - type?: "preset" | "clone" - status?: string - skip?: number - limit?: number -} +import apiClient from "../client" +import type { + CreateVoiceRequest, + PresetVoiceListResponse, + UnifiedVoiceListParams, + UnifiedVoiceListResponse, + VoiceItem, +} from "./types" /** 获取统一音色列表(推荐) */ export const fetchVoices = async ( @@ -86,28 +33,6 @@ export const fetchPresetVoices = async (): Promise => { /* ── 向后兼容(旧接口) ────────────────────────────────── */ -/** 配音条目(旧) */ -export interface VoiceItem { - id: string - name: string - text: string - voice_type?: string - duration_seconds?: number - storage_key?: string - audio_url?: string - status?: string - is_favorite?: boolean - created_at?: string - updated_at?: string -} - -/** 创建配音请求(旧) */ -export interface CreateVoiceRequest { - name: string - text: string - voice_type?: string -} - /** 获取当前用户的所有配音(旧 → /voices/legacy) */ export const getVoices = async (): Promise => { const response = await apiClient.get("/voices/legacy") diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index c6adc7683..d327dd0bf 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -1,25 +1,27 @@ /** * 视频库页面 — V21 设计系统 * 两栏布局:左侧视频库列表(260px)+ 右侧素材网格 - * 使用 useQuery 对接后端真实 API(api/assets.ts) + * + * 主组件仅保留 Hook 组装与整体布局 + * 数据查询 → hooks/useAssetsData + * 库管理 → hooks/useLibraryManagement + * 上传 → hooks/useAssetUpload + * 选中态 → hooks/useAssetSelection + * 素材操作 → hooks/useAssetOperations + * 上传区 → components/AssetUploadZone + * 网格区 → components/AssetGridSection + * 弹窗集合 → components/AssetModals */ import React, { useState } from "react" -import { Upload } from "antd" -import { InboxOutlined, PictureOutlined, ExclamationCircleOutlined } from "@ant-design/icons" -import { Button } from "@/components/ui" import type { AssetItem } from "@/pages/assets/types" -import AssetCard from "@/pages/assets/components/AssetCard" +import type { SmartViewType } from "@/pages/assets/components/BatchMarkModal" import { SkeletonCard } from "@/pages/assets/components/AssetSkeleton" import LibrarySidebar from "@/pages/assets/components/LibrarySidebar" import AssetFilterBar from "@/pages/assets/components/AssetFilterBar" import BatchOperationBar from "@/pages/assets/components/BatchOperationBar" -import CreateLibraryModal from "@/pages/assets/components/CreateLibraryModal" -import PlayModal from "@/pages/assets/components/PlayModal" -import BatchTagModal from "@/pages/assets/components/BatchTagModal" -import BatchClassifyModal from "@/pages/assets/components/BatchClassifyModal" -import BatchMarkModal from "@/pages/assets/components/BatchMarkModal" -import ResultDrawer from "@/pages/assets/components/ResultDrawer" -import UploadProgressModal from "@/pages/assets/components/UploadProgressModal" +import AssetUploadZone from "@/pages/assets/components/AssetUploadZone" +import AssetGridSection from "@/pages/assets/components/AssetGridSection" +import AssetModals from "@/pages/assets/components/AssetModals" import { useAssetsData } from "@/pages/assets/hooks/useAssetsData" import { useLibraryManagement } from "@/pages/assets/hooks/useLibraryManagement" import { useAssetUpload } from "@/pages/assets/hooks/useAssetUpload" @@ -27,9 +29,6 @@ import { useAssetSelection } from "@/pages/assets/hooks/useAssetSelection" import { useAssetOperations } from "@/pages/assets/hooks/useAssetOperations" import "./assets.css" -/* ============================================================ - * 主组件 - * ============================================================ */ const AssetLibrary: React.FC = () => { /* ── 数据查询与筛选 ── */ const { @@ -129,9 +128,6 @@ const AssetLibrary: React.FC = () => { return (
- {/* ─── 上传进度弹窗 ─── */} - - {/* 两栏布局 */}
{/* ─── 左侧:视频库列表 ─── */} @@ -146,25 +142,11 @@ const AssetLibrary: React.FC = () => { {/* ─── 右侧:内容区 ─── */}
{/* 上传区域 */} - { - handleUpload(file as File) - return false - }} - showUploadList={false} - multiple - accept="video/*,image/*" - > -
-

- -

-

- {uploading ? "上传中..." : "点击或拖拽文件到此区域上传"} -

-

支持视频、图片,单文件不超过 2GB

-
-
+ {/* 筛选栏 */} { )} {/* 素材网格 */} - {assetsLoading ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( - - ))} -
- ) : assetsError ? ( -
-
- -
-

{assetsErrorObj?.message || "加载失败"}

- -
- ) : filteredAssets.length > 0 ? ( -
- {filteredAssets.map((asset) => ( - toggleSelect(asset.id)} - onDiagnose={() => handleDiagnose(asset)} - onPlay={() => setPlayingAsset(asset)} - onDelete={() => handleSingleDelete(asset.id)} - /> - ))} -
- ) : ( -
-
- -
-

暂无素材,请上传或切换视频库

-
- )} +
- {/* ─── 新建视频库弹窗 ─── */} - setCreateModalOpen(false)} - onOk={handleCreateLibrary} - name={newLibName} - onNameChange={setNewLibName} - kind={newLibKind} - onKindChange={setNewLibKind} - confirmLoading={isCreating} - /> - - {/* ─── 视频/音频播放弹窗 ─── */} - setPlayingAsset(null)} /> - - {/* ─── 批量打标签弹窗 ─── */} - setCreateModalOpen(false)} + onCreateModalOk={handleCreateLibrary} + newLibName={newLibName} + onNewLibNameChange={setNewLibName} + newLibKind={newLibKind} + onNewLibKindChange={setNewLibKind} + createLoading={isCreating} + playingAsset={playingAsset} + onPlayClose={() => setPlayingAsset(null)} + tagModalOpen={tagModalOpen} selectedCount={selectedIds.size} - onCancel={() => { + onTagCancel={() => { setTagModalOpen(false) setBatchTags([]) setBatchTagInput("") }} - onOk={handleBatchTag} - tags={batchTags} + onTagOk={handleBatchTag} + batchTags={batchTags} + batchTagInput={batchTagInput} onTagInputChange={setBatchTagInput} onTagInputKeyDown={handleTagInputKeyDown} onRemoveTag={removeBatchTag} - tagInput={batchTagInput} - tagMode={tagMode} - onTagModeChange={setTagMode} - confirmLoading={batchLoading} - /> - - {/* ─── 批量改分类弹窗 ─── */} - { + tagMode={tagMode as "add" | "replace"} + onTagModeChange={setTagMode as (mode: "add" | "replace") => void} + batchLoading={batchLoading} + classifyModalOpen={classifyModalOpen} + onClassifyCancel={() => { setClassifyModalOpen(false) setBatchCategory("") }} - onOk={handleBatchClassify} - category={batchCategory} + onClassifyOk={handleBatchClassify} + batchCategory={batchCategory} onCategoryChange={setBatchCategory} - confirmLoading={batchLoading} - /> - - {/* ─── 批量智能标记弹窗 ─── */} - setMarkModalOpen(false)} - onOk={handleBatchMark} - smartView={batchSmartView} - onSmartViewChange={setBatchSmartView} - confirmLoading={batchLoading} - /> - - {/* ─── 操作结果 Drawer ─── */} - setMarkModalOpen(false)} + onMarkOk={handleBatchMark} + batchSmartView={batchSmartView as SmartViewType} + onSmartViewChange={setBatchSmartView as (val: SmartViewType) => void} + resultDrawerOpen={resultDrawerOpen} + operationTitle={operationTitle} + operationResult={operationResult} + onResultDrawerClose={handleResultDrawerClose} />
) diff --git a/apps/web/src/pages/assets/components/AssetGridSection.tsx b/apps/web/src/pages/assets/components/AssetGridSection.tsx new file mode 100644 index 000000000..81f206330 --- /dev/null +++ b/apps/web/src/pages/assets/components/AssetGridSection.tsx @@ -0,0 +1,93 @@ +/** + * AssetLibrary 素材网格区域(含加载/错误/空状态) + */ +import React from "react" +import { PictureOutlined, ExclamationCircleOutlined } from "@ant-design/icons" +import { Button } from "@/components/ui" +import type { AssetItem } from "../types" +import AssetCard from "./AssetCard" +import { SkeletonCard } from "./AssetSkeleton" + +export interface AssetGridSectionProps { + loading: boolean + error: boolean + errorMessage?: string + assets: AssetItem[] + selectedIds: Set + diagnosingId: string | null + onRetry?: () => void + onToggleSelect: (id: string) => void + onDiagnose: (asset: AssetItem) => void + onPlay: (asset: AssetItem) => void + onDelete: (id: string) => void +} + +export const AssetGridSection: React.FC = ({ + loading, + error, + errorMessage, + assets, + selectedIds, + diagnosingId, + onRetry, + onToggleSelect, + onDiagnose, + onPlay, + onDelete, +}) => { + if (loading) { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) + } + + if (error) { + return ( +
+
+ +
+

{errorMessage || "加载失败"}

+ {onRetry && ( + + )} +
+ ) + } + + if (assets.length > 0) { + return ( +
+ {assets.map((asset) => ( + onToggleSelect(asset.id)} + onDiagnose={() => onDiagnose(asset)} + onPlay={() => onPlay(asset)} + onDelete={() => onDelete(asset.id)} + /> + ))} +
+ ) + } + + return ( +
+
+ +
+

暂无素材,请上传或切换视频库

+
+ ) +} + +export default AssetGridSection diff --git a/apps/web/src/pages/assets/components/AssetModals.tsx b/apps/web/src/pages/assets/components/AssetModals.tsx new file mode 100644 index 000000000..3fb4e7314 --- /dev/null +++ b/apps/web/src/pages/assets/components/AssetModals.tsx @@ -0,0 +1,179 @@ +/** + * AssetLibrary 弹窗集合 + */ +import React from "react" +import type { AssetItem, AssetKind } from "../types" +import type { SmartViewType } from "./BatchMarkModal" +import type { BatchOperationResult } from "@/api/assets" +import CreateLibraryModal from "./CreateLibraryModal" +import PlayModal from "./PlayModal" +import BatchTagModal from "./BatchTagModal" +import BatchClassifyModal from "./BatchClassifyModal" +import BatchMarkModal from "./BatchMarkModal" +import ResultDrawer from "./ResultDrawer" +import UploadProgressModal from "./UploadProgressModal" + +export interface AssetModalsProps { + /* 上传进度 */ + uploading: boolean + uploadProgress: number + + /* 新建视频库 */ + createModalOpen: boolean + onCreateModalCancel: () => void + onCreateModalOk: () => void + newLibName: string + onNewLibNameChange: (name: string) => void + newLibKind: AssetKind + onNewLibKindChange: (kind: AssetKind) => void + createLoading: boolean + + /* 播放弹窗 */ + playingAsset: AssetItem | null + onPlayClose: () => void + + /* 批量打标签 */ + tagModalOpen: boolean + selectedCount: number + onTagCancel: () => void + onTagOk: () => void + batchTags: string[] + batchTagInput: string + onTagInputChange: (val: string) => void + onTagInputKeyDown: (e: React.KeyboardEvent) => void + onRemoveTag: (tag: string) => void + tagMode: "add" | "replace" + onTagModeChange: (mode: "add" | "replace") => void + batchLoading: boolean + + /* 批量改分类 */ + classifyModalOpen: boolean + onClassifyCancel: () => void + onClassifyOk: () => void + batchCategory: string + onCategoryChange: (val: string) => void + + /* 批量智能标记 */ + markModalOpen: boolean + onMarkCancel: () => void + onMarkOk: () => void + batchSmartView: SmartViewType + onSmartViewChange: (val: SmartViewType) => void + + /* 操作结果 Drawer */ + resultDrawerOpen: boolean + operationTitle: string + operationResult: BatchOperationResult | null + onResultDrawerClose: () => void +} + +export const AssetModals: React.FC = ({ + uploading, + uploadProgress, + createModalOpen, + onCreateModalCancel, + onCreateModalOk, + newLibName, + onNewLibNameChange, + newLibKind, + onNewLibKindChange, + createLoading, + playingAsset, + onPlayClose, + tagModalOpen, + selectedCount, + onTagCancel, + onTagOk, + batchTags, + batchTagInput, + onTagInputChange, + onTagInputKeyDown, + onRemoveTag, + tagMode, + onTagModeChange, + batchLoading, + classifyModalOpen, + onClassifyCancel, + onClassifyOk, + batchCategory, + onCategoryChange, + markModalOpen, + onMarkCancel, + onMarkOk, + batchSmartView, + onSmartViewChange, + resultDrawerOpen, + operationTitle, + operationResult, + onResultDrawerClose, +}) => { + return ( + <> + {/* 上传进度弹窗 */} + + + {/* 新建视频库弹窗 */} + + + {/* 视频/音频播放弹窗 */} + + + {/* 批量打标签弹窗 */} + + + {/* 批量改分类弹窗 */} + + + {/* 批量智能标记弹窗 */} + + + {/* 操作结果 Drawer */} + + + ) +} + +export default AssetModals diff --git a/apps/web/src/pages/assets/components/AssetUploadZone.tsx b/apps/web/src/pages/assets/components/AssetUploadZone.tsx new file mode 100644 index 000000000..34f803b32 --- /dev/null +++ b/apps/web/src/pages/assets/components/AssetUploadZone.tsx @@ -0,0 +1,41 @@ +/** + * AssetLibrary 上传拖拽区域 + */ +import React from "react" +import { Upload } from "antd" +import { InboxOutlined } from "@ant-design/icons" + +export interface AssetUploadZoneProps { + uploading: boolean + uploadProgress: number + onUpload: (file: File) => void +} + +export const AssetUploadZone: React.FC = ({ + uploading, + onUpload, +}) => { + return ( + { + onUpload(file as File) + return false + }} + showUploadList={false} + multiple + accept="video/*,image/*" + > +
+

+ +

+

+ {uploading ? "上传中..." : "点击或拖拽文件到此区域上传"} +

+

支持视频、图片,单文件不超过 2GB

+
+
+ ) +} + +export default AssetUploadZone diff --git a/apps/web/src/pages/duplication/DuplicationResults.tsx b/apps/web/src/pages/duplication/DuplicationResults.tsx index e311c9a89..68cd47cb2 100644 --- a/apps/web/src/pages/duplication/DuplicationResults.tsx +++ b/apps/web/src/pages/duplication/DuplicationResults.tsx @@ -2,130 +2,27 @@ * 查重结果列表页面 — V21 设计系统 * 胶囊筛选 + 卡片列表,零 antd 依赖 */ -import React, { useState, useMemo } from "react" -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { Button, Tag, Tooltip } from "@/components/ui" -import { useNavigate } from "react-router-dom" -import { - getDuplicationRecords, - deleteDuplicationRecord, - retryDuplication, - type DuplicationStatus, -} from "@/api/duplication" -import "./duplication.css" +import React from "react" +import { Button } from "@/components/ui" import PageHead from "@/components/layout/PageHead" - -/** 风险等级分类 */ -type RiskFilter = "all" | "high" | "medium" | "low" - -/** 状态配置 */ -const STATUS_CONFIG: Record< - DuplicationStatus, - { - variant: "primary" | "warning" | "success" | "error" - text: string - icon: string - } -> = { - pending: { variant: "primary", text: "等待中", icon: "⏳" }, - processing: { variant: "warning", text: "查重中", icon: "🔄" }, - completed: { variant: "success", text: "已完成", icon: "✅" }, - failed: { variant: "error", text: "失败", icon: "❌" }, -} - -/** 根据查重率获取风险等级 */ -const getRiskLevel = (rate?: number): "low" | "medium" | "high" => { - if (rate === undefined) return "low" - if (rate <= 10) return "low" - if (rate <= 30) return "medium" - return "high" -} - -/** 风险等级标签 */ -const RISK_LABELS: Record = { - low: "低风险", - medium: "中风险", - high: "高风险", -} - -/** 格式化文件大小 */ -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}秒` -} - -/** 简易 toast */ -interface ToastState { - message: string - type: "success" | "error" | "warning" -} - -const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [ - { key: "all", label: "全部" }, - { key: "low", label: "低风险" }, - { key: "medium", label: "中风险" }, - { key: "high", label: "高风险" }, -] +import "./duplication.css" +import useDuplicationResults from "./hooks/useDuplicationResults" +import FilterBar from "./components/FilterBar" +import EmptyState from "./components/EmptyState" +import ResultCard from "./components/ResultCard" const DuplicationResults: React.FC = () => { - const navigate = useNavigate() - const queryClient = useQueryClient() - const [riskFilter, setRiskFilter] = useState("all") - const [toast, setToast] = useState(null) - - const showToast = (message: string, type: "success" | "error" | "warning") => { - setToast({ message, type }) - setTimeout(() => setToast(null), 3000) - } - - // 获取查重记录 - const { data: records = [], isLoading } = useQuery({ - queryKey: ["duplication-records"], - queryFn: getDuplicationRecords, - }) - - // 删除 - const deleteMutation = useMutation({ - mutationFn: deleteDuplicationRecord, - onSuccess: () => { - showToast("已删除", "success") - queryClient.invalidateQueries({ queryKey: ["duplication-records"] }) - }, - onError: () => { - showToast("删除失败", "error") - }, - }) - - // 重新查重 - const retryMutation = useMutation({ - mutationFn: retryDuplication, - onSuccess: () => { - showToast("已重新提交查重", "success") - queryClient.invalidateQueries({ queryKey: ["duplication-records"] }) - }, - onError: () => { - showToast("重新查重失败", "error") - }, - }) - - /** 按风险等级筛选 */ - const filteredRecords = useMemo(() => { - if (riskFilter === "all") return records - return records.filter((r) => { - if (r.status !== "completed") return riskFilter === "low" - return getRiskLevel(r.duplicate_rate) === riskFilter - }) - }, [records, riskFilter]) + const { + isLoading, + filteredRecords, + riskFilter, + setRiskFilter, + toast, + handleDelete, + handleRetry, + handleView, + handleUpload, + } = useDuplicationResults() return (
@@ -136,136 +33,31 @@ const DuplicationResults: React.FC = () => { title="查重记录" actions={
- {/* 筛选胶囊 */} -
- {FILTER_OPTIONS.map((opt) => ( - - ))} -
-
} /> - {/* 加载中 */} - {isLoading && ( -
-
-

加载中...

-
- )} - - {/* 空状态 */} - {!isLoading && filteredRecords.length === 0 && ( -
-
📭
-

- {riskFilter === "all" - ? "暂无查重记录,上传视频开始查重吧" - : `没有${RISK_LABELS[riskFilter]}的记录`} -

-
+ {/* 加载中 / 空状态 */} + {(isLoading || filteredRecords.length === 0) && ( + )} {/* 结果卡片列表 */} {!isLoading && filteredRecords.length > 0 && (
- {filteredRecords.map((record) => { - const statusCfg = STATUS_CONFIG[record.status] - const riskLevel = getRiskLevel(record.duplicate_rate) - const rateValue = record.duplicate_rate - - return ( -
{ - if (record.status === "completed") { - navigate(`/duplication/${record.id}`) - } - }} - > - {/* 缩略图 */} -
🎬
- - {/* 信息区 */} -
-

{record.filename}

-
- - {statusCfg.icon} {statusCfg.text} - - {formatSize(record.file_size)} - {formatDuration(record.duration_seconds)} - {new Date(record.created_at).toLocaleDateString("zh-CN")} - {record.status === "completed" && record.duplicate_count !== undefined && ( - {record.duplicate_count} 个重复片段 - )} -
-
- - {/* 查重率 */} -
- {record.status === "completed" && rateValue !== undefined ? ( - <> -
-
-
- - {rateValue.toFixed(1)}% - - - ) : record.status === "failed" ? ( - - - - ) : ( - - {record.status === "processing" ? "分析中..." : "—"} - - )} -
- - {/* 删除按钮 */} - -
- ) - })} + {filteredRecords.map((record) => ( + + ))}
)}
diff --git a/apps/web/src/pages/duplication/components/EmptyState.tsx b/apps/web/src/pages/duplication/components/EmptyState.tsx new file mode 100644 index 000000000..2b14c6944 --- /dev/null +++ b/apps/web/src/pages/duplication/components/EmptyState.tsx @@ -0,0 +1,32 @@ +import React from "react" +import { RISK_LABELS } from "../constants" +import type { RiskFilter } from "../types" + +interface EmptyStateProps { + isLoading: boolean + riskFilter: RiskFilter +} + +const EmptyState: React.FC = ({ isLoading, riskFilter }) => { + if (isLoading) { + return ( +
+
+

加载中...

+
+ ) + } + + return ( +
+
📭
+

+ {riskFilter === "all" + ? "暂无查重记录,上传视频开始查重吧" + : `没有${RISK_LABELS[riskFilter]}的记录`} +

+
+ ) +} + +export default EmptyState diff --git a/apps/web/src/pages/duplication/components/FilterBar.tsx b/apps/web/src/pages/duplication/components/FilterBar.tsx new file mode 100644 index 000000000..a945841e4 --- /dev/null +++ b/apps/web/src/pages/duplication/components/FilterBar.tsx @@ -0,0 +1,26 @@ +import React from "react" +import type { RiskFilter } from "../types" +import { FILTER_OPTIONS } from "../constants" + +interface FilterBarProps { + value: RiskFilter + onChange: (value: RiskFilter) => void +} + +const FilterBar: React.FC = ({ value, onChange }) => { + return ( +
+ {FILTER_OPTIONS.map((opt) => ( + + ))} +
+ ) +} + +export default FilterBar diff --git a/apps/web/src/pages/duplication/components/ResultCard.tsx b/apps/web/src/pages/duplication/components/ResultCard.tsx new file mode 100644 index 000000000..27881f3af --- /dev/null +++ b/apps/web/src/pages/duplication/components/ResultCard.tsx @@ -0,0 +1,95 @@ +import React from "react" +import { Button, Tag, Tooltip } from "@/components/ui" +import type { DuplicationRecord } from "@/api/duplication" +import { STATUS_CONFIG } from "../constants" +import { getRiskLevel, formatSize, formatDuration } from "../utils" + +interface ResultCardProps { + record: DuplicationRecord + onView: (id: string) => void + onDelete: (id: string) => void + onRetry: (id: string) => void +} + +const ResultCard: React.FC = ({ record, onView, onDelete, onRetry }) => { + const statusCfg = STATUS_CONFIG[record.status] + const riskLevel = getRiskLevel(record.duplicate_rate) + const rateValue = record.duplicate_rate + + const handleClick = () => { + if (record.status === "completed") { + onView(record.id) + } + } + + return ( +
+ {/* 缩略图 */} +
🎬
+ + {/* 信息区 */} +
+

{record.filename}

+
+ + {statusCfg.icon} {statusCfg.text} + + {formatSize(record.file_size)} + {formatDuration(record.duration_seconds)} + {new Date(record.created_at).toLocaleDateString("zh-CN")} + {record.status === "completed" && record.duplicate_count !== undefined && ( + {record.duplicate_count} 个重复片段 + )} +
+
+ + {/* 查重率 */} +
+ {record.status === "completed" && rateValue !== undefined ? ( + <> +
+
+
+ {rateValue.toFixed(1)}% + + ) : record.status === "failed" ? ( + + + + ) : ( + + {record.status === "processing" ? "分析中..." : "—"} + + )} +
+ + {/* 删除按钮 */} + +
+ ) +} + +export default ResultCard diff --git a/apps/web/src/pages/duplication/constants.ts b/apps/web/src/pages/duplication/constants.ts new file mode 100644 index 000000000..16d6d65ff --- /dev/null +++ b/apps/web/src/pages/duplication/constants.ts @@ -0,0 +1,31 @@ +import type { DuplicationStatus } from "@/api/duplication" +import type { RiskFilter } from "./types" + +/** 状态配置 */ +export const STATUS_CONFIG: Record< + DuplicationStatus, + { + variant: "primary" | "warning" | "success" | "error" + text: string + icon: string + } +> = { + pending: { variant: "primary", text: "等待中", icon: "⏳" }, + processing: { variant: "warning", text: "查重中", icon: "🔄" }, + completed: { variant: "success", text: "已完成", icon: "✅" }, + failed: { variant: "error", text: "失败", icon: "❌" }, +} + +/** 风险等级标签 */ +export const RISK_LABELS: Record = { + low: "低风险", + medium: "中风险", + high: "高风险", +} + +export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [ + { key: "all", label: "全部" }, + { key: "low", label: "低风险" }, + { key: "medium", label: "中风险" }, + { key: "high", label: "高风险" }, +] diff --git a/apps/web/src/pages/duplication/hooks/useDuplicationResults.ts b/apps/web/src/pages/duplication/hooks/useDuplicationResults.ts new file mode 100644 index 000000000..23118fe15 --- /dev/null +++ b/apps/web/src/pages/duplication/hooks/useDuplicationResults.ts @@ -0,0 +1,106 @@ +import { useState, useMemo } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { useNavigate } from "react-router-dom" +import { + getDuplicationRecords, + deleteDuplicationRecord, + retryDuplication, + type DuplicationRecord, +} from "@/api/duplication" +import type { RiskFilter, ToastState } from "../types" +import { getRiskLevel } from "../utils" + +interface UseDuplicationResultsReturn { + records: DuplicationRecord[] + isLoading: boolean + filteredRecords: DuplicationRecord[] + riskFilter: RiskFilter + setRiskFilter: (filter: RiskFilter) => void + toast: ToastState | null + handleDelete: (id: string) => void + handleRetry: (id: string) => void + handleView: (id: string) => void + handleUpload: () => void +} + +const useDuplicationResults = (): UseDuplicationResultsReturn => { + const navigate = useNavigate() + const queryClient = useQueryClient() + const [riskFilter, setRiskFilter] = useState("all") + const [toast, setToast] = useState(null) + + const showToast = (message: string, type: "success" | "error" | "warning") => { + setToast({ message, type }) + setTimeout(() => setToast(null), 3000) + } + + // 获取查重记录 + const { data: records = [], isLoading } = useQuery({ + queryKey: ["duplication-records"], + queryFn: getDuplicationRecords, + }) + + // 删除 + const deleteMutation = useMutation({ + mutationFn: deleteDuplicationRecord, + onSuccess: () => { + showToast("已删除", "success") + queryClient.invalidateQueries({ queryKey: ["duplication-records"] }) + }, + onError: () => { + showToast("删除失败", "error") + }, + }) + + // 重新查重 + const retryMutation = useMutation({ + mutationFn: retryDuplication, + onSuccess: () => { + showToast("已重新提交查重", "success") + queryClient.invalidateQueries({ queryKey: ["duplication-records"] }) + }, + onError: () => { + showToast("重新查重失败", "error") + }, + }) + + /** 按风险等级筛选 */ + const filteredRecords = useMemo(() => { + if (riskFilter === "all") return records + return records.filter((r) => { + if (r.status !== "completed") return riskFilter === "low" + return getRiskLevel(r.duplicate_rate) === riskFilter + }) + }, [records, riskFilter]) + + const handleDelete = (id: string) => { + deleteMutation.mutate(id) + } + + const handleRetry = (id: string) => { + retryMutation.mutate(id) + } + + const handleView = (id: string) => { + navigate(`/duplication/${id}`) + } + + const handleUpload = () => { + navigate("/app/duplication") + } + + return { + records, + isLoading, + filteredRecords, + riskFilter, + setRiskFilter, + toast, + handleDelete, + handleRetry, + handleView, + handleUpload, + } +} + +export default useDuplicationResults diff --git a/apps/web/src/pages/duplication/types.ts b/apps/web/src/pages/duplication/types.ts new file mode 100644 index 000000000..fffe5694c --- /dev/null +++ b/apps/web/src/pages/duplication/types.ts @@ -0,0 +1,8 @@ +/** 风险等级分类 */ +export type RiskFilter = "all" | "high" | "medium" | "low" + +/** 简易 toast */ +export interface ToastState { + message: string + type: "success" | "error" | "warning" +} diff --git a/apps/web/src/pages/duplication/utils.ts b/apps/web/src/pages/duplication/utils.ts new file mode 100644 index 000000000..673df26e3 --- /dev/null +++ b/apps/web/src/pages/duplication/utils.ts @@ -0,0 +1,23 @@ +/** 根据查重率获取风险等级 */ +export const getRiskLevel = (rate?: number): "low" | "medium" | "high" => { + if (rate === undefined) return "low" + if (rate <= 10) return "low" + if (rate <= 30) return "medium" + return "high" +} + +/** 格式化文件大小 */ +export 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` +} + +/** 格式化时长 */ +export 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}秒` +} diff --git a/apps/web/src/pages/editing-planner/components/BgmSelector.tsx b/apps/web/src/pages/editing-planner/components/BgmSelector.tsx index 785524e9e..1d0206adc 100644 --- a/apps/web/src/pages/editing-planner/components/BgmSelector.tsx +++ b/apps/web/src/pages/editing-planner/components/BgmSelector.tsx @@ -1,273 +1,5 @@ /** - * BGM 选择器 — Drawer 形式 - * 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置 + * BGM 选择器入口(向后兼容) + * 实际实现位于 ./bgm-selector/ 目录 */ -import React, { useState, useRef, useCallback, useEffect } from "react" -import { Drawer, Slider, Input, Tag, message } from "antd" -import { - getBgmPresets, - type BgmPreset, - type BgmCategory, - type BgmMixConfig, - DEFAULT_BGM_MIX_CONFIG, -} from "@/api/bgm" - -const { Search } = Input - -/* ──────────── 分类标签 ──────────── */ -const CATEGORY_LIST: { - key: BgmCategory | "all" - label: string - icon: string -}[] = [ - { key: "all", label: "全部", icon: "🎶" }, - { key: "轻快", label: "轻快", icon: "🎉" }, - { key: "治愈", label: "治愈", icon: "🌿" }, - { key: "科技", label: "科技", icon: "🔬" }, - { key: "电商", label: "电商", icon: "🛒" }, -] - -/* ──────────── Props ──────────── */ -interface BgmSelectorProps { - open: boolean - onClose: () => void - config: BgmMixConfig - onChange: (config: BgmMixConfig) => void -} - -const BgmSelector: React.FC = ({ open, onClose, config, onChange }) => { - const [presets, setPresets] = useState([]) - const [loading, setLoading] = useState(false) - const [activeCategory, setActiveCategory] = useState("all") - const [keyword, setKeyword] = useState("") - const [previewingId, setPreviewingId] = useState(null) - - const audioRef = useRef(null) - - /* ── 加载 BGM 列表 ── */ - const loadPresets = useCallback(async () => { - setLoading(true) - try { - const params: { category?: string; keyword?: string } = {} - if (activeCategory !== "all") params.category = activeCategory - if (keyword.trim()) params.keyword = keyword.trim() - const data = await getBgmPresets(params) - setPresets(data) - } catch { - message.error("加载 BGM 列表失败") - } finally { - setLoading(false) - } - }, [activeCategory, keyword]) - - useEffect(() => { - if (open) loadPresets() - }, [open, loadPresets]) - - /* ── 试听 ── */ - const handlePreview = useCallback( - (bgm: BgmPreset) => { - if (previewingId === bgm.id) { - audioRef.current?.pause() - setPreviewingId(null) - return - } - audioRef.current?.pause() - const audio = new Audio(bgm.url) - audioRef.current = audio - audio.play().catch(() => {}) - audio.onended = () => setPreviewingId(null) - setPreviewingId(bgm.id) - }, - [previewingId], - ) - - /* ── 选中 BGM ── */ - const handleSelect = useCallback( - (bgm: BgmPreset) => { - onChange({ - ...config, - enabled: true, - music_id: bgm.id, - }) - }, - [config, onChange], - ) - - /* ── 关闭时停止播放 ── */ - const handleClose = useCallback(() => { - audioRef.current?.pause() - setPreviewingId(null) - onClose() - }, [onClose]) - - /* ── 移除 BGM ── */ - const handleClear = useCallback(() => { - audioRef.current?.pause() - setPreviewingId(null) - onChange({ ...DEFAULT_BGM_MIX_CONFIG }) - }, [onChange]) - - /* ── 当前选中的 BGM ── */ - const selectedBgm = presets.find((p) => p.id === config.music_id) - - return ( - - {/* ── 搜索框 ── */} -
- setKeyword(e.target.value)} - onSearch={() => loadPresets()} - /> -
- - {/* ── 分类标签 ── */} -
- {CATEGORY_LIST.map((cat) => ( - setActiveCategory(cat.key)} - > - {cat.icon} {cat.label} - - ))} -
- - {/* ── BGM 列表 ── */} -
- {loading &&
加载中...
} - {!loading && presets.length === 0 &&
暂无 BGM 数据
} - {presets.map((bgm) => { - const isSelected = config.music_id === bgm.id - const isPlaying = previewingId === bgm.id - return ( -
handleSelect(bgm)} - > -
- {bgm.cover_url ? ( - {bgm.name} - ) : ( - 🎵 - )} -
-
-
{bgm.name}
-
- {bgm.category} - - {Math.floor(bgm.duration / 60)}: - {String(Math.floor(bgm.duration % 60)).padStart(2, "0")} - -
- {bgm.tags.length > 0 && ( -
- {bgm.tags.slice(0, 3).map((t) => ( - - {t} - - ))} -
- )} -
- - {isSelected && } -
- ) - })} -
- - {/* ── 混音配置 ── */} - {config.enabled && config.music_id && ( -
-
- 混音配置 - -
- -
- {selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`} -
- - {/* 音量 */} -
- - onChange({ ...config, volume: v })} - /> -
- - {/* 淡入 */} -
- - onChange({ ...config, fade_in: v })} - /> -
- - {/* 淡出 */} -
- - onChange({ ...config, fade_out: v })} - /> -
- - {/* 人声闪避 */} -
- -
onChange({ ...config, voice_dodge: !config.voice_dodge })} - > -
-
-
-
- )} - - ) -} - -export default BgmSelector +export { default } from "./bgm-selector" diff --git a/apps/web/src/pages/editing-planner/components/CoverSelector.tsx b/apps/web/src/pages/editing-planner/components/CoverSelector.tsx index 7c1784175..f4c64ef26 100644 --- a/apps/web/src/pages/editing-planner/components/CoverSelector.tsx +++ b/apps/web/src/pages/editing-planner/components/CoverSelector.tsx @@ -1,286 +1,5 @@ /** - * 封面选择器 - * 抽帧选封面 + 上传自定义封面 + 智能封面推荐 + * 封面选择器入口(向后兼容) + * 实际实现位于 ./cover-selector/ 目录 */ -import React, { useCallback, useRef, useState } from "react" -import { Drawer } from "antd" -import type { CoverConfig, CoverMode } from "../types" -import { DEFAULT_COVER_CONFIG } from "../types" - -interface CoverSelectorProps { - open: boolean - onClose: () => void - config: CoverConfig - onChange: (config: CoverConfig) => void - totalDuration: number -} - -/** 封面模式标签 */ -const MODE_LABELS: Record = { - auto: "智能封面", - frame: "抽帧选封面", - upload: "上传封面", -} - -/** 封面模式图标 */ -const MODE_ICONS: Record = { - auto: "🤖", - frame: "🎞️", - upload: "📤", -} - -const CoverSelector: React.FC = ({ - open, - onClose, - config, - onChange, - totalDuration, -}) => { - const fileInputRef = useRef(null) - const [isDragging, setIsDragging] = useState(false) - - const update = useCallback( - (partial: Partial) => { - onChange({ ...config, ...partial }) - }, - [config, onChange], - ) - - const handleReset = useCallback(() => { - onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled }) - }, [config.enabled, onChange]) - - /** 切换模式 */ - const handleModeChange = useCallback( - (mode: CoverMode) => { - update({ mode }) - }, - [update], - ) - - /** 处理文件上传 */ - const handleFileUpload = useCallback( - (file: File) => { - if (!file.type.startsWith("image/")) return - const reader = new FileReader() - reader.onload = (e) => { - const url = e.target?.result as string - update({ upload_url: url, thumbnail_url: url, mode: "upload" }) - } - reader.readAsDataURL(file) - }, - [update], - ) - - /** 拖拽上传 */ - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault() - setIsDragging(false) - const file = e.dataTransfer.files[0] - if (file) handleFileUpload(file) - }, - [handleFileUpload], - ) - - /** 使用 AI 推荐时间 */ - const handleUseAiSuggestion = useCallback(() => { - if (config.ai_suggested_time !== null) { - update({ frame_time: config.ai_suggested_time, mode: "frame" }) - } - }, [config.ai_suggested_time, update]) - - /** 格式化时间 */ - const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60) - const s = Math.floor(seconds % 60) - const ms = Math.floor((seconds % 1) * 10) - return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}` - } - - return ( - - {/* 顶部开关 */} -
- 启用自定义封面 - -
- - {/* 模式选择 */} -
-
封面来源
-
- {(["auto", "frame", "upload"] as CoverMode[]).map((m) => ( - - ))} -
-
- - {/* 模式内容区 */} -
- {/* 智能封面 */} - {config.mode === "auto" && ( -
-
- AI 将分析视频内容,自动选择最具吸引力的画面作为封面。 -
- {config.ai_suggested_time !== null ? ( -
-
AI 推荐
-
- 推荐时间点:{formatTime(config.ai_suggested_time)} -
- -
- ) : ( -
-
- AI 分析中...(生成视频后自动推荐) -
- )} -
- )} - - {/* 抽帧选封面 */} - {config.mode === "frame" && ( -
-
-
- 🎞️ - {formatTime(config.frame_time)} -
-
-
-
- 拖动选择封面帧 - {formatTime(config.frame_time)} -
- update({ frame_time: Number(e.target.value) })} - /> -
- 00:00 - {formatTime(totalDuration)} -
-
- {/* 快捷时间点 */} -
- 快捷选帧: - {[0, 0.25, 0.5, 0.75].map((ratio) => { - const t = totalDuration * ratio - return ( - - ) - })} -
-
- )} - - {/* 上传封面 */} - {config.mode === "upload" && ( -
-
{ - e.preventDefault() - setIsDragging(true) - }} - onDragLeave={() => setIsDragging(false)} - onDrop={handleDrop} - onClick={() => fileInputRef.current?.click()} - > - {config.upload_url ? ( -
- 封面预览 -
点击更换
-
- ) : ( -
- 📤 - 点击或拖拽上传封面图片 - 支持 JPG / PNG,建议 16:9 比例 -
- )} - { - const file = e.target.files?.[0] - if (file) handleFileUpload(file) - }} - /> -
-
- )} -
- - {/* 封面预览 */} -
-
封面预览
-
- {config.upload_url ? ( - 封面预览 - ) : ( -
- 🖼️ - - {config.mode === "auto" - ? "AI 智能选择" - : config.mode === "frame" - ? `帧 ${formatTime(config.frame_time)}` - : "未上传封面"} - -
- )} -
16:9
-
-
- - {/* 底部 */} -
- -
- - ) -} - -export default CoverSelector +export { default } from "./cover-selector" diff --git a/apps/web/src/pages/editing-planner/components/bgm-selector/BgmItem.tsx b/apps/web/src/pages/editing-planner/components/bgm-selector/BgmItem.tsx new file mode 100644 index 000000000..19cf747e6 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/bgm-selector/BgmItem.tsx @@ -0,0 +1,66 @@ +import React from "react" +import type { BgmPreset } from "@/api/bgm" + +interface BgmItemProps { + bgm: BgmPreset + isSelected: boolean + isPlaying: boolean + onSelect: () => void + onPreview: () => void +} + +/** + * 单个 BGM 列表项组件 + */ +export const BgmItem: React.FC = ({ + bgm, + isSelected, + isPlaying, + onSelect, + onPreview, +}) => { + const formatDuration = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = String(Math.floor(seconds % 60)).padStart(2, "0") + return `${mins}:${secs}` + } + + return ( +
+
+ {bgm.cover_url ? ( + {bgm.name} + ) : ( + 🎵 + )} +
+
+
{bgm.name}
+
+ {bgm.category} + {formatDuration(bgm.duration)} +
+ {bgm.tags.length > 0 && ( +
+ {bgm.tags.slice(0, 3).map((t) => ( + + {t} + + ))} +
+ )} +
+ + {isSelected && } +
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/bgm-selector/BgmMixConfig.tsx b/apps/web/src/pages/editing-planner/components/bgm-selector/BgmMixConfig.tsx new file mode 100644 index 000000000..ff89561e1 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/bgm-selector/BgmMixConfig.tsx @@ -0,0 +1,88 @@ +import React from "react" +import { Slider } from "antd" +import type { BgmMixConfig as BgmMixConfigType, BgmPreset } from "@/api/bgm" + +interface BgmMixConfigProps { + config: BgmMixConfigType + selectedBgm: BgmPreset | undefined + onChange: (config: BgmMixConfigType) => void + onClear: () => void +} + +/** + * BGM 混音配置面板 + * 音量、淡入淡出、人声闪避等设置 + */ +export const BgmMixConfig: React.FC = ({ + config, + selectedBgm, + onChange, + onClear, +}) => { + return ( +
+
+ 混音配置 + +
+ +
+ {selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`} +
+ + {/* 音量 */} +
+ + onChange({ ...config, volume: v })} + /> +
+ + {/* 淡入 */} +
+ + onChange({ ...config, fade_in: v })} + /> +
+ + {/* 淡出 */} +
+ + onChange({ ...config, fade_out: v })} + /> +
+ + {/* 人声闪避 */} +
+ +
onChange({ ...config, voice_dodge: !config.voice_dodge })} + > +
+
+
+
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx b/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx new file mode 100644 index 000000000..51aa19e31 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx @@ -0,0 +1,124 @@ +/** + * BGM 选择器 — Drawer 形式 + * 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置 + */ +import React, { useCallback } from "react" +import { Drawer, Input, Tag } from "antd" +import { type BgmMixConfig, DEFAULT_BGM_MIX_CONFIG } from "@/api/bgm" +import { useBgmSelector, CATEGORY_LIST } from "./useBgmSelector" +import { BgmItem } from "./BgmItem" +import { BgmMixConfig as BgmMixConfigPanel } from "./BgmMixConfig" + +const { Search } = Input + +interface BgmSelectorProps { + open: boolean + onClose: () => void + config: BgmMixConfig + onChange: (config: BgmMixConfig) => void +} + +const BgmSelector: React.FC = ({ open, onClose, config, onChange }) => { + const { + presets, + loading, + activeCategory, + setActiveCategory, + keyword, + setKeyword, + previewingId, + loadPresets, + handlePreview, + stopPreview, + } = useBgmSelector(open) + + /* ── 选中 BGM ── */ + const handleSelect = useCallback( + (bgmId: string) => { + onChange({ + ...config, + enabled: true, + music_id: bgmId, + }) + }, + [config, onChange], + ) + + /* ── 关闭时停止播放 ── */ + const handleClose = useCallback(() => { + stopPreview() + onClose() + }, [stopPreview, onClose]) + + /* ── 移除 BGM ── */ + const handleClear = useCallback(() => { + stopPreview() + onChange({ ...DEFAULT_BGM_MIX_CONFIG }) + }, [stopPreview, onChange]) + + /* ── 当前选中的 BGM ── */ + const selectedBgm = presets.find((p) => p.id === config.music_id) + + return ( + + {/* 搜索框 */} +
+ setKeyword(e.target.value)} + onSearch={() => loadPresets()} + /> +
+ + {/* 分类标签 */} +
+ {CATEGORY_LIST.map((cat) => ( + setActiveCategory(cat.key)} + > + {cat.icon} {cat.label} + + ))} +
+ + {/* BGM 列表 */} +
+ {loading &&
加载中...
} + {!loading && presets.length === 0 &&
暂无 BGM 数据
} + {presets.map((bgm) => ( + handleSelect(bgm.id)} + onPreview={() => handlePreview(bgm)} + /> + ))} +
+ + {/* 混音配置 */} + {config.enabled && config.music_id && ( + + )} +
+ ) +} + +export default BgmSelector diff --git a/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts b/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts new file mode 100644 index 000000000..8a47b6a6a --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts @@ -0,0 +1,87 @@ +import { useState, useRef, useCallback, useEffect } from "react" +import { message } from "antd" +import { getBgmPresets, type BgmPreset, type BgmCategory } from "@/api/bgm" + +/* ──────────── 分类标签 ──────────── */ +export const CATEGORY_LIST: { + key: BgmCategory | "all" + label: string + icon: string +}[] = [ + { key: "all", label: "全部", icon: "🎶" }, + { key: "轻快", label: "轻快", icon: "🎉" }, + { key: "治愈", label: "治愈", icon: "🌿" }, + { key: "科技", label: "科技", icon: "🔬" }, + { key: "电商", label: "电商", icon: "🛒" }, +] + +/** + * BGM 选择器数据与交互 Hook + * 封装列表加载、搜索、分类筛选、试听播放逻辑 + */ +export function useBgmSelector(open: boolean) { + const [presets, setPresets] = useState([]) + const [loading, setLoading] = useState(false) + const [activeCategory, setActiveCategory] = useState("all") + const [keyword, setKeyword] = useState("") + const [previewingId, setPreviewingId] = useState(null) + + const audioRef = useRef(null) + + /* ── 加载 BGM 列表 ── */ + const loadPresets = useCallback(async () => { + setLoading(true) + try { + const params: { category?: string; keyword?: string } = {} + if (activeCategory !== "all") params.category = activeCategory + if (keyword.trim()) params.keyword = keyword.trim() + const data = await getBgmPresets(params) + setPresets(data) + } catch { + message.error("加载 BGM 列表失败") + } finally { + setLoading(false) + } + }, [activeCategory, keyword]) + + useEffect(() => { + if (open) loadPresets() + }, [open, loadPresets]) + + /* ── 试听 ── */ + const handlePreview = useCallback( + (bgm: BgmPreset) => { + if (previewingId === bgm.id) { + audioRef.current?.pause() + setPreviewingId(null) + return + } + audioRef.current?.pause() + const audio = new Audio(bgm.url) + audioRef.current = audio + audio.play().catch(() => {}) + audio.onended = () => setPreviewingId(null) + setPreviewingId(bgm.id) + }, + [previewingId], + ) + + /* ── 停止播放(关闭/移除时调用) ── */ + const stopPreview = useCallback(() => { + audioRef.current?.pause() + setPreviewingId(null) + }, []) + + return { + presets, + loading, + activeCategory, + setActiveCategory, + keyword, + setKeyword, + previewingId, + loadPresets, + handlePreview, + stopPreview, + } +} diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx index f2a48dbe9..af9aa97d3 100644 --- a/apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx +++ b/apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx @@ -1,266 +1,5 @@ /** - * 片段详情区块 + * ClipDetailSection 入口(向后兼容) + * 实际实现位于 ./clip-detail-section/ 目录 */ -import React from "react" -import { useNavigate } from "react-router-dom" -import { TRANSITION_OPTIONS } from "@/api/template-editor" -import type { ClipData, ClipType } from "@/pages/editing-planner/types" -import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties" -import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties" -import type { AssetItem } from "@/api/assets" -import type { TemplateMode } from "@/api/editing-planner" - -interface ClipDetailSectionProps { - clip: ClipData - currentMode: TemplateMode - voiceMaterials?: AssetItem[] - voiceMaterialsLoading?: boolean - onClipUpdate: (clipId: string, data: Partial) => void - onRefreshVoiceMaterials?: () => void - onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void - onOpenTransitionDrawer?: (clipId: string) => void - onOpenSpeedDrawer?: (clipId: string) => void - onOpenTtsDrawer?: (clipId: string) => void - previewingId: string | null - onPreviewVoice: (asset: AssetItem) => void - onStopPreview: () => void -} - -const ClipDetailSection: React.FC = ({ - clip, - currentMode, - voiceMaterials = [], - voiceMaterialsLoading = false, - onClipUpdate, - onRefreshVoiceMaterials, - onClipVoiceSelect, - onOpenTransitionDrawer, - onOpenSpeedDrawer, - onOpenTtsDrawer, - previewingId, - onPreviewVoice, - onStopPreview, -}) => { - const navigate = useNavigate() - - const isTypeDisabled = (t: ClipType) => { - if (currentMode === "pip") return t !== "pip" - if (currentMode === "voice_over") return t !== "voice" - return false - } - - const transitionLabel = (() => { - const t = clip.transition - if (!t || t.type === "none") return "无转场" - const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type) - return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s` - })() - - const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x" - - const ttsLabel = (() => { - const tts = clip.tts_config - if (!tts || tts.mode === "none") return "无配音" - if (tts.mode === "upload") return "上传配音" - return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}` - })() - - return ( -
-
- 🎞️ - 片段详情 -
- -
- {/* 类型选择器 */} -
-
类型
-
- {(["voice", "pip"] as ClipType[]).map((t) => { - const disabled = isTypeDisabled(t) - return ( - - ) - })} -
-
- - {/* 时长 */} -
-
时长
-
- - onClipUpdate(clip.id, { - duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)), - }) - } - /> - -
-
- - {/* 转场效果入口 */} - {onOpenTransitionDrawer && ( -
- -
- )} - - {/* 播放速度入口 */} - {onOpenSpeedDrawer && ( -
- -
- )} - - {/* TTS 配音入口 */} - {onOpenTtsDrawer && ( -
- -
- )} - - {/* 素材起始时间 — 仅 voice 类型显示 */} - {clip.type === "voice" && ( -
-
素材起始时间
-
- - onClipUpdate(clip.id, { - startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)), - }) - } - /> - -
-
- )} - - {/* 配音素材选择 — 仅 voice 类型显示 */} - {clip.type === "voice" && ( -
-
- 配音素材 - {onRefreshVoiceMaterials && ( - - )} -
- - {voiceMaterialsLoading && voiceMaterials.length === 0 ? ( -
加载中...
- ) : ( - <> -
- - {clip.voice_asset_id && ( - - )} -
- {voiceMaterials.length === 0 && ( -
暂无配音素材,请先上传
- )} - - )} - - -
- )} -
-
- ) -} - -export default ClipDetailSection +export { default } from "./clip-detail-section" diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/AdvancedEntries.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/AdvancedEntries.tsx new file mode 100644 index 000000000..0ab96c7be --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/AdvancedEntries.tsx @@ -0,0 +1,85 @@ +import React from "react" +import { TRANSITION_OPTIONS } from "@/api/template-editor" +import type { ClipData } from "@/pages/editing-planner/types" + +interface AdvancedEntriesProps { + clip: ClipData + onOpenTransitionDrawer?: (clipId: string) => void + onOpenSpeedDrawer?: (clipId: string) => void + onOpenTtsDrawer?: (clipId: string) => void +} + +/** + * 高级功能入口按钮(转场/调速/TTS) + */ +export const AdvancedEntries: React.FC = ({ + clip, + onOpenTransitionDrawer, + onOpenSpeedDrawer, + onOpenTtsDrawer, +}) => { + const transitionLabel = (() => { + const t = clip.transition + if (!t || t.type === "none") return "无转场" + const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type) + return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s` + })() + + const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x" + + const ttsLabel = (() => { + const tts = clip.tts_config + if (!tts || tts.mode === "none") return "无配音" + if (tts.mode === "upload") return "上传配音" + return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}` + })() + + return ( + <> + {/* 转场效果入口 */} + {onOpenTransitionDrawer && ( +
+ +
+ )} + + {/* 播放速度入口 */} + {onOpenSpeedDrawer && ( +
+ +
+ )} + + {/* TTS 配音入口 */} + {onOpenTtsDrawer && ( +
+ +
+ )} + + ) +} diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/ClipTypeAndDuration.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/ClipTypeAndDuration.tsx new file mode 100644 index 000000000..af558e09a --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/ClipTypeAndDuration.tsx @@ -0,0 +1,69 @@ +import React from "react" +import type { ClipData, ClipType } from "@/pages/editing-planner/types" +import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties" +import type { TemplateMode } from "@/api/editing-planner" + +interface ClipTypeAndDurationProps { + clip: ClipData + currentMode: TemplateMode + onClipUpdate: (clipId: string, data: Partial) => void +} + +/** + * 片段类型选择 + 时长设置 + */ +export const ClipTypeAndDuration: React.FC = ({ + clip, + currentMode, + onClipUpdate, +}) => { + const isTypeDisabled = (t: ClipType) => { + if (currentMode === "pip") return t !== "pip" + if (currentMode === "voice_over") return t !== "voice" + return false + } + + return ( + <> + {/* 类型选择器 */} +
+
类型
+
+ {(["voice", "pip"] as ClipType[]).map((t) => { + const disabled = isTypeDisabled(t) + return ( + + ) + })} +
+
+ + {/* 时长 */} +
+
时长
+
+ + onClipUpdate(clip.id, { + duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)), + }) + } + /> + +
+
+ + ) +} diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/VoiceMaterialSection.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/VoiceMaterialSection.tsx new file mode 100644 index 000000000..9b77a1624 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/VoiceMaterialSection.tsx @@ -0,0 +1,132 @@ +import React from "react" +import { useNavigate } from "react-router-dom" +import type { ClipData } from "@/pages/editing-planner/types" +import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties" +import type { AssetItem } from "@/api/assets" + +interface VoiceMaterialSectionProps { + clip: ClipData + voiceMaterials: AssetItem[] + voiceMaterialsLoading: boolean + onClipUpdate: (clipId: string, data: Partial) => void + onRefreshVoiceMaterials?: () => void + onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void + previewingId: string | null + onPreviewVoice: (asset: AssetItem) => void + onStopPreview: () => void +} + +/** + * 配音素材选择区(仅 voice 类型显示) + */ +export const VoiceMaterialSection: React.FC = ({ + clip, + voiceMaterials, + voiceMaterialsLoading, + onClipUpdate, + onRefreshVoiceMaterials, + onClipVoiceSelect, + previewingId, + onPreviewVoice, + onStopPreview, +}) => { + const navigate = useNavigate() + + return ( + <> + {/* 素材起始时间 */} +
+
素材起始时间
+
+ + onClipUpdate(clip.id, { + startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)), + }) + } + /> + +
+
+ + {/* 配音素材选择 */} +
+
+ 配音素材 + {onRefreshVoiceMaterials && ( + + )} +
+ + {voiceMaterialsLoading && voiceMaterials.length === 0 ? ( +
加载中...
+ ) : ( + <> +
+ + {clip.voice_asset_id && ( + + )} +
+ {voiceMaterials.length === 0 && ( +
暂无配音素材,请先上传
+ )} + + )} + + +
+ + ) +} diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/index.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/index.tsx new file mode 100644 index 000000000..5ba4ea27e --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/index.tsx @@ -0,0 +1,78 @@ +/** + * 片段详情区块 + */ +import React from "react" +import type { ClipData } from "@/pages/editing-planner/types" +import type { AssetItem } from "@/api/assets" +import type { TemplateMode } from "@/api/editing-planner" +import { ClipTypeAndDuration } from "./ClipTypeAndDuration" +import { AdvancedEntries } from "./AdvancedEntries" +import { VoiceMaterialSection } from "./VoiceMaterialSection" + +interface ClipDetailSectionProps { + clip: ClipData + currentMode: TemplateMode + voiceMaterials?: AssetItem[] + voiceMaterialsLoading?: boolean + onClipUpdate: (clipId: string, data: Partial) => void + onRefreshVoiceMaterials?: () => void + onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void + onOpenTransitionDrawer?: (clipId: string) => void + onOpenSpeedDrawer?: (clipId: string) => void + onOpenTtsDrawer?: (clipId: string) => void + previewingId: string | null + onPreviewVoice: (asset: AssetItem) => void + onStopPreview: () => void +} + +const ClipDetailSection: React.FC = ({ + clip, + currentMode, + voiceMaterials = [], + voiceMaterialsLoading = false, + onClipUpdate, + onRefreshVoiceMaterials, + onClipVoiceSelect, + onOpenTransitionDrawer, + onOpenSpeedDrawer, + onOpenTtsDrawer, + previewingId, + onPreviewVoice, + onStopPreview, +}) => { + return ( +
+
+ 🎞️ + 片段详情 +
+ +
+ + + + + {clip.type === "voice" && ( + + )} +
+
+ ) +} + +export default ClipDetailSection diff --git a/apps/web/src/pages/editing-planner/components/cover-selector/CoverModePanels.tsx b/apps/web/src/pages/editing-planner/components/cover-selector/CoverModePanels.tsx new file mode 100644 index 000000000..d13dd5072 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/cover-selector/CoverModePanels.tsx @@ -0,0 +1,143 @@ +import React from "react" +import type { CoverConfig } from "../../types" + +interface CoverAutoModeProps { + config: CoverConfig + formatTime: (s: number) => string + onUseAiSuggestion: () => void +} + +/** 智能封面模式面板 */ +export const CoverAutoMode: React.FC = ({ + config, + formatTime, + onUseAiSuggestion, +}) => ( +
+
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
+ {config.ai_suggested_time !== null ? ( +
+
AI 推荐
+
推荐时间点:{formatTime(config.ai_suggested_time)}
+ +
+ ) : ( +
+
+ AI 分析中...(生成视频后自动推荐) +
+ )} +
+) + +interface CoverFrameModeProps { + config: CoverConfig + totalDuration: number + formatTime: (s: number) => string + onFrameTimeChange: (time: number) => void +} + +/** 抽帧选封面模式面板 */ +export const CoverFrameMode: React.FC = ({ + config, + totalDuration, + formatTime, + onFrameTimeChange, +}) => ( +
+
+
+ 🎞️ + {formatTime(config.frame_time)} +
+
+
+
+ 拖动选择封面帧 + {formatTime(config.frame_time)} +
+ onFrameTimeChange(Number(e.target.value))} + /> +
+ 00:00 + {formatTime(totalDuration)} +
+
+
+ 快捷选帧: + {[0, 0.25, 0.5, 0.75].map((ratio) => { + const t = totalDuration * ratio + return ( + + ) + })} +
+
+) + +interface CoverUploadModeProps { + config: CoverConfig + isDragging: boolean + fileInputRef: React.RefObject + onDragOver: (e: React.DragEvent) => void + onDragLeave: () => void + onDrop: (e: React.DragEvent) => void + onAreaClick: () => void + onFileChange: (file: File) => void +} + +/** 上传封面模式面板 */ +export const CoverUploadMode: React.FC = ({ + config, + isDragging, + fileInputRef, + onDragOver, + onDragLeave, + onDrop, + onAreaClick, + onFileChange, +}) => ( +
+
+ {config.upload_url ? ( +
+ 封面预览 +
点击更换
+
+ ) : ( +
+ 📤 + 点击或拖拽上传封面图片 + 支持 JPG / PNG,建议 16:9 比例 +
+ )} + { + const file = e.target.files?.[0] + if (file) onFileChange(file) + }} + /> +
+
+) diff --git a/apps/web/src/pages/editing-planner/components/cover-selector/index.tsx b/apps/web/src/pages/editing-planner/components/cover-selector/index.tsx new file mode 100644 index 000000000..6bb5a6d96 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/cover-selector/index.tsx @@ -0,0 +1,146 @@ +/** + * 封面选择器 + * 抽帧选封面 + 上传自定义封面 + 智能封面推荐 + */ +import React from "react" +import { Drawer } from "antd" +import type { CoverConfig, CoverMode } from "../../types" +import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector" +import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels" + +interface CoverSelectorProps { + open: boolean + onClose: () => void + config: CoverConfig + onChange: (config: CoverConfig) => void + totalDuration: number +} + +const CoverSelector: React.FC = ({ + open, + onClose, + config, + onChange, + totalDuration, +}) => { + const { + fileInputRef, + isDragging, + setIsDragging, + update, + handleReset, + handleModeChange, + handleFileUpload, + handleDrop, + handleUseAiSuggestion, + formatTime, + } = useCoverSelector({ config, onChange }) + + return ( + + {/* 顶部开关 */} +
+ 启用自定义封面 + +
+ + {/* 模式选择 */} +
+
封面来源
+
+ {(["auto", "frame", "upload"] as CoverMode[]).map((m) => ( + + ))} +
+
+ + {/* 模式内容区 */} +
+ {config.mode === "auto" && ( + + )} + + {config.mode === "frame" && ( + update({ frame_time: t })} + /> + )} + + {config.mode === "upload" && ( + { + e.preventDefault() + setIsDragging(true) + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + onAreaClick={() => fileInputRef.current?.click()} + onFileChange={handleFileUpload} + /> + )} +
+ + {/* 封面预览 */} +
+
封面预览
+
+ {config.upload_url ? ( + 封面预览 + ) : ( +
+ 🖼️ + + {config.mode === "auto" + ? "AI 智能选择" + : config.mode === "frame" + ? `帧 ${formatTime(config.frame_time)}` + : "未上传封面"} + +
+ )} +
16:9
+
+
+ + {/* 底部 */} +
+ +
+
+ ) +} + +export default CoverSelector diff --git a/apps/web/src/pages/editing-planner/components/cover-selector/useCoverSelector.ts b/apps/web/src/pages/editing-planner/components/cover-selector/useCoverSelector.ts new file mode 100644 index 000000000..dbb649e22 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/cover-selector/useCoverSelector.ts @@ -0,0 +1,98 @@ +import { useCallback, useRef, useState } from "react" +import type { CoverConfig, CoverMode } from "../../types" +import { DEFAULT_COVER_CONFIG } from "../../types" + +/** 封面模式标签 */ +export const MODE_LABELS: Record = { + auto: "智能封面", + frame: "抽帧选封面", + upload: "上传封面", +} + +/** 封面模式图标 */ +export const MODE_ICONS: Record = { + auto: "🤖", + frame: "🎞️", + upload: "📤", +} + +interface UseCoverSelectorOptions { + config: CoverConfig + onChange: (config: CoverConfig) => void +} + +/** + * 封面选择器 Hook + * 封装状态管理、文件上传、模式切换等逻辑 + */ +export function useCoverSelector({ config, onChange }: UseCoverSelectorOptions) { + const fileInputRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + + const update = useCallback( + (partial: Partial) => { + onChange({ ...config, ...partial }) + }, + [config, onChange], + ) + + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled }) + }, [config.enabled, onChange]) + + const handleModeChange = useCallback( + (mode: CoverMode) => { + update({ mode }) + }, + [update], + ) + + const handleFileUpload = useCallback( + (file: File) => { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (e) => { + const url = e.target?.result as string + update({ upload_url: url, thumbnail_url: url, mode: "upload" }) + } + reader.readAsDataURL(file) + }, + [update], + ) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files[0] + if (file) handleFileUpload(file) + }, + [handleFileUpload], + ) + + const handleUseAiSuggestion = useCallback(() => { + if (config.ai_suggested_time !== null) { + update({ frame_time: config.ai_suggested_time, mode: "frame" }) + } + }, [config.ai_suggested_time, update]) + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60) + const s = Math.floor(seconds % 60) + const ms = Math.floor((seconds % 1) * 10) + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}` + } + + return { + fileInputRef, + isDragging, + setIsDragging, + update, + handleReset, + handleModeChange, + handleFileUpload, + handleDrop, + handleUseAiSuggestion, + formatTime, + } +} diff --git a/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx b/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx old mode 100755 new mode 100644 index 2fb7842c5..7800c4f8c --- a/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx +++ b/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx @@ -1,286 +1,5 @@ /** - * 混剪单图层配置区 + * LayerConfig 入口(向后兼容) + * 实际实现位于 ./layer-config/ 目录 */ -import React from "react" -import type { - PipLayer, - PipAnimType, - PipSlideDirection, - PipGridPosition, -} from "@/pages/editing-planner/types" -import { - GRID_POSITIONS, - ANIM_OPTIONS, - SLIDE_DIR_OPTIONS, - LAYER_COLORS, -} from "@/pages/editing-planner/constants/pipConfig" - -interface LayerConfigProps { - layer: PipLayer | null - layers: PipLayer[] - totalDuration: number - onUpdate: (id: string, partial: Partial) => void - onGridClick: (pos: PipGridPosition) => void - onWidthChange: (val: number) => void - onHeightChange: (val: number) => void -} - -const LayerConfig: React.FC = ({ - layer, - layers, - totalDuration, - onUpdate, - onGridClick, - onWidthChange, - onHeightChange, -}) => { - if (!layer) { - return ( -
-
选择或添加图层以配置
-
- ) - } - - return ( -
- {/* ── 迷你预览 ── */} -
- {layers.map((l, idx) => ( -
- {l.name} -
- ))} -
- - {/* ── 素材类型 ── */} -
- -
- - -
-
- - {/* ── 素材 URL ── */} -
- - onUpdate(layer.id, { material_url: e.target.value })} - /> -
- - {/* ── 位置:九宫格 + 坐标 ── */} -
- -
-
- {GRID_POSITIONS.map((pos) => ( - - ))} -
-
-
- - onUpdate(layer.id, { x: Number(e.target.value) })} - /> -
-
- - onUpdate(layer.id, { y: Number(e.target.value) })} - /> -
-
-
-
- - {/* ── 尺寸 ── */} -
- -
- - onWidthChange(Number(e.target.value))} - /> - {layer.width}% -
-
- - onHeightChange(Number(e.target.value))} - /> - {layer.height}% -
-
onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })} - > - {layer.aspect_lock ? "🔒" : "🔓"} - {layer.aspect_lock ? "已锁定比例" : "锁定宽高比"} -
-
- - {/* ── 圆角 ── */} -
- -
- onUpdate(layer.id, { border_radius: Number(e.target.value) })} - /> - {layer.border_radius}% -
-
- - {/* ── 透明度 ── */} -
- -
- onUpdate(layer.id, { opacity: Number(e.target.value) })} - /> - {layer.opacity}% -
-
- - {/* ── 时间 ── */} -
- -
-
- - onUpdate(layer.id, { start_time: Number(e.target.value) })} - /> -
-
- - onUpdate(layer.id, { duration: Number(e.target.value) })} - /> -
-
-
- - {/* ── 入场动画 ── */} -
- - -
- - {/* 滑入方向(仅 slide_in 时显示) */} - {layer.animation === "slide_in" && ( -
- - -
- )} -
- ) -} - -export default LayerConfig +export { default } from "./layer-config" diff --git a/apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerPositionSize.tsx b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerPositionSize.tsx new file mode 100644 index 000000000..6c6eb10a6 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerPositionSize.tsx @@ -0,0 +1,172 @@ +import React from "react" +import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types" +import { GRID_POSITIONS } from "@/pages/editing-planner/constants/pipConfig" + +interface LayerPositionSizeProps { + layer: PipLayer + onUpdate: (id: string, partial: Partial) => void + onGridClick: (pos: PipGridPosition) => void + onWidthChange: (val: number) => void + onHeightChange: (val: number) => void +} + +/** + * 图层位置与尺寸配置面板 + */ +export const LayerPositionSize: React.FC = ({ + layer, + onUpdate, + onGridClick, + onWidthChange, + onHeightChange, +}) => ( + <> + {/* 素材类型 */} +
+ +
+ + +
+
+ + {/* 素材 URL */} +
+ + onUpdate(layer.id, { material_url: e.target.value })} + /> +
+ + {/* 位置:九宫格 + 坐标 */} +
+ +
+
+ {GRID_POSITIONS.map((pos) => ( + + ))} +
+
+
+ + onUpdate(layer.id, { x: Number(e.target.value) })} + /> +
+
+ + onUpdate(layer.id, { y: Number(e.target.value) })} + /> +
+
+
+
+ + {/* 尺寸 */} +
+ +
+ + onWidthChange(Number(e.target.value))} + /> + {layer.width}% +
+
+ + onHeightChange(Number(e.target.value))} + /> + {layer.height}% +
+
onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })} + > + {layer.aspect_lock ? "🔒" : "🔓"} + {layer.aspect_lock ? "已锁定比例" : "锁定宽高比"} +
+
+ + {/* 圆角 */} +
+ +
+ onUpdate(layer.id, { border_radius: Number(e.target.value) })} + /> + {layer.border_radius}% +
+
+ + {/* 透明度 */} +
+ +
+ onUpdate(layer.id, { opacity: Number(e.target.value) })} + /> + {layer.opacity}% +
+
+ +) diff --git a/apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerTimingAnimation.tsx b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerTimingAnimation.tsx new file mode 100644 index 000000000..a9138f5bc --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerTimingAnimation.tsx @@ -0,0 +1,87 @@ +import React from "react" +import type { PipLayer, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types" +import { ANIM_OPTIONS, SLIDE_DIR_OPTIONS } from "@/pages/editing-planner/constants/pipConfig" + +interface LayerTimingAnimationProps { + layer: PipLayer + totalDuration: number + onUpdate: (id: string, partial: Partial) => void +} + +/** + * 图层时间与动画配置面板 + */ +export const LayerTimingAnimation: React.FC = ({ + layer, + totalDuration, + onUpdate, +}) => ( + <> + {/* 时间 */} +
+ +
+
+ + onUpdate(layer.id, { start_time: Number(e.target.value) })} + /> +
+
+ + onUpdate(layer.id, { duration: Number(e.target.value) })} + /> +
+
+
+ + {/* 入场动画 */} +
+ + +
+ + {/* 滑入方向(仅 slide_in 时显示) */} + {layer.animation === "slide_in" && ( +
+ + +
+ )} + +) diff --git a/apps/web/src/pages/editing-planner/components/pip-config/layer-config/PipPreview.tsx b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/PipPreview.tsx new file mode 100644 index 000000000..6f4356eec --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/PipPreview.tsx @@ -0,0 +1,33 @@ +import React from "react" +import type { PipLayer } from "@/pages/editing-planner/types" +import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig" + +interface PipPreviewProps { + layers: PipLayer[] + selectedId: string +} + +/** + * PIP 图层迷你预览组件 + */ +export const PipPreview: React.FC = ({ layers, selectedId }) => ( +
+ {layers.map((l, idx) => ( +
+ {l.name} +
+ ))} +
+) diff --git a/apps/web/src/pages/editing-planner/components/pip-config/layer-config/index.tsx b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/index.tsx new file mode 100644 index 000000000..4f522e8cb --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/index.tsx @@ -0,0 +1,57 @@ +/** + * 混剪单图层配置区 + */ +import React from "react" +import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types" +import { PipPreview } from "./PipPreview" +import { LayerPositionSize } from "./LayerPositionSize" +import { LayerTimingAnimation } from "./LayerTimingAnimation" + +interface LayerConfigProps { + layer: PipLayer | null + layers: PipLayer[] + totalDuration: number + onUpdate: (id: string, partial: Partial) => void + onGridClick: (pos: PipGridPosition) => void + onWidthChange: (val: number) => void + onHeightChange: (val: number) => void +} + +const LayerConfig: React.FC = ({ + layer, + layers, + totalDuration, + onUpdate, + onGridClick, + onWidthChange, + onHeightChange, +}) => { + if (!layer) { + return ( +
+
选择或添加图层以配置
+
+ ) + } + + return ( +
+ {/* 迷你预览 */} + + + {/* 位置与尺寸 */} + + + {/* 时间与动画 */} + +
+ ) +} + +export default LayerConfig diff --git a/apps/web/src/pages/editing-planner/hooks/template-management/usePlanLoading.ts b/apps/web/src/pages/editing-planner/hooks/template-management/usePlanLoading.ts new file mode 100644 index 000000000..8f15ce36c --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/template-management/usePlanLoading.ts @@ -0,0 +1,157 @@ +import { useEffect, type Dispatch, type SetStateAction } from "react" +import { message } from "antd" +import { getEditPlan, getEditPlanClips } from "@/api/template-editor" +import type { ClipData, ClipType, TtsConfig, TtsMode, TrimConfig } from "../../types" +import type { SubtitleStyleConfig } from "../../types/subtitle" +import type { TitleConfig } from "@/api/template-editor" +import type { BgmMixConfig } from "@/api/bgm" +import type { TransitionEffect } from "@/api/template-editor" +import type { CoverConfig } from "../../types" + +interface UsePlanLoadingOptions { + loadedPlanId: string | null + resetClips: (clips: ClipData[]) => void + setLoadedTemplateId: (id: string | null) => void + setDraftName: (name: string) => void + setTitleConfig: Dispatch> + setSubtitleSettings: Dispatch> + setBgmSettings: Dispatch> + setCoverConfig: Dispatch> +} + +/** + * 加载已有计划草稿数据到编辑器 + * 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置 + */ +export function usePlanLoading({ + loadedPlanId, + resetClips, + setLoadedTemplateId, + setDraftName, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setCoverConfig, +}: UsePlanLoadingOptions) { + useEffect(() => { + if (!loadedPlanId) return + + Promise.all([ + getEditPlan(loadedPlanId), + getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({ + items: [], + total: 0, + })), + ]) + .then(([plan, clipsRes]) => { + setLoadedTemplateId(plan.template_id) + setDraftName(plan.name) + + const cfg = plan.config + if (cfg.title_config) { + setTitleConfig({ + ai_auto_select: cfg.title_config!.ai_auto_select, + content: cfg.title_config!.content, + position: cfg.title_config!.position, + font_preset: cfg.title_config!.font_preset, + font_size: cfg.title_config!.font_size, + font_color: cfg.title_config!.font_color || "#ffffff", + }) + } + if (cfg.subtitle_config) { + setSubtitleSettings((prev) => ({ + ...prev, + enabled: cfg.subtitle_config!.enabled, + position: (cfg.subtitle_config!.position || + "bottom") as SubtitleStyleConfig["position"], + font: cfg.subtitle_config!.font, + fontSize: cfg.subtitle_config!.size, + fontColor: cfg.subtitle_config!.color || "#ffffff", + animation: cfg.subtitle_config!.animation, + })) + } + if (cfg.bgm_config) { + setBgmSettings((prev) => ({ + ...prev, + enabled: cfg.bgm_config!.enabled, + music_id: cfg.bgm_config!.music_id || "", + })) + } + if (cfg.cover_config) { + setCoverConfig((prev: CoverConfig) => ({ + ...prev, + enabled: cfg.cover_config!.enabled ?? prev.enabled, + mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode, + frame_time: cfg.cover_config!.frame_time ?? prev.frame_time, + upload_url: cfg.cover_config!.upload_url || prev.upload_url, + thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url, + ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time, + })) + } + + /* 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 */ + const backendClips = clipsRes?.items || [] + if (backendClips.length > 0) { + const sorted = [...backendClips].sort((a, b) => a.order - b.order) + const mapped: ClipData[] = sorted.map((clip) => ({ + id: clip.id, + template_segment_id: (clip.config?.template_segment_id as string) || "", + type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType, + duration: clip.duration || 3, + startOffset: 0, + script_text: clip.text_content || "", + order: clip.order, + media_asset_id: clip.asset_id || undefined, + transition: + clip.transition_effect && clip.transition_effect !== "none" + ? { + type: clip.transition_effect as TransitionEffect["type"], + duration: clip.transition_duration || 0.3, + } + : undefined, + speed: clip.playback_speed + ? { rate: clip.playback_speed, pitchCorrection: true } + : undefined, + tts_config: (clip.config?.tts_config as TtsConfig) || undefined, + trim_config: (clip.config?.trim_config as TrimConfig) || undefined, + })) + setTimeout(() => resetClips(mapped), 100) + } else if (cfg.segments && cfg.segments.length > 0) { + /* 兜底:从 config.segments 还原(老数据兼容) */ + const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({ + id: `seg-${idx}`, + template_segment_id: `seg-${idx}`, + type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType, + duration: (seg.duration_min + seg.duration_max) / 2, + startOffset: 0, + script_text: "", + order: seg.segment_order, + transition: seg.transition + ? { + type: seg.transition.type as TransitionEffect["type"], + duration: seg.transition.duration, + } + : undefined, + speed: seg.playback_speed + ? { rate: seg.playback_speed, pitchCorrection: true } + : undefined, + tts_config: seg.tts_config + ? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode } + : undefined, + trim_config: seg.trim_config || undefined, + })) + setTimeout(() => resetClips(mapped), 100) + } + }) + .catch(() => message.error("加载模板草稿失败")) + }, [ + loadedPlanId, + resetClips, + setLoadedTemplateId, + setDraftName, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setCoverConfig, + ]) +} diff --git a/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateDetail.ts b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateDetail.ts new file mode 100644 index 000000000..f3ce6c19f --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateDetail.ts @@ -0,0 +1,91 @@ +import { useEffect, type Dispatch, type SetStateAction } from "react" +import { message } from "antd" +import { getEditingTemplate, type TemplateMode } from "@/api/editing-planner" +import type { TitleConfig } from "@/api/template-editor" +import type { ClipData, ClipType } from "../../types" +import type { SubtitleStyleConfig } from "../../types/subtitle" +import type { BgmMixConfig } from "@/api/bgm" + +interface UseTemplateDetailOptions { + loadedTemplateId: string | null + resetClips: (clips: ClipData[]) => void + setCurrentMode: (mode: TemplateMode) => void + setTitleConfig: Dispatch> + setSubtitleSettings: Dispatch> + setBgmSettings: Dispatch> + setDraftName: (name: string) => void + setDraftCategory: (cat: string) => void + setDraftTags: (tags: string) => void +} + +/** + * 加载模板详情并初始化片段列表 + 配置 + */ +export function useTemplateDetail({ + loadedTemplateId, + resetClips, + setCurrentMode, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setDraftName, + setDraftCategory, + setDraftTags, +}: UseTemplateDetailOptions) { + useEffect(() => { + if (!loadedTemplateId) return + getEditingTemplate(loadedTemplateId) + .then((tpl) => { + if (!tpl) return + setCurrentMode(tpl.mode) + + const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({ + id: seg.id || `seg-${idx}`, + template_segment_id: seg.id || `seg-${idx}`, + type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType, + duration: (seg.duration_min + seg.duration_max) / 2, + startOffset: 0, + script_text: "", + order: seg.segment_order, + })) + resetClips(mapped) + + setTitleConfig({ + ai_auto_select: tpl.title_config.ai_auto_select, + content: tpl.title_config.content, + position: tpl.title_config.position, + font_preset: tpl.title_config.font_preset, + font_size: tpl.title_config.font_size, + font_color: tpl.title_config.font_color || "#ffffff", + }) + setSubtitleSettings((prev) => ({ + ...prev, + enabled: tpl.subtitle_config.enabled, + position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"], + font: tpl.subtitle_config.font, + fontSize: tpl.subtitle_config.size, + fontColor: tpl.subtitle_config.color || "#ffffff", + animation: tpl.subtitle_config.animation, + })) + setBgmSettings((prev) => ({ + ...prev, + enabled: tpl.bgm_config.enabled, + music_id: tpl.bgm_config.music_id || "", + })) + setDraftName(tpl.name) + setDraftCategory(tpl.category) + setDraftTags(tpl.tags.join(", ")) + }) + .catch(() => message.error("加载模板详情失败")) + }, [ + loadedTemplateId, + resetClips, + setCurrentMode, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setDraftName, + setDraftCategory, + setDraftTags, + ]) +} diff --git a/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateList.ts b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateList.ts new file mode 100644 index 000000000..2e09e1aca --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateList.ts @@ -0,0 +1,82 @@ +import { useState, useCallback, useEffect, useMemo } from "react" +import { message } from "antd" +import { + getEditingTemplates, + getTemplateCategories, + type EditingTemplate, + type TemplateCategory, +} from "@/api/editing-planner" +import { getMediaAssets, type MediaAsset } from "@/api/template-editor" +import { FILTER_CATEGORIES } from "../../constants" + +/** + * 模板列表 + 分类 + 筛选搜索 + */ +export function useTemplateList( + setMediaAssets: (assets: MediaAsset[]) => void, + initialTemplateId: string | null, +) { + const [templates, setTemplates] = useState([]) + const [categories, setCategories] = useState([]) + const [loadingTemplates, setLoadingTemplates] = useState(false) + const [currentFilter, setCurrentFilter] = useState("全部") + const [searchQuery, setSearchQuery] = useState("") + const [loadedTemplateId, setLoadedTemplateId] = useState(initialTemplateId) + + /** + * 并行加载模板列表、分类、素材库 + * 三个接口无依赖关系,用 Promise.all 并发 + */ + const loadTemplates = useCallback(async () => { + setLoadingTemplates(true) + try { + const [tpls, cats, assets] = await Promise.all([ + getEditingTemplates(), + getTemplateCategories(), + getMediaAssets(), + ]) + setTemplates(tpls) + setCategories(cats) + setMediaAssets(assets) + } catch { + message.error("加载模板失败") + } finally { + setLoadingTemplates(false) + } + }, [setMediaAssets]) + + useEffect(() => { + loadTemplates() + }, [loadTemplates]) + + const filteredTemplates = useMemo( + () => + templates.filter((t) => { + if (currentFilter !== "全部" && t.category !== currentFilter) return false + if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false + return true + }), + [templates, currentFilter, searchQuery], + ) + + const currentTemplate = useMemo( + () => templates.find((t) => t.id === loadedTemplateId) || null, + [templates, loadedTemplateId], + ) + + return { + templates, + categories, + loadingTemplates, + loadedTemplateId, + setLoadedTemplateId, + currentFilter, + setCurrentFilter, + searchQuery, + setSearchQuery, + filteredTemplates, + currentTemplate, + loadTemplates, + FILTER_CATEGORIES, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateSave.ts b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateSave.ts new file mode 100644 index 000000000..0ba6f2223 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateSave.ts @@ -0,0 +1,184 @@ +import { useState, useCallback } from "react" +import { message } from "antd" +import { + createEditingTemplate, + updateEditingTemplate, + type SaveTemplatePayload, + type TemplateMode, +} from "@/api/editing-planner" +import type { + ClipData, + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, +} from "../../types" +import type { SubtitleStyleConfig } from "../../types/subtitle" +import type { TitleConfig } from "@/api/template-editor" +import type { CoverConfig } from "../../types" +import type { BgmMixConfig } from "@/api/bgm" + +interface UseTemplateSaveOptions { + currentMode: TemplateMode + clips: ClipData[] + totalDuration: number + titleConfig: TitleConfig + subtitleSettings: SubtitleStyleConfig + bgmSettings: BgmMixConfig + watermarkSettings: WatermarkConfig + introOutroSettings: IntroOutroConfig + pipSettings: PipConfig + filterSettings: FilterConfig + chromaKeySettings: ChromaKeyConfig + stickerSettings: StickerConfig + coverConfig: CoverConfig + loadedTemplateId: string | null + loadTemplates: () => Promise +} + +/** + * 模板保存(创建/更新) + */ +export function useTemplateSave(options: UseTemplateSaveOptions) { + const { + currentMode, + clips, + totalDuration, + titleConfig, + subtitleSettings, + bgmSettings, + watermarkSettings, + introOutroSettings, + pipSettings, + filterSettings, + chromaKeySettings, + stickerSettings, + coverConfig, + loadedTemplateId, + loadTemplates, + } = options + + const [saveModalOpen, setSaveModalOpen] = useState(false) + const [draftName, setDraftName] = useState("") + const [draftCategory, setDraftCategory] = useState("") + const [draftTags, setDraftTags] = useState("") + const [saveLoading, setSaveLoading] = useState(false) + + const handleOpenSaveModal = useCallback(() => { + setSaveModalOpen(true) + }, []) + + const handleSave = useCallback(async () => { + if (!draftName.trim()) { + message.warning("请输入模板名称") + return + } + setSaveLoading(true) + try { + const payload: SaveTemplatePayload = { + name: draftName, + mode: currentMode, + category: draftCategory, + tags: draftTags + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + title_config: titleConfig, + subtitle_config: { + enabled: subtitleSettings.enabled, + position: subtitleSettings.position, + font: subtitleSettings.font, + color: subtitleSettings.fontColor, + size: subtitleSettings.fontSize, + animation: subtitleSettings.animation, + }, + bgm_config: { + enabled: bgmSettings.enabled, + music_id: bgmSettings.music_id, + }, + estimated_duration: totalDuration, + segments: clips.map((c, i) => ({ + segment_order: i, + duration_min: Math.max(1, c.duration - 2), + duration_max: c.duration + 2, + material_type: c.type === "voice" ? "voiceover" : "video", + transition: c.transition + ? { type: c.transition.type, duration: c.transition.duration } + : undefined, + playback_speed: c.speed ? c.speed.rate : undefined, + tts_config: c.tts_config + ? { + mode: c.tts_config.mode, + text: c.tts_config.text, + voice_id: c.tts_config.voice_id, + speed: c.tts_config.speed, + pitch: c.tts_config.pitch, + volume: c.tts_config.volume, + subtitle_sync: c.tts_config.subtitle_sync, + } + : undefined, + trim_config: c.trim_config + ? { + start_time: c.trim_config.start_time, + end_time: c.trim_config.end_time, + } + : undefined, + })), + watermark_config: { ...watermarkSettings }, + intro_outro_config: { ...introOutroSettings }, + pip_config: { ...pipSettings }, + filter_config: { ...filterSettings }, + green_screen_config: { ...chromaKeySettings }, + sticker_config: { ...stickerSettings }, + cover_config: { ...coverConfig }, + } + if (loadedTemplateId) { + await updateEditingTemplate(loadedTemplateId, payload) + } else { + await createEditingTemplate(payload) + } + message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功") + setSaveModalOpen(false) + loadTemplates() + } catch { + message.error("保存失败") + } finally { + setSaveLoading(false) + } + }, [ + draftName, + draftCategory, + draftTags, + currentMode, + clips, + totalDuration, + titleConfig, + subtitleSettings, + bgmSettings, + watermarkSettings, + introOutroSettings, + pipSettings, + filterSettings, + chromaKeySettings, + stickerSettings, + coverConfig, + loadedTemplateId, + loadTemplates, + ]) + + return { + saveModalOpen, + setSaveModalOpen, + draftName, + setDraftName, + draftCategory, + setDraftCategory, + draftTags, + setDraftTags, + saveLoading, + handleOpenSaveModal, + handleSave, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/useClipOperations.ts b/apps/web/src/pages/editing-planner/hooks/useClipOperations.ts deleted file mode 100644 index ad1dade67..000000000 --- a/apps/web/src/pages/editing-planner/hooks/useClipOperations.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { useState, useCallback, useMemo } from "react" -import { Modal, message } from "antd" -import type { - ClipData, - ClipType, - TransitionConfig, - SpeedConfig, - TtsConfig, - TrimConfig, -} from "../types" -import type { AssetItem } from "@/api/assets" - -interface UseClipOperationsParams { - clips: ClipData[] - setClips: (updater: (prev: ClipData[]) => ClipData[]) => void -} - -/** - * 片段操作 Hook - * 片段增删改查、排序、裁剪、分割、转场、调速、TTS、配音选择 - */ -export const useClipOperations = ({ clips, setClips }: UseClipOperationsParams) => { - const [selectedClipId, setSelectedClipId] = useState(null) - - const selectedClip = useMemo( - () => clips.find((c) => c.id === selectedClipId) || null, - [clips, selectedClipId], - ) - - /* ── 选中 / 重排 / 删除 ── */ - - const handleClipSelect = useCallback((clipId: string) => { - setSelectedClipId(clipId) - }, []) - - const handleClipReorder = useCallback( - (fromIdx: number, toIdx: number) => { - setClips((prev) => { - const updated = [...prev] - const [moved] = updated.splice(fromIdx, 1) - updated.splice(toIdx, 0, moved) - return updated.map((c, i) => ({ ...c, order: i })) - }) - }, - [setClips], - ) - - const handleClipRemove = useCallback( - (clipId: string) => { - Modal.confirm({ - title: "删除片段", - content: "确定要删除这个片段吗?此操作可通过撤销恢复。", - okText: "删除", - okType: "danger", - cancelText: "取消", - onOk: () => { - setClips((prev) => prev.filter((c) => c.id !== clipId)) - if (selectedClipId === clipId) setSelectedClipId(null) - }, - }) - }, - [setClips, selectedClipId], - ) - - const handleClipUpdate = useCallback( - (clipId: string, data: Partial) => { - setClips((prev) => prev.map((c) => (c.id === clipId ? { ...c, ...data } : c))) - }, - [setClips], - ) - - /** - * 添加片段(不绑定任何素材) - * 片段 = 时间规划 + 类型标记 - */ - const handleAddClip = useCallback( - (type: ClipType, duration: number) => { - const newClip: ClipData = { - id: `clip-${Date.now()}`, - type, - duration, - startOffset: 0, - order: clips.length, - } - setClips((prev) => [...prev, newClip]) - }, - [clips.length, setClips], - ) - - /* ── 裁剪 / 分割 / 重置 ── */ - - const handleClipTrim = useCallback( - (clipId: string, trimConfig: TrimConfig, newDuration: number) => { - setClips((prev) => - prev.map((c) => - c.id === clipId ? { ...c, trim_config: trimConfig, duration: newDuration } : c, - ), - ) - }, - [setClips], - ) - - const handleClipSplit = useCallback( - (clipId: string, splitRatio: number) => { - setClips((prev) => { - const idx = prev.findIndex((c) => c.id === clipId) - if (idx === -1) return prev - const clip = prev[idx] - const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10 - if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev - - // 前半段 - const firstHalf: ClipData = { - ...clip, - duration: splitPoint, - trim_config: clip.trim_config - ? { - ...clip.trim_config, - end_time: clip.trim_config.start_time + splitPoint, - } - : undefined, - } - - // 后半段 - const secondHalf: ClipData = { - ...clip, - id: `clip-${Date.now()}`, - duration: clip.duration - splitPoint, - startOffset: clip.startOffset + splitPoint, - trim_config: clip.trim_config - ? { - ...clip.trim_config, - start_time: clip.trim_config.start_time + splitPoint, - } - : undefined, - order: (clip.order ?? idx) + 1, - } - - const updated = [...prev] - updated[idx] = firstHalf - updated.splice(idx + 1, 0, secondHalf) - return updated.map((c, i) => ({ ...c, order: i })) - }) - }, - [setClips], - ) - - const handleClipResetTrim = useCallback( - (clipId: string) => { - setClips((prev) => - prev.map((c) => { - if (c.id !== clipId || !c.trim_config) return c - const originalDuration = c.trim_config.original_duration ?? c.duration - return { - ...c, - duration: originalDuration, - trim_config: undefined, - } - }), - ) - }, - [setClips], - ) - - /* ── 转场 / 调速 / TTS ── */ - - const handleTransitionChange = useCallback( - (targetClipId: string | null, config: TransitionConfig) => { - if (targetClipId) { - handleClipUpdate(targetClipId, { transition: config }) - } - // 同时更新全局默认转场(供新片段使用)—— 暂未实现全局默认 - }, - [handleClipUpdate], - ) - - const handleSpeedChange = useCallback( - (targetClipId: string | null, config: SpeedConfig) => { - if (targetClipId) { - handleClipUpdate(targetClipId, { speed: config }) - } - }, - [handleClipUpdate], - ) - - const handleApplySpeedAll = useCallback( - (config: SpeedConfig) => { - setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } }))) - message.success("已应用到所有片段") - }, - [setClips], - ) - - const handleTtsChange = useCallback( - (targetClipId: string | null, ttsConfig: TtsConfig) => { - if (!targetClipId) return - handleClipUpdate(targetClipId, { tts_config: ttsConfig }) - }, - [handleClipUpdate], - ) - - /** 为片段选择配音素材 */ - const handleClipVoiceSelect = useCallback( - (clipId: string, asset: AssetItem | null) => { - setClips((prev) => - prev.map((c) => - c.id === clipId - ? { - ...c, - voice_asset_id: asset?.id ?? undefined, - voice_file_url: asset?.file_url ?? undefined, - } - : c, - ), - ) - }, - [setClips], - ) - - return { - selectedClipId, - setSelectedClipId, - selectedClip, - handleClipSelect, - handleClipReorder, - handleClipRemove, - handleClipUpdate, - handleAddClip, - handleClipTrim, - handleClipSplit, - handleClipResetTrim, - handleTransitionChange, - handleSpeedChange, - handleApplySpeedAll, - handleTtsChange, - handleClipVoiceSelect, - } -} diff --git a/apps/web/src/pages/editing-planner/hooks/useClipOperations/index.ts b/apps/web/src/pages/editing-planner/hooks/useClipOperations/index.ts new file mode 100644 index 000000000..4ea722310 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useClipOperations/index.ts @@ -0,0 +1,38 @@ +import type { ClipData } from "../../types" +import { useClipBasicOps } from "./useClipBasicOps" +import { useClipTrim } from "./useClipTrim" +import { useClipEffects } from "./useClipEffects" + +interface UseClipOperationsParams { + clips: ClipData[] + setClips: (updater: (prev: ClipData[]) => ClipData[]) => void +} + +/** + * 片段操作 Hook + * 片段增删改查、排序、裁剪、分割、转场、调速、TTS、配音选择 + */ +export const useClipOperations = ({ clips, setClips }: UseClipOperationsParams) => { + const basicOps = useClipBasicOps({ clips, setClips }) + const trimOps = useClipTrim({ setClips }) + const effectOps = useClipEffects({ setClips, handleClipUpdate: basicOps.handleClipUpdate }) + + return { + selectedClipId: basicOps.selectedClipId, + setSelectedClipId: basicOps.setSelectedClipId, + selectedClip: basicOps.selectedClip, + handleClipSelect: basicOps.handleClipSelect, + handleClipReorder: basicOps.handleClipReorder, + handleClipRemove: basicOps.handleClipRemove, + handleClipUpdate: basicOps.handleClipUpdate, + handleAddClip: basicOps.handleAddClip, + handleClipTrim: trimOps.handleClipTrim, + handleClipSplit: trimOps.handleClipSplit, + handleClipResetTrim: trimOps.handleClipResetTrim, + handleTransitionChange: effectOps.handleTransitionChange, + handleSpeedChange: effectOps.handleSpeedChange, + handleApplySpeedAll: effectOps.handleApplySpeedAll, + handleTtsChange: effectOps.handleTtsChange, + handleClipVoiceSelect: effectOps.handleClipVoiceSelect, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/useClipOperations/useClipBasicOps.ts b/apps/web/src/pages/editing-planner/hooks/useClipOperations/useClipBasicOps.ts new file mode 100644 index 000000000..015541705 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useClipOperations/useClipBasicOps.ts @@ -0,0 +1,86 @@ +import { useState, useMemo, useCallback } from "react" +import { Modal } from "antd" +import type { ClipData, ClipType } from "../../types" + +interface UseClipBasicOpsParams { + clips: ClipData[] + setClips: (updater: (prev: ClipData[]) => ClipData[]) => void +} + +/** + * 片段基础操作 Hook + * 选中状态、增删改查、重排 + */ +export function useClipBasicOps({ clips, setClips }: UseClipBasicOpsParams) { + const [selectedClipId, setSelectedClipId] = useState(null) + + const selectedClip = useMemo( + () => clips.find((c) => c.id === selectedClipId) || null, + [clips, selectedClipId], + ) + + const handleClipSelect = useCallback((clipId: string) => { + setSelectedClipId(clipId) + }, []) + + const handleClipReorder = useCallback( + (fromIdx: number, toIdx: number) => { + setClips((prev) => { + const updated = [...prev] + const [moved] = updated.splice(fromIdx, 1) + updated.splice(toIdx, 0, moved) + return updated.map((c, i) => ({ ...c, order: i })) + }) + }, + [setClips], + ) + + const handleClipRemove = useCallback( + (clipId: string) => { + Modal.confirm({ + title: "删除片段", + content: "确定要删除这个片段吗?此操作可通过撤销恢复。", + okText: "删除", + okType: "danger", + cancelText: "取消", + onOk: () => { + setClips((prev) => prev.filter((c) => c.id !== clipId)) + if (selectedClipId === clipId) setSelectedClipId(null) + }, + }) + }, + [setClips, selectedClipId], + ) + + const handleClipUpdate = useCallback( + (clipId: string, data: Partial) => { + setClips((prev) => prev.map((c) => (c.id === clipId ? { ...c, ...data } : c))) + }, + [setClips], + ) + + const handleAddClip = useCallback( + (type: ClipType, duration: number) => { + const newClip: ClipData = { + id: `clip-${Date.now()}`, + type, + duration, + startOffset: 0, + order: clips.length, + } + setClips((prev) => [...prev, newClip]) + }, + [clips.length, setClips], + ) + + return { + selectedClipId, + setSelectedClipId, + selectedClip, + handleClipSelect, + handleClipReorder, + handleClipRemove, + handleClipUpdate, + handleAddClip, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/useClipOperations/useClipEffects.ts b/apps/web/src/pages/editing-planner/hooks/useClipOperations/useClipEffects.ts new file mode 100644 index 000000000..f192d07c0 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useClipOperations/useClipEffects.ts @@ -0,0 +1,74 @@ +import { useCallback } from "react" +import { message } from "antd" +import type { ClipData, TransitionConfig, SpeedConfig, TtsConfig } from "../../types" +import type { AssetItem } from "@/api/assets" + +interface UseClipEffectsParams { + setClips: (updater: (prev: ClipData[]) => ClipData[]) => void + handleClipUpdate: (clipId: string, data: Partial) => void +} + +/** + * 片段效果操作 Hook + * 转场、调速、TTS、配音选择 + */ +export function useClipEffects({ setClips, handleClipUpdate }: UseClipEffectsParams) { + const handleTransitionChange = useCallback( + (targetClipId: string | null, config: TransitionConfig) => { + if (targetClipId) { + handleClipUpdate(targetClipId, { transition: config }) + } + }, + [handleClipUpdate], + ) + + const handleSpeedChange = useCallback( + (targetClipId: string | null, config: SpeedConfig) => { + if (targetClipId) { + handleClipUpdate(targetClipId, { speed: config }) + } + }, + [handleClipUpdate], + ) + + const handleApplySpeedAll = useCallback( + (config: SpeedConfig) => { + setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } }))) + message.success("已应用到所有片段") + }, + [setClips], + ) + + const handleTtsChange = useCallback( + (targetClipId: string | null, ttsConfig: TtsConfig) => { + if (!targetClipId) return + handleClipUpdate(targetClipId, { tts_config: ttsConfig }) + }, + [handleClipUpdate], + ) + + const handleClipVoiceSelect = useCallback( + (clipId: string, asset: AssetItem | null) => { + setClips((prev) => + prev.map((c) => + c.id === clipId + ? { + ...c, + voice_asset_id: asset?.id ?? undefined, + voice_file_url: asset?.file_url ?? undefined, + } + : c, + ), + ) + }, + [setClips], + ) + + return { + handleTransitionChange, + handleSpeedChange, + handleApplySpeedAll, + handleTtsChange, + handleClipVoiceSelect, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/useClipOperations/useClipTrim.ts b/apps/web/src/pages/editing-planner/hooks/useClipOperations/useClipTrim.ts new file mode 100644 index 000000000..9272d627c --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useClipOperations/useClipTrim.ts @@ -0,0 +1,89 @@ +import { useCallback } from "react" +import type { ClipData, TrimConfig } from "../../types" + +interface UseClipTrimParams { + setClips: (updater: (prev: ClipData[]) => ClipData[]) => void +} + +/** + * 片段裁剪分割 Hook + * 裁剪、分割、重置裁剪 + */ +export function useClipTrim({ setClips }: UseClipTrimParams) { + const handleClipTrim = useCallback( + (clipId: string, trimConfig: TrimConfig, newDuration: number) => { + setClips((prev) => + prev.map((c) => + c.id === clipId ? { ...c, trim_config: trimConfig, duration: newDuration } : c, + ), + ) + }, + [setClips], + ) + + const handleClipSplit = useCallback( + (clipId: string, splitRatio: number) => { + setClips((prev) => { + const idx = prev.findIndex((c) => c.id === clipId) + if (idx === -1) return prev + const clip = prev[idx] + const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10 + if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev + + const firstHalf: ClipData = { + ...clip, + duration: splitPoint, + trim_config: clip.trim_config + ? { + ...clip.trim_config, + end_time: clip.trim_config.start_time + splitPoint, + } + : undefined, + } + + const secondHalf: ClipData = { + ...clip, + id: `clip-${Date.now()}`, + duration: clip.duration - splitPoint, + startOffset: clip.startOffset + splitPoint, + trim_config: clip.trim_config + ? { + ...clip.trim_config, + start_time: clip.trim_config.start_time + splitPoint, + } + : undefined, + order: (clip.order ?? idx) + 1, + } + + const updated = [...prev] + updated[idx] = firstHalf + updated.splice(idx + 1, 0, secondHalf) + return updated.map((c, i) => ({ ...c, order: i })) + }) + }, + [setClips], + ) + + const handleClipResetTrim = useCallback( + (clipId: string) => { + setClips((prev) => + prev.map((c) => { + if (c.id !== clipId || !c.trim_config) return c + const originalDuration = c.trim_config.original_duration ?? c.duration + return { + ...c, + duration: originalDuration, + trim_config: undefined, + } + }), + ) + }, + [setClips], + ) + + return { + handleClipTrim, + handleClipSplit, + handleClipResetTrim, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/useTemplateManagement.ts b/apps/web/src/pages/editing-planner/hooks/useTemplateManagement.ts index b9db52ddf..e84787819 100644 --- a/apps/web/src/pages/editing-planner/hooks/useTemplateManagement.ts +++ b/apps/web/src/pages/editing-planner/hooks/useTemplateManagement.ts @@ -1,27 +1,8 @@ -import { useState, useCallback, useEffect, type Dispatch, type SetStateAction } from "react" -import { FILTER_CATEGORIES } from "../constants" -import { message } from "antd" -import type { - EditingTemplate, - TemplateCategory, - TemplateMode, - SaveTemplatePayload, -} from "@/api/editing-planner" -import { - getEditingTemplates, - getEditingTemplate, - createEditingTemplate, - updateEditingTemplate, - getTemplateCategories, -} from "@/api/editing-planner" -import type { MediaAsset, TitleConfig, TransitionEffect } from "@/api/template-editor" -import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/template-editor" +import { useState, useCallback, type Dispatch, type SetStateAction } from "react" +import type { TemplateMode } from "@/api/editing-planner" +import type { MediaAsset, TitleConfig } from "@/api/template-editor" import type { ClipData, - ClipType, - TtsConfig, - TtsMode, - TrimConfig, WatermarkConfig, IntroOutroConfig, PipConfig, @@ -32,6 +13,11 @@ import type { } from "../types" import type { SubtitleStyleConfig } from "../types/subtitle" import type { BgmMixConfig } from "@/api/bgm" +import { useTemplateList } from "./template-management/useTemplateList" +import { useTemplateDetail } from "./template-management/useTemplateDetail" +import { usePlanLoading } from "./template-management/usePlanLoading" +import { useTemplateSave } from "./template-management/useTemplateSave" +import { FILTER_CATEGORIES } from "../constants" interface UseTemplateManagementParams { urlTemplateId: string @@ -44,7 +30,6 @@ interface UseTemplateManagementParams { setSubtitleSettings: Dispatch> setBgmSettings: Dispatch> setCoverConfig: Dispatch> - // 保存时需要的配置 clips: ClipData[] totalDuration: number titleConfig: TitleConfig @@ -61,7 +46,7 @@ interface UseTemplateManagementParams { /** * 模板管理 Hook - * 模板列表/分类/加载/保存/模式切换/筛选搜索 + 3 个 useEffect + * 模板列表/分类/加载/保存/模式切换/筛选搜索 */ export const useTemplateManagement = (params: UseTemplateManagementParams) => { const { @@ -89,346 +74,101 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => { coverConfig, } = params - /* ── 模板列表 ── */ - const [templates, setTemplates] = useState([]) - const [categories, setCategories] = useState([]) - const [loadingTemplates, setLoadingTemplates] = useState(false) - const [loadedTemplateId, setLoadedTemplateId] = useState(urlTemplateId || null) const [currentMode, setCurrentMode] = useState("pip") - - /* ── 左栏筛选 ── */ - const [currentFilter, setCurrentFilter] = useState("全部") - const [searchQuery, setSearchQuery] = useState("") - - /* ── 保存弹窗 ── */ - const [saveModalOpen, setSaveModalOpen] = useState(false) - const [draftName, setDraftName] = useState("") - const [draftCategory, setDraftCategory] = useState("") - const [draftTags, setDraftTags] = useState("") - const [saveLoading, setSaveLoading] = useState(false) - - /* ── 计划 ID(从 URL 传入,不变) ── */ const [loadedPlanId] = useState(urlPlanId || null) - /* ── 计算 ── */ - const filteredTemplates = templates.filter((t) => { - if (currentFilter !== "全部" && t.category !== currentFilter) return false - if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false - return true + /* ── 模板列表 ── */ + const { + templates, + categories, + loadingTemplates, + loadedTemplateId, + setLoadedTemplateId, + currentFilter, + setCurrentFilter, + searchQuery, + setSearchQuery, + filteredTemplates, + currentTemplate, + loadTemplates, + } = useTemplateList(setMediaAssets, urlTemplateId || null) + + /* ── 保存 ── */ + const { + saveModalOpen, + setSaveModalOpen, + draftName, + setDraftName, + draftCategory, + setDraftCategory, + draftTags, + setDraftTags, + saveLoading, + handleOpenSaveModal, + handleSave, + } = useTemplateSave({ + currentMode, + clips, + totalDuration, + titleConfig, + subtitleSettings, + bgmSettings, + watermarkSettings, + introOutroSettings, + pipSettings, + filterSettings, + chromaKeySettings, + stickerSettings, + coverConfig, + loadedTemplateId, + loadTemplates, }) - const currentTemplate = templates.find((t) => t.id === loadedTemplateId) || null + /* ── 模板详情加载 ── */ + useTemplateDetail({ + loadedTemplateId, + resetClips, + setCurrentMode, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setDraftName, + setDraftCategory, + setDraftTags, + }) - /* ──────────── 加载 ──────────── */ - - /** - * 并行加载模板列表、分类、素材库 - * 首次挂载时调用,三个接口无依赖关系,用 Promise.all 并发 - */ - const loadTemplates = useCallback(async () => { - setLoadingTemplates(true) - try { - const [tpls, cats, assets] = await Promise.all([ - getEditingTemplates(), - getTemplateCategories(), - getMediaAssets(), - ]) - setTemplates(tpls) - setCategories(cats) - setMediaAssets(assets) - } catch { - message.error("加载模板失败") - } finally { - setLoadingTemplates(false) - } - }, [setMediaAssets]) - - useEffect(() => { - loadTemplates() - }, [loadTemplates]) - - /** - * 加载模板详情并初始化片段列表 - * 将后端 segments 映射为前端 ClipData,取 duration_min/max 均值作为默认时长 - * 同时还原标题/字幕/BGM 配置 - */ - useEffect(() => { - if (!loadedTemplateId) return - getEditingTemplate(loadedTemplateId) - .then((tpl) => { - if (!tpl) return - setCurrentMode(tpl.mode) - const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({ - id: seg.id || `seg-${idx}`, - template_segment_id: seg.id || `seg-${idx}`, - type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType, - duration: (seg.duration_min + seg.duration_max) / 2, - startOffset: 0, - script_text: "", - order: seg.segment_order, - })) - resetClips(mapped) - - setTitleConfig({ - ai_auto_select: tpl.title_config.ai_auto_select, - content: tpl.title_config.content, - position: tpl.title_config.position, - font_preset: tpl.title_config.font_preset, - font_size: tpl.title_config.font_size, - font_color: tpl.title_config.font_color || "#ffffff", - }) - setSubtitleSettings((prev) => ({ - ...prev, - enabled: tpl.subtitle_config.enabled, - position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"], - font: tpl.subtitle_config.font, - fontSize: tpl.subtitle_config.size, - fontColor: tpl.subtitle_config.color || "#ffffff", - animation: tpl.subtitle_config.animation, - })) - setBgmSettings((prev) => ({ - ...prev, - enabled: tpl.bgm_config.enabled, - music_id: tpl.bgm_config.music_id || "", - })) - setDraftName(tpl.name) - setDraftCategory(tpl.category) - setDraftTags(tpl.tags.join(", ")) - }) - .catch(() => message.error("加载模板详情失败")) - }, [loadedTemplateId, resetClips, setTitleConfig, setSubtitleSettings, setBgmSettings]) - - /** - * 加载已有模板草稿数据到编辑器 - * 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置 - */ - useEffect(() => { - if (!loadedPlanId) return - - // 并行加载计划基本信息 + 片段列表 - Promise.all([ - getEditPlan(loadedPlanId), - getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({ - items: [], - total: 0, - })), - ]) - .then(([plan, clipsRes]) => { - // 设置关联的模板(触发模板加载 effect) - setLoadedTemplateId(plan.template_id) - - // 还原基本信息 - setDraftName(plan.name) - - // 还原 config 中的编辑器状态 - const cfg = plan.config - if (cfg.title_config) { - setTitleConfig({ - ai_auto_select: cfg.title_config!.ai_auto_select, - content: cfg.title_config!.content, - position: cfg.title_config!.position, - font_preset: cfg.title_config!.font_preset, - font_size: cfg.title_config!.font_size, - font_color: cfg.title_config!.font_color || "#ffffff", - }) - } - if (cfg.subtitle_config) { - setSubtitleSettings((prev) => ({ - ...prev, - enabled: cfg.subtitle_config!.enabled, - position: (cfg.subtitle_config!.position || - "bottom") as SubtitleStyleConfig["position"], - font: cfg.subtitle_config!.font, - fontSize: cfg.subtitle_config!.size, - fontColor: cfg.subtitle_config!.color || "#ffffff", - animation: cfg.subtitle_config!.animation, - })) - } - if (cfg.bgm_config) { - setBgmSettings((prev) => ({ - ...prev, - enabled: cfg.bgm_config!.enabled, - music_id: cfg.bgm_config!.music_id || "", - })) - } - // 还原封面配置 - if (cfg.cover_config) { - setCoverConfig((prev) => ({ - ...prev, - enabled: cfg.cover_config!.enabled ?? prev.enabled, - mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode, - frame_time: cfg.cover_config!.frame_time ?? prev.frame_time, - upload_url: cfg.cover_config!.upload_url || prev.upload_url, - thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url, - ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time, - })) - } - - // 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 - const backendClips = clipsRes?.items || [] - if (backendClips.length > 0) { - // 从后端 clips 表还原 - const sorted = [...backendClips].sort((a, b) => a.order - b.order) - const mapped: ClipData[] = sorted.map((clip) => ({ - id: clip.id, - template_segment_id: (clip.config?.template_segment_id as string) || "", - type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType, - duration: clip.duration || 3, - startOffset: 0, - script_text: clip.text_content || "", - order: clip.order, - media_asset_id: clip.asset_id || undefined, - transition: - clip.transition_effect && clip.transition_effect !== "none" - ? { - type: clip.transition_effect as TransitionEffect["type"], - duration: clip.transition_duration || 0.3, - } - : undefined, - speed: clip.playback_speed - ? { rate: clip.playback_speed, pitchCorrection: true } - : undefined, - tts_config: (clip.config?.tts_config as TtsConfig) || undefined, - trim_config: (clip.config?.trim_config as TrimConfig) || undefined, - })) - setTimeout(() => resetClips(mapped), 100) - } else if (cfg.segments && cfg.segments.length > 0) { - // 兜底:从 config.segments 还原(老数据兼容) - const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({ - id: `seg-${idx}`, - template_segment_id: `seg-${idx}`, - type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType, - duration: (seg.duration_min + seg.duration_max) / 2, - startOffset: 0, - script_text: "", - order: seg.segment_order, - transition: seg.transition - ? { - type: seg.transition.type as TransitionEffect["type"], - duration: seg.transition.duration, - } - : undefined, - speed: seg.playback_speed - ? { rate: seg.playback_speed, pitchCorrection: true } - : undefined, - tts_config: seg.tts_config - ? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode } - : undefined, - trim_config: seg.trim_config || undefined, - })) - setTimeout(() => resetClips(mapped), 100) - } - }) - .catch(() => message.error("加载模板草稿失败")) - }, [ + /* ── 计划草稿加载 ── */ + usePlanLoading({ loadedPlanId, resetClips, + setLoadedTemplateId, + setDraftName, setTitleConfig, setSubtitleSettings, setBgmSettings, setCoverConfig, - ]) + }) - /* ──────────── 事件 ──────────── */ + /* ── 事件 ── */ + const handleLoadTemplate = useCallback( + (templateId: string) => { + setLoadedTemplateId(templateId) + setSelectedClipId(null) + }, + [setLoadedTemplateId, setSelectedClipId], + ) - const handleLoadTemplate = (templateId: string) => { - setLoadedTemplateId(templateId) - setSelectedClipId(null) - } - - const handleModeChange = (mode: TemplateMode) => { - setCurrentMode(mode) - // 切换纯单类型模式时,自动转换所有已有片段的类型 - if (mode === "voice_over") { - setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const }))) - } else if (mode === "pip") { - setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const }))) - } - // 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型 - } - - const handleOpenSaveModal = () => { - setSaveModalOpen(true) - } - - const handleSave = async () => { - if (!draftName.trim()) { - message.warning("请输入模板名称") - return - } - setSaveLoading(true) - try { - const payload: SaveTemplatePayload = { - name: draftName, - mode: currentMode, - category: draftCategory, - tags: draftTags - .split(",") - .map((t) => t.trim()) - .filter(Boolean), - title_config: titleConfig, - subtitle_config: { - enabled: subtitleSettings.enabled, - position: subtitleSettings.position, - font: subtitleSettings.font, - color: subtitleSettings.fontColor, - size: subtitleSettings.fontSize, - animation: subtitleSettings.animation, - }, - bgm_config: { - enabled: bgmSettings.enabled, - music_id: bgmSettings.music_id, - }, - estimated_duration: totalDuration, - segments: clips.map((c, i) => ({ - segment_order: i, - duration_min: Math.max(1, c.duration - 2), - duration_max: c.duration + 2, - material_type: c.type === "voice" ? "voiceover" : "video", - transition: c.transition - ? { type: c.transition.type, duration: c.transition.duration } - : undefined, - playback_speed: c.speed ? c.speed.rate : undefined, - tts_config: c.tts_config - ? { - mode: c.tts_config.mode, - text: c.tts_config.text, - voice_id: c.tts_config.voice_id, - speed: c.tts_config.speed, - pitch: c.tts_config.pitch, - volume: c.tts_config.volume, - subtitle_sync: c.tts_config.subtitle_sync, - } - : undefined, - trim_config: c.trim_config - ? { - start_time: c.trim_config.start_time, - end_time: c.trim_config.end_time, - } - : undefined, - })), - watermark_config: { ...watermarkSettings }, - intro_outro_config: { ...introOutroSettings }, - pip_config: { ...pipSettings }, - filter_config: { ...filterSettings }, - green_screen_config: { ...chromaKeySettings }, - sticker_config: { ...stickerSettings }, - cover_config: { ...coverConfig }, + const handleModeChange = useCallback( + (mode: TemplateMode) => { + setCurrentMode(mode) + if (mode === "voice_over") { + setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const }))) + } else if (mode === "pip") { + setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const }))) } - if (loadedTemplateId) { - await updateEditingTemplate(loadedTemplateId, payload) - } else { - await createEditingTemplate(payload) - } - message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功") - setSaveModalOpen(false) - loadTemplates() - } catch { - message.error("保存失败") - } finally { - setSaveLoading(false) - } - } + }, + [setClips], + ) return { // state diff --git a/apps/web/src/pages/history/TaskHistory.tsx b/apps/web/src/pages/history/TaskHistory.tsx old mode 100644 new mode 100755 index def38c9b8..93dd65e73 --- a/apps/web/src/pages/history/TaskHistory.tsx +++ b/apps/web/src/pages/history/TaskHistory.tsx @@ -1,300 +1,88 @@ /** * 任务历史页面 — V21 设计系统 * 页面头部 + 圆角胶囊 Tab 筛选(含计数)+ 卡片式任务列表 + 分页 + 空状态 - * 使用 useQuery 对接后端真实 API(api/tasks.ts) */ -import React, { useState } from "react" -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { Button } from "@/components/ui" -import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks" +import React from "react" +import { PageHeader, LoadingState, ErrorState, EmptyState } from "./components/States" +import { HistoryTabs, TaskItem, Pagination } from "./components/TaskList" +import { useTaskHistory } from "./hooks/useTaskHistory" import "./history.css" -/* ============================================================ - * 类型 & 常量 - * ============================================================ */ -type TaskStatus = "completed" | "processing" | "pending" | "failed" - -const statusLabel: Record = { - completed: "已完成", - processing: "进行中", - pending: "排队中", - failed: "失败", -} - -/** 将后端 status 字符串映射为前端 TaskStatus */ -const normalizeStatus = (s: string): TaskStatus => { - const map: Record = { - completed: "completed", - succeeded: "completed", - success: "completed", - processing: "processing", - running: "processing", - pending: "pending", - queued: "pending", - failed: "failed", - error: "failed", - } - return map[s] ?? "pending" -} - -/** 格式化日期 */ -const formatDate = (iso?: string | null): string => { - if (!iso) return "—" - const d = new Date(iso) - if (isNaN(d.getTime())) return "—" - const pad = (n: number) => String(n).padStart(2, "0") - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` -} - -/* ============================================================ - * Tab 配置 - * ============================================================ */ -interface TabConfig { - key: string - label: string - statusFilter?: TaskStatus -} - -const tabs: TabConfig[] = [ - { key: "all", label: "全部" }, - { key: "processing", label: "进行中", statusFilter: "processing" }, - { key: "completed", label: "已完成", statusFilter: "completed" }, - { key: "failed", label: "失败", statusFilter: "failed" }, -] - -/* ============================================================ - * 分页配置 - * ============================================================ */ -const PAGE_SIZE = 10 - -/* ============================================================ - * 组件 - * ============================================================ */ const TaskHistory: React.FC = () => { - const [activeTab, setActiveTab] = useState("all") - const [currentPage, setCurrentPage] = useState(1) - const queryClient = useQueryClient() - - // ── 获取任务列表 ── const { - data: tasks = [], + activeTab, + currentPage, + totalPages, + tabs, + tabCounts, + paginatedTasks, isLoading, isError, error, + retryLoading, + setCurrentPage, + handleTabChange, + handleRetry, + handleView, refetch, - } = useQuery({ - queryKey: ["tasks"], - queryFn: getUserTasks, - staleTime: 30_000, - }) + } = useTaskHistory() - // ── 重试任务 mutation ── - const retryMutation = useMutation({ - mutationFn: retryTask, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tasks"] }) - }, - }) - - // 将后端数据映射为页面展示用的结构 - const mappedTasks = tasks.map((t) => ({ - id: t.id, - name: t.user_message || t.task_type, - type: t.task_type, - template: t.template_id, - status: normalizeStatus(t.status), - date: formatDate(t.created_at), - duration: undefined as string | undefined, - progress: t.progress, - retryable: t.retryable, - errorMessage: t.error_message, - })) - - // 获取当前 Tab 的筛选状态 - const currentTab = tabs.find((t) => t.key === activeTab) - const statusFilter = currentTab?.statusFilter - - // 过滤任务 - const filteredTasks = statusFilter - ? mappedTasks.filter((t) => t.status === statusFilter) - : mappedTasks - - // 计算各 Tab 的数量 - const tabCounts: Record = { - all: mappedTasks.length, - processing: mappedTasks.filter((t) => t.status === "processing").length, - completed: mappedTasks.filter((t) => t.status === "completed").length, - failed: mappedTasks.filter((t) => t.status === "failed").length, - } - - // 分页 - const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE) - const paginatedTasks = filteredTasks.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE) - - // 切换 Tab 时重置页码 - const handleTabChange = (key: string) => { - setActiveTab(key) - setCurrentPage(1) - } - - // 重试任务 - const handleRetry = (taskId: string) => { - retryMutation.mutate(taskId) - } - - // 查看任务详情 - const handleView = (taskId: string) => { - // TODO: 跳转到任务详情页(待路由实现) - console.log("查看任务:", taskId) - } - - // ── Loading 状态 ── + // Loading 状态 if (isLoading) { return (
-
-

任务历史

-

查看和管理所有生成任务

-
-
-
-

加载中...

-
+ +
) } - // ── Error 状态 ── + // Error 状态 if (isError) { return (
-
-

任务历史

-

查看和管理所有生成任务

-
-
-
-

加载失败

-

{error?.message || "网络异常,请稍后重试"}

- -
+ + refetch()} />
) } + const totalCount = tabCounts[activeTab] ?? paginatedTasks.length + return (
- {/* ── 页面头部 ──────────────────────────────────────────── */} -
-

任务历史

-

查看和管理所有生成任务

-
+ - {/* ── Tab 切换 ──────────────────────────────────────────── */} -
- {tabs.map((tab) => ( - - ))} -
+ - {/* ── 任务列表 ──────────────────────────────────────────── */} + {/* 任务列表 */} {paginatedTasks.length === 0 ? ( -
-
📭
-

暂无任务记录

-

{activeTab === "all" ? "点击上方按钮开始创建任务" : "当前分类下没有任务"}

-
+ ) : (
{paginatedTasks.map((task) => ( -
- {/* 任务信息 */} -
-

{task.name}

- - {task.type} · 模板:{task.template} - -
- - {/* 状态标签 */} - - {statusLabel[task.status]} - - - {/* 时间区 */} -
- {task.date} - {task.status === "completed" ? ( - 完成 - ) : task.status === "processing" ? ( - 进度 {task.progress}% - ) : task.status === "failed" ? ( - {task.errorMessage || "请重试"} - ) : ( - 等待中 - )} -
- - {/* 操作按钮 */} -
- {task.status === "failed" && task.retryable ? ( - - ) : ( - - )} -
-
+ ))}
)} - {/* ── 分页 ──────────────────────────────────────────────── */} - {totalPages > 1 && ( -
- - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( - - ))} - - 共 {filteredTasks.length} 条 -
- )} +
) } diff --git a/apps/web/src/pages/history/components/States.tsx b/apps/web/src/pages/history/components/States.tsx new file mode 100644 index 000000000..ae4251de7 --- /dev/null +++ b/apps/web/src/pages/history/components/States.tsx @@ -0,0 +1,52 @@ +import React from "react" +import { Button } from "@/components/ui" + +interface LoadingStateProps { + title?: string +} + +/** 加载状态 */ +export const LoadingState: React.FC = ({ title = "加载中..." }) => ( +
+
+

{title}

+
+) + +interface ErrorStateProps { + message?: string + onRetry: () => void +} + +/** 错误状态 */ +export const ErrorState: React.FC = ({ message, onRetry }) => ( +
+
+

加载失败

+

{message || "网络异常,请稍后重试"}

+ +
+) + +interface EmptyStateProps { + activeTab?: string +} + +/** 空状态 */ +export const EmptyState: React.FC = ({ activeTab = "all" }) => ( +
+
📭
+

暂无任务记录

+

{activeTab === "all" ? "点击上方按钮开始创建任务" : "当前分类下没有任务"}

+
+) + +/** 页面头部 */ +export const PageHeader: React.FC = () => ( +
+

任务历史

+

查看和管理所有生成任务

+
+) diff --git a/apps/web/src/pages/history/components/TaskList.tsx b/apps/web/src/pages/history/components/TaskList.tsx new file mode 100644 index 000000000..12102d15a --- /dev/null +++ b/apps/web/src/pages/history/components/TaskList.tsx @@ -0,0 +1,142 @@ +import React from "react" +import { Button } from "@/components/ui" +import type { TabConfig } from "../constants" +import type { MappedTask } from "../hooks/useTaskHistory" +import { statusLabel } from "../constants" + +interface HistoryTabsProps { + tabs: TabConfig[] + activeTab: string + tabCounts: Record + onChange: (key: string) => void +} + +/** Tab 切换栏 */ +export const HistoryTabs: React.FC = ({ + tabs, + activeTab, + tabCounts, + onChange, +}) => ( +
+ {tabs.map((tab) => ( + + ))} +
+) + +interface TaskItemProps { + task: MappedTask + onRetry: (id: string) => void + onView: (id: string) => void + retryLoading?: boolean +} + +/** 单个任务卡片 */ +export const TaskItem: React.FC = ({ task, onRetry, onView, retryLoading }) => { + const getSubText = () => { + switch (task.status) { + case "completed": + return "完成" + case "processing": + return `进度 ${task.progress}%` + case "failed": + return task.errorMessage || "请重试" + default: + return "等待中" + } + } + + return ( +
+ {/* 任务信息 */} +
+

{task.name}

+ + {task.type} · 模板:{task.template} + +
+ + {/* 状态标签 */} + + {statusLabel[task.status]} + + + {/* 时间区 */} +
+ {task.date} + {getSubText()} +
+ + {/* 操作按钮 */} +
+ {task.status === "failed" && task.retryable ? ( + + ) : ( + + )} +
+
+ ) +} + +interface PaginationProps { + currentPage: number + totalPages: number + total: number + onChange: (page: number) => void +} + +/** 分页组件 */ +export const Pagination: React.FC = ({ + currentPage, + totalPages, + total, + onChange, +}) => { + if (totalPages <= 1) return null + return ( +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + ))} + + 共 {total} 条 +
+ ) +} diff --git a/apps/web/src/pages/history/constants.ts b/apps/web/src/pages/history/constants.ts new file mode 100644 index 000000000..e8f765596 --- /dev/null +++ b/apps/web/src/pages/history/constants.ts @@ -0,0 +1,28 @@ +/** 任务状态 */ +export type TaskStatus = "completed" | "processing" | "pending" | "failed" + +/** 状态标签 */ +export const statusLabel: Record = { + completed: "已完成", + processing: "进行中", + pending: "排队中", + failed: "失败", +} + +/** Tab 配置 */ +export interface TabConfig { + key: string + label: string + statusFilter?: TaskStatus +} + +/** 默认 Tab 列表 */ +export const TABS: TabConfig[] = [ + { key: "all", label: "全部" }, + { key: "processing", label: "进行中", statusFilter: "processing" }, + { key: "completed", label: "已完成", statusFilter: "completed" }, + { key: "failed", label: "失败", statusFilter: "failed" }, +] + +/** 每页数量 */ +export const PAGE_SIZE = 10 diff --git a/apps/web/src/pages/history/hooks/useTaskHistory.ts b/apps/web/src/pages/history/hooks/useTaskHistory.ts new file mode 100644 index 000000000..c50d28927 --- /dev/null +++ b/apps/web/src/pages/history/hooks/useTaskHistory.ts @@ -0,0 +1,131 @@ +import { useState, useMemo, useCallback } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks" +import { TABS, PAGE_SIZE, type TabConfig } from "../constants" +import { normalizeStatus, formatDate } from "../utils" + +/** 映射后的任务列表项 */ +export interface MappedTask { + id: string + name: string + type: string + template?: string + status: "completed" | "processing" | "pending" | "failed" + date: string + progress?: number + retryable?: boolean + errorMessage?: string +} + +/** + * 任务历史业务 Hook + */ +export const useTaskHistory = () => { + const [activeTab, setActiveTab] = useState("all") + const [currentPage, setCurrentPage] = useState(1) + const queryClient = useQueryClient() + + // 获取任务列表 + const { + data: tasks = [], + isLoading, + isError, + error, + refetch, + } = useQuery({ + queryKey: ["tasks"], + queryFn: getUserTasks, + staleTime: 30_000, + }) + + // 重试 mutation + const retryMutation = useMutation({ + mutationFn: retryTask, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks"] }) + }, + }) + + // 映射后端数据 + const mappedTasks: MappedTask[] = useMemo( + () => + tasks.map((t) => ({ + id: t.id, + name: t.user_message || t.task_type, + type: t.task_type, + template: t.template_id, + status: normalizeStatus(t.status), + date: formatDate(t.created_at), + progress: t.progress, + retryable: t.retryable, + errorMessage: t.error_message, + })), + [tasks], + ) + + // 当前 Tab 筛选 + const currentTabConfig: TabConfig | undefined = TABS.find((t) => t.key === activeTab) + const statusFilter = currentTabConfig?.statusFilter + + // 过滤任务 + const filteredTasks = useMemo( + () => (statusFilter ? mappedTasks.filter((t) => t.status === statusFilter) : mappedTasks), + [mappedTasks, statusFilter], + ) + + // 各 Tab 计数 + const tabCounts: Record = useMemo( + () => ({ + all: mappedTasks.length, + processing: mappedTasks.filter((t) => t.status === "processing").length, + completed: mappedTasks.filter((t) => t.status === "completed").length, + failed: mappedTasks.filter((t) => t.status === "failed").length, + }), + [mappedTasks], + ) + + // 分页 + const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE) + const paginatedTasks = filteredTasks.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE) + + // 切换 Tab + const handleTabChange = useCallback((key: string) => { + setActiveTab(key) + setCurrentPage(1) + }, []) + + // 重试 + const handleRetry = useCallback( + (taskId: string) => { + retryMutation.mutate(taskId) + }, + [retryMutation], + ) + + // 查看详情 + const handleView = useCallback((taskId: string) => { + // TODO: 跳转到任务详情页(待路由实现) + console.log("查看任务:", taskId) + }, []) + + return { + // 状态 + activeTab, + currentPage, + totalPages, + // 数据 + tabs: TABS, + tabCounts, + paginatedTasks, + isLoading, + isError, + error, + retryLoading: retryMutation.isPending, + // 操作 + setCurrentPage, + handleTabChange, + handleRetry, + handleView, + refetch, + } +} diff --git a/apps/web/src/pages/history/utils.ts b/apps/web/src/pages/history/utils.ts new file mode 100644 index 000000000..025aa80ca --- /dev/null +++ b/apps/web/src/pages/history/utils.ts @@ -0,0 +1,26 @@ +import type { TaskStatus } from "./constants" + +/** 将后端 status 字符串映射为前端 TaskStatus */ +export const normalizeStatus = (s: string): TaskStatus => { + const map: Record = { + completed: "completed", + succeeded: "completed", + success: "completed", + processing: "processing", + running: "processing", + pending: "pending", + queued: "pending", + failed: "failed", + error: "failed", + } + return map[s] ?? "pending" +} + +/** 格式化日期 */ +export const formatDate = (iso?: string | null): string => { + if (!iso) return "—" + const d = new Date(iso) + if (isNaN(d.getTime())) return "—" + const pad = (n: number) => String(n).padStart(2, "0") + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` +} diff --git a/apps/web/src/pages/home/HomePage.tsx b/apps/web/src/pages/home/HomePage.tsx old mode 100644 new mode 100755 index 3ac84739d..720cfcddb --- a/apps/web/src/pages/home/HomePage.tsx +++ b/apps/web/src/pages/home/HomePage.tsx @@ -8,249 +8,15 @@ */ import React from "react" import { useNavigate } from "react-router-dom" -import { Button } from "@/components/ui" import { useAuthStore } from "@/store/authStore" +import { NavBar, Footer } from "./sections/Layout" +import { HeroSection } from "./sections/HeroSection" +import { FeatureSection } from "./sections/FeatureSection" +import { WorkflowSection } from "./sections/WorkflowSection" +import { PricingSection } from "./sections/PricingSection" +import { CTASection } from "./sections/CTASection" import "./home-page.css" -/* ── HeroSection ─────────────────────────────────────────── */ - -const HeroSection: React.FC = () => { - const navigate = useNavigate() - - return ( -
-
- {/* 左侧文案 */} -
- 🦐 小虾智剪 · AI智能视频创作平台 -

- 上传素材,AI自动剪辑 -
- 智能剪辑短视频 -

-

- 基于先进的 AI 技术,自动识别视频亮点,智能剪辑、配音、加字幕。 30 - 秒内将长视频转化为适合各平台传播的精品短视频。 -

-
- - -
-
- - {/* 右侧视觉 */} -
-
-
- 🎬 -
- -
-
-
- ✨ AI智能剪辑 - ⚡ 30秒生成 -
- 可发布 -
-
-
-
- ) -} - -/* ── FeatureSection ──────────────────────────────────────── */ - -const FEATURES = [ - { - icon: "🤖", - title: "AI 智能剪辑", - desc: "自动识别视频高光片段,智能去除冗余内容,智能剪辑精彩短视频。", - }, - { - icon: "🎙️", - title: "AI 配音克隆", - desc: "克隆您的声音,支持多种音色风格,自动生成自然流畅的配音。", - }, - { - icon: "📝", - title: "智能字幕标题", - desc: "自动语音识别生成精准字幕,AI 创作吸睛标题,提升内容传播力。", - }, - { - icon: "📱", - title: "多平台一键发布", - desc: "支持抖音、快手、小红书、微信视频号等主流平台,一键同步发布。", - }, -] - -const FeatureSection: React.FC = () => { - return ( -
-
-

核心功能

-

从素材上传到视频发布,全流程 AI 赋能,让短视频创作更简单

-
- {FEATURES.map((f) => ( -
-
{f.icon}
-

{f.title}

-

{f.desc}

-
- ))} -
-
-
- ) -} - -/* ── WorkflowSection ─────────────────────────────────────── */ - -const STEPS = [ - { icon: "📤", title: "上传素材", desc: "拖拽或选择视频素材,支持批量上传" }, - { icon: "🧠", title: "AI 处理", desc: "AI 自动分析、剪辑、配音、加字幕" }, - { icon: "👀", title: "预览调整", desc: "在线预览生成结果,支持微调编辑" }, - { icon: "🚀", title: "一键发布", desc: "多平台同步发布,追踪数据表现" }, -] - -const WorkflowSection: React.FC = () => { - return ( -
-
-

工作流程

-

四步完成短视频创作,从素材到发布仅需 30 秒

-
- {STEPS.map((step, idx) => ( -
-
{idx + 1}
-
{step.icon}
-

{step.title}

-

{step.desc}

- {idx < STEPS.length - 1 &&
} -
- ))} -
-
-
- ) -} - -/* ── PricingSection ──────────────────────────────────────── */ - -const PLANS = [ - { - name: "基础版", - price: "免费", - period: "", - desc: "适合个人体验,快速上手", - features: ["每月 5 次 AI 生成", "720p 视频导出", "基础模板库", "1 个平台账号绑定"], - highlighted: false, - cta: "免费开始", - }, - { - name: "专业版", - price: "¥99", - period: "/月", - desc: "适合内容创作者,高效产出", - features: [ - "每月 100 次 AI 生成", - "1080p 视频导出", - "全部模板库", - "4 个平台账号绑定", - "AI 配音克隆", - "优先客服支持", - ], - highlighted: true, - cta: "立即订阅", - }, - { - name: "企业版", - price: "¥399", - period: "/月", - desc: "适合团队与企业,规模化运营", - features: [ - "无限次 AI 生成", - "4K 视频导出", - "全部模板 + 定制模板", - "无限平台账号绑定", - "团队协作管理", - "API 接入支持", - "专属客户经理", - ], - highlighted: false, - cta: "联系销售", - }, -] - -const PricingSection: React.FC = () => { - const navigate = useNavigate() - - return ( -
-
-

定价方案

-

选择适合您的方案,随时升级或取消

-
- {PLANS.map((plan) => ( -
- {plan.highlighted &&
推荐
} -

{plan.name}

-
- {plan.price} - {plan.period && {plan.period}} -
-

{plan.desc}

-
    - {plan.features.map((f) => ( -
  • - - {f} -
  • - ))} -
- -
- ))} -
-
-
- ) -} - -/* ── CTASection ──────────────────────────────────────────── */ - -const CTASection: React.FC = () => { - const navigate = useNavigate() - - return ( -
-
-

开始用 AI 创作短视频

-

免费注册,立即体验 AI 智能视频创作。无需信用卡,零风险上手。

- -
-
- ) -} - -/* ── 主页面 ─────────────────────────────────────────────── */ - const HomePage: React.FC = () => { const isAuthenticated = useAuthStore((state) => state.isAuthenticated) const navigate = useNavigate() @@ -264,35 +30,13 @@ const HomePage: React.FC = () => { return (
- {/* 顶部导航栏 */} -
-
- -
- - -
-
-
- - {/* 5 个区域 */} + - - {/* 底部 */} -
-

© 2026 小虾智剪 · AI智能视频创作平台

-
+
) } diff --git a/apps/web/src/pages/home/sections/CTASection.tsx b/apps/web/src/pages/home/sections/CTASection.tsx new file mode 100644 index 000000000..8f6886720 --- /dev/null +++ b/apps/web/src/pages/home/sections/CTASection.tsx @@ -0,0 +1,20 @@ +import React from "react" +import { useNavigate } from "react-router-dom" +import { Button } from "@/components/ui" + +/** CTA 行动号召区域 */ +export const CTASection: React.FC = () => { + const navigate = useNavigate() + + return ( +
+
+

开始用 AI 创作短视频

+

免费注册,立即体验 AI 智能视频创作。无需信用卡,零风险上手。

+ +
+
+ ) +} diff --git a/apps/web/src/pages/home/sections/FeatureSection.tsx b/apps/web/src/pages/home/sections/FeatureSection.tsx new file mode 100644 index 000000000..631fd4848 --- /dev/null +++ b/apps/web/src/pages/home/sections/FeatureSection.tsx @@ -0,0 +1,45 @@ +import React from "react" + +const FEATURES = [ + { + icon: "🤖", + title: "AI 智能剪辑", + desc: "自动识别视频高光片段,智能去除冗余内容,智能剪辑精彩短视频。", + }, + { + icon: "🎙️", + title: "AI 配音克隆", + desc: "克隆您的声音,支持多种音色风格,自动生成自然流畅的配音。", + }, + { + icon: "📝", + title: "智能字幕标题", + desc: "自动语音识别生成精准字幕,AI 创作吸睛标题,提升内容传播力。", + }, + { + icon: "📱", + title: "多平台一键发布", + desc: "支持抖音、快手、小红书、微信视频号等主流平台,一键同步发布。", + }, +] + +/** 核心功能区域 */ +export const FeatureSection: React.FC = () => { + return ( +
+
+

核心功能

+

从素材上传到视频发布,全流程 AI 赋能,让短视频创作更简单

+
+ {FEATURES.map((f) => ( +
+
{f.icon}
+

{f.title}

+

{f.desc}

+
+ ))} +
+
+
+ ) +} diff --git a/apps/web/src/pages/home/sections/HeroSection.tsx b/apps/web/src/pages/home/sections/HeroSection.tsx new file mode 100644 index 000000000..4617cecb9 --- /dev/null +++ b/apps/web/src/pages/home/sections/HeroSection.tsx @@ -0,0 +1,55 @@ +import React from "react" +import { useNavigate } from "react-router-dom" +import { Button } from "@/components/ui" + +/** Hero 首屏区域 */ +export const HeroSection: React.FC = () => { + const navigate = useNavigate() + + return ( +
+
+ {/* 左侧文案 */} +
+ 🦐 小虾智剪 · AI智能视频创作平台 +

+ 上传素材,AI自动剪辑 +
+ 智能剪辑短视频 +

+

+ 基于先进的 AI 技术,自动识别视频亮点,智能剪辑、配音、加字幕。 30 + 秒内将长视频转化为适合各平台传播的精品短视频。 +

+
+ + +
+
+ + {/* 右侧视觉 */} +
+
+
+ 🎬 +
+ +
+
+
+ ✨ AI智能剪辑 + ⚡ 30秒生成 +
+ 可发布 +
+
+
+
+ ) +} diff --git a/apps/web/src/pages/home/sections/Layout.tsx b/apps/web/src/pages/home/sections/Layout.tsx new file mode 100644 index 000000000..b0d8201e7 --- /dev/null +++ b/apps/web/src/pages/home/sections/Layout.tsx @@ -0,0 +1,34 @@ +import React from "react" +import { useNavigate } from "react-router-dom" +import { Button } from "@/components/ui" + +/** 顶部导航栏 */ +export const NavBar: React.FC = () => { + const navigate = useNavigate() + + return ( +
+
+ +
+ + +
+
+
+ ) +} + +/** 页脚 */ +export const Footer: React.FC = () => ( +
+

© 2026 小虾智剪 · AI智能视频创作平台

+
+) diff --git a/apps/web/src/pages/home/sections/PricingSection.tsx b/apps/web/src/pages/home/sections/PricingSection.tsx new file mode 100644 index 000000000..1cf6d2524 --- /dev/null +++ b/apps/web/src/pages/home/sections/PricingSection.tsx @@ -0,0 +1,102 @@ +import React from "react" +import { useNavigate } from "react-router-dom" +import { Button } from "@/components/ui" + +interface Plan { + name: string + price: string + period: string + desc: string + features: string[] + highlighted: boolean + cta: string +} + +const PLANS: Plan[] = [ + { + name: "基础版", + price: "免费", + period: "", + desc: "适合个人体验,快速上手", + features: ["每月 5 次 AI 生成", "720p 视频导出", "基础模板库", "1 个平台账号绑定"], + highlighted: false, + cta: "免费开始", + }, + { + name: "专业版", + price: "¥99", + period: "/月", + desc: "适合内容创作者,高效产出", + features: [ + "每月 100 次 AI 生成", + "1080p 视频导出", + "全部模板库", + "4 个平台账号绑定", + "AI 配音克隆", + "优先客服支持", + ], + highlighted: true, + cta: "立即订阅", + }, + { + name: "企业版", + price: "¥399", + period: "/月", + desc: "适合团队与企业,规模化运营", + features: [ + "无限次 AI 生成", + "4K 视频导出", + "全部模板 + 定制模板", + "无限平台账号绑定", + "团队协作管理", + "API 接入支持", + "专属客户经理", + ], + highlighted: false, + cta: "联系销售", + }, +] + +/** 定价方案区域 */ +export const PricingSection: React.FC = () => { + const navigate = useNavigate() + + return ( +
+
+

定价方案

+

选择适合您的方案,随时升级或取消

+
+ {PLANS.map((plan) => ( +
+ {plan.highlighted &&
推荐
} +

{plan.name}

+
+ {plan.price} + {plan.period && {plan.period}} +
+

{plan.desc}

+
    + {plan.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+ +
+ ))} +
+
+
+ ) +} diff --git a/apps/web/src/pages/home/sections/WorkflowSection.tsx b/apps/web/src/pages/home/sections/WorkflowSection.tsx new file mode 100644 index 000000000..f72f0180b --- /dev/null +++ b/apps/web/src/pages/home/sections/WorkflowSection.tsx @@ -0,0 +1,31 @@ +import React from "react" + +const STEPS = [ + { icon: "📤", title: "上传素材", desc: "拖拽或选择视频素材,支持批量上传" }, + { icon: "🧠", title: "AI 处理", desc: "AI 自动分析、剪辑、配音、加字幕" }, + { icon: "👀", title: "预览调整", desc: "在线预览生成结果,支持微调编辑" }, + { icon: "🚀", title: "一键发布", desc: "多平台同步发布,追踪数据表现" }, +] + +/** 工作流程区域 */ +export const WorkflowSection: React.FC = () => { + return ( +
+
+

工作流程

+

四步完成短视频创作,从素材到发布仅需 30 秒

+
+ {STEPS.map((step, idx) => ( +
+
{idx + 1}
+
{step.icon}
+

{step.title}

+

{step.desc}

+ {idx < STEPS.length - 1 &&
} +
+ ))} +
+
+
+ ) +} diff --git a/apps/web/src/pages/products/ProductDetail.tsx b/apps/web/src/pages/products/ProductDetail.tsx old mode 100644 new mode 100755 index a83ae9719..d85e4b1c7 --- a/apps/web/src/pages/products/ProductDetail.tsx +++ b/apps/web/src/pages/products/ProductDetail.tsx @@ -3,252 +3,28 @@ * 路由:/app/products/:id * 展示视频播放器 + 完整元数据 + 下载/分享/删除操作 */ -import React, { useRef, useState, useEffect, useCallback } from "react" -import { useParams, useNavigate } from "react-router-dom" -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { - ArrowLeftOutlined, - DownloadOutlined, - ShareAltOutlined, - DeleteOutlined, - PlayCircleFilled, - PauseCircleFilled, - SoundOutlined, - MutedOutlined, - ExpandOutlined, - LoadingOutlined, - WarningOutlined, -} from "@ant-design/icons" -import { - getProduct, - deleteProduct, - getProductDownloadUrl, - type ProductItem, -} from "../../api/products" +import React from "react" +import { LoadingOutlined, WarningOutlined, ArrowLeftOutlined } from "@ant-design/icons" import { Button } from "../../components/ui" +import { DetailHeader } from "./components/DetailHeader" +import { DetailVideoPlayer } from "./components/DetailVideoPlayer" +import { ProductInfoPanel } from "./components/ProductInfoPanel" +import { useProductDetail } from "./hooks/useProductDetail" import "./products.css" -/* ============================================================ - * 工具函数 - * ============================================================ */ - -/** 格式化时长(秒 → "MM:SS") */ -const formatDuration = (seconds: number): string => { - if (!seconds || seconds <= 0) return "00:00" - const m = Math.floor(seconds / 60) - const s = Math.floor(seconds % 60) - return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` -} - -/** 格式化文件大小(MB) */ -const formatFileSize = (mb: number): string => { - if (!mb || mb <= 0) return "-" - if (mb < 1024) return `${mb.toFixed(1)} MB` - return `${(mb / 1024).toFixed(2)} GB` -} - -/** 格式化日期 */ -const formatDate = (dateStr: string): string => { - if (!dateStr) return "-" - const d = new Date(dateStr) - return d.toLocaleDateString("zh-CN", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }) -} - -/** 状态标签 */ -const STATUS_MAP: Record = { - completed: { label: "已完成", color: "#10b981" }, - processing: { label: "处理中", color: "#6366f1" }, - pending: { label: "待处理", color: "#f59e0b" }, - failed: { label: "失败", color: "#ef4444" }, -} - -/* ============================================================ - * 主组件 - * ============================================================ */ const ProductDetail: React.FC = () => { - const { id } = useParams<{ id: string }>() - const navigate = useNavigate() - const queryClient = useQueryClient() - - /* ── 获取产品详情 ── */ const { - data: product, + product, isLoading, isError, error, - } = useQuery({ - queryKey: ["product", id], - queryFn: () => getProduct(id!), - enabled: !!id, - staleTime: 10_000, - }) - - /* ── 删除 mutation ── */ - const deleteMutation = useMutation({ - mutationFn: () => deleteProduct(id!), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["products"] }) - navigate("/app/products") - }, - }) - - /* ── 视频播放器状态 ── */ - const videoRef = useRef(null) - const progressRef = useRef(null) - const hideTimerRef = useRef>() - - const [isPlaying, setIsPlaying] = useState(false) - const [currentTime, setCurrentTime] = useState(0) - const [duration, setDuration] = useState(0) - const [buffered, setBuffered] = useState(0) - const [volume, setVolume] = useState(1) - const [isMuted, setIsMuted] = useState(false) - const [showControls, setShowControls] = useState(true) - const [isFullscreen, setIsFullscreen] = useState(false) - const containerRef = useRef(null) - - /* ── 自动隐藏控制条 ── */ - const resetHideTimer = useCallback(() => { - setShowControls(true) - if (hideTimerRef.current) clearTimeout(hideTimerRef.current) - if (isPlaying) { - hideTimerRef.current = setTimeout(() => setShowControls(false), 3000) - } - }, [isPlaying]) - - /* ── 播放控制 ── */ - const togglePlay = useCallback(() => { - const v = videoRef.current - if (!v) return - if (v.paused) { - v.play().catch(() => {}) - } else { - v.pause() - } - }, []) - - const handleSeek = useCallback( - (e: React.MouseEvent) => { - const v = videoRef.current - const bar = progressRef.current - if (!v || !bar || !duration) return - const rect = bar.getBoundingClientRect() - const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) - v.currentTime = ratio * duration - }, - [duration], - ) - - const handleVolumeChange = useCallback((e: React.ChangeEvent) => { - const v = videoRef.current - const val = parseFloat(e.target.value) - if (v) v.volume = val - setVolume(val) - setIsMuted(val === 0) - }, []) - - const toggleMute = useCallback(() => { - const v = videoRef.current - if (!v) return - if (isMuted) { - v.muted = false - v.volume = volume || 1 - setIsMuted(false) - } else { - v.muted = true - setIsMuted(true) - } - }, [isMuted, volume]) - - const toggleFullscreen = useCallback(() => { - const el = containerRef.current - if (!el) return - if (!document.fullscreenElement) { - el.requestFullscreen?.().catch(() => {}) - } else { - document.exitFullscreen?.().catch(() => {}) - } - }, []) - - /* ── 视频事件监听 ── */ - useEffect(() => { - const v = videoRef.current - if (!v) return - - const onPlay = () => setIsPlaying(true) - const onPause = () => setIsPlaying(false) - const onTimeUpdate = () => setCurrentTime(v.currentTime) - const onLoadedMetadata = () => setDuration(v.duration) - const onProgress = () => { - if (v.buffered.length > 0) { - setBuffered(v.buffered.end(v.buffered.length - 1)) - } - } - const onEnded = () => setIsPlaying(false) - const onFSChange = () => setIsFullscreen(!!document.fullscreenElement) - - v.addEventListener("play", onPlay) - v.addEventListener("pause", onPause) - v.addEventListener("timeupdate", onTimeUpdate) - v.addEventListener("loadedmetadata", onLoadedMetadata) - v.addEventListener("progress", onProgress) - v.addEventListener("ended", onEnded) - document.addEventListener("fullscreenchange", onFSChange) - - return () => { - v.removeEventListener("play", onPlay) - v.removeEventListener("pause", onPause) - v.removeEventListener("timeupdate", onTimeUpdate) - v.removeEventListener("loadedmetadata", onLoadedMetadata) - v.removeEventListener("progress", onProgress) - v.removeEventListener("ended", onEnded) - document.removeEventListener("fullscreenchange", onFSChange) - } - }, []) - - /* 播放时自动隐藏/显示控制条 */ - useEffect(() => { - resetHideTimer() - return () => { - if (hideTimerRef.current) clearTimeout(hideTimerRef.current) - } - }, [isPlaying, resetHideTimer]) - - /* ── 下载 ── */ - const handleDownload = async () => { - if (!product || product.status !== "completed") return - try { - const { url } = await getProductDownloadUrl(product.id) - const a = document.createElement("a") - a.href = url - a.download = "" - a.click() - } catch { - // message.error handled by caller - } - } - - /* ── 分享 ── */ - const handleShare = () => { - if (!product) return - const link = `${window.location.origin}/app/products/${product.id}` - navigator.clipboard?.writeText(link).then( - () => {}, - () => {}, - ) - } - - /* ── 删除 ── */ - const handleDelete = () => { - if (!id) return - deleteMutation.mutate() - } + goBack, + handleDownload, + handleShare, + handleDelete, + deleteLoading, + canDownload, + } = useProductDetail() /* ── 加载状态 ── */ if (isLoading) { @@ -270,7 +46,7 @@ const ProductDetail: React.FC = () => {

加载失败

{error?.message || "无法获取产品信息"}

-
@@ -278,170 +54,24 @@ const ProductDetail: React.FC = () => { ) } - const statusInfo = STATUS_MAP[product.status] || { - label: product.status, - color: "#94a3b8", - } - const progress = Math.round((currentTime / (duration || 1)) * 100) - const bufferedPct = Math.round((buffered / (duration || 1)) * 100) - return (
- {/* ── 顶部导航 ── */} -
- -
- - - -
-
+ - {/* ── 主体内容 ── */}
- {/* 视频播放器 */} -
- {product.video_url ? ( -