Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1da3ce95e2 | |||
| 683c0aba41 | |||
| a6636033f4 | |||
| a1c6074b3d | |||
| 783fe0bab3 | |||
| e6cf7548b1 | |||
| bc1c454fad | |||
| 9e2daf0022 | |||
| f61648bb0f | |||
| f4791e726e | |||
| 6dbbb5b71a | |||
| a6ebb762e6 | |||
| 5289e427e2 | |||
| b8db7f3b73 | |||
| 189c29869f | |||
| 14c0e502ea | |||
| 49b63aa6ca | |||
| a59e3cc155 | |||
| e353829d5c | |||
| 4caa5b5cef | |||
| e5284120f8 | |||
| db733a1574 | |||
| 0beea9ff5a | |||
| 5f58a570f2 | |||
| 95e1b2f63a | |||
| cc4939827b | |||
| 799f53c855 | |||
| 215c5c5336 | |||
| dc3a54b451 | |||
| 74ea454bb0 | |||
| 76373fdac3 | |||
| d8d625ca62 | |||
| cc08251c6f | |||
| 433f89d780 | |||
| 688d32fe8a | |||
| d9f2436bd3 | |||
| 4d9131f2e4 | |||
| b4bde72b44 | |||
| 1d4c7d6aea | |||
| 20b593dcf8 | |||
| bc37ce2ee7 | |||
| 38fda9aa9e | |||
| 77f9a908ad | |||
| 7936be3339 | |||
| d2c067e9bd | |||
| 925b365d6a | |||
| 7c7f33fbd6 | |||
| b891eeab43 | |||
| dfed224b7d | |||
| 9a849d319e | |||
| 07bbe7ee01 | |||
| af7088a549 | |||
| 7d75ee6586 | |||
| 83bf454daf | |||
| ea04bb8525 | |||
| d537dd2ed0 | |||
| 18590e22e5 | |||
| a96819ce22 | |||
| b98acefe0f | |||
| 2d67fe8631 | |||
| dc500acbf2 | |||
| 2253b7d15a | |||
| 9a25eb6642 | |||
| ad3dc06101 | |||
| dc555bc8c1 | |||
| dcab4180e5 | |||
| 8d7e13ea73 | |||
| 09a19f69c7 | |||
| ac667e60c9 | |||
| 3ec74bfc32 | |||
| c0af8e7c43 | |||
| 1399912095 | |||
| 9f153eec54 | |||
| b392bb1b78 | |||
| b852603664 | |||
| bda2170b9d | |||
| bb0d01f080 | |||
| f6a458564f | |||
| 48bf66c298 | |||
| 8e2c563b42 | |||
| b99437fd81 | |||
| 81eff29e7b | |||
| 02d60a02f2 | |||
| 5b0ba40ecd | |||
| 9b9dc243ed | |||
| 6514b8c34d | |||
| 4e8265ec5e | |||
| 23de3906c2 | |||
| 50b05db03e | |||
| 7f3c462617 | |||
| 190d9e46f7 | |||
| 3ff041440b | |||
| b896873ece | |||
| 4df4a937e4 | |||
| 673d18aa83 | |||
| 3b949a464f | |||
| daa0e1f7b5 | |||
| 5fa915cdad | |||
| 1242b62165 | |||
| 4251970b49 | |||
| 64387c00bb | |||
| eae6dcac4f | |||
| dab2a4fdb2 | |||
| 6a303a3b6e | |||
| 23573a8209 | |||
| 38e40d727b | |||
| dcfddc6695 | |||
| e43737658a | |||
| 30457629da | |||
| 7136773ee5 | |||
| 37d0694b68 | |||
| d932f6e0f7 | |||
| 35e171c2ac | |||
| 39c89f018a | |||
| c704f9b844 | |||
| c71352c2b7 | |||
| 7e63bf7e26 | |||
| 1943630f8a | |||
| eaf035626f | |||
| e946dbf625 | |||
| 56ab6bcef4 | |||
| 6fffcd105e | |||
| 7a8e99cd8e | |||
| 6a6f8e7a22 | |||
| 246367f6f0 | |||
| 107a1bd724 | |||
| cfc836484f | |||
| ef30c7db26 | |||
| 5d9544a76f | |||
| f6c366b979 | |||
| aeefd7aba5 | |||
| a5f1a31ca3 | |||
| 07805e72c5 | |||
| 4105a4df41 | |||
| a1bda3e484 | |||
| 7bf25023a0 | |||
| e8f7788e56 | |||
| 27c1f05f74 | |||
| de1311fe31 | |||
| 7ce78a3e8a | |||
| edb141b1da | |||
| 2e67be39f7 | |||
| f55d0d6f0e | |||
| f506048240 | |||
| 69f2846c95 | |||
| 6437b96e54 |
@@ -36,12 +36,11 @@ jobs:
|
||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
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
|
||||
|
||||
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
|
||||
- name: Get staging running images (whitelist)
|
||||
|
||||
@@ -8,7 +8,7 @@ permissions:
|
||||
jobs:
|
||||
ci-health-report:
|
||||
name: CI健康度每日巡检
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -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
|
||||
@@ -262,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:
|
||||
@@ -346,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
|
||||
@@ -411,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
|
||||
@@ -640,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: |
|
||||
@@ -730,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
|
||||
|
||||
@@ -1119,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 \
|
||||
@@ -1184,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 \
|
||||
@@ -1700,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
|
||||
|
||||
@@ -16,15 +16,15 @@ permissions:
|
||||
jobs:
|
||||
monitor:
|
||||
name: Monitor CI Trigger Reliability
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
# 网络波动自动重试2次
|
||||
retry:
|
||||
max_attempts: 2
|
||||
retry_on: error
|
||||
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: Check CI trigger status for all open PRs
|
||||
env:
|
||||
|
||||
@@ -15,20 +15,18 @@ concurrency:
|
||||
jobs:
|
||||
code-review:
|
||||
name: AI Code Review
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-l2
|
||||
# 跳过草稿 PR
|
||||
if: ${{ !gitea.event.pull_request.draft }}
|
||||
|
||||
steps:
|
||||
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# 网络波动自动重试2次
|
||||
retry:
|
||||
max_attempts: 2
|
||||
retry_on: error
|
||||
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: Install dependencies
|
||||
run: |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Daily Health Check
|
||||
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -12,7 +13,7 @@ jobs:
|
||||
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
|
||||
production-smoke:
|
||||
name: Production Smoke Test
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
@@ -23,47 +24,9 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
- name: Production health check & smoke test
|
||||
id: smoke
|
||||
shell: sh
|
||||
@@ -121,7 +84,7 @@ jobs:
|
||||
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
@@ -132,50 +95,15 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
- name: Run API smoke test on staging
|
||||
id: smoke
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
|
||||
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
@@ -183,8 +111,8 @@ jobs:
|
||||
docker run --rm \
|
||||
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-e WEB_URL=https://staging.xiaoxiajianji.com \
|
||||
-e TEST_USER=18314979086@163.com \
|
||||
-e TEST_PASSWORD=Ying1234 \
|
||||
-e TEST_USER="$STAGING_TEST_USER" \
|
||||
-e TEST_PASSWORD="$STAGING_TEST_PASSWORD" \
|
||||
-e CLEANUP_ENABLED=1 \
|
||||
-e PERF_CHECK_ENABLED=1 \
|
||||
-e PERF_WARN_THRESHOLD_MS=500 \
|
||||
@@ -270,7 +198,7 @@ jobs:
|
||||
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
||||
staging-e2e:
|
||||
name: Staging Browser E2E
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
@@ -281,47 +209,9 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
- name: Run Playwright E2E on staging
|
||||
id: e2e
|
||||
shell: sh
|
||||
@@ -371,7 +261,7 @@ jobs:
|
||||
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
||||
performance-check:
|
||||
name: Performance Baseline Check
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
outputs:
|
||||
report: ${{ steps.report.outputs.report }}
|
||||
@@ -380,6 +270,9 @@ jobs:
|
||||
- name: Run performance baseline checks
|
||||
id: perf
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
|
||||
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
@@ -415,9 +308,10 @@ jobs:
|
||||
|
||||
# 先登录获取 token
|
||||
echo "--- 准备: 获取测试 Token ---"
|
||||
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
|
||||
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||
-d "$LOGIN_BODY" \
|
||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||
--max-time 10 2>&1)
|
||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||
@@ -447,7 +341,7 @@ jobs:
|
||||
# 构建 curl 命令
|
||||
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
||||
if [ "$method" = "POST" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
|
||||
fi
|
||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||
@@ -495,6 +389,9 @@ jobs:
|
||||
- name: Generate performance report
|
||||
id: report
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
|
||||
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
||||
run: |
|
||||
set +e
|
||||
echo ""
|
||||
@@ -509,10 +406,11 @@ jobs:
|
||||
RESULTS=""
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
|
||||
# 先登录获取 token
|
||||
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||
-d "$LOGIN_BODY" \
|
||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||
--max-time 10 2>&1)
|
||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||
@@ -528,7 +426,7 @@ jobs:
|
||||
|
||||
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
||||
if [ "$method" = "POST" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
|
||||
fi
|
||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||
@@ -631,7 +529,7 @@ jobs:
|
||||
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
||||
daily-report:
|
||||
name: Daily Check Report
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 2
|
||||
if: always()
|
||||
needs:
|
||||
|
||||
@@ -93,44 +93,31 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
|
||||
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
|
||||
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
|
||||
echo "Created npm cache volume: $NPM_CACHE_VOLUME"
|
||||
fi
|
||||
cd apps/web
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/workspace" \
|
||||
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
|
||||
-w /workspace/apps/web \
|
||||
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc '
|
||||
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
|
||||
CACHE_HASH_FILE="node_modules/.package-lock-hash"
|
||||
CACHE_VALID=false
|
||||
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
|
||||
CACHE_VALID=true
|
||||
echo "Cache hit: dependencies valid, skipping npm ci"
|
||||
fi
|
||||
if [ "$CACHE_VALID" = "false" ]; then
|
||||
echo "Cache miss or invalid: running npm ci..."
|
||||
if ! npm ci; then
|
||||
echo "npm ci failed, cleaning node_modules and retrying..."
|
||||
rm -rf node_modules
|
||||
mkdir -p node_modules
|
||||
npm ci
|
||||
fi
|
||||
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
|
||||
echo "Dependencies installed, cache updated"
|
||||
fi
|
||||
echo "Running TypeScript check..."
|
||||
npx --no-install tsc
|
||||
echo "Running Vite build..."
|
||||
npx --no-install vite build
|
||||
echo "Build completed successfully"
|
||||
ls -la dist/
|
||||
'
|
||||
# Config npm mirror for speed
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# Install dependencies with retry
|
||||
for i in 1 2 3; do
|
||||
npm ci --no-audit --no-fund && break
|
||||
echo "npm ci failed, retry $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
rm -rf node_modules
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# TypeScript check
|
||||
echo "=== TypeScript check ==="
|
||||
npx --no-install tsc --noEmit
|
||||
|
||||
# Vite build
|
||||
echo "=== Vite build ==="
|
||||
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
|
||||
npx --no-install vite build
|
||||
|
||||
echo "=== Build completed ==="
|
||||
ls -la dist/
|
||||
|
||||
- name: Install SSH client and rsync
|
||||
shell: sh
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from packages.domain.ai_parsing import generate_titles_fallback as _generate_titles_fallback_base
|
||||
from packages.domain.ai_parsing import keyword_match_fallback as _semantic_match_fallback_base
|
||||
from packages.domain.ai_parsing import parse_semantic_match_response as _parse_semantic_match_base
|
||||
from packages.domain.ai_parsing import parse_titles_from_response as _parse_titles_from_response
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -64,85 +65,9 @@ def _generate_titles_fallback(
|
||||
style: str = "viral",
|
||||
count: int = 5,
|
||||
) -> List[str]:
|
||||
"""本地降级:基于模板规则生成标题.
|
||||
|
||||
当豆包 API 不可用或调用失败时使用,保证接口始终有返回。
|
||||
"""
|
||||
"""本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
|
||||
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
||||
examples = style_info["examples"]
|
||||
|
||||
# 从描述中提取关键词(取前几个词)
|
||||
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
|
||||
keyword = keywords[0] if keywords else "精彩内容"
|
||||
|
||||
# 基于模板生成
|
||||
templates = [
|
||||
f"「{keyword}」{examples[0][:10]}...",
|
||||
f"{keyword}:{examples[1]}",
|
||||
f"关于{keyword},你不知道的3件事",
|
||||
f"{keyword}入门指南,新手必看",
|
||||
f"深度解析:{keyword}背后的秘密",
|
||||
f"{keyword}怎么做?手把手教你",
|
||||
f"干货分享 | {keyword}全攻略",
|
||||
f"建议收藏:{keyword}实用技巧",
|
||||
f"{keyword}避坑指南,别再踩雷了",
|
||||
f"一分钟搞懂{keyword}",
|
||||
]
|
||||
|
||||
random.shuffle(templates)
|
||||
return templates[: min(count, len(templates))]
|
||||
|
||||
|
||||
def _parse_titles_from_response(content: str) -> List[str]:
|
||||
"""从模型返回中解析标题列表.
|
||||
|
||||
支持多种返回格式:
|
||||
- JSON 数组: ["标题1", "标题2"]
|
||||
- 编号列表: 1. 标题1 / 2. 标题2
|
||||
- 换行分隔: 标题1\n标题2
|
||||
- 带破折号: - 标题1
|
||||
"""
|
||||
if not content:
|
||||
return []
|
||||
|
||||
# 尝试解析 JSON
|
||||
try:
|
||||
# 清理可能的 markdown 代码块标记
|
||||
cleaned = content.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.lower().startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
data = json.loads(cleaned)
|
||||
if isinstance(data, list):
|
||||
return [str(item).strip() for item in data if str(item).strip()]
|
||||
if isinstance(data, dict) and "titles" in data:
|
||||
titles = data["titles"]
|
||||
if isinstance(titles, list):
|
||||
return [str(t).strip() for t in titles if str(t).strip()]
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# 尝试按行解析
|
||||
titles: List[str] = []
|
||||
for line in content.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# 去掉编号前缀 "1. " "1、" "(1)"
|
||||
import re
|
||||
|
||||
line = re.sub(r"^[\d]+[\.、\))]\s*", "", line)
|
||||
# 去掉破折号前缀 "- " "• "
|
||||
line = re.sub(r"^[-•·]\s*", "", line)
|
||||
# 去掉引号
|
||||
line = line.strip('"').strip("'").strip("「」")
|
||||
if line and len(line) < 100: # 过滤过长的行
|
||||
titles.append(line)
|
||||
|
||||
return titles
|
||||
return _generate_titles_fallback_base(description, style_info, count)
|
||||
|
||||
|
||||
def generate_smart_titles(
|
||||
@@ -241,132 +166,19 @@ def _semantic_match_fallback(
|
||||
description: str,
|
||||
assets: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""本地降级:基于关键词的简单匹配.
|
||||
|
||||
计算描述中的关键词与素材名称/标签/描述的重叠度,
|
||||
作为匹配度评分。0-1分。
|
||||
"""
|
||||
import re
|
||||
|
||||
# 提取关键词(中文按2字以上片段,英文按单词)
|
||||
desc = description.lower()
|
||||
# 简单分词:提取2字以上的中文字符串和英文单词
|
||||
keywords = set()
|
||||
# 英文单词
|
||||
for word in re.findall(r"[a-zA-Z]{3,}", desc):
|
||||
keywords.add(word)
|
||||
# 中文2-4字片段
|
||||
for i in range(len(desc)):
|
||||
for j in range(i + 2, min(i + 5, len(desc) + 1)):
|
||||
fragment = desc[i:j]
|
||||
if all("\u4e00" <= c <= "\u9fff" for c in fragment):
|
||||
keywords.add(fragment)
|
||||
|
||||
if not keywords:
|
||||
# 没有关键词时给所有素材中等分数
|
||||
for asset in assets:
|
||||
asset["match_score"] = 0.5
|
||||
asset["match_reason"] = "fallback_default"
|
||||
return assets
|
||||
|
||||
results = []
|
||||
for asset in assets:
|
||||
# 组合素材的文本信息:名称 + 标签 + 描述
|
||||
asset_text_parts = [
|
||||
str(asset.get("name", "")).lower(),
|
||||
" ".join(str(t) for t in asset.get("tags", [])).lower(),
|
||||
str(asset.get("description", "")).lower(),
|
||||
]
|
||||
asset_text = " | ".join(asset_text_parts)
|
||||
|
||||
# 计算匹配度:命中关键词占比 + 稀有关键词加权
|
||||
hit_count = 0
|
||||
hit_keywords = []
|
||||
for kw in keywords:
|
||||
if kw in asset_text:
|
||||
hit_count += 1
|
||||
hit_keywords.append(kw)
|
||||
|
||||
# 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑)
|
||||
base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5
|
||||
|
||||
# 名称命中加分(名称匹配更重要)
|
||||
name = str(asset.get("name", "")).lower()
|
||||
name_hits = sum(1 for kw in hit_keywords if kw in name)
|
||||
name_bonus = min(0.2, name_hits * 0.05)
|
||||
|
||||
score = min(1.0, base_score * 0.8 + name_bonus)
|
||||
score = round(score, 3)
|
||||
|
||||
results.append(
|
||||
{
|
||||
**asset,
|
||||
"match_score": score,
|
||||
"match_reason": "fallback_keyword",
|
||||
}
|
||||
)
|
||||
|
||||
# 按匹配度降序
|
||||
results.sort(key=lambda x: x["match_score"], reverse=True)
|
||||
return results
|
||||
"""本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
|
||||
return _semantic_match_fallback_base(description, assets)
|
||||
|
||||
|
||||
def _parse_semantic_match_response(
|
||||
content: str,
|
||||
asset_ids: List[str],
|
||||
) -> Optional[Dict[str, float]]:
|
||||
"""从模型返回中解析素材匹配度.
|
||||
|
||||
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
|
||||
score 范围 0-1。
|
||||
"""
|
||||
if not content:
|
||||
"""从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
|
||||
result = _parse_semantic_match_base(content, asset_ids)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
# 尝试解析 JSON
|
||||
try:
|
||||
cleaned = content.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.lower().startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
data = json.loads(cleaned)
|
||||
|
||||
result: Dict[str, float] = {}
|
||||
|
||||
# 格式1: {"asset_id1": 0.8, "asset_id2": 0.6}
|
||||
if isinstance(data, dict):
|
||||
if "matches" in data and isinstance(data["matches"], list):
|
||||
# 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]}
|
||||
for item in data["matches"]:
|
||||
if isinstance(item, dict):
|
||||
aid = item.get("asset_id") or item.get("id")
|
||||
score = item.get("score", 0)
|
||||
if aid and isinstance(score, (int, float)):
|
||||
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||||
else:
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (int, float)):
|
||||
result[str(key)] = max(0.0, min(1.0, float(value)))
|
||||
|
||||
# 格式3: [{"asset_id": "...", "score": 0.8}]
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
aid = item.get("asset_id") or item.get("id")
|
||||
score = item.get("score", 0)
|
||||
if aid and isinstance(score, (int, float)):
|
||||
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||||
|
||||
if len(result) >= max(1, len(asset_ids) // 2): # 至少一半素材有评分才算成功
|
||||
return result
|
||||
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
return None
|
||||
return dict(result)
|
||||
|
||||
|
||||
def semantic_match_assets(
|
||||
|
||||
@@ -16,6 +16,11 @@ from packages.adapters.sqlalchemy_impl import (
|
||||
SQLAlchemyEditPlanRepository,
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.domain.clip_operations import calculate_merge as _calc_merge
|
||||
from packages.domain.clip_operations import calculate_shift_orders as _calc_shift_orders
|
||||
from packages.domain.clip_operations import calculate_split as _calc_split
|
||||
from packages.domain.clip_operations import validate_merge_clips as _validate_merge
|
||||
from packages.domain.clip_operations import validate_split_time as _validate_split
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
@@ -384,36 +389,45 @@ class EditPlanService:
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
plan_id = clip.plan_id
|
||||
|
||||
if split_time <= 0 or split_time >= clip.duration:
|
||||
raise ValueError(f"分割时间必须在 (0, {clip.duration:.3f}) 范围内,当前: {split_time}")
|
||||
# 纯逻辑:校验 + 计算
|
||||
_validate_split(split_time, clip.duration)
|
||||
split = _calc_split(
|
||||
duration=clip.duration,
|
||||
split_time=split_time,
|
||||
start_time=clip.start_time,
|
||||
)
|
||||
|
||||
self._auto_resume_editing(plan_id)
|
||||
|
||||
original_duration = clip.duration
|
||||
left_duration = round(split_time, 3)
|
||||
right_duration = round(original_duration - split_time, 3)
|
||||
original_order = clip.order
|
||||
|
||||
# 更新左半部分(原片段)
|
||||
clip.duration = left_duration
|
||||
clip.duration = split.left_duration
|
||||
left_clip = self._clip_repo.update(clip)
|
||||
|
||||
# 后面片段的 order 全部 +1(给右半部分腾位置)
|
||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||||
for c in all_clips:
|
||||
if c.order > original_order and c.id != clip_id:
|
||||
c.order += 1
|
||||
self._clip_repo.update(c)
|
||||
shifts = _calc_shift_orders(
|
||||
all_clips,
|
||||
threshold_order=original_order,
|
||||
shift=1,
|
||||
excluded_ids={clip_id},
|
||||
id_attr="id",
|
||||
order_attr="order",
|
||||
)
|
||||
for c, new_order in shifts:
|
||||
c.order = new_order
|
||||
self._clip_repo.update(c)
|
||||
|
||||
# 创建右半部分新片段(继承原片段的大部分属性)
|
||||
right_config = dict(clip.config) if clip.config else {}
|
||||
# 素材裁剪信息
|
||||
if clip.asset_id:
|
||||
# 右半部分从 split_time 开始播放
|
||||
right_config["trim_start"] = left_duration
|
||||
right_config["trim_start"] = split.right_trim_start
|
||||
# 左半部分在 split_time 处结束
|
||||
left_config = dict(left_clip.config) if left_clip.config else {}
|
||||
left_config["trim_end"] = right_duration
|
||||
left_config["trim_end"] = split.left_trim_end
|
||||
left_clip.config = left_config
|
||||
left_clip = self._clip_repo.update(left_clip)
|
||||
|
||||
@@ -424,8 +438,8 @@ class EditPlanService:
|
||||
template_clip_config_id=clip.template_clip_config_id,
|
||||
asset_id=clip.asset_id,
|
||||
text_content=clip.text_content,
|
||||
start_time=clip.start_time + left_duration,
|
||||
duration=right_duration,
|
||||
start_time=split.right_start_time,
|
||||
duration=split.right_duration,
|
||||
transition_effect=clip.transition_effect,
|
||||
transition_duration=clip.transition_duration,
|
||||
playback_speed=clip.playback_speed,
|
||||
@@ -438,8 +452,8 @@ class EditPlanService:
|
||||
clip_id,
|
||||
plan_id,
|
||||
split_time,
|
||||
left_duration,
|
||||
right_duration,
|
||||
split.left_duration,
|
||||
split.right_duration,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -468,70 +482,45 @@ class EditPlanService:
|
||||
clip = self.get_clip_or_raise(cid)
|
||||
clips.append(clip)
|
||||
|
||||
# 校验:同一计划
|
||||
plan_id = clips[0].plan_id
|
||||
for c in clips[1:]:
|
||||
if c.plan_id != plan_id:
|
||||
raise ValueError("只能合并同一计划下的片段")
|
||||
|
||||
# 按 order 排序
|
||||
clips.sort(key=lambda c: c.order)
|
||||
|
||||
# 校验:order 连续
|
||||
for i in range(1, len(clips)):
|
||||
if clips[i].order != clips[i - 1].order + 1:
|
||||
raise ValueError(f"片段不连续:order {clips[i-1].order} → {clips[i].order}")
|
||||
|
||||
# 校验:类型一致
|
||||
clip_type = clips[0].clip_type
|
||||
for c in clips[1:]:
|
||||
if c.clip_type != clip_type:
|
||||
raise ValueError("只能合并相同类型的片段")
|
||||
# 纯逻辑:校验 + 计算
|
||||
plan_id, first_order = _validate_merge(clips)
|
||||
merge = _calc_merge(clips)
|
||||
|
||||
self._auto_resume_editing(plan_id)
|
||||
|
||||
# 计算合并后的属性
|
||||
first_clip = clips[0]
|
||||
total_duration = round(sum(c.duration for c in clips), 3)
|
||||
first_order = first_clip.order
|
||||
|
||||
# 合并文案(用换行连接)
|
||||
merged_text = "\n".join(c.text_content for c in clips if c.text_content.strip())
|
||||
|
||||
# 合并 config(后面的覆盖前面的)
|
||||
merged_config: Dict[str, Any] = {}
|
||||
for c in clips:
|
||||
if c.config:
|
||||
merged_config.update(c.config)
|
||||
# 清理 trim 相关字段(合并后就是完整片段了)
|
||||
merged_config.pop("trim_start", None)
|
||||
merged_config.pop("trim_end", None)
|
||||
|
||||
# 更新第一个片段(保留它作为合并结果)
|
||||
first_clip.duration = total_duration
|
||||
first_clip.text_content = merged_text
|
||||
first_clip.config = merged_config
|
||||
first_clip = sorted(clips, key=lambda c: c.order)[0]
|
||||
first_clip.duration = merge.total_duration
|
||||
first_clip.text_content = merge.merged_text
|
||||
first_clip.config = merge.merged_config
|
||||
# 转场保留第一个的(合并后的入点转场)
|
||||
# playback_speed 取第一个的
|
||||
merged_clip = self._clip_repo.update(first_clip)
|
||||
|
||||
# 删除其余片段
|
||||
for c in clips[1:]:
|
||||
self._clip_repo.delete(c.id)
|
||||
rest_ids = [c.id for c in clips if c.id != merged_clip.id]
|
||||
for cid in rest_ids:
|
||||
self._clip_repo.delete(cid)
|
||||
|
||||
# 后面的片段 order 前移 (len - 1) 位
|
||||
shift = len(clips) - 1
|
||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||||
for c in all_clips:
|
||||
if c.order > first_order and c.id != merged_clip.id:
|
||||
c.order -= shift
|
||||
self._clip_repo.update(c)
|
||||
shifts = _calc_shift_orders(
|
||||
all_clips,
|
||||
threshold_order=first_order,
|
||||
shift=-merge.shift_amount,
|
||||
excluded_ids={merged_clip.id},
|
||||
id_attr="id",
|
||||
order_attr="order",
|
||||
)
|
||||
for c, new_order in shifts:
|
||||
c.order = new_order
|
||||
self._clip_repo.update(c)
|
||||
|
||||
logger.info(
|
||||
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
|
||||
plan_id,
|
||||
len(clips),
|
||||
total_duration,
|
||||
merge.total_duration,
|
||||
)
|
||||
|
||||
return merged_clip
|
||||
|
||||
@@ -23,6 +23,13 @@ from packages.domain.template_clip_config import (
|
||||
TemplateClipConfig,
|
||||
TransitionEffect,
|
||||
)
|
||||
from packages.domain.template_clip_converter import (
|
||||
clip_configs_to_snapshots,
|
||||
clips_to_template_clip_configs,
|
||||
filter_plan_config_to_template,
|
||||
snapshots_to_template_clip_configs,
|
||||
validate_template_name,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -121,9 +128,7 @@ class EditTemplateService:
|
||||
ValueError: 名称为空或重复
|
||||
"""
|
||||
# 名称校验
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValueError("模板名称不能为空")
|
||||
clean_name = validate_template_name(name)
|
||||
|
||||
# 名称重复检查
|
||||
existing = self._template_repo.list_all(skip=0, limit=1000)
|
||||
@@ -471,12 +476,7 @@ class EditTemplateService:
|
||||
raise ValueError(f"模板名称已存在: {clean_name}")
|
||||
|
||||
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
|
||||
plan_config = plan.config or {}
|
||||
template_config: dict[str, Any] = {}
|
||||
for key, value in plan_config.items():
|
||||
# 跳过明显的运行时/实例字段,保留风格/模式类配置
|
||||
if key not in {"asset_ids", "source_edit_plan_id", "generation_task_id"}:
|
||||
template_config[key] = value
|
||||
template_config = filter_plan_config_to_template(plan.config)
|
||||
|
||||
template = EditTemplate.create(
|
||||
name=clean_name,
|
||||
@@ -497,40 +497,7 @@ class EditTemplateService:
|
||||
|
||||
# 5. 转换每个片段为模板片段配置
|
||||
created_configs: List[TemplateClipConfig] = []
|
||||
for clip in clips:
|
||||
clip_config: dict[str, Any] = {}
|
||||
# 播放速度存入 config
|
||||
if clip.playback_speed and clip.playback_speed != 1.0:
|
||||
clip_config["playback_speed"] = clip.playback_speed
|
||||
# 片段自有 config 合并(优先级:clip.config 覆盖上面的)
|
||||
if clip.config:
|
||||
clip_config.update(clip.config)
|
||||
# 去掉素材相关字段
|
||||
clip_config.pop("asset_info", None)
|
||||
clip_config.pop("source_asset_id", None)
|
||||
|
||||
# 转场效果兼容校验
|
||||
try:
|
||||
transition = TransitionEffect(clip.transition_effect)
|
||||
except ValueError:
|
||||
transition = TransitionEffect.CUT
|
||||
|
||||
# 片段类型兼容校验
|
||||
try:
|
||||
clip_type = ClipType(clip.clip_type)
|
||||
except ValueError:
|
||||
clip_type = ClipType.MAIN
|
||||
|
||||
clip_config_obj = TemplateClipConfig.create(
|
||||
template_id=created_template.id,
|
||||
clip_type=clip_type,
|
||||
order=clip.order,
|
||||
min_duration=clip.duration,
|
||||
max_duration=clip.duration,
|
||||
text_template=clip.text_content or "",
|
||||
transition_effect=transition,
|
||||
config=clip_config,
|
||||
)
|
||||
for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips):
|
||||
created = self._clip_config_repo.create(clip_config_obj)
|
||||
created_configs.append(created)
|
||||
|
||||
@@ -679,8 +646,6 @@ class EditTemplateService:
|
||||
Raises:
|
||||
ValueError: 模板/草稿不存在,或草稿不属于该模板
|
||||
"""
|
||||
from packages.domain.template_clip_config import TemplateClipConfig
|
||||
|
||||
# 1. 校验模板和草稿
|
||||
template = self.get_template_or_raise(template_id)
|
||||
draft = self._plan_repo.get(draft_plan_id)
|
||||
@@ -700,39 +665,14 @@ class EditTemplateService:
|
||||
editing_mode = config.get("editing_mode", "one_take")
|
||||
|
||||
# 4. 提取模板配置(去掉草稿/运行时字段)
|
||||
draft_config = draft.config or {}
|
||||
template_config: dict[str, Any] = {}
|
||||
skip_keys = {
|
||||
"is_template_draft",
|
||||
"asset_ids",
|
||||
"source_edit_plan_id",
|
||||
"generation_task_id",
|
||||
}
|
||||
for key, value in draft_config.items():
|
||||
if key not in skip_keys:
|
||||
template_config[key] = value
|
||||
template_config = filter_plan_config_to_template(draft.config)
|
||||
|
||||
# 5. 事务更新
|
||||
try:
|
||||
# 5.0 先保存旧版快照(发布前的状态),用于回滚
|
||||
old_version = template.version or 1
|
||||
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
||||
old_clip_snapshots = [
|
||||
{
|
||||
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
"order": cfg.order,
|
||||
"min_duration": cfg.min_duration,
|
||||
"max_duration": cfg.max_duration,
|
||||
"text_template": cfg.text_template or "",
|
||||
"transition_effect": (
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
"config": cfg.config or {},
|
||||
}
|
||||
for cfg in old_clip_configs
|
||||
]
|
||||
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
|
||||
|
||||
from packages.domain.template_version import EditTemplateVersion
|
||||
|
||||
@@ -759,46 +699,7 @@ class EditTemplateService:
|
||||
|
||||
# 创建新的片段配置
|
||||
created_configs: list[TemplateClipConfig] = []
|
||||
for clip in draft_clips:
|
||||
clip_config: dict[str, Any] = {}
|
||||
# 播放速度存入 config
|
||||
if clip.playback_speed and clip.playback_speed != 1.0:
|
||||
clip_config["playback_speed"] = clip.playback_speed
|
||||
# 片段自有 config 合并
|
||||
if clip.config:
|
||||
clip_config.update(clip.config)
|
||||
# 去掉素材相关字段
|
||||
clip_config.pop("asset_info", None)
|
||||
clip_config.pop("source_asset_id", None)
|
||||
|
||||
# 转场效果兼容校验
|
||||
try:
|
||||
from packages.domain.template_clip_config import (
|
||||
TransitionEffect,
|
||||
)
|
||||
|
||||
transition = TransitionEffect(clip.transition_effect)
|
||||
except (ValueError, ImportError):
|
||||
transition = TransitionEffect.CUT # type: ignore
|
||||
|
||||
# 片段类型兼容校验
|
||||
try:
|
||||
from packages.domain.template_clip_config import ClipType
|
||||
|
||||
clip_type = ClipType(clip.clip_type)
|
||||
except (ValueError, ImportError):
|
||||
clip_type = ClipType.MAIN # type: ignore
|
||||
|
||||
config_obj = TemplateClipConfig.create(
|
||||
template_id=template_id,
|
||||
clip_type=clip_type,
|
||||
order=clip.order,
|
||||
min_duration=clip.duration,
|
||||
max_duration=clip.duration,
|
||||
text_template=clip.text_content or "",
|
||||
transition_effect=transition,
|
||||
config=clip_config,
|
||||
)
|
||||
for config_obj in clips_to_template_clip_configs(template_id, draft_clips):
|
||||
created = self._clip_config_repo.create(config_obj)
|
||||
created_configs.append(created)
|
||||
|
||||
@@ -843,8 +744,6 @@ class EditTemplateService:
|
||||
Raises:
|
||||
ValueError: 模板/版本不存在
|
||||
"""
|
||||
from packages.domain.template_clip_config import TemplateClipConfig
|
||||
|
||||
template = self.get_template_or_raise(template_id)
|
||||
|
||||
# 1. 读取目标版本快照
|
||||
@@ -857,22 +756,7 @@ class EditTemplateService:
|
||||
try:
|
||||
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
|
||||
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
||||
old_clip_snapshots = [
|
||||
{
|
||||
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
"order": cfg.order,
|
||||
"min_duration": cfg.min_duration,
|
||||
"max_duration": cfg.max_duration,
|
||||
"text_template": cfg.text_template or "",
|
||||
"transition_effect": (
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
"config": cfg.config or {},
|
||||
}
|
||||
for cfg in old_clip_configs
|
||||
]
|
||||
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
|
||||
|
||||
from packages.domain.template_version import EditTemplateVersion
|
||||
|
||||
@@ -905,37 +789,7 @@ class EditTemplateService:
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
for clip_snap in target_version.clip_configs:
|
||||
# 转场效果兼容校验
|
||||
try:
|
||||
from packages.domain.template_clip_config import TransitionEffect
|
||||
|
||||
transition = TransitionEffect(clip_snap.get("transition_effect", "cut"))
|
||||
except (ValueError, ImportError):
|
||||
from packages.domain.template_clip_config import TransitionEffect
|
||||
|
||||
transition = TransitionEffect.CUT
|
||||
|
||||
# 片段类型兼容校验
|
||||
try:
|
||||
from packages.domain.template_clip_config import ClipType
|
||||
|
||||
clip_type = ClipType(clip_snap.get("clip_type", "main"))
|
||||
except (ValueError, ImportError):
|
||||
from packages.domain.template_clip_config import ClipType
|
||||
|
||||
clip_type = ClipType.MAIN
|
||||
|
||||
config_obj = TemplateClipConfig.create(
|
||||
template_id=template_id,
|
||||
clip_type=clip_type,
|
||||
order=clip_snap.get("order", 0),
|
||||
min_duration=clip_snap.get("min_duration", 0.0),
|
||||
max_duration=clip_snap.get("max_duration", 0.0),
|
||||
text_template=clip_snap.get("text_template", ""),
|
||||
transition_effect=transition,
|
||||
config=clip_snap.get("config", {}) or {},
|
||||
)
|
||||
for config_obj in snapshots_to_template_clip_configs(template_id, target_version.clip_configs):
|
||||
self._clip_config_repo.create(config_obj)
|
||||
|
||||
self._db.commit()
|
||||
|
||||
@@ -26,7 +26,13 @@ from packages.domain.edit_plan import EditPlan
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
from packages.domain.edit_template import EditTemplate
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
from packages.domain.plan_generator_utils import (
|
||||
create_clips_from_configs,
|
||||
distribute_assets,
|
||||
generate_default_clips,
|
||||
map_clip_types_for_mode,
|
||||
)
|
||||
from packages.domain.template_clip_config import TemplateClipConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -163,83 +169,18 @@ class PlanGeneratorService:
|
||||
plan_id: str,
|
||||
clip_configs: List[TemplateClipConfig],
|
||||
) -> List[EditPlanClip]:
|
||||
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
|
||||
clips: List[EditPlanClip] = []
|
||||
# 按 order 排序
|
||||
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
|
||||
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化).
|
||||
|
||||
for cfg in sorted_configs:
|
||||
# 计算时长:取 min_duration 和 max_duration 的中间值
|
||||
if cfg.min_duration > 0 and cfg.max_duration > 0:
|
||||
duration = (cfg.min_duration + cfg.max_duration) / 2
|
||||
elif cfg.min_duration > 0:
|
||||
duration = cfg.min_duration
|
||||
elif cfg.max_duration > 0:
|
||||
duration = cfg.max_duration
|
||||
else:
|
||||
duration = _DEFAULT_CLIP_DURATION
|
||||
|
||||
# clip_type 可能是枚举或字符串
|
||||
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
|
||||
|
||||
# transition_effect 可能是枚举或字符串
|
||||
transition = (
|
||||
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
|
||||
)
|
||||
|
||||
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
|
||||
clip_cfg = cfg.config or {}
|
||||
playback_speed = clip_cfg.get("playback_speed", clip_cfg.get("speed_ratio", 1.0)) or 1.0
|
||||
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=cfg.order,
|
||||
template_clip_config_id=cfg.id,
|
||||
text_content=getattr(cfg, "text_template", "") or "",
|
||||
duration=duration,
|
||||
transition_effect=transition or "cut",
|
||||
playback_speed=playback_speed,
|
||||
config=clip_cfg,
|
||||
)
|
||||
clips.append(clip)
|
||||
|
||||
return clips
|
||||
委托给 plan_generator_utils.create_clips_from_configs 纯函数。
|
||||
"""
|
||||
return create_clips_from_configs(plan_id, clip_configs)
|
||||
|
||||
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
|
||||
"""将模板 clip_config 生成的 MAIN 类型片段,按 editing_mode 映射为对应角色类型。
|
||||
"""将 MAIN 类型片段按 editing_mode 映射为对应角色类型.
|
||||
|
||||
模板的 clip_config 使用 ClipType 枚举(main/intro/outro 等),
|
||||
但 PIP / VOICE_PIP 模式的素材分配和渲染分层依赖特定的 clip_type 命名
|
||||
(overlay / background / corner_voice / b_roll)。
|
||||
|
||||
映射规则(仅修改 MAIN 类型片段,非 MAIN 片段保持原类型):
|
||||
- PIP: 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画)
|
||||
- VOICE_PIP: 第1个 → background,第2个 → corner_voice,第3+个 → b_roll
|
||||
- ONE_TAKE / VOICE_OVER: 保持 main 不变
|
||||
委托给 plan_generator_utils.map_clip_types_for_mode 纯函数。
|
||||
"""
|
||||
from packages.domain.template_clip_config import ClipType
|
||||
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
if not main_clips:
|
||||
return
|
||||
|
||||
if editing_mode == EditingMode.PIP.value:
|
||||
# 第1个 main 保持(背景层),其余改为 overlay(画中画层)
|
||||
for i, clip in enumerate(main_clips):
|
||||
if i > 0:
|
||||
clip.clip_type = "overlay"
|
||||
|
||||
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||
for i, clip in enumerate(main_clips):
|
||||
if i == 0:
|
||||
clip.clip_type = "background"
|
||||
elif i == 1:
|
||||
clip.clip_type = "corner_voice"
|
||||
else:
|
||||
clip.clip_type = "b_roll"
|
||||
|
||||
# ONE_TAKE / VOICE_OVER: 保持 main 不变,无需处理
|
||||
map_clip_types_for_mode(clips, editing_mode)
|
||||
|
||||
def _generate_default_clips(
|
||||
self,
|
||||
@@ -247,101 +188,11 @@ class PlanGeneratorService:
|
||||
editing_mode: str,
|
||||
asset_count: int,
|
||||
) -> List[EditPlanClip]:
|
||||
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
|
||||
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
|
||||
|
||||
- ONE_TAKE: N 个 main clips(N = asset_count,至少1个)
|
||||
- PIP: 1 个 main + (N-1) 个 overlay(N = asset_count)
|
||||
- VOICE_OVER: N 个 main clips + 标记需要配音
|
||||
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
|
||||
委托给 plan_generator_utils.generate_default_clips 纯函数。
|
||||
"""
|
||||
n = max(asset_count, 1)
|
||||
clips: List[EditPlanClip] = []
|
||||
order = 0
|
||||
|
||||
if editing_mode == EditingMode.PIP.value:
|
||||
# 1 个 main(全屏背景)
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
# 剩余为 overlay
|
||||
for _ in range(1, n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="overlay",
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||
# N 个 main clips(B-roll)
|
||||
for _ in range(n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
config={"role": "b_roll"},
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||
# 1 个 background
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="background",
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
# 1 个 corner_voice
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="corner_voice",
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
# 剩余为 b_roll
|
||||
for _ in range(2, n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="b_roll",
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
else:
|
||||
# ONE_TAKE: N 个 main clips
|
||||
for _ in range(n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
return clips
|
||||
return generate_default_clips(plan_id, editing_mode, asset_count)
|
||||
|
||||
def _distribute_assets(
|
||||
self,
|
||||
@@ -349,89 +200,8 @@ class PlanGeneratorService:
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
) -> None:
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化)
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
|
||||
|
||||
分配策略:
|
||||
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
|
||||
- PIP: 第1个素材→main(全屏背景),其余→交替分配给 overlay clips
|
||||
- VOICE_OVER: 素材→main clips (B-roll)
|
||||
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
|
||||
委托给 plan_generator_utils.distribute_assets 纯函数。
|
||||
"""
|
||||
if not asset_ids or not clips:
|
||||
return
|
||||
|
||||
if editing_mode == EditingMode.ONE_TAKE.value:
|
||||
self._distribute_one_take(clips, asset_ids)
|
||||
elif editing_mode == EditingMode.PIP.value:
|
||||
self._distribute_pip(clips, asset_ids)
|
||||
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||
self._distribute_voice_over(clips, asset_ids)
|
||||
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||
self._distribute_voice_pip(clips, asset_ids)
|
||||
else:
|
||||
# 未知模式,退化为 one_take
|
||||
self._distribute_one_take(clips, asset_ids)
|
||||
|
||||
def _distribute_one_take(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
) -> None:
|
||||
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips"""
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
for i, clip in enumerate(main_clips):
|
||||
if i < len(asset_ids):
|
||||
clip.assign_asset(asset_ids[i])
|
||||
|
||||
def _distribute_pip(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
) -> None:
|
||||
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips"""
|
||||
# 第1个素材 → main clip
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
if main_clips and asset_ids:
|
||||
main_clips[0].assign_asset(asset_ids[0])
|
||||
|
||||
# 其余素材 → overlay clips
|
||||
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
|
||||
remaining = asset_ids[1:]
|
||||
for i, clip in enumerate(overlay_clips):
|
||||
if i < len(remaining):
|
||||
clip.assign_asset(remaining[i])
|
||||
|
||||
def _distribute_voice_over(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
) -> None:
|
||||
"""VOICE_OVER: 素材→main clips (B-roll)"""
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
for i, clip in enumerate(main_clips):
|
||||
if i < len(asset_ids):
|
||||
clip.assign_asset(asset_ids[i])
|
||||
|
||||
def _distribute_voice_pip(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
) -> None:
|
||||
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll"""
|
||||
bg_clips = [c for c in clips if c.clip_type == "background"]
|
||||
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
|
||||
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
|
||||
|
||||
# 第1个素材 → background
|
||||
if bg_clips and len(asset_ids) > 0:
|
||||
bg_clips[0].assign_asset(asset_ids[0])
|
||||
|
||||
# 第2个素材 → corner_voice
|
||||
if corner_clips and len(asset_ids) > 1:
|
||||
corner_clips[0].assign_asset(asset_ids[1])
|
||||
|
||||
# 其余素材 → b_roll
|
||||
remaining = asset_ids[2:]
|
||||
for i, clip in enumerate(broll_clips):
|
||||
if i < len(remaining):
|
||||
clip.assign_asset(remaining[i])
|
||||
distribute_assets(clips, asset_ids, editing_mode)
|
||||
|
||||
@@ -13,57 +13,26 @@
|
||||
- 最低质量分门槛:自动过滤低质量素材
|
||||
- 时长多样性:保证选出的素材时长分布均匀(短/中/长各占一定比例)
|
||||
- 兼容全部模式:素材库模式和项目模式都可用
|
||||
|
||||
纯逻辑部分已抽离到 packages.domain.asset_scoring。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packages.domain.asset_scoring import MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX # noqa: F401 - re-export for tests
|
||||
from packages.domain.asset_scoring import SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX # noqa: F401 - re-export for tests
|
||||
from packages.domain.asset_scoring import (
|
||||
AssetScoreDetail,
|
||||
SmartSelectResult,
|
||||
diverse_selection,
|
||||
filter_candidates,
|
||||
score_asset_detail,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 评分权重 ──────────────────────────────────────────────────────────────────
|
||||
_WEIGHT_QUALITY = 0.5
|
||||
_WEIGHT_RESOLUTION = 0.2
|
||||
_WEIGHT_DURATION = 0.2
|
||||
_WEIGHT_BITRATE = 0.1
|
||||
|
||||
# ── 评分参数 ──────────────────────────────────────────────────────────────────
|
||||
_TARGET_WIDTH = 1920 # 目标分辨率宽度基准
|
||||
_TARGET_HEIGHT = 1080 # 目标分辨率高度基准
|
||||
_MIN_QUALITY_SCORE = 30.0 # 最低质量分门槛(低于此值的素材直接排除)
|
||||
_OPTIMAL_DURATION_MIN = 3.0 # 最佳时长区间(秒)
|
||||
_OPTIMAL_DURATION_MAX = 30.0
|
||||
|
||||
# ── 多样性分桶 ───────────────────────────────────────────────────────────────
|
||||
_SHORT_BUCKET_MAX = 5.0 # 短素材:< 5s
|
||||
_MEDIUM_BUCKET_MAX = 15.0 # 中素材:5-15s
|
||||
# 长素材:> 15s
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmartSelectResult:
|
||||
"""智能选择结果."""
|
||||
|
||||
selected_ids: list[str]
|
||||
total_candidates: int
|
||||
filtered_out: int # 被质量门槛过滤的数量
|
||||
avg_score: float
|
||||
details: list[AssetScoreDetail]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetScoreDetail:
|
||||
"""单个素材的评分详情."""
|
||||
|
||||
asset_id: str
|
||||
total_score: float
|
||||
quality_score: float
|
||||
resolution_score: float
|
||||
duration_score: float
|
||||
bitrate_score: float
|
||||
duration: float | None
|
||||
|
||||
|
||||
class SmartAssetSelector:
|
||||
"""智能素材选择器.
|
||||
@@ -74,9 +43,9 @@ class SmartAssetSelector:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_quality_score: float = _MIN_QUALITY_SCORE,
|
||||
target_width: int = _TARGET_WIDTH,
|
||||
target_height: int = _TARGET_HEIGHT,
|
||||
min_quality_score: float = 30.0,
|
||||
target_width: int = 1920,
|
||||
target_height: int = 1080,
|
||||
):
|
||||
self.min_quality_score = min_quality_score
|
||||
self.target_width = target_width
|
||||
@@ -102,21 +71,7 @@ class SmartAssetSelector:
|
||||
SmartSelectResult 选择结果
|
||||
"""
|
||||
# 1. 过滤:只保留 ready 状态的视频素材 + 最低质量分门槛
|
||||
candidates = []
|
||||
filtered_out = 0
|
||||
for asset in assets:
|
||||
status = getattr(asset, "status", None)
|
||||
status_val = status.value if hasattr(status, "value") else str(status)
|
||||
if status_val != "ready":
|
||||
continue
|
||||
mime_type = getattr(asset, "mime_type", "") or ""
|
||||
if not mime_type.startswith("video"):
|
||||
continue
|
||||
quality = getattr(asset, "quality_score", None)
|
||||
if quality is not None and quality < self.min_quality_score:
|
||||
filtered_out += 1
|
||||
continue
|
||||
candidates.append(asset)
|
||||
candidates, filtered_out = filter_candidates(assets, self.min_quality_score)
|
||||
|
||||
if not candidates:
|
||||
return SmartSelectResult(
|
||||
@@ -130,7 +85,16 @@ class SmartAssetSelector:
|
||||
# 2. 对每个候选素材评分
|
||||
scored: list[AssetScoreDetail] = []
|
||||
for asset in candidates:
|
||||
detail = self._score_asset(asset)
|
||||
detail = score_asset_detail(
|
||||
asset_id=asset.id,
|
||||
quality=getattr(asset, "quality_score", None),
|
||||
width=getattr(asset, "width", None),
|
||||
height=getattr(asset, "height", None),
|
||||
duration=getattr(asset, "duration", None),
|
||||
file_size=getattr(asset, "file_size", 0) or 0,
|
||||
target_width=self.target_width,
|
||||
target_height=self.target_height,
|
||||
)
|
||||
scored.append(detail)
|
||||
|
||||
# 3. 按总分降序排列
|
||||
@@ -138,7 +102,7 @@ class SmartAssetSelector:
|
||||
|
||||
# 4. 多样性选择(如果需要且数量有限制)
|
||||
if ensure_diversity and count > 0 and len(scored) > count:
|
||||
selected = self._diverse_selection(scored, count)
|
||||
selected = diverse_selection(scored, count)
|
||||
else:
|
||||
# 无数量限制或不要求多样性,直接按排名取
|
||||
selected = scored if count <= 0 else scored[:count]
|
||||
@@ -162,165 +126,39 @@ class SmartAssetSelector:
|
||||
)
|
||||
return result
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────────
|
||||
# ── 向后兼容:私有方法别名(委托给 asset_scoring 纯函数) ────────────────
|
||||
|
||||
def _score_asset(self, asset) -> AssetScoreDetail:
|
||||
"""对单个素材进行多维度评分."""
|
||||
# 质量分
|
||||
quality = getattr(asset, "quality_score", None)
|
||||
quality_score = (quality / 100.0) if quality is not None else 0.5
|
||||
|
||||
# 分辨率评分:越接近目标分辨率得分越高
|
||||
width = getattr(asset, "width", None)
|
||||
height = getattr(asset, "height", None)
|
||||
resolution_score = self._score_resolution(width, height)
|
||||
|
||||
# 时长评分:在最佳区间内得分高,过短过长扣分
|
||||
duration = getattr(asset, "duration", None)
|
||||
duration_score = self._score_duration(duration)
|
||||
|
||||
# 码率评分:用 file_size/duration 估算,适中得分高
|
||||
file_size = getattr(asset, "file_size", 0) or 0
|
||||
bitrate_score = self._score_bitrate(file_size, duration)
|
||||
|
||||
# 加权总分
|
||||
total = (
|
||||
_WEIGHT_QUALITY * quality_score
|
||||
+ _WEIGHT_RESOLUTION * resolution_score
|
||||
+ _WEIGHT_DURATION * duration_score
|
||||
+ _WEIGHT_BITRATE * bitrate_score
|
||||
)
|
||||
|
||||
return AssetScoreDetail(
|
||||
"""对单个素材进行多维度评分(向后兼容)."""
|
||||
return score_asset_detail(
|
||||
asset_id=asset.id,
|
||||
total_score=round(total, 4),
|
||||
quality_score=round(quality_score, 4),
|
||||
resolution_score=round(resolution_score, 4),
|
||||
duration_score=round(duration_score, 4),
|
||||
bitrate_score=round(bitrate_score, 4),
|
||||
duration=duration,
|
||||
quality=getattr(asset, "quality_score", None),
|
||||
width=getattr(asset, "width", None),
|
||||
height=getattr(asset, "height", None),
|
||||
duration=getattr(asset, "duration", None),
|
||||
file_size=getattr(asset, "file_size", 0) or 0,
|
||||
target_width=self.target_width,
|
||||
target_height=self.target_height,
|
||||
)
|
||||
|
||||
def _score_resolution(self, width: int | None, height: int | None) -> float:
|
||||
"""分辨率评分:越接近目标分辨率得分越高,低于480p扣分严重."""
|
||||
if width is None or height is None or width <= 0 or height <= 0:
|
||||
return 0.5 # 未知分辨率给中评分
|
||||
"""分辨率评分(向后兼容)."""
|
||||
from packages.domain.asset_scoring import score_resolution
|
||||
|
||||
target_pixels = self.target_width * self.target_height
|
||||
actual_pixels = width * height
|
||||
|
||||
# 计算像素数比例
|
||||
ratio = actual_pixels / target_pixels
|
||||
|
||||
if ratio >= 1.0:
|
||||
# 高于或等于目标分辨率:满分,略高不扣分(4K也给满分)
|
||||
return 1.0
|
||||
else:
|
||||
# 低于目标分辨率:线性衰减,但最低不低于 0.1
|
||||
# 例如:720p (921600) / 1080p (2073600) = 0.44 → 得分 0.6
|
||||
score = 0.3 + 0.7 * ratio
|
||||
return max(0.1, min(1.0, score))
|
||||
return score_resolution(width, height, self.target_width, self.target_height)
|
||||
|
||||
def _score_duration(self, duration: float | None) -> float:
|
||||
"""时长评分:3-30秒最佳,过短或过长都扣分."""
|
||||
if duration is None or duration <= 0:
|
||||
return 0.5 # 未知时长给中评分
|
||||
"""时长评分(向后兼容)."""
|
||||
from packages.domain.asset_scoring import score_duration
|
||||
|
||||
if _OPTIMAL_DURATION_MIN <= duration <= _OPTIMAL_DURATION_MAX:
|
||||
# 最佳区间:满分
|
||||
return 1.0
|
||||
|
||||
if duration < _OPTIMAL_DURATION_MIN:
|
||||
# 太短:线性衰减,1秒以下给 0.3
|
||||
ratio = duration / _OPTIMAL_DURATION_MIN
|
||||
return 0.3 + 0.7 * ratio
|
||||
|
||||
# 太长:每超过最佳区间上限10秒扣 0.1 分,最低 0.2
|
||||
excess = duration - _OPTIMAL_DURATION_MAX
|
||||
penalty = min(0.8, excess / 10.0 * 0.1)
|
||||
return max(0.2, 1.0 - penalty)
|
||||
return score_duration(duration)
|
||||
|
||||
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
|
||||
"""码率评分:根据文件大小和时长估算码率,适中得分高."""
|
||||
if not file_size or not duration or duration <= 0:
|
||||
return 0.5 # 未知给中评分
|
||||
"""码率评分(向后兼容)."""
|
||||
from packages.domain.asset_scoring import score_bitrate
|
||||
|
||||
# 估算码率(bps)
|
||||
bitrate = (file_size * 8) / duration
|
||||
|
||||
# 最佳码率范围:2-8 Mbps
|
||||
optimal_low = 2_000_000 # 2 Mbps
|
||||
optimal_high = 8_000_000 # 8 Mbps
|
||||
|
||||
if optimal_low <= bitrate <= optimal_high:
|
||||
return 1.0
|
||||
|
||||
if bitrate < optimal_low:
|
||||
# 码率太低:线性衰减
|
||||
ratio = bitrate / optimal_low
|
||||
return 0.3 + 0.7 * ratio
|
||||
|
||||
# 码率太高(文件太大):适度扣分
|
||||
excess = bitrate / optimal_high - 1.0
|
||||
penalty = min(0.5, excess * 0.2)
|
||||
return max(0.5, 1.0 - penalty)
|
||||
return score_bitrate(file_size, duration)
|
||||
|
||||
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
|
||||
"""多样性选择:按时长分桶,保证每个桶都有素材.
|
||||
|
||||
策略:
|
||||
1. 按时长分为三桶:短(<5s)、中(5-15s)、长(>15s)
|
||||
2. 每个桶配额 = max(1, count / 3)
|
||||
3. 先从每桶按配额取最高分的
|
||||
4. 剩余名额从全局最高分中取(不重复)
|
||||
"""
|
||||
# 分桶
|
||||
short_bucket = [d for d in scored if d.duration is not None and d.duration < _SHORT_BUCKET_MAX]
|
||||
medium_bucket = [
|
||||
d for d in scored if d.duration is not None and _SHORT_BUCKET_MAX <= d.duration < _MEDIUM_BUCKET_MAX
|
||||
]
|
||||
long_bucket = [d for d in scored if d.duration is not None and d.duration >= _MEDIUM_BUCKET_MAX]
|
||||
unknown_bucket = [d for d in scored if d.duration is None]
|
||||
|
||||
buckets = [short_bucket, medium_bucket, long_bucket]
|
||||
bucket_names = ["short", "medium", "long"]
|
||||
|
||||
# 每个桶基础配额(至少1个,如果桶非空且需要的话)
|
||||
base_quota = max(1, count // 3)
|
||||
|
||||
selected: list[AssetScoreDetail] = []
|
||||
selected_ids: set[str] = set()
|
||||
|
||||
# 先按配额从每个桶取
|
||||
for bucket, _name in zip(buckets, bucket_names, strict=False):
|
||||
quota = min(base_quota, len(bucket))
|
||||
if quota <= 0:
|
||||
continue
|
||||
# 桶内已经按分数排好序了,直接取前 quota 个
|
||||
for item in bucket[:quota]:
|
||||
if item.asset_id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.asset_id)
|
||||
if len(selected) >= count:
|
||||
return selected
|
||||
|
||||
# 剩余名额:从全局(未被选中的)中按分数高低取
|
||||
remaining_needed = count - len(selected)
|
||||
if remaining_needed > 0:
|
||||
for item in scored:
|
||||
if item.asset_id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.asset_id)
|
||||
if len(selected) >= count:
|
||||
break
|
||||
|
||||
# 如果还不够(不应该发生),加上未知时长的
|
||||
if len(selected) < count and unknown_bucket:
|
||||
for item in unknown_bucket:
|
||||
if item.asset_id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.asset_id)
|
||||
if len(selected) >= count:
|
||||
break
|
||||
|
||||
return selected[:count]
|
||||
"""多样性选择(向后兼容)."""
|
||||
return diverse_selection(scored, count)
|
||||
|
||||
@@ -29,47 +29,33 @@ from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
from packages.domain.template_clip_config import TransitionEffect
|
||||
from packages.domain.video_filter_builder import (
|
||||
DEFAULT_FPS,
|
||||
DEFAULT_OUTPUT_HEIGHT,
|
||||
DEFAULT_OUTPUT_WIDTH,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
ClipFilterChain,
|
||||
build_clip_filter,
|
||||
)
|
||||
from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func
|
||||
from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex
|
||||
from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func
|
||||
from packages.domain.video_filter_builder import chain_filters as _chain_filters_func
|
||||
from packages.domain.video_filter_builder import has_audio as _has_audio_func
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
# ── 常量(向后兼容别名) ──────────────────────────────────────────────────────
|
||||
# 实际定义已迁移至 packages/domain/video_filter_builder.py
|
||||
|
||||
DEFAULT_OUTPUT_WIDTH = 1280
|
||||
DEFAULT_OUTPUT_HEIGHT = 720
|
||||
DEFAULT_FPS = 25
|
||||
DEFAULT_CODEC = "libx264"
|
||||
DEFAULT_CRF = 23
|
||||
DEFAULT_PRESET = "medium"
|
||||
|
||||
# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称
|
||||
_XFADE_TRANSITION_MAP: dict[str, str] = {
|
||||
TransitionEffect.FADE: "fade",
|
||||
TransitionEffect.SLIDE_LEFT: "slideleft",
|
||||
TransitionEffect.SLIDE_RIGHT: "slideright",
|
||||
TransitionEffect.DISSOLVE: "dissolve",
|
||||
TransitionEffect.WIPE: "wipeleft",
|
||||
}
|
||||
|
||||
# 转场默认时长(秒)
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
|
||||
|
||||
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClipFilterChain:
|
||||
"""单个片段的滤镜链描述。"""
|
||||
|
||||
clip_id: str
|
||||
input_index: int
|
||||
video_label: str
|
||||
audio_label: str | None
|
||||
filters: list[str]
|
||||
duration: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComposeCommand:
|
||||
"""完整的 FFmpeg 合成命令描述。"""
|
||||
@@ -401,62 +387,8 @@ class VideoComposeService:
|
||||
output_height: int,
|
||||
fps: int,
|
||||
) -> ClipFilterChain:
|
||||
"""为单个片段构建滤镜链。
|
||||
|
||||
滤镜顺序:
|
||||
1. scale — 等比缩放到目标分辨率(保证覆盖)
|
||||
2. crop — 居中裁剪到目标分辨率
|
||||
3. fps — 统一输出帧率(concat 要求所有输入帧率一致)
|
||||
4. setpts — 重置时间戳 + 偏移
|
||||
5. trim — 视频时长裁剪
|
||||
6. atrim — 音频时长裁剪(如有音频流)
|
||||
"""
|
||||
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
|
||||
start = clip.start_time
|
||||
|
||||
filters: list[str] = []
|
||||
|
||||
# 1. scale: 等比缩放(保持比例,不裁剪)
|
||||
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease")
|
||||
|
||||
# 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容)
|
||||
filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black")
|
||||
|
||||
# 3. format: 统一像素格式为 yuv420p(H.264 标准格式,concat 要求所有输入像素格式一致)
|
||||
# 不同素材可能是 yuv420p / yuv422p / yuv444p / nv12 等,必须统一
|
||||
filters.append("format=yuv420p")
|
||||
|
||||
# 4. fps: 统一帧率(concat 要求所有输入帧率一致)
|
||||
# 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一
|
||||
if fps and fps > 0:
|
||||
filters.append(f"fps={fps}")
|
||||
|
||||
# 3. setpts: 重置时间戳
|
||||
if start > 0:
|
||||
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
|
||||
else:
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# 4. trim: 视频时长
|
||||
filters.append(f"trim=0:{duration}")
|
||||
filters.append("setpts=PTS-STARTPTS") # trim 后需要重置 PTS
|
||||
|
||||
video_label = f"v{input_index}"
|
||||
|
||||
# 5. 音频标签:仅当片段类型可能有音频时才设置
|
||||
# title/subtitle 是纯文字/图片卡片,没有音频流
|
||||
clip_type = clip.clip_type.lower() if clip.clip_type else ""
|
||||
has_audio_stream = clip_type not in ("title", "subtitle")
|
||||
audio_label = f"a{input_index}" if has_audio_stream else None
|
||||
|
||||
return ClipFilterChain(
|
||||
clip_id=clip.id,
|
||||
input_index=input_index,
|
||||
video_label=video_label,
|
||||
audio_label=audio_label,
|
||||
filters=filters,
|
||||
duration=duration,
|
||||
)
|
||||
"""向后兼容:委托给 video_filter_builder.build_clip_filter。"""
|
||||
return build_clip_filter(clip, input_index, output_width, output_height, fps)
|
||||
|
||||
@staticmethod
|
||||
def _build_filter_complex(
|
||||
@@ -466,102 +398,30 @@ class VideoComposeService:
|
||||
transition_duration: float,
|
||||
transitions: list[str],
|
||||
) -> tuple[str, float]:
|
||||
"""构建完整的 filter_complex 字符串。
|
||||
|
||||
策略:
|
||||
- 单片段:直接输出
|
||||
- 多片段 + 全 cut:使用 concat 滤镜(高效)
|
||||
- 多片段 + 有转场:使用 xfade 滤镜链
|
||||
|
||||
返回 (filter_complex_string, estimated_total_duration)。
|
||||
"""
|
||||
n = len(clip_chains)
|
||||
|
||||
if n == 0:
|
||||
return "", 0.0
|
||||
|
||||
# ── 单片段 ─────────────────────────────────────────────────────
|
||||
if n == 1:
|
||||
chain = clip_chains[0]
|
||||
filter_str = _chain_filters(chain.filters, chain.video_label)
|
||||
# 音频
|
||||
if chain.audio_label:
|
||||
filter_str += f";[0:a]{chain.audio_label}"
|
||||
total_duration = chain.duration
|
||||
return filter_str, total_duration
|
||||
|
||||
# ── 检查是否有转场 ─────────────────────────────────────────────
|
||||
has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions)
|
||||
|
||||
if not has_transitions:
|
||||
return _build_concat_filter(clip_chains)
|
||||
|
||||
# ── 有转场:使用 xfade ─────────────────────────────────────────
|
||||
return _build_xfade_filter(
|
||||
clip_chains=clip_chains,
|
||||
transition_duration=transition_duration,
|
||||
transitions=transitions,
|
||||
)
|
||||
"""向后兼容:委托给 video_filter_builder.build_filter_complex。"""
|
||||
return _build_filter_complex(clip_chains, output_width, output_height, transition_duration, transitions)
|
||||
|
||||
@staticmethod
|
||||
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
|
||||
"""是否有任何片段包含音频流。"""
|
||||
return any(c.audio_label is not None for c in clip_chains)
|
||||
"""向后兼容:委托给 video_filter_builder.has_audio。"""
|
||||
return _has_audio_func(clip_chains)
|
||||
|
||||
|
||||
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
|
||||
# ── 模块级辅助函数(向后兼容别名) ──────────────────────────────────────────
|
||||
# 实际实现已迁移至 packages/domain/video_filter_builder.py
|
||||
# 保留此处别名以兼容现有测试与调用方
|
||||
|
||||
|
||||
def _chain_filters(filters: list[str], output_label: str) -> str:
|
||||
"""将滤镜列表串联为 FFmpeg 滤镜字符串。"""
|
||||
filter_body = ",".join(filters)
|
||||
return f"[0:v]{filter_body}[{output_label}]"
|
||||
"""向后兼容:委托给 video_filter_builder.chain_filters。"""
|
||||
return _chain_filters_func(filters, output_label)
|
||||
|
||||
|
||||
def _build_concat_filter(
|
||||
clip_chains: list[ClipFilterChain],
|
||||
) -> tuple[str, float]:
|
||||
"""构建 concat 滤镜(无转场,高效拼接)。
|
||||
|
||||
格式:
|
||||
[0:v]filters[v0]; [1:v]filters[v1]; ...
|
||||
[v0][v1]...[vN]concat=n=N:v=1:a=0[outv]
|
||||
"""
|
||||
n = len(clip_chains)
|
||||
parts: list[str] = []
|
||||
total_duration = 0.0
|
||||
|
||||
# 每个片段的滤镜链
|
||||
for idx, chain in enumerate(clip_chains):
|
||||
filter_body = ",".join(chain.filters)
|
||||
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
||||
total_duration += chain.duration
|
||||
|
||||
# concat 滤镜
|
||||
concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains)
|
||||
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
|
||||
parts.append(concat_filter)
|
||||
|
||||
# 音频 concat(如果有)— 先统一音频格式再拼接,否则不同采样率/声道会导致concat失败
|
||||
audio_parts: list[str] = []
|
||||
for idx, chain in enumerate(clip_chains):
|
||||
if chain.audio_label:
|
||||
# aformat: 统一采样率48000Hz + 双声道stereo + fltp采样格式(AAC标准格式)
|
||||
audio_filters = [
|
||||
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
|
||||
f"atrim=0:{chain.duration}",
|
||||
"asetpts=PTS-STARTPTS",
|
||||
]
|
||||
audio_parts.append(f"[{idx}:a]{','.join(audio_filters)}[{chain.audio_label}]")
|
||||
|
||||
if audio_parts:
|
||||
parts.extend(audio_parts)
|
||||
audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label)
|
||||
audio_count = sum(1 for c in clip_chains if c.audio_label)
|
||||
if audio_count > 0:
|
||||
parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]")
|
||||
|
||||
return ";".join(parts), total_duration
|
||||
"""向后兼容:委托给 video_filter_builder.build_concat_filter。"""
|
||||
return _build_concat_filter_func(clip_chains)
|
||||
|
||||
|
||||
def _build_xfade_filter(
|
||||
@@ -569,80 +429,5 @@ def _build_xfade_filter(
|
||||
transition_duration: float,
|
||||
transitions: list[str],
|
||||
) -> tuple[str, float]:
|
||||
"""构建 xfade 转场滤镜链。
|
||||
|
||||
每两个相邻片段之间插入 xfade 转场。
|
||||
offset = 前一个片段的累积时长 - 转场时长。
|
||||
|
||||
格式(2 片段):
|
||||
[0:v]filters[v0]; [1:v]filters[v1];
|
||||
[v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv]
|
||||
|
||||
格式(3+ 片段):
|
||||
[v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv]
|
||||
"""
|
||||
n = len(clip_chains)
|
||||
parts: list[str] = []
|
||||
total_duration = 0.0
|
||||
|
||||
# 每个片段的滤镜链
|
||||
for idx, chain in enumerate(clip_chains):
|
||||
filter_body = ",".join(chain.filters)
|
||||
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
||||
total_duration += chain.duration
|
||||
|
||||
# xfade 链
|
||||
if n == 1:
|
||||
# 单片段不需要 xfade
|
||||
parts.append(f"[{clip_chains[0].video_label}]copy[outv]")
|
||||
return ";".join(parts), total_duration
|
||||
|
||||
# 计算每个转场的 offset
|
||||
cumulative = 0.0
|
||||
prev_label = clip_chains[0].video_label
|
||||
|
||||
for i in range(1, n):
|
||||
cumulative += clip_chains[i - 1].duration
|
||||
offset = max(0.0, cumulative - transition_duration * i)
|
||||
|
||||
# 获取转场类型
|
||||
transition = transitions[i] if i < len(transitions) else "cut"
|
||||
xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade")
|
||||
|
||||
if i == n - 1:
|
||||
# 最后一个转场,输出到 [outv]
|
||||
out_label = "outv"
|
||||
else:
|
||||
out_label = f"xf{i}"
|
||||
|
||||
parts.append(
|
||||
f"[{prev_label}][{clip_chains[i].video_label}]"
|
||||
f"xfade=transition={xfade_transition}"
|
||||
f":duration={transition_duration}"
|
||||
f":offset={offset:.3f}"
|
||||
f"[{out_label}]"
|
||||
)
|
||||
prev_label = out_label
|
||||
|
||||
# 总时长需要减去转场重叠部分
|
||||
total_duration -= transition_duration * (n - 1)
|
||||
|
||||
# 音频:先 aformat 归一化再 concat(不同采样率/声道/采样格式会导致concat失败)
|
||||
audio_chains_with_label = [(c, c.audio_label) for c in clip_chains if c.audio_label]
|
||||
if len(audio_chains_with_label) >= 2:
|
||||
normalized_audio_labels: list[str] = []
|
||||
for chain, _ in audio_chains_with_label:
|
||||
norm_label = f"anorm_{chain.video_label}"
|
||||
audio_filters = [
|
||||
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
|
||||
f"atrim=0:{chain.duration}",
|
||||
"asetpts=PTS-STARTPTS",
|
||||
]
|
||||
parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]")
|
||||
normalized_audio_labels.append(norm_label)
|
||||
audio_inputs = "".join(f"[{label}]" for label in normalized_audio_labels)
|
||||
parts.append(f"{audio_inputs}concat=n={len(normalized_audio_labels)}:v=0:a=1[outa]")
|
||||
elif len(audio_chains_with_label) == 1:
|
||||
parts.append(f"[{audio_chains_with_label[0][0].audio_label}]acopy[outa]")
|
||||
|
||||
return ";".join(parts), max(0.0, total_duration)
|
||||
"""向后兼容:委托给 video_filter_builder.build_xfade_filter。"""
|
||||
return _build_xfade_filter_func(clip_chains, transition_duration, transitions)
|
||||
|
||||
@@ -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<AssetDiagnosis> => {
|
||||
const params: Record<string, string> = {}
|
||||
if (assetId) params.asset_id = assetId
|
||||
const response = await apiClient.get("/asset-diagnosis", { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ─── 素材库 ────────────────────────────────────────────────
|
||||
|
||||
/** 获取当前用户的所有素材库 */
|
||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||
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<AssetLibraryItem> => {
|
||||
// 后端要求 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<AssetLibraryItem> => {
|
||||
const response = await apiClient.post("/asset-libraries/ensure-default", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除素材库 */
|
||||
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
||||
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<string, string | number> = { 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<AssetItem[]> => {
|
||||
const params: Record<string, string | number> = { 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<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
data: { name?: string; metadata?: AssetMetadata },
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材审核状态 */
|
||||
export const updateAssetReviewStatus = async (
|
||||
assetId: string,
|
||||
reviewStatus: "pending_review" | "approved" | "rejected",
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.patch(`/assets/${assetId}/review`, {
|
||||
review_status: reviewStatus,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除素材 */
|
||||
export const deleteAsset = async (assetId: string): Promise<void> => {
|
||||
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<string, string>
|
||||
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<void>((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>([^<]+)<\/Code>/)
|
||||
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/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<IngestJob> => {
|
||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 提交素材分类任务 */
|
||||
export const submitClassificationJob = async (data: {
|
||||
asset_id: string
|
||||
}): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.post("/classification-jobs", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 查询分类任务状态 */
|
||||
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
|
||||
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<string, unknown>): 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<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-delete", {
|
||||
asset_ids: assetIds,
|
||||
})
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量打标签 */
|
||||
export const batchTagAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
tags: string[]
|
||||
mode: "add" | "replace"
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-tag", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量改分类 */
|
||||
export const batchClassifyAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
category: string
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-classify", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量智能标记 */
|
||||
export const batchMarkAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
smart_view: "recommended" | "caution" | "high_risk"
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-mark", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
@@ -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<string, string | number> = { 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<AssetItem[]> => {
|
||||
const params: Record<string, string | number> = { 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<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
data: { name?: string; metadata?: AssetMetadata },
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材审核状态 */
|
||||
export const updateAssetReviewStatus = async (
|
||||
assetId: string,
|
||||
reviewStatus: "pending_review" | "approved" | "rejected",
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.patch(`/assets/${assetId}/review`, {
|
||||
review_status: reviewStatus,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除素材 */
|
||||
export const deleteAsset = async (assetId: string): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}`)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 素材批量操作 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { BatchOperationResult } from "./types"
|
||||
|
||||
/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */
|
||||
export const normalizeBatchResult = (raw: Record<string, unknown>): 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<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-delete", {
|
||||
asset_ids: assetIds,
|
||||
})
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量打标签 */
|
||||
export const batchTagAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
tags: string[]
|
||||
mode: "add" | "replace"
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-tag", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量改分类 */
|
||||
export const batchClassifyAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
category: string
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-classify", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量智能标记 */
|
||||
export const batchMarkAssets = async (data: {
|
||||
asset_ids: string[]
|
||||
smart_view: "recommended" | "caution" | "high_risk"
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-mark", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 素材诊断 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { AssetDiagnosis } from "./types"
|
||||
|
||||
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
|
||||
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
|
||||
const params: Record<string, string> = {}
|
||||
if (assetId) params.asset_id = assetId
|
||||
const response = await apiClient.get("/asset-diagnosis", { params })
|
||||
return response.data
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 入库任务 & 分类任务 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { IngestJob, ClassificationJob } from "./types"
|
||||
|
||||
/** 查询入库任务状态 */
|
||||
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
|
||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 提交素材分类任务 */
|
||||
export const submitClassificationJob = async (data: {
|
||||
asset_id: string
|
||||
}): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.post("/classification-jobs", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 查询分类任务状态 */
|
||||
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.get(`/classification-jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 素材库 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
import type { AssetLibraryItem } from "./types"
|
||||
|
||||
/** 获取当前用户的所有素材库 */
|
||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||
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<AssetLibraryItem> => {
|
||||
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<AssetLibraryItem> => {
|
||||
const response = await apiClient.post("/asset-libraries/ensure-default", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除素材库 */
|
||||
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
||||
await apiClient.delete(`/asset-libraries/${libraryId}`)
|
||||
}
|
||||
@@ -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<string, string>
|
||||
max_size_bytes: number
|
||||
}
|
||||
|
||||
/** 直传完成确认返回 */
|
||||
export interface DirectUploadCompleteResult {
|
||||
storage_key: string
|
||||
ingest_job_id: string
|
||||
}
|
||||
@@ -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<UploadResult> => {
|
||||
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<DirectUploadPrepareResult> => {
|
||||
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<DirectUploadCompleteResult> => {
|
||||
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<DirectUploadCompleteResult> => {
|
||||
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<void>((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>([^<]+)<\/Code>/)
|
||||
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/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,
|
||||
})
|
||||
}
|
||||
@@ -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<BgmPreset[]> => {
|
||||
const searchParams: Record<string, string> = {}
|
||||
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 ?? []
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* BGM 预设音乐 API 函数
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { BgmPreset, BgmPresetsQuery } from "./types"
|
||||
|
||||
/** 获取 BGM 预设列表 */
|
||||
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
|
||||
const searchParams: Record<string, string> = {}
|
||||
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 ?? []
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 查重 API 函数
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { DuplicationDetail, DuplicationRecord, DuplicationUploadResponse } from "./types"
|
||||
|
||||
/** 上传视频进行查重 */
|
||||
export const uploadForDuplication = async (file: File): Promise<DuplicationUploadResponse> => {
|
||||
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<DuplicationRecord[]> => {
|
||||
const response = await apiClient.get("/duplication/records")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取查重详情 */
|
||||
export const getDuplicationDetail = async (recordId: string): Promise<DuplicationDetail> => {
|
||||
const response = await apiClient.get(`/duplication/records/${recordId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除查重记录 */
|
||||
export const deleteDuplicationRecord = async (recordId: string): Promise<void> => {
|
||||
await apiClient.delete(`/duplication/records/${recordId}`)
|
||||
}
|
||||
|
||||
/** 重新查重 */
|
||||
export const retryDuplication = async (recordId: string): Promise<DuplicationUploadResponse> => {
|
||||
const response = await apiClient.post(`/duplication/records/${recordId}/retry`)
|
||||
return response.data
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<DuplicationUploadResponse> => {
|
||||
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<DuplicationRecord[]> => {
|
||||
const response = await apiClient.get("/duplication/records")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取查重详情 */
|
||||
export const getDuplicationDetail = async (recordId: string): Promise<DuplicationDetail> => {
|
||||
const response = await apiClient.get(`/duplication/records/${recordId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除查重记录 */
|
||||
export const deleteDuplicationRecord = async (recordId: string): Promise<void> => {
|
||||
await apiClient.delete(`/duplication/records/${recordId}`)
|
||||
}
|
||||
|
||||
/** 重新查重 */
|
||||
export const retryDuplication = async (recordId: string): Promise<DuplicationUploadResponse> => {
|
||||
const response = await apiClient.post(`/duplication/records/${recordId}/retry`)
|
||||
return response.data
|
||||
}
|
||||
@@ -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<TemplateMode, string> = {
|
||||
pip: "混剪",
|
||||
voice_over: "人物口播",
|
||||
one_take: "一镜到底",
|
||||
voice_pip: "口播+混剪",
|
||||
}
|
||||
|
||||
/** 模式颜色映射 */
|
||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
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<TemplateSegment, "id">[]
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
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<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/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<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/templates/${id}`)
|
||||
}
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
||||
return response.data.items
|
||||
}
|
||||
|
||||
/** 使用模板生成视频(调用 validate 端点) */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data: GenerateFromTemplatePayload,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/validate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 模板编辑器常量
|
||||
*/
|
||||
import type { TemplateMode } from "./types"
|
||||
|
||||
/** 模式显示名称映射 */
|
||||
export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
pip: "混剪",
|
||||
voice_over: "人物口播",
|
||||
one_take: "一镜到底",
|
||||
voice_pip: "口播+混剪",
|
||||
}
|
||||
|
||||
/** 模式颜色映射 */
|
||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
pip: "blue",
|
||||
voice_over: "green",
|
||||
one_take: "orange",
|
||||
voice_pip: "purple",
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/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<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/templates/${id}`)
|
||||
}
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
||||
return response.data.items
|
||||
}
|
||||
|
||||
/** 使用模板生成视频(调用 validate 端点) */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data: GenerateFromTemplatePayload,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/validate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -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<TemplateSegment, "id">[]
|
||||
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[]
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
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<ProductItem[]> => {
|
||||
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<ProductItem> => {
|
||||
const response = await apiClient.get(`/videos/${productId}`)
|
||||
return mapVideoToProductItem(response.data as VideoItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成品
|
||||
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
|
||||
* 待后端实现后自动生效
|
||||
*/
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
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<ProductItem> => {
|
||||
// 后端暂无 /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<BatchDownloadStatus> => {
|
||||
// 后端暂无 /videos/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
||||
return { job_id: jobId, status: "processing", progress: 0 }
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<ProductItem[]> => {
|
||||
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<ProductItem> => {
|
||||
const response = await apiClient.get(`/videos/${productId}`)
|
||||
return mapVideoToProductItem(response.data as VideoItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成品
|
||||
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
|
||||
* 待后端实现后自动生效
|
||||
*/
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
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<ProductItem> => {
|
||||
// 后端暂无 /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<BatchDownloadStatus> => {
|
||||
// 后端暂无 /videos/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
||||
return { job_id: jobId, status: "processing", progress: 0 }
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
download_url: string
|
||||
generated_at: string
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<ProjectItem[]> => {
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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"
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 订阅相关 API 函数
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
BillingRecord,
|
||||
ChangePlanRequest,
|
||||
ChangePlanResponse,
|
||||
SubscriptionInfo,
|
||||
} from "./types"
|
||||
|
||||
/** 获取当前订阅信息 */
|
||||
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
|
||||
const response = await apiClient.get("/subscription/current")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取账单记录列表 */
|
||||
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
|
||||
const response = await apiClient.get("/subscription/billing-records")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 升级/降级套餐 */
|
||||
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
|
||||
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
|
||||
}
|
||||
@@ -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<SubscriptionInfo> => {
|
||||
const response = await apiClient.get("/subscription/current")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取账单记录列表 */
|
||||
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
|
||||
const response = await apiClient.get("/subscription/billing-records")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 升级/降级套餐 */
|
||||
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 标签 API — 目录化入口
|
||||
* 保持与原 tags.ts 相同导出,向后兼容
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type { TagItem } from "./types"
|
||||
|
||||
// API 函数
|
||||
export { getTags, createTag, deleteTag, tagAsset, untagAsset } from "./tags"
|
||||
@@ -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<TagItem[]> => {
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 标签相关类型定义
|
||||
*/
|
||||
|
||||
export interface TagItem {
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
usage_count?: number
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<CreateGenerationTaskResponse> => {
|
||||
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取任务列表(支持分页和筛选) */
|
||||
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
|
||||
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||
params,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
|
||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||
const { data } = await apiClient.get("/tasks")
|
||||
return data.items || data || []
|
||||
}
|
||||
|
||||
/** 获取单个任务详情(含 error_info) */
|
||||
export const getTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const { data } = await apiClient.get(`/tasks/${taskId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 重试失败的任务 */
|
||||
export const retryTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
|
||||
return data
|
||||
}
|
||||
@@ -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<CreateGenerationTaskResponse> => {
|
||||
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取任务列表(支持分页和筛选) */
|
||||
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
|
||||
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||
params,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
|
||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||
const { data } = await apiClient.get("/tasks")
|
||||
return data.items || data || []
|
||||
}
|
||||
|
||||
/** 获取单个任务详情(含 error_info) */
|
||||
export const getTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const { data } = await apiClient.get(`/tasks/${taskId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 重试失败的任务 */
|
||||
export const retryTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
|
||||
return data
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
logs: Array<Record<string, unknown>>
|
||||
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<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个模板草稿 */
|
||||
export async function getEditPlan(templateId: string): Promise<EditPlan> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板草稿 */
|
||||
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post("/templates/drafts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板草稿 */
|
||||
export async function updateEditPlan(
|
||||
templateId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板草稿 */
|
||||
export async function deleteEditPlan(templateId: string): Promise<void> {
|
||||
await apiClient.delete(`/templates/${templateId}/editor`)
|
||||
}
|
||||
|
||||
/** 触发生成 */
|
||||
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取生成状态(轮询用) */
|
||||
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** AI 推荐片段方案 */
|
||||
export async function aiRecommendClips(
|
||||
templateId: string,
|
||||
data: AIRecommendRequest,
|
||||
): Promise<AIRecommendResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** AI 生成封面 */
|
||||
export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取模板草稿关联的生成记录 */
|
||||
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 获取生成任务的视频结果列表 */
|
||||
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 取消生成任务 */
|
||||
export async function cancelGeneration(templateId: string): Promise<void> {
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
/** 更新片段请求 */
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
/** 片段列表响应 */
|
||||
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<EditPlanClipListResponse> {
|
||||
const response = await apiClient.get<EditPlanClipListResponse>(
|
||||
`/templates/${templateId}/editor/clips`,
|
||||
{
|
||||
params,
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个片段详情 */
|
||||
export async function getEditPlanClip(templateId: string, clipId: string): Promise<EditPlanClip> {
|
||||
const response = await apiClient.get<EditPlanClip>(
|
||||
`/templates/${templateId}/editor/clips/${clipId}`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建片段 */
|
||||
export async function createEditPlanClip(
|
||||
templateId: string,
|
||||
data: CreateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.post<EditPlanClip>(`/templates/${templateId}/editor/clips`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新片段 */
|
||||
export async function updateEditPlanClip(
|
||||
templateId: string,
|
||||
clipId: string,
|
||||
data: UpdateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.put<EditPlanClip>(
|
||||
`/templates/${templateId}/editor/clips/${clipId}`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除片段 */
|
||||
export async function deleteEditPlanClip(templateId: string, clipId: string): Promise<void> {
|
||||
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<ClipReorderResponse> {
|
||||
const response = await apiClient.post<ClipReorderResponse>(
|
||||
`/templates/${templateId}/editor/clips/reorder`,
|
||||
{ items },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 批量删除片段 */
|
||||
export async function batchDeleteEditPlanClips(
|
||||
templateId: string,
|
||||
clipIds: string[],
|
||||
): Promise<ClipBatchDeleteResponse> {
|
||||
const response = await apiClient.post<ClipBatchDeleteResponse>(
|
||||
`/templates/${templateId}/editor/clips/batch-delete`,
|
||||
{ clip_ids: clipIds },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 从素材批量创建片段(追加到时间线末尾) */
|
||||
export async function createClipsFromAssets(
|
||||
templateId: string,
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/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<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(
|
||||
`/templates/${templateId}/editor/copy`,
|
||||
data || {},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
||||
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
||||
*/
|
||||
export async function getMediaAssets(libraryId?: string): Promise<MediaAsset[]> {
|
||||
const response = await apiClient.get("/assets", {
|
||||
params: libraryId ? { library_id: libraryId } : undefined,
|
||||
})
|
||||
const items: AssetItem[] = response.data.items || []
|
||||
return items.map(mapAssetToMediaAsset)
|
||||
}
|
||||
|
||||
/** 获取单个素材 */
|
||||
export async function getMediaAsset(id: string): Promise<MediaAsset> {
|
||||
const response = await apiClient.get(`/assets/${id}`)
|
||||
return mapAssetToMediaAsset(response.data)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 映射函数:AssetResponse → MediaAsset
|
||||
* ============================================================ */
|
||||
|
||||
function inferMediaType(mimeType: string): "video" | "image" | "audio" {
|
||||
if (mimeType.startsWith("video/")) return "video"
|
||||
if (mimeType.startsWith("image/")) return "image"
|
||||
return "audio"
|
||||
}
|
||||
|
||||
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
// 优先取顶层 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<string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
audio: "音频",
|
||||
voiceover: "配音",
|
||||
}
|
||||
|
||||
/** 素材类型图标 */
|
||||
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
|
||||
video: "🎬",
|
||||
image: "🖼️",
|
||||
audio: "🎵",
|
||||
voiceover: "🎙️",
|
||||
}
|
||||
|
||||
/** 计划状态标签 */
|
||||
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
|
||||
draft: "草稿",
|
||||
editing: "编辑中",
|
||||
rendering: "渲染中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
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 },
|
||||
]
|
||||
@@ -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<AIRecommendResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** AI 生成封面 */
|
||||
export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
|
||||
return response.data
|
||||
}
|
||||
@@ -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<EditPlanClipListResponse> {
|
||||
const response = await apiClient.get<EditPlanClipListResponse>(
|
||||
`/templates/${templateId}/editor/clips`,
|
||||
{ params },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个片段详情 */
|
||||
export async function getEditPlanClip(templateId: string, clipId: string): Promise<EditPlanClip> {
|
||||
const response = await apiClient.get<EditPlanClip>(
|
||||
`/templates/${templateId}/editor/clips/${clipId}`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建片段 */
|
||||
export async function createEditPlanClip(
|
||||
templateId: string,
|
||||
data: CreateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.post<EditPlanClip>(`/templates/${templateId}/editor/clips`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新片段 */
|
||||
export async function updateEditPlanClip(
|
||||
templateId: string,
|
||||
clipId: string,
|
||||
data: UpdateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.put<EditPlanClip>(
|
||||
`/templates/${templateId}/editor/clips/${clipId}`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除片段 */
|
||||
export async function deleteEditPlanClip(templateId: string, clipId: string): Promise<void> {
|
||||
await apiClient.delete(`/templates/${templateId}/editor/clips/${clipId}`)
|
||||
}
|
||||
|
||||
/** 片段重排序(拖拽排序后一次性提交) */
|
||||
export async function reorderEditPlanClips(
|
||||
templateId: string,
|
||||
items: ClipReorderItem[],
|
||||
): Promise<ClipReorderResponse> {
|
||||
const response = await apiClient.post<ClipReorderResponse>(
|
||||
`/templates/${templateId}/editor/clips/reorder`,
|
||||
{ items },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 批量删除片段 */
|
||||
export async function batchDeleteEditPlanClips(
|
||||
templateId: string,
|
||||
clipIds: string[],
|
||||
): Promise<ClipBatchDeleteResponse> {
|
||||
const response = await apiClient.post<ClipBatchDeleteResponse>(
|
||||
`/templates/${templateId}/editor/clips/batch-delete`,
|
||||
{ clip_ids: clipIds },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 从素材批量创建片段(追加到时间线末尾) */
|
||||
export async function createClipsFromAssets(
|
||||
templateId: string,
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/templates/${templateId}/editor/clips/from-assets`,
|
||||
{ asset_ids: assetIds, clip_type: clipType },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
audio: "音频",
|
||||
voiceover: "配音",
|
||||
}
|
||||
|
||||
/** 素材类型图标 */
|
||||
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
|
||||
video: "🎬",
|
||||
image: "🖼️",
|
||||
audio: "🎵",
|
||||
voiceover: "🎙️",
|
||||
}
|
||||
|
||||
/** 计划状态标签 */
|
||||
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
|
||||
draft: "草稿",
|
||||
editing: "编辑中",
|
||||
rendering: "渲染中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
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 },
|
||||
]
|
||||
@@ -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<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个模板草稿 */
|
||||
export async function getEditPlan(templateId: string): Promise<EditPlan> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板草稿 */
|
||||
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post("/templates/drafts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板草稿 */
|
||||
export async function updateEditPlan(
|
||||
templateId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板草稿 */
|
||||
export async function deleteEditPlan(templateId: string): Promise<void> {
|
||||
await apiClient.delete(`/templates/${templateId}/editor`)
|
||||
}
|
||||
|
||||
/** 触发生成 */
|
||||
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取生成状态(轮询用) */
|
||||
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取模板草稿关联的生成记录 */
|
||||
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 获取生成任务的视频结果列表 */
|
||||
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 取消生成任务 */
|
||||
export async function cancelGeneration(templateId: string): Promise<void> {
|
||||
await apiClient.post(`/templates/${templateId}/editor/cancel`)
|
||||
}
|
||||
|
||||
/** 复制模板草稿(含所有片段配置) */
|
||||
export async function copyEditPlan(
|
||||
templateId: string,
|
||||
data?: CopyEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(
|
||||
`/templates/${templateId}/editor/copy`,
|
||||
data || {},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<MediaAsset[]> {
|
||||
const response = await apiClient.get("/assets", {
|
||||
params: libraryId ? { library_id: libraryId } : undefined,
|
||||
})
|
||||
const items: AssetItem[] = response.data.items || []
|
||||
return items.map(mapAssetToMediaAsset)
|
||||
}
|
||||
|
||||
/** 获取单个素材 */
|
||||
export async function getMediaAsset(id: string): Promise<MediaAsset> {
|
||||
const response = await apiClient.get(`/assets/${id}`)
|
||||
return mapAssetToMediaAsset(response.data)
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
logs: Array<Record<string, unknown>>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
/** 更新片段请求 */
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
/** 片段列表响应 */
|
||||
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"
|
||||
}
|
||||
@@ -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<TemplateListResponse> => {
|
||||
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
|
||||
params,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取模板列表(兼容旧接口,返回数组) */
|
||||
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
|
||||
const response = await apiClient.get("/templates")
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 获取单个模板详情 */
|
||||
export const getTemplate = async (templateId: string): Promise<TemplateItem> => {
|
||||
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<CopyTemplateResponse> => {
|
||||
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 从模板生成 */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data?: GenerateFromTemplateRequest,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/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分钟以上" },
|
||||
]
|
||||
@@ -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分钟以上" },
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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<TemplateListResponse> => {
|
||||
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
|
||||
params,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取模板列表(兼容旧接口,返回数组) */
|
||||
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
|
||||
const response = await apiClient.get("/templates")
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 获取单个模板详情 */
|
||||
export const getTemplate = async (templateId: string): Promise<TemplateItem> => {
|
||||
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<CopyTemplateResponse> => {
|
||||
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 从模板生成 */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data?: GenerateFromTemplateRequest,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/generate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<TitleItem[]> => {
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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<TTSSynthesizeResponse> => {
|
||||
const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务详情 */
|
||||
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
|
||||
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务状态(轻量轮询) */
|
||||
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
|
||||
const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务列表 */
|
||||
export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 存为素材请求参数 */
|
||||
export interface SaveTtsToLibraryRequest {
|
||||
name?: string
|
||||
tag_ids?: string[]
|
||||
}
|
||||
|
||||
/** 将 TTS 合成结果保存到配音库 */
|
||||
export const saveTtsToLibrary = async (
|
||||
jobId: string,
|
||||
data?: SaveTtsToLibraryRequest,
|
||||
): Promise<void> => {
|
||||
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
|
||||
}
|
||||
|
||||
/** 删除 TTS 任务 */
|
||||
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
||||
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<TTSVoice[]> => {
|
||||
const response = await apiClient.get<TTSVoice[]>("/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<TTSPreviewResponse> => {
|
||||
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<TTSSynthesizeResponse> => {
|
||||
const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务详情 */
|
||||
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
|
||||
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务状态(轻量轮询) */
|
||||
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
|
||||
const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务列表 */
|
||||
export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 将 TTS 合成结果保存到配音库 */
|
||||
export const saveTtsToLibrary = async (
|
||||
jobId: string,
|
||||
data?: SaveTtsToLibraryRequest,
|
||||
): Promise<void> => {
|
||||
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
|
||||
}
|
||||
|
||||
/** 删除 TTS 任务 */
|
||||
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tts/jobs/${jobId}`)
|
||||
}
|
||||
|
||||
/** 获取 TTS 音色列表 */
|
||||
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
|
||||
const response = await apiClient.get<TTSVoice[]>("/tts/voices")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** TTS 试听 */
|
||||
export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewResponse> => {
|
||||
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<VoiceClone[]> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
|
||||
return response.data.items.map(toVoiceClone)
|
||||
}
|
||||
|
||||
/** 获取克隆音色列表(返回完整响应含 total) */
|
||||
export const getVoiceClonesWithTotal = async (
|
||||
params?: VoiceCloneListParams,
|
||||
): Promise<ListVoiceCloneResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个克隆音色详情 */
|
||||
export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建克隆音色 */
|
||||
export const createVoiceClone = async (
|
||||
data: CreateVoiceCloneRequest,
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const payload: CreateVoiceCloneRequestFull = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
source_audio_url: data.audio_url,
|
||||
}
|
||||
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除克隆音色 */
|
||||
export const deleteVoiceClone = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/voice-clones/${id}`)
|
||||
}
|
||||
|
||||
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
|
||||
export const updateVoiceClone = async (
|
||||
id: string,
|
||||
data: Partial<Pick<VoiceClone, "name">>,
|
||||
): Promise<VoiceClone> => {
|
||||
// 后端暂未提供更新端点,暂用详情接口模拟
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
|
||||
return toVoiceClone({
|
||||
...response.data,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取克隆状态 */
|
||||
export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
|
||||
const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 重试克隆 */
|
||||
export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
|
||||
return response.data
|
||||
}
|
||||
@@ -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<VoiceClone[]> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
|
||||
return response.data.items.map(toVoiceClone)
|
||||
}
|
||||
|
||||
/** 获取克隆音色列表(返回完整响应含 total) */
|
||||
export const getVoiceClonesWithTotal = async (
|
||||
params?: VoiceCloneListParams,
|
||||
): Promise<ListVoiceCloneResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个克隆音色详情 */
|
||||
export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建克隆音色 */
|
||||
export const createVoiceClone = async (
|
||||
data: CreateVoiceCloneRequest,
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const payload: CreateVoiceCloneRequestFull = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
source_audio_url: data.audio_url,
|
||||
}
|
||||
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除克隆音色 */
|
||||
export const deleteVoiceClone = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/voice-clones/${id}`)
|
||||
}
|
||||
|
||||
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
|
||||
export const updateVoiceClone = async (
|
||||
id: string,
|
||||
data: Partial<Pick<VoiceClone, "name">>,
|
||||
): Promise<VoiceClone> => {
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
|
||||
return toVoiceClone({
|
||||
...response.data,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取克隆状态 */
|
||||
export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
|
||||
const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 重试克隆 */
|
||||
export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
|
||||
return response.data
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")}`
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<PresetVoiceListResponse> => {
|
||||
|
||||
/* ── 向后兼容(旧接口) ────────────────────────────────── */
|
||||
|
||||
/** 配音条目(旧) */
|
||||
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<VoiceItem[]> => {
|
||||
const response = await apiClient.get("/voices/legacy")
|
||||
@@ -1,470 +1,118 @@
|
||||
/**
|
||||
* 视频库页面 — 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, { useMemo, useState } from "react"
|
||||
import { Upload, message } from "antd"
|
||||
import { InboxOutlined, PictureOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
getAssetLibraries,
|
||||
createAssetLibrary,
|
||||
deleteAssetLibrary,
|
||||
getAssets,
|
||||
deleteAsset,
|
||||
uploadAssetDirect,
|
||||
getAssetDiagnosis,
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type AssetLibraryItem,
|
||||
type AssetItem as ApiAssetItem,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets"
|
||||
import { Button } from "@/components/ui"
|
||||
import { type AssetItem, type AssetKind, mapLibrary, mapAsset } from "@/pages/assets/types"
|
||||
import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "@/pages/assets/constants"
|
||||
import AssetCard from "@/pages/assets/components/AssetCard"
|
||||
import React, { useState } from "react"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
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 type { SmartViewType } 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"
|
||||
import { useAssetSelection } from "@/pages/assets/hooks/useAssetSelection"
|
||||
import { useAssetOperations } from "@/pages/assets/hooks/useAssetOperations"
|
||||
import "./assets.css"
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const AssetLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 获取视频库列表 ── */
|
||||
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const libraries = useMemo(
|
||||
() =>
|
||||
(Array.isArray(apiLibraries) ? apiLibraries : [])
|
||||
.map(mapLibrary)
|
||||
.filter((lib) => lib.kind === "video"),
|
||||
[apiLibraries],
|
||||
)
|
||||
|
||||
/* ── 当前选中的视频库 ── */
|
||||
const [activeLibId, setActiveLibId] = useState<string>("")
|
||||
|
||||
// 当库列表加载完成后,自动选中第一个
|
||||
const effectiveLibId = activeLibId || libraries[0]?.id || ""
|
||||
|
||||
/* ── 获取当前库的素材列表 ── */
|
||||
/* ── 数据查询与筛选 ── */
|
||||
const {
|
||||
data: apiAssets = { items: [], total: 0 },
|
||||
isLoading: assetsLoading,
|
||||
isError: assetsError,
|
||||
error: assetsErrorObj,
|
||||
refetch: refetchAssets,
|
||||
} = useQuery<{ items: ApiAssetItem[]; total: number }, Error>({
|
||||
queryKey: ["assets", effectiveLibId],
|
||||
queryFn: () =>
|
||||
getAssets(effectiveLibId, {
|
||||
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
|
||||
status: "ready,uploading,ingesting,processing,pending,error,failed",
|
||||
}),
|
||||
enabled: !!effectiveLibId,
|
||||
staleTime: 30_000,
|
||||
libraries,
|
||||
libLoading,
|
||||
activeLibId,
|
||||
setActiveLibId,
|
||||
effectiveLibId,
|
||||
assetsLoading,
|
||||
assetsError,
|
||||
assetsErrorObj,
|
||||
refetchAssets,
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterType,
|
||||
setFilterType,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
filteredAssets,
|
||||
} = useAssetsData()
|
||||
|
||||
/* ── 视频库管理 ── */
|
||||
const {
|
||||
createModalOpen,
|
||||
setCreateModalOpen,
|
||||
newLibName,
|
||||
setNewLibName,
|
||||
newLibKind,
|
||||
setNewLibKind,
|
||||
isCreating,
|
||||
handleCreateLibrary,
|
||||
handleDeleteLibrary,
|
||||
} = useLibraryManagement({
|
||||
libraries,
|
||||
activeLibId,
|
||||
setActiveLibId,
|
||||
effectiveLibId,
|
||||
})
|
||||
|
||||
const assets = useMemo(
|
||||
() => (Array.isArray(apiAssets?.items) ? apiAssets.items : []).map(mapAsset),
|
||||
[apiAssets],
|
||||
)
|
||||
/* ── 上传 ── */
|
||||
const { uploading, uploadProgress, handleUpload } = useAssetUpload({ effectiveLibId })
|
||||
|
||||
/* ── Mutations ── */
|
||||
const createLibMutation = useMutation({
|
||||
mutationFn: createAssetLibrary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
message.success("视频库创建成功")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("创建视频库失败")
|
||||
},
|
||||
/* ── 选中态管理 ── */
|
||||
const { selectedIds, setSelectedIds, toggleSelect, selectAll, deselectAll } = useAssetSelection({
|
||||
filteredAssets,
|
||||
})
|
||||
|
||||
const deleteLibMutation = useMutation({
|
||||
mutationFn: deleteAssetLibrary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
message.success("视频库已删除")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除视频库失败")
|
||||
},
|
||||
})
|
||||
/* ── 素材操作 ── */
|
||||
const {
|
||||
diagnosingId,
|
||||
handleDiagnose,
|
||||
handleSingleDelete,
|
||||
batchLoading,
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
handleBatchClassify,
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
handleBatchMark,
|
||||
handleBatchDelete,
|
||||
resultDrawerOpen,
|
||||
operationResult,
|
||||
operationTitle,
|
||||
handleResultDrawerClose,
|
||||
} = useAssetOperations({ selectedIds, setSelectedIds })
|
||||
|
||||
/* 状态 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// 大文件直传由 handleUpload 直接调用 uploadAssetDirect 处理
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState<string>("all")
|
||||
const [filterTime, setFilterTime] = useState<string>("all")
|
||||
|
||||
/* 上传 */
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
/* 新建视频库 */
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
const [newLibName, setNewLibName] = useState("")
|
||||
const [newLibKind, setNewLibKind] = useState<AssetKind>("video")
|
||||
|
||||
/* 视频播放 */
|
||||
/* ── 视频播放 ── */
|
||||
const [playingAsset, setPlayingAsset] = useState<AssetItem | null>(null)
|
||||
|
||||
/* 诊断中状态 — 记录正在诊断的素材 ID */
|
||||
const [diagnosingId, setDiagnosingId] = useState<string | null>(null)
|
||||
|
||||
/* ── 批量操作弹窗状态 ── */
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
|
||||
|
||||
/* 批量打标签 */
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
|
||||
/* 批量改分类 */
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
|
||||
/* 批量智能标记 */
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
|
||||
/* 操作结果 */
|
||||
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
|
||||
const [operationTitle, setOperationTitle] = useState("")
|
||||
|
||||
/* 批量操作 loading */
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
/* 派生数据 */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets
|
||||
|
||||
/* 按视频库类型过滤(如果筛选类型不是 all) */
|
||||
if (filterType !== "all") {
|
||||
list = list.filter((a) => a.kind === filterType)
|
||||
}
|
||||
|
||||
/* 按时间筛选 */
|
||||
if (filterTime !== "all") {
|
||||
const now = new Date()
|
||||
list = list.filter((a) => {
|
||||
const d = new Date(a.createdAt)
|
||||
const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (filterTime === "today") return diffDays < 1
|
||||
if (filterTime === "week") return diffDays < 7
|
||||
if (filterTime === "month") return diffDays < 30
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter((a) => a.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
}, [assets, filterType, filterTime, searchText])
|
||||
|
||||
/* 选择操作 */
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedIds(new Set(filteredAssets.map((a) => a.id)))
|
||||
}
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
/* 上传 — 调用真实 API */
|
||||
const handleUpload = async (file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error(`文件 "${file.name}" 超过 2GB 限制`)
|
||||
return
|
||||
}
|
||||
if (!effectiveLibId) {
|
||||
message.warning("请先选择或创建一个视频库")
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||||
message.info(`大文件 "${file.name}" 将使用直传上传`)
|
||||
}
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: effectiveLibId,
|
||||
onProgress: (pct) => setUploadProgress(pct),
|
||||
})
|
||||
message.success(`"${file.name}" 上传成功`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : ""
|
||||
console.error("[handleUpload] 上传失败:", err)
|
||||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`)
|
||||
// 错误时延迟关闭弹窗,让用户能看到错误提示
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
/* 新建视频库 */
|
||||
const handleCreateLibrary = async () => {
|
||||
if (!newLibName.trim()) {
|
||||
message.warning("请输入视频库名称")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const newLib = await createLibMutation.mutateAsync({
|
||||
name: newLibName.trim(),
|
||||
kind: newLibKind,
|
||||
})
|
||||
setActiveLibId(newLib.id)
|
||||
setCreateModalOpen(false)
|
||||
setNewLibName("")
|
||||
setNewLibKind("video")
|
||||
} catch {
|
||||
// error handled in mutation
|
||||
}
|
||||
}
|
||||
|
||||
/* 删除视频库 */
|
||||
const handleDeleteLibrary = async (id: string) => {
|
||||
try {
|
||||
await deleteLibMutation.mutateAsync(id)
|
||||
if (effectiveLibId === id) {
|
||||
const remaining = libraries.filter((l) => l.id !== id)
|
||||
if (remaining.length > 0) setActiveLibId(remaining[0].id)
|
||||
else setActiveLibId("")
|
||||
}
|
||||
} catch {
|
||||
// error handled in mutation
|
||||
}
|
||||
}
|
||||
|
||||
/* 诊断 — 调用真实 API,带 loading 状态 */
|
||||
const handleDiagnose = async (asset: AssetItem) => {
|
||||
setDiagnosingId(asset.id)
|
||||
try {
|
||||
const result = await getAssetDiagnosis(asset.id)
|
||||
const score = result.readiness_score ?? "-"
|
||||
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
} catch {
|
||||
message.error(`"${asset.name}" 诊断失败`)
|
||||
} finally {
|
||||
setDiagnosingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
/* 单个素材删除 */
|
||||
const handleSingleDelete = async (assetId: string) => {
|
||||
try {
|
||||
await deleteAsset(assetId)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
// 从选中集合中移除
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(assetId)
|
||||
return next
|
||||
})
|
||||
message.success("素材已删除")
|
||||
} catch {
|
||||
message.error("删除失败,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量删除 */
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
setOperationResult(result)
|
||||
setOperationTitle("批量删除")
|
||||
setResultDrawerOpen(true)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
setSelectedIds(new Set())
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量打标签 */
|
||||
const handleBatchTag = async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
setOperationResult(result)
|
||||
setOperationTitle("批量打标签")
|
||||
setResultDrawerOpen(true)
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
setSelectedIds(new Set())
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量改分类 */
|
||||
const handleBatchClassify = async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
setOperationResult(result)
|
||||
setOperationTitle("批量改分类")
|
||||
setResultDrawerOpen(true)
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
setSelectedIds(new Set())
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量智能标记 */
|
||||
const handleBatchMark = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
setOperationResult(result)
|
||||
setOperationTitle("批量智能标记")
|
||||
setResultDrawerOpen(true)
|
||||
setMarkModalOpen(false)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
setSelectedIds(new Set())
|
||||
const labelMap: Record<SmartViewType, string> = {
|
||||
recommended: "推荐",
|
||||
caution: "慎用",
|
||||
high_risk: "高风险",
|
||||
}
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 标签输入处理 */
|
||||
const handleTagInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
}
|
||||
|
||||
const removeBatchTag = (tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
}
|
||||
|
||||
// ── Loading 状态 ──
|
||||
if (libLoading) {
|
||||
return (
|
||||
@@ -480,9 +128,6 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="xx-assets-page">
|
||||
{/* ─── 上传进度弹窗 ─── */}
|
||||
<UploadProgressModal open={uploading} progress={uploadProgress} />
|
||||
|
||||
{/* 两栏布局 */}
|
||||
<div className="xx-assets-layout">
|
||||
{/* ─── 左侧:视频库列表 ─── */}
|
||||
@@ -497,27 +142,11 @@ const AssetLibrary: React.FC = () => {
|
||||
{/* ─── 右侧:内容区 ─── */}
|
||||
<div className="xx-assets-content">
|
||||
{/* 上传区域 */}
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
// 同步返回 false 阻止 antd 默认上传行为
|
||||
// 异步上传由 handleUpload 处理
|
||||
handleUpload(file as File)
|
||||
return false
|
||||
}}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept="video/*,image/*"
|
||||
>
|
||||
<div className="xx-asset-upload-zone">
|
||||
<p className="xx-asset-upload-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="xx-asset-upload-text">
|
||||
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">支持视频、图片,单文件不超过 2GB</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
<AssetUploadZone
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<AssetFilterBar
|
||||
@@ -544,117 +173,69 @@ const AssetLibrary: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 素材网格 */}
|
||||
{assetsLoading ? (
|
||||
<div className="xx-asset-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : assetsError ? (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">
|
||||
<ExclamationCircleOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">{assetsErrorObj?.message || "加载失败"}</p>
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={() => refetchAssets()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredAssets.length > 0 ? (
|
||||
<div className="xx-asset-grid">
|
||||
{filteredAssets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
selected={selectedIds.has(asset.id)}
|
||||
diagnosing={diagnosingId === asset.id}
|
||||
onToggle={() => toggleSelect(asset.id)}
|
||||
onDiagnose={() => handleDiagnose(asset)}
|
||||
onPlay={() => setPlayingAsset(asset)}
|
||||
onDelete={() => handleSingleDelete(asset.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">暂无素材,请上传或切换视频库</p>
|
||||
</div>
|
||||
)}
|
||||
<AssetGridSection
|
||||
loading={assetsLoading}
|
||||
error={assetsError}
|
||||
errorMessage={assetsErrorObj?.message}
|
||||
assets={filteredAssets}
|
||||
selectedIds={selectedIds}
|
||||
diagnosingId={diagnosingId}
|
||||
onRetry={refetchAssets}
|
||||
onToggleSelect={toggleSelect}
|
||||
onDiagnose={handleDiagnose}
|
||||
onPlay={setPlayingAsset}
|
||||
onDelete={handleSingleDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 新建视频库弹窗 ─── */}
|
||||
<CreateLibraryModal
|
||||
open={createModalOpen}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
onOk={handleCreateLibrary}
|
||||
name={newLibName}
|
||||
onNameChange={setNewLibName}
|
||||
kind={newLibKind}
|
||||
onKindChange={setNewLibKind}
|
||||
confirmLoading={createLibMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* ─── 视频/音频播放弹窗 ─── */}
|
||||
<PlayModal open={!!playingAsset} asset={playingAsset} onClose={() => setPlayingAsset(null)} />
|
||||
|
||||
{/* ─── 批量打标签弹窗 ─── */}
|
||||
<BatchTagModal
|
||||
open={tagModalOpen}
|
||||
{/* ─── 弹窗集合 ─── */}
|
||||
<AssetModals
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
createModalOpen={createModalOpen}
|
||||
onCreateModalCancel={() => 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}
|
||||
/>
|
||||
|
||||
{/* ─── 批量改分类弹窗 ─── */}
|
||||
<BatchClassifyModal
|
||||
open={classifyModalOpen}
|
||||
selectedCount={selectedIds.size}
|
||||
onCancel={() => {
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* ─── 批量智能标记弹窗 ─── */}
|
||||
<BatchMarkModal
|
||||
open={markModalOpen}
|
||||
selectedCount={selectedIds.size}
|
||||
onCancel={() => setMarkModalOpen(false)}
|
||||
onOk={handleBatchMark}
|
||||
smartView={batchSmartView}
|
||||
onSmartViewChange={setBatchSmartView}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* ─── 操作结果 Drawer ─── */}
|
||||
<ResultDrawer
|
||||
open={resultDrawerOpen}
|
||||
title={operationTitle}
|
||||
result={operationResult}
|
||||
onClose={() => {
|
||||
setResultDrawerOpen(false)
|
||||
setOperationResult(null)
|
||||
}}
|
||||
markModalOpen={markModalOpen}
|
||||
onMarkCancel={() => setMarkModalOpen(false)}
|
||||
onMarkOk={handleBatchMark}
|
||||
batchSmartView={batchSmartView as SmartViewType}
|
||||
onSmartViewChange={setBatchSmartView as (val: SmartViewType) => void}
|
||||
resultDrawerOpen={resultDrawerOpen}
|
||||
operationTitle={operationTitle}
|
||||
operationResult={operationResult}
|
||||
onResultDrawerClose={handleResultDrawerClose}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<string>
|
||||
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<AssetGridSectionProps> = ({
|
||||
loading,
|
||||
error,
|
||||
errorMessage,
|
||||
assets,
|
||||
selectedIds,
|
||||
diagnosingId,
|
||||
onRetry,
|
||||
onToggleSelect,
|
||||
onDiagnose,
|
||||
onPlay,
|
||||
onDelete,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="xx-asset-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">
|
||||
<ExclamationCircleOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">{errorMessage || "加载失败"}</p>
|
||||
{onRetry && (
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onRetry}>
|
||||
重新加载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (assets.length > 0) {
|
||||
return (
|
||||
<div className="xx-asset-grid">
|
||||
{assets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
selected={selectedIds.has(asset.id)}
|
||||
diagnosing={diagnosingId === asset.id}
|
||||
onToggle={() => onToggleSelect(asset.id)}
|
||||
onDiagnose={() => onDiagnose(asset)}
|
||||
onPlay={() => onPlay(asset)}
|
||||
onDelete={() => onDelete(asset.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">暂无素材,请上传或切换视频库</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetGridSection
|
||||
@@ -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<AssetModalsProps> = ({
|
||||
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 (
|
||||
<>
|
||||
{/* 上传进度弹窗 */}
|
||||
<UploadProgressModal open={uploading} progress={uploadProgress} />
|
||||
|
||||
{/* 新建视频库弹窗 */}
|
||||
<CreateLibraryModal
|
||||
open={createModalOpen}
|
||||
onCancel={onCreateModalCancel}
|
||||
onOk={onCreateModalOk}
|
||||
name={newLibName}
|
||||
onNameChange={onNewLibNameChange}
|
||||
kind={newLibKind}
|
||||
onKindChange={onNewLibKindChange}
|
||||
confirmLoading={createLoading}
|
||||
/>
|
||||
|
||||
{/* 视频/音频播放弹窗 */}
|
||||
<PlayModal open={!!playingAsset} asset={playingAsset} onClose={onPlayClose} />
|
||||
|
||||
{/* 批量打标签弹窗 */}
|
||||
<BatchTagModal
|
||||
open={tagModalOpen}
|
||||
selectedCount={selectedCount}
|
||||
onCancel={onTagCancel}
|
||||
onOk={onTagOk}
|
||||
tags={batchTags}
|
||||
onTagInputChange={onTagInputChange}
|
||||
onTagInputKeyDown={onTagInputKeyDown}
|
||||
onRemoveTag={onRemoveTag}
|
||||
tagInput={batchTagInput}
|
||||
tagMode={tagMode}
|
||||
onTagModeChange={onTagModeChange}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* 批量改分类弹窗 */}
|
||||
<BatchClassifyModal
|
||||
open={classifyModalOpen}
|
||||
selectedCount={selectedCount}
|
||||
onCancel={onClassifyCancel}
|
||||
onOk={onClassifyOk}
|
||||
category={batchCategory}
|
||||
onCategoryChange={onCategoryChange}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* 批量智能标记弹窗 */}
|
||||
<BatchMarkModal
|
||||
open={markModalOpen}
|
||||
selectedCount={selectedCount}
|
||||
onCancel={onMarkCancel}
|
||||
onOk={onMarkOk}
|
||||
smartView={batchSmartView}
|
||||
onSmartViewChange={onSmartViewChange}
|
||||
confirmLoading={batchLoading}
|
||||
/>
|
||||
|
||||
{/* 操作结果 Drawer */}
|
||||
<ResultDrawer
|
||||
open={resultDrawerOpen}
|
||||
title={operationTitle}
|
||||
result={operationResult}
|
||||
onClose={onResultDrawerClose}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetModals
|
||||
@@ -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<AssetUploadZoneProps> = ({
|
||||
uploading,
|
||||
onUpload,
|
||||
}) => {
|
||||
return (
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
onUpload(file as File)
|
||||
return false
|
||||
}}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept="video/*,image/*"
|
||||
>
|
||||
<div className="xx-asset-upload-zone">
|
||||
<p className="xx-asset-upload-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="xx-asset-upload-text">
|
||||
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">支持视频、图片,单文件不超过 2GB</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetUploadZone
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
deleteAsset,
|
||||
getAssetDiagnosis,
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets"
|
||||
import type { AssetItem } from "../types"
|
||||
import type { SmartViewType } from "../components/BatchMarkModal"
|
||||
|
||||
/**
|
||||
* 素材操作 Hook
|
||||
* 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作,
|
||||
* 以及相关弹窗和结果展示的状态管理
|
||||
*/
|
||||
interface UseAssetOperationsProps {
|
||||
selectedIds: Set<string>
|
||||
setSelectedIds: (ids: Set<string>) => void
|
||||
}
|
||||
|
||||
export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOperationsProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 诊断状态 ── */
|
||||
const [diagnosingId, setDiagnosingId] = useState<string | null>(null)
|
||||
|
||||
/* ── 批量操作弹窗状态 ── */
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 批量打标签表单 ── */
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
|
||||
/* ── 批量改分类表单 ── */
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
|
||||
/* ── 批量智能标记表单 ── */
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
|
||||
/* ── 操作结果 ── */
|
||||
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
|
||||
const [operationTitle, setOperationTitle] = useState("")
|
||||
|
||||
/* ── 批量操作 loading ── */
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
/* ── 刷新数据辅助函数 ── */
|
||||
const invalidateAssets = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
}, [queryClient])
|
||||
|
||||
/* ── 诊断 ── */
|
||||
const handleDiagnose = useCallback(
|
||||
async (asset: AssetItem) => {
|
||||
setDiagnosingId(asset.id)
|
||||
try {
|
||||
const result = await getAssetDiagnosis(asset.id)
|
||||
const score = result.readiness_score ?? "-"
|
||||
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
} catch {
|
||||
message.error(`"${asset.name}" 诊断失败`)
|
||||
} finally {
|
||||
setDiagnosingId(null)
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
)
|
||||
|
||||
/* ── 单个素材删除 ── */
|
||||
const handleSingleDelete = useCallback(
|
||||
async (assetId: string) => {
|
||||
try {
|
||||
await deleteAsset(assetId)
|
||||
invalidateAssets()
|
||||
// 从选中集合中移除
|
||||
setSelectedIds(
|
||||
(() => {
|
||||
const next = new Set(selectedIds)
|
||||
next.delete(assetId)
|
||||
return next
|
||||
})(),
|
||||
)
|
||||
message.success("素材已删除")
|
||||
} catch {
|
||||
message.error("删除失败,请重试")
|
||||
}
|
||||
},
|
||||
[invalidateAssets, selectedIds, setSelectedIds],
|
||||
)
|
||||
|
||||
/* ── 显示操作结果 ── */
|
||||
const showOperationResult = useCallback(
|
||||
(result: BatchOperationResult, title: string, clearSelection = true) => {
|
||||
setOperationResult(result)
|
||||
setOperationTitle(title)
|
||||
setResultDrawerOpen(true)
|
||||
if (clearSelection) setSelectedIds(new Set())
|
||||
},
|
||||
[setSelectedIds],
|
||||
)
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showOperationResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showOperationResult])
|
||||
|
||||
/* ── 批量打标签 ── */
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showOperationResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showOperationResult])
|
||||
|
||||
/* ── 标签输入处理 ── */
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
/* ── 批量改分类 ── */
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showOperationResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showOperationResult])
|
||||
|
||||
/* ── 批量智能标记 ── */
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showOperationResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
const labelMap: Record<SmartViewType, string> = {
|
||||
recommended: "推荐",
|
||||
caution: "慎用",
|
||||
high_risk: "高风险",
|
||||
}
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showOperationResult])
|
||||
|
||||
/* ── 关闭结果 Drawer ── */
|
||||
const handleResultDrawerClose = useCallback(() => {
|
||||
setResultDrawerOpen(false)
|
||||
setOperationResult(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 诊断
|
||||
diagnosingId,
|
||||
handleDiagnose,
|
||||
// 单个操作
|
||||
handleSingleDelete,
|
||||
// 批量操作 loading
|
||||
batchLoading,
|
||||
// 批量打标签
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
// 批量改分类
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
handleBatchClassify,
|
||||
// 批量智能标记
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
handleBatchMark,
|
||||
// 批量删除
|
||||
handleBatchDelete,
|
||||
// 操作结果
|
||||
resultDrawerOpen,
|
||||
operationResult,
|
||||
operationTitle,
|
||||
handleResultDrawerClose,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import type { AssetItem } from "../types"
|
||||
|
||||
/**
|
||||
* 素材选中态管理 Hook
|
||||
* 封装单选、全选、取消全选等选中逻辑
|
||||
*/
|
||||
interface UseAssetSelectionProps {
|
||||
filteredAssets: AssetItem[]
|
||||
}
|
||||
|
||||
export function useAssetSelection({ filteredAssets }: UseAssetSelectionProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedIds(new Set(filteredAssets.map((a) => a.id)))
|
||||
}, [filteredAssets])
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [])
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
toggleSelect,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
selectedCount: selectedIds.size,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { uploadAssetDirect } from "@/api/assets"
|
||||
import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants"
|
||||
|
||||
/**
|
||||
* 素材上传 Hook
|
||||
* 封装上传状态、进度管理和上传逻辑
|
||||
*/
|
||||
interface UseAssetUploadProps {
|
||||
effectiveLibId: string
|
||||
}
|
||||
|
||||
export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error(`文件 "${file.name}" 超过 2GB 限制`)
|
||||
return
|
||||
}
|
||||
if (!effectiveLibId) {
|
||||
message.warning("请先选择或创建一个视频库")
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||||
message.info(`大文件 "${file.name}" 将使用直传上传`)
|
||||
}
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: effectiveLibId,
|
||||
onProgress: (pct) => setUploadProgress(pct),
|
||||
})
|
||||
message.success(`"${file.name}" 上传成功`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : ""
|
||||
console.error("[handleUpload] 上传失败:", err)
|
||||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`)
|
||||
// 错误时延迟关闭弹窗,让用户能看到错误提示
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
}
|
||||
},
|
||||
[effectiveLibId, queryClient],
|
||||
)
|
||||
|
||||
return {
|
||||
uploading,
|
||||
uploadProgress,
|
||||
handleUpload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import {
|
||||
getAssetLibraries,
|
||||
getAssets,
|
||||
type AssetLibraryItem,
|
||||
type AssetItem as ApiAssetItem,
|
||||
} from "@/api/assets"
|
||||
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
|
||||
|
||||
/**
|
||||
* 素材库数据 Hook
|
||||
* 封装视频库列表、素材列表的数据查询,以及筛选、搜索状态管理
|
||||
*/
|
||||
export function useAssetsData() {
|
||||
/* ── 视频库列表查询 ── */
|
||||
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const libraries: LibraryItem[] = useMemo(
|
||||
() =>
|
||||
(Array.isArray(apiLibraries) ? apiLibraries : [])
|
||||
.map(mapLibrary)
|
||||
.filter((lib) => lib.kind === "video"),
|
||||
[apiLibraries],
|
||||
)
|
||||
|
||||
/* ── 当前选中的视频库 ── */
|
||||
const [activeLibId, setActiveLibId] = useState<string>("")
|
||||
|
||||
// 当库列表加载完成后,自动选中第一个
|
||||
const effectiveLibId = activeLibId || libraries[0]?.id || ""
|
||||
|
||||
/* ── 当前库的素材列表查询 ── */
|
||||
const {
|
||||
data: apiAssets = { items: [], total: 0 },
|
||||
isLoading: assetsLoading,
|
||||
isError: assetsError,
|
||||
error: assetsErrorObj,
|
||||
refetch: refetchAssets,
|
||||
} = useQuery<{ items: ApiAssetItem[]; total: number }, Error>({
|
||||
queryKey: ["assets", effectiveLibId],
|
||||
queryFn: () =>
|
||||
getAssets(effectiveLibId, {
|
||||
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
|
||||
status: "ready,uploading,ingesting,processing,pending,error,failed",
|
||||
}),
|
||||
enabled: !!effectiveLibId,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const assets: AssetItem[] = useMemo(
|
||||
() => (Array.isArray(apiAssets?.items) ? apiAssets.items : []).map(mapAsset),
|
||||
[apiAssets],
|
||||
)
|
||||
|
||||
/* ── 筛选状态 ── */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState<string>("all")
|
||||
const [filterTime, setFilterTime] = useState<string>("all")
|
||||
|
||||
/* ── 筛选后的素材列表 ── */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets
|
||||
|
||||
/* 按素材类型过滤 */
|
||||
if (filterType !== "all") {
|
||||
list = list.filter((a) => a.kind === filterType)
|
||||
}
|
||||
|
||||
/* 按时间筛选 */
|
||||
if (filterTime !== "all") {
|
||||
const now = new Date()
|
||||
list = list.filter((a) => {
|
||||
const d = new Date(a.createdAt)
|
||||
const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (filterTime === "today") return diffDays < 1
|
||||
if (filterTime === "week") return diffDays < 7
|
||||
if (filterTime === "month") return diffDays < 30
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter((a) => a.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
}, [assets, filterType, filterTime, searchText])
|
||||
|
||||
return {
|
||||
// 视频库
|
||||
libraries,
|
||||
libLoading,
|
||||
activeLibId,
|
||||
setActiveLibId,
|
||||
effectiveLibId,
|
||||
// 素材列表
|
||||
assets,
|
||||
assetsLoading,
|
||||
assetsError,
|
||||
assetsErrorObj,
|
||||
refetchAssets,
|
||||
// 筛选
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterType,
|
||||
setFilterType,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
filteredAssets,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { createAssetLibrary, deleteAssetLibrary } from "@/api/assets"
|
||||
import type { AssetKind, LibraryItem } from "../types"
|
||||
|
||||
/**
|
||||
* 视频库管理 Hook
|
||||
* 封装视频库的创建、删除操作,以及新建弹窗的表单状态
|
||||
*/
|
||||
interface UseLibraryManagementProps {
|
||||
libraries: LibraryItem[]
|
||||
activeLibId: string
|
||||
setActiveLibId: (id: string) => void
|
||||
effectiveLibId: string
|
||||
}
|
||||
|
||||
export function useLibraryManagement({
|
||||
libraries,
|
||||
setActiveLibId,
|
||||
effectiveLibId,
|
||||
}: UseLibraryManagementProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 状态 ── */
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
const [newLibName, setNewLibName] = useState("")
|
||||
const [newLibKind, setNewLibKind] = useState<AssetKind>("video")
|
||||
|
||||
/* ── Mutations ── */
|
||||
const createLibMutation = useMutation({
|
||||
mutationFn: createAssetLibrary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
message.success("视频库创建成功")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("创建视频库失败")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteLibMutation = useMutation({
|
||||
mutationFn: deleteAssetLibrary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
message.success("视频库已删除")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除视频库失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 新建视频库 ── */
|
||||
const handleCreateLibrary = useCallback(async () => {
|
||||
if (!newLibName.trim()) {
|
||||
message.warning("请输入视频库名称")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const newLib = await createLibMutation.mutateAsync({
|
||||
name: newLibName.trim(),
|
||||
kind: newLibKind,
|
||||
})
|
||||
setActiveLibId(newLib.id)
|
||||
setCreateModalOpen(false)
|
||||
setNewLibName("")
|
||||
setNewLibKind("video")
|
||||
} catch {
|
||||
// error handled in mutation
|
||||
}
|
||||
}, [newLibName, newLibKind, createLibMutation, setActiveLibId])
|
||||
|
||||
/* ── 删除视频库 ── */
|
||||
const handleDeleteLibrary = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await deleteLibMutation.mutateAsync(id)
|
||||
if (effectiveLibId === id) {
|
||||
const remaining = libraries.filter((l) => l.id !== id)
|
||||
if (remaining.length > 0) setActiveLibId(remaining[0].id)
|
||||
else setActiveLibId("")
|
||||
}
|
||||
} catch {
|
||||
// error handled in mutation
|
||||
}
|
||||
},
|
||||
[deleteLibMutation, effectiveLibId, libraries, setActiveLibId],
|
||||
)
|
||||
|
||||
return {
|
||||
// 弹窗状态
|
||||
createModalOpen,
|
||||
setCreateModalOpen,
|
||||
// 表单状态
|
||||
newLibName,
|
||||
setNewLibName,
|
||||
newLibKind,
|
||||
setNewLibKind,
|
||||
// Mutations
|
||||
isCreating: createLibMutation.isPending,
|
||||
isDeleting: deleteLibMutation.isPending,
|
||||
// Handlers
|
||||
handleCreateLibrary,
|
||||
handleDeleteLibrary,
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<RiskFilter>("all")
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
const showToast = (message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
// 获取查重记录
|
||||
const { data: records = [], isLoading } = useQuery({
|
||||
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 (
|
||||
<div className="dup-page">
|
||||
@@ -136,136 +33,31 @@ const DuplicationResults: React.FC = () => {
|
||||
title="查重记录"
|
||||
actions={
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{/* 筛选胶囊 */}
|
||||
<div className="dup-filter">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
className={`dup-filter-btn ${riskFilter === opt.key ? "active" : ""}`}
|
||||
onClick={() => setRiskFilter(opt.key)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/app/duplication")}
|
||||
>
|
||||
<FilterBar value={riskFilter} onChange={setRiskFilter} />
|
||||
<Button buttonType="primary" buttonSize="md" onClick={handleUpload}>
|
||||
📤 上传查重
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 加载中 */}
|
||||
{isLoading && (
|
||||
<div className="dup-results-empty">
|
||||
<div className="dup-results-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!isLoading && filteredRecords.length === 0 && (
|
||||
<div className="dup-results-empty">
|
||||
<div className="dup-results-empty-icon">📭</div>
|
||||
<p>
|
||||
{riskFilter === "all"
|
||||
? "暂无查重记录,上传视频开始查重吧"
|
||||
: `没有${RISK_LABELS[riskFilter]}的记录`}
|
||||
</p>
|
||||
</div>
|
||||
{/* 加载中 / 空状态 */}
|
||||
{(isLoading || filteredRecords.length === 0) && (
|
||||
<EmptyState isLoading={isLoading} riskFilter={riskFilter} />
|
||||
)}
|
||||
|
||||
{/* 结果卡片列表 */}
|
||||
{!isLoading && filteredRecords.length > 0 && (
|
||||
<div className="dup-results-list">
|
||||
{filteredRecords.map((record) => {
|
||||
const statusCfg = STATUS_CONFIG[record.status]
|
||||
const riskLevel = getRiskLevel(record.duplicate_rate)
|
||||
const rateValue = record.duplicate_rate
|
||||
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="dup-result-card"
|
||||
onClick={() => {
|
||||
if (record.status === "completed") {
|
||||
navigate(`/duplication/${record.id}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
<div className="dup-result-card-thumb">🎬</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="dup-result-card-body">
|
||||
<h4>{record.filename}</h4>
|
||||
<div className="dup-result-card-meta">
|
||||
<Tag variant={statusCfg.variant}>
|
||||
{statusCfg.icon} {statusCfg.text}
|
||||
</Tag>
|
||||
<span>{formatSize(record.file_size)}</span>
|
||||
<span>{formatDuration(record.duration_seconds)}</span>
|
||||
<span>{new Date(record.created_at).toLocaleDateString("zh-CN")}</span>
|
||||
{record.status === "completed" && record.duplicate_count !== undefined && (
|
||||
<span>{record.duplicate_count} 个重复片段</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 查重率 */}
|
||||
<div className="dup-result-card-score">
|
||||
{record.status === "completed" && rateValue !== undefined ? (
|
||||
<>
|
||||
<div className="dup-score-bar">
|
||||
<div
|
||||
className={`dup-score-bar-fill ${riskLevel}`}
|
||||
style={{ width: `${Math.min(rateValue, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`dup-score-value ${riskLevel}`}>
|
||||
{rateValue.toFixed(1)}%
|
||||
</span>
|
||||
</>
|
||||
) : record.status === "failed" ? (
|
||||
<Tooltip title="重新查重">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
retryMutation.mutate(record.id)
|
||||
}}
|
||||
>
|
||||
🔄 重试
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{record.status === "processing" ? "分析中..." : "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm("确定删除此记录?")) {
|
||||
deleteMutation.mutate(record.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filteredRecords.map((record) => (
|
||||
<ResultCard
|
||||
key={record.id}
|
||||
record={record}
|
||||
onView={handleView}
|
||||
onDelete={handleDelete}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<EmptyStateProps> = ({ isLoading, riskFilter }) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="dup-results-empty">
|
||||
<div className="dup-results-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dup-results-empty">
|
||||
<div className="dup-results-empty-icon">📭</div>
|
||||
<p>
|
||||
{riskFilter === "all"
|
||||
? "暂无查重记录,上传视频开始查重吧"
|
||||
: `没有${RISK_LABELS[riskFilter]}的记录`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
@@ -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<FilterBarProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="dup-filter">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
className={`dup-filter-btn ${value === opt.key ? "active" : ""}`}
|
||||
onClick={() => onChange(opt.key)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterBar
|
||||
@@ -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<ResultCardProps> = ({ 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 (
|
||||
<div key={record.id} className="dup-result-card" onClick={handleClick}>
|
||||
{/* 缩略图 */}
|
||||
<div className="dup-result-card-thumb">🎬</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="dup-result-card-body">
|
||||
<h4>{record.filename}</h4>
|
||||
<div className="dup-result-card-meta">
|
||||
<Tag variant={statusCfg.variant}>
|
||||
{statusCfg.icon} {statusCfg.text}
|
||||
</Tag>
|
||||
<span>{formatSize(record.file_size)}</span>
|
||||
<span>{formatDuration(record.duration_seconds)}</span>
|
||||
<span>{new Date(record.created_at).toLocaleDateString("zh-CN")}</span>
|
||||
{record.status === "completed" && record.duplicate_count !== undefined && (
|
||||
<span>{record.duplicate_count} 个重复片段</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 查重率 */}
|
||||
<div className="dup-result-card-score">
|
||||
{record.status === "completed" && rateValue !== undefined ? (
|
||||
<>
|
||||
<div className="dup-score-bar">
|
||||
<div
|
||||
className={`dup-score-bar-fill ${riskLevel}`}
|
||||
style={{ width: `${Math.min(rateValue, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`dup-score-value ${riskLevel}`}>{rateValue.toFixed(1)}%</span>
|
||||
</>
|
||||
) : record.status === "failed" ? (
|
||||
<Tooltip title="重新查重">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRetry(record.id)
|
||||
}}
|
||||
>
|
||||
🔄 重试
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{record.status === "processing" ? "分析中..." : "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm("确定删除此记录?")) {
|
||||
onDelete(record.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResultCard
|
||||
@@ -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<string, string> = {
|
||||
low: "低风险",
|
||||
medium: "中风险",
|
||||
high: "高风险",
|
||||
}
|
||||
|
||||
export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "low", label: "低风险" },
|
||||
{ key: "medium", label: "中风险" },
|
||||
{ key: "high", label: "高风险" },
|
||||
]
|
||||
@@ -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<RiskFilter>("all")
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
const showToast = (message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
// 获取查重记录
|
||||
const { data: records = [], isLoading } = useQuery({
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
/** 风险等级分类 */
|
||||
export type RiskFilter = "all" | "high" | "medium" | "low"
|
||||
|
||||
/** 简易 toast */
|
||||
export interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
@@ -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}秒`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user