Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 862340ace5 | |||
| b40ae9cec7 | |||
| eb636b8fef | |||
| 09b069a65d | |||
| 378e4c8751 | |||
| f9afdd95ce | |||
| 456718ad84 | |||
| edd4b6b1ea | |||
| 4578b65965 | |||
| e4b3af78b9 | |||
| ff827aba01 | |||
| a75cce0a3b | |||
| b051acc8b4 | |||
| 271db4e989 | |||
| 9eca947f88 | |||
| 8d3647f414 | |||
| a258d7f9d1 | |||
| 30713e698c | |||
| 2b120f5c65 | |||
| bf55cfc3ca | |||
| f23b0ceae4 | |||
| 66b6ed9f12 | |||
| 1e49618817 | |||
| 06716a0678 | |||
| 31dc5b382a | |||
| 57c7543c7f | |||
| 796cac249f | |||
| abe275acc4 | |||
| 1f11981a95 | |||
| 1b7a7bcb55 | |||
| da3ca4bc46 | |||
| 8849f5b358 | |||
| f246f7d8aa | |||
| 602bb388d1 | |||
| fba0ade0ca | |||
| 8882e24fd4 | |||
| 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 | |||
| 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 |
@@ -36,12 +36,11 @@ jobs:
|
|||||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
GITEA_REPO: xiaoxia/xiaoxia-saas
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
shell: sh
|
||||||
|
env:
|
||||||
- name: Setup Python
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
uses: actions/setup-python@v5
|
run: |
|
||||||
with:
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
|
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
|
||||||
- name: Get staging running images (whitelist)
|
- name: Get staging running images (whitelist)
|
||||||
|
|||||||
+223
-135
@@ -24,17 +24,6 @@ concurrency:
|
|||||||
group: ci-pipeline-${{ gitea.event_name }}-${{ gitea.ref }}
|
group: ci-pipeline-${{ gitea.event_name }}-${{ gitea.ref }}
|
||||||
# PR事件取消进行中的旧run,push事件不取消(确保完整CI跑完)
|
# PR事件取消进行中的旧run,push事件不取消(确保完整CI跑完)
|
||||||
cancel-in-progress: ${{ gitea.event_name == 'pull_request' }}
|
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:
|
jobs:
|
||||||
check-frontend-only:
|
check-frontend-only:
|
||||||
name: Check if frontend-only change
|
name: Check if frontend-only change
|
||||||
@@ -58,56 +47,24 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||||
|
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||||
# 分页获取所有变更文件(修复>300文件时漏判)
|
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||||
ALL_FILES=""
|
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||||
PAGE=1
|
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||||
while true; do
|
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300&page=${PAGE}"
|
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||||
PAGE_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; files=json.load(sys.stdin); [print(f['filename']) for f in files]; sys.exit(0 if len(files)==300 else 1)" 2>/dev/null || true)
|
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||||
PAGE_COUNT=$(echo "$PAGE_FILES" | wc -l)
|
|
||||||
if [ "$PAGE_COUNT" -lt 300 ]; then HAS_MORE=1; else HAS_MORE=0; fi
|
|
||||||
ALL_FILES="${ALL_FILES}${PAGE_FILES}"$'\n'
|
|
||||||
if [ "$HAS_MORE" != "0" ]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
PAGE=$((PAGE + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
FRONTEND_COUNT=$(echo "$ALL_FILES" | grep -c '^apps/web/' || true)
|
|
||||||
TOTAL=$(echo "$ALL_FILES" | grep -cv '^$' || true)
|
|
||||||
BACKEND_COUNT=$(( TOTAL - FRONTEND_COUNT ))
|
|
||||||
|
|
||||||
# 基础设施文件:改了就强制全量CI(不跳过任何检查)
|
|
||||||
INFRA_COUNT=$(echo "$ALL_FILES" | grep -cE '^(infra/|Dockerfile|\.gitea/workflows/|scripts/ci/|docker/)' || true)
|
|
||||||
|
|
||||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT}, 基础设施: ${INFRA_COUNT})"
|
|
||||||
|
|
||||||
# 判定是否纯前端/纯后端
|
|
||||||
PURE_FRONTEND=false
|
|
||||||
PURE_BACKEND=false
|
|
||||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ] && [ "$INFRA_COUNT" = "0" ]; then
|
|
||||||
PURE_FRONTEND=true
|
|
||||||
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ] && [ "$INFRA_COUNT" = "0" ]; then
|
|
||||||
PURE_BACKEND=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$PURE_FRONTEND" = "true" ]; then
|
|
||||||
echo "skip_backend=true" >> $GITHUB_OUTPUT
|
echo "skip_backend=true" >> $GITHUB_OUTPUT
|
||||||
echo "skip_frontend=false" >> $GITHUB_OUTPUT
|
echo "skip_frontend=false" >> $GITHUB_OUTPUT
|
||||||
echo "✅ 纯前端改动,跳过后端检查"
|
echo "✅ 纯前端改动,跳过后端检查"
|
||||||
elif [ "$PURE_BACKEND" = "true" ]; then
|
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ]; then
|
||||||
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
||||||
echo "skip_frontend=true" >> $GITHUB_OUTPUT
|
echo "skip_frontend=true" >> $GITHUB_OUTPUT
|
||||||
echo "🔧 纯后端改动,跳过前端检查"
|
echo "🔧 纯后端改动,跳过前端检查"
|
||||||
else
|
else
|
||||||
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
||||||
echo "skip_frontend=false" >> $GITHUB_OUTPUT
|
echo "skip_frontend=false" >> $GITHUB_OUTPUT
|
||||||
if [ "$INFRA_COUNT" -gt "0" ]; then
|
echo "🔧 包含全栈变更,运行完整CI"
|
||||||
echo "🏗️ 包含基础设施变更,强制运行完整CI"
|
|
||||||
else
|
|
||||||
echo "🔧 包含全栈变更,运行完整CI"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Report CI trace
|
- name: Report CI trace
|
||||||
@@ -294,7 +251,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
env:
|
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'
|
USE_IN_MEMORY_DB: 'false'
|
||||||
CI_USE_SHARED_PG: 'true'
|
CI_USE_SHARED_PG: 'true'
|
||||||
steps:
|
steps:
|
||||||
@@ -378,7 +335,6 @@ jobs:
|
|||||||
OSS_ACCESS_KEY_SECRET: placeholder
|
OSS_ACCESS_KEY_SECRET: placeholder
|
||||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||||
JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -443,14 +399,13 @@ jobs:
|
|||||||
- validate-type-check
|
- validate-type-check
|
||||||
- validate-migration
|
- validate-migration
|
||||||
env:
|
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'
|
USE_IN_MEMORY_DB: 'false'
|
||||||
CI_USE_SHARED_PG: 'true'
|
CI_USE_SHARED_PG: 'true'
|
||||||
OSS_ACCESS_KEY_ID: placeholder
|
OSS_ACCESS_KEY_ID: placeholder
|
||||||
OSS_ACCESS_KEY_SECRET: placeholder
|
OSS_ACCESS_KEY_SECRET: placeholder
|
||||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||||
JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -672,83 +627,60 @@ jobs:
|
|||||||
echo "Docker login failed ($i/3), retrying in 5s..."
|
echo "Docker login failed ($i/3), retrying in 5s..."
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
- name: Pre-build worker base images (3-level cache)
|
- name: Pre-build worker base images (fallback if not exist)
|
||||||
if: matrix.service == 'worker'
|
if: matrix.service == 'worker'
|
||||||
id: prebuild
|
id: prebuild
|
||||||
shell: bash
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
GITEA_REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||||
ACR_REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
|
||||||
GITEA_BUILDER="${GITEA_REGISTRY}/worker-base-builder:latest"
|
BASE_RUNTIME="${REGISTRY}/worker-base-runtime: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"
|
echo "检查基础镜像..."
|
||||||
|
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
|
||||||
# L1: 本地daemon缓存(DooD模式8runner共享宿主机daemon)
|
echo "基础镜像已存在,使用远程镜像"
|
||||||
echo "=== L1 本地缓存 ==="
|
echo "fallback=false" >> $GITHUB_OUTPUT
|
||||||
if docker image inspect "$ACR_BUILDER" > /dev/null 2>&1 \
|
|
||||||
&& docker image inspect "$ACR_RUNTIME" > /dev/null 2>&1; then
|
|
||||||
echo "本地缓存命中"
|
|
||||||
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "本地无缓存"
|
|
||||||
|
|
||||||
# L2: Gitea registry缓存(内网快)
|
|
||||||
echo "=== L2 Registry拉取 ==="
|
|
||||||
if docker pull "$GITEA_BUILDER" 2>/dev/null && docker pull "$GITEA_RUNTIME" 2>/dev/null; then
|
|
||||||
echo "Registry拉取成功,重tag供Dockerfile使用"
|
|
||||||
docker tag "$GITEA_BUILDER" "$ACR_BUILDER"
|
|
||||||
docker tag "$GITEA_RUNTIME" "$ACR_RUNTIME"
|
|
||||||
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Registry无缓存,需本地构建"
|
|
||||||
|
|
||||||
# L3: 本地构建
|
|
||||||
echo "=== L3 本地构建 ==="
|
|
||||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
|
||||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
|
||||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
|
||||||
else
|
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
|
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)
|
- name: Build PR image (verify only, no push)
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
@@ -762,15 +694,15 @@ jobs:
|
|||||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Worker有本地base镜像时:用BuildKit直接构建(快,无需起buildx容器)
|
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
|
||||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.has_local_base }}" = "true" ]; then
|
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
|
||||||
echo "本地base镜像已就绪,BuildKit快速构建"
|
echo "Fallback模式:用普通docker build(基础镜像本地已构建)"
|
||||||
BUILD_ARG_STR=""
|
BUILD_ARG_STR=""
|
||||||
for arg in $EXTRA_BUILD_ARGS; do
|
for arg in $EXTRA_BUILD_ARGS; do
|
||||||
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
|
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
|
||||||
done
|
done
|
||||||
DOCKER_BUILDKIT=1 docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||||
echo "快速构建成功"
|
echo "Fallback PR Build successful"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1151,8 +1083,7 @@ jobs:
|
|||||||
set -eu
|
set -eu
|
||||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||||
CONTAINER_NAME="staging-e2e-${GITHUB_SHA::8}"
|
CONTAINER_NAME="staging-e2e-$$"
|
||||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
|
||||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||||
@@ -1216,8 +1147,7 @@ jobs:
|
|||||||
set -eu
|
set -eu
|
||||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||||
CONTAINER_NAME="staging-api-tests-${GITHUB_SHA::8}"
|
CONTAINER_NAME="staging-api-tests-$$"
|
||||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
|
||||||
docker create --name "$CONTAINER_NAME" \
|
docker create --name "$CONTAINER_NAME" \
|
||||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||||
@@ -1620,6 +1550,7 @@ jobs:
|
|||||||
set -eu
|
set -eu
|
||||||
python3 scripts/ci/acr_cleanup.py \
|
python3 scripts/ci/acr_cleanup.py \
|
||||||
--keep 20 \
|
--keep 20 \
|
||||||
|
--pr-days 7 \
|
||||||
--execute
|
--execute
|
||||||
|
|
||||||
- name: Job duration summary
|
- name: Job duration summary
|
||||||
@@ -1731,3 +1662,160 @@ jobs:
|
|||||||
START_TIME=""
|
START_TIME=""
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||||
|
|
||||||
|
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 1
|
||||||
|
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
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ jobs:
|
|||||||
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||||
REPO_NAME: ${{ gitea.repository }}
|
REPO_NAME: ${{ gitea.repository }}
|
||||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||||
|
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
|
||||||
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
||||||
LLM_PROVIDER: "coze"
|
LLM_PROVIDER: "coze"
|
||||||
# 扣子模式配置(默认国内站 api.coze.cn)
|
# 扣子模式配置(默认国内站 api.coze.cn)
|
||||||
@@ -60,8 +61,9 @@ jobs:
|
|||||||
LLM_TIMEOUT: "120"
|
LLM_TIMEOUT: "120"
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/ci_code_review.py
|
python3 scripts/ci_code_review.py
|
||||||
# 审查脚本异常不影响 CI 通过
|
# 注意:脚本退出码决定job状态
|
||||||
continue-on-error: true
|
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
|
||||||
|
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open)
|
||||||
|
|
||||||
- name: Report CI trace
|
- name: Report CI trace
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
name: Daily Health Check
|
name: Daily Health Check
|
||||||
|
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
@@ -23,47 +24,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||||
python3 - <<'PY'
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||||
import io, os, tarfile, time, urllib.request, urllib.error
|
| bash
|
||||||
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
|
|
||||||
|
|
||||||
- name: Production health check & smoke test
|
- name: Production health check & smoke test
|
||||||
id: smoke
|
id: smoke
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -132,47 +95,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||||
python3 - <<'PY'
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||||
import io, os, tarfile, time, urllib.request, urllib.error
|
| bash
|
||||||
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
|
|
||||||
|
|
||||||
- name: Run API smoke test on staging
|
- name: Run API smoke test on staging
|
||||||
id: smoke
|
id: smoke
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -284,47 +209,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||||
python3 - <<'PY'
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||||
import io, os, tarfile, time, urllib.request, urllib.error
|
| bash
|
||||||
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
|
|
||||||
|
|
||||||
- name: Run Playwright E2E on staging
|
- name: Run Playwright E2E on staging
|
||||||
id: e2e
|
id: e2e
|
||||||
shell: sh
|
shell: sh
|
||||||
|
|||||||
@@ -12,12 +12,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import random
|
|
||||||
from typing import Any, Dict, List, Optional
|
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
|
from packages.shared.ai_client import get_doubao_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -64,85 +65,9 @@ def _generate_titles_fallback(
|
|||||||
style: str = "viral",
|
style: str = "viral",
|
||||||
count: int = 5,
|
count: int = 5,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""本地降级:基于模板规则生成标题.
|
"""本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
|
||||||
|
|
||||||
当豆包 API 不可用或调用失败时使用,保证接口始终有返回。
|
|
||||||
"""
|
|
||||||
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
||||||
examples = style_info["examples"]
|
return _generate_titles_fallback_base(description, style_info, count)
|
||||||
|
|
||||||
# 从描述中提取关键词(取前几个词)
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def generate_smart_titles(
|
def generate_smart_titles(
|
||||||
@@ -241,132 +166,19 @@ def _semantic_match_fallback(
|
|||||||
description: str,
|
description: str,
|
||||||
assets: List[Dict[str, Any]],
|
assets: List[Dict[str, Any]],
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""本地降级:基于关键词的简单匹配.
|
"""本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
|
||||||
|
return _semantic_match_fallback_base(description, assets)
|
||||||
计算描述中的关键词与素材名称/标签/描述的重叠度,
|
|
||||||
作为匹配度评分。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
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_semantic_match_response(
|
def _parse_semantic_match_response(
|
||||||
content: str,
|
content: str,
|
||||||
asset_ids: List[str],
|
asset_ids: List[str],
|
||||||
) -> Optional[Dict[str, float]]:
|
) -> Optional[Dict[str, float]]:
|
||||||
"""从模型返回中解析素材匹配度.
|
"""从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
|
||||||
|
result = _parse_semantic_match_base(content, asset_ids)
|
||||||
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
|
if result is None:
|
||||||
score 范围 0-1。
|
|
||||||
"""
|
|
||||||
if not content:
|
|
||||||
return None
|
return None
|
||||||
|
return dict(result)
|
||||||
# 尝试解析 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
|
|
||||||
|
|
||||||
|
|
||||||
def semantic_match_assets(
|
def semantic_match_assets(
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ from packages.adapters.sqlalchemy_impl import (
|
|||||||
SQLAlchemyEditPlanRepository,
|
SQLAlchemyEditPlanRepository,
|
||||||
SQLAlchemyGenerationTaskRepository,
|
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 import EditPlan, EditPlanStatus
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||||
|
|
||||||
@@ -384,36 +389,45 @@ class EditPlanService:
|
|||||||
clip = self.get_clip_or_raise(clip_id)
|
clip = self.get_clip_or_raise(clip_id)
|
||||||
plan_id = clip.plan_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)
|
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
|
original_order = clip.order
|
||||||
|
|
||||||
# 更新左半部分(原片段)
|
# 更新左半部分(原片段)
|
||||||
clip.duration = left_duration
|
clip.duration = split.left_duration
|
||||||
left_clip = self._clip_repo.update(clip)
|
left_clip = self._clip_repo.update(clip)
|
||||||
|
|
||||||
# 后面片段的 order 全部 +1(给右半部分腾位置)
|
# 后面片段的 order 全部 +1(给右半部分腾位置)
|
||||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||||||
for c in all_clips:
|
shifts = _calc_shift_orders(
|
||||||
if c.order > original_order and c.id != clip_id:
|
all_clips,
|
||||||
c.order += 1
|
threshold_order=original_order,
|
||||||
self._clip_repo.update(c)
|
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 {}
|
right_config = dict(clip.config) if clip.config else {}
|
||||||
# 素材裁剪信息
|
# 素材裁剪信息
|
||||||
if clip.asset_id:
|
if clip.asset_id:
|
||||||
# 右半部分从 split_time 开始播放
|
# 右半部分从 split_time 开始播放
|
||||||
right_config["trim_start"] = left_duration
|
right_config["trim_start"] = split.right_trim_start
|
||||||
# 左半部分在 split_time 处结束
|
# 左半部分在 split_time 处结束
|
||||||
left_config = dict(left_clip.config) if left_clip.config else {}
|
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.config = left_config
|
||||||
left_clip = self._clip_repo.update(left_clip)
|
left_clip = self._clip_repo.update(left_clip)
|
||||||
|
|
||||||
@@ -424,8 +438,8 @@ class EditPlanService:
|
|||||||
template_clip_config_id=clip.template_clip_config_id,
|
template_clip_config_id=clip.template_clip_config_id,
|
||||||
asset_id=clip.asset_id,
|
asset_id=clip.asset_id,
|
||||||
text_content=clip.text_content,
|
text_content=clip.text_content,
|
||||||
start_time=clip.start_time + left_duration,
|
start_time=split.right_start_time,
|
||||||
duration=right_duration,
|
duration=split.right_duration,
|
||||||
transition_effect=clip.transition_effect,
|
transition_effect=clip.transition_effect,
|
||||||
transition_duration=clip.transition_duration,
|
transition_duration=clip.transition_duration,
|
||||||
playback_speed=clip.playback_speed,
|
playback_speed=clip.playback_speed,
|
||||||
@@ -438,8 +452,8 @@ class EditPlanService:
|
|||||||
clip_id,
|
clip_id,
|
||||||
plan_id,
|
plan_id,
|
||||||
split_time,
|
split_time,
|
||||||
left_duration,
|
split.left_duration,
|
||||||
right_duration,
|
split.right_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -468,70 +482,45 @@ class EditPlanService:
|
|||||||
clip = self.get_clip_or_raise(cid)
|
clip = self.get_clip_or_raise(cid)
|
||||||
clips.append(clip)
|
clips.append(clip)
|
||||||
|
|
||||||
# 校验:同一计划
|
# 纯逻辑:校验 + 计算
|
||||||
plan_id = clips[0].plan_id
|
plan_id, first_order = _validate_merge(clips)
|
||||||
for c in clips[1:]:
|
merge = _calc_merge(clips)
|
||||||
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("只能合并相同类型的片段")
|
|
||||||
|
|
||||||
self._auto_resume_editing(plan_id)
|
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 = sorted(clips, key=lambda c: c.order)[0]
|
||||||
first_clip.text_content = merged_text
|
first_clip.duration = merge.total_duration
|
||||||
first_clip.config = merged_config
|
first_clip.text_content = merge.merged_text
|
||||||
|
first_clip.config = merge.merged_config
|
||||||
# 转场保留第一个的(合并后的入点转场)
|
# 转场保留第一个的(合并后的入点转场)
|
||||||
# playback_speed 取第一个的
|
# playback_speed 取第一个的
|
||||||
merged_clip = self._clip_repo.update(first_clip)
|
merged_clip = self._clip_repo.update(first_clip)
|
||||||
|
|
||||||
# 删除其余片段
|
# 删除其余片段
|
||||||
for c in clips[1:]:
|
rest_ids = [c.id for c in clips if c.id != merged_clip.id]
|
||||||
self._clip_repo.delete(c.id)
|
for cid in rest_ids:
|
||||||
|
self._clip_repo.delete(cid)
|
||||||
|
|
||||||
# 后面的片段 order 前移 (len - 1) 位
|
# 后面的片段 order 前移 (len - 1) 位
|
||||||
shift = len(clips) - 1
|
|
||||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||||||
for c in all_clips:
|
shifts = _calc_shift_orders(
|
||||||
if c.order > first_order and c.id != merged_clip.id:
|
all_clips,
|
||||||
c.order -= shift
|
threshold_order=first_order,
|
||||||
self._clip_repo.update(c)
|
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(
|
logger.info(
|
||||||
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
|
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
|
||||||
plan_id,
|
plan_id,
|
||||||
len(clips),
|
len(clips),
|
||||||
total_duration,
|
merge.total_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
return merged_clip
|
return merged_clip
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ from packages.domain.template_clip_config import (
|
|||||||
TemplateClipConfig,
|
TemplateClipConfig,
|
||||||
TransitionEffect,
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -121,9 +128,7 @@ class EditTemplateService:
|
|||||||
ValueError: 名称为空或重复
|
ValueError: 名称为空或重复
|
||||||
"""
|
"""
|
||||||
# 名称校验
|
# 名称校验
|
||||||
clean_name = name.strip()
|
clean_name = validate_template_name(name)
|
||||||
if not clean_name:
|
|
||||||
raise ValueError("模板名称不能为空")
|
|
||||||
|
|
||||||
# 名称重复检查
|
# 名称重复检查
|
||||||
existing = self._template_repo.list_all(skip=0, limit=1000)
|
existing = self._template_repo.list_all(skip=0, limit=1000)
|
||||||
@@ -471,12 +476,7 @@ class EditTemplateService:
|
|||||||
raise ValueError(f"模板名称已存在: {clean_name}")
|
raise ValueError(f"模板名称已存在: {clean_name}")
|
||||||
|
|
||||||
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
|
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
|
||||||
plan_config = plan.config or {}
|
template_config = filter_plan_config_to_template(plan.config)
|
||||||
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 = EditTemplate.create(
|
template = EditTemplate.create(
|
||||||
name=clean_name,
|
name=clean_name,
|
||||||
@@ -497,40 +497,7 @@ class EditTemplateService:
|
|||||||
|
|
||||||
# 5. 转换每个片段为模板片段配置
|
# 5. 转换每个片段为模板片段配置
|
||||||
created_configs: List[TemplateClipConfig] = []
|
created_configs: List[TemplateClipConfig] = []
|
||||||
for clip in clips:
|
for clip_config_obj in clips_to_template_clip_configs(created_template.id, 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,
|
|
||||||
)
|
|
||||||
created = self._clip_config_repo.create(clip_config_obj)
|
created = self._clip_config_repo.create(clip_config_obj)
|
||||||
created_configs.append(created)
|
created_configs.append(created)
|
||||||
|
|
||||||
@@ -679,8 +646,6 @@ class EditTemplateService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: 模板/草稿不存在,或草稿不属于该模板
|
ValueError: 模板/草稿不存在,或草稿不属于该模板
|
||||||
"""
|
"""
|
||||||
from packages.domain.template_clip_config import TemplateClipConfig
|
|
||||||
|
|
||||||
# 1. 校验模板和草稿
|
# 1. 校验模板和草稿
|
||||||
template = self.get_template_or_raise(template_id)
|
template = self.get_template_or_raise(template_id)
|
||||||
draft = self._plan_repo.get(draft_plan_id)
|
draft = self._plan_repo.get(draft_plan_id)
|
||||||
@@ -700,39 +665,14 @@ class EditTemplateService:
|
|||||||
editing_mode = config.get("editing_mode", "one_take")
|
editing_mode = config.get("editing_mode", "one_take")
|
||||||
|
|
||||||
# 4. 提取模板配置(去掉草稿/运行时字段)
|
# 4. 提取模板配置(去掉草稿/运行时字段)
|
||||||
draft_config = draft.config or {}
|
template_config = filter_plan_config_to_template(draft.config)
|
||||||
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
|
|
||||||
|
|
||||||
# 5. 事务更新
|
# 5. 事务更新
|
||||||
try:
|
try:
|
||||||
# 5.0 先保存旧版快照(发布前的状态),用于回滚
|
# 5.0 先保存旧版快照(发布前的状态),用于回滚
|
||||||
old_version = template.version or 1
|
old_version = template.version or 1
|
||||||
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
||||||
old_clip_snapshots = [
|
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
|
||||||
{
|
|
||||||
"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
|
|
||||||
]
|
|
||||||
|
|
||||||
from packages.domain.template_version import EditTemplateVersion
|
from packages.domain.template_version import EditTemplateVersion
|
||||||
|
|
||||||
@@ -759,46 +699,7 @@ class EditTemplateService:
|
|||||||
|
|
||||||
# 创建新的片段配置
|
# 创建新的片段配置
|
||||||
created_configs: list[TemplateClipConfig] = []
|
created_configs: list[TemplateClipConfig] = []
|
||||||
for clip in draft_clips:
|
for config_obj in clips_to_template_clip_configs(template_id, 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,
|
|
||||||
)
|
|
||||||
created = self._clip_config_repo.create(config_obj)
|
created = self._clip_config_repo.create(config_obj)
|
||||||
created_configs.append(created)
|
created_configs.append(created)
|
||||||
|
|
||||||
@@ -843,8 +744,6 @@ class EditTemplateService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: 模板/版本不存在
|
ValueError: 模板/版本不存在
|
||||||
"""
|
"""
|
||||||
from packages.domain.template_clip_config import TemplateClipConfig
|
|
||||||
|
|
||||||
template = self.get_template_or_raise(template_id)
|
template = self.get_template_or_raise(template_id)
|
||||||
|
|
||||||
# 1. 读取目标版本快照
|
# 1. 读取目标版本快照
|
||||||
@@ -857,22 +756,7 @@ class EditTemplateService:
|
|||||||
try:
|
try:
|
||||||
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
|
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
|
||||||
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
||||||
old_clip_snapshots = [
|
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
|
||||||
{
|
|
||||||
"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
|
|
||||||
]
|
|
||||||
|
|
||||||
from packages.domain.template_version import EditTemplateVersion
|
from packages.domain.template_version import EditTemplateVersion
|
||||||
|
|
||||||
@@ -905,37 +789,7 @@ class EditTemplateService:
|
|||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
|
|
||||||
for clip_snap in target_version.clip_configs:
|
for config_obj in snapshots_to_template_clip_configs(template_id, 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 {},
|
|
||||||
)
|
|
||||||
self._clip_config_repo.create(config_obj)
|
self._clip_config_repo.create(config_obj)
|
||||||
|
|
||||||
self._db.commit()
|
self._db.commit()
|
||||||
|
|||||||
@@ -27,13 +27,12 @@ from packages.domain.edit_plan_clip import EditPlanClip
|
|||||||
from packages.domain.edit_template import EditTemplate
|
from packages.domain.edit_template import EditTemplate
|
||||||
from packages.domain.editing_mode import EditingMode
|
from packages.domain.editing_mode import EditingMode
|
||||||
from packages.domain.plan_generator_utils import (
|
from packages.domain.plan_generator_utils import (
|
||||||
DEFAULT_CLIP_DURATION,
|
|
||||||
create_clips_from_configs,
|
create_clips_from_configs,
|
||||||
distribute_assets,
|
distribute_assets,
|
||||||
generate_default_clips,
|
generate_default_clips,
|
||||||
map_clip_types_for_mode,
|
map_clip_types_for_mode,
|
||||||
)
|
)
|
||||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
from packages.domain.template_clip_config import TemplateClipConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -206,4 +205,3 @@ class PlanGeneratorService:
|
|||||||
委托给 plan_generator_utils.distribute_assets 纯函数。
|
委托给 plan_generator_utils.distribute_assets 纯函数。
|
||||||
"""
|
"""
|
||||||
distribute_assets(clips, asset_ids, editing_mode)
|
distribute_assets(clips, asset_ids, editing_mode)
|
||||||
|
|
||||||
|
|||||||
@@ -21,14 +21,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
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 (
|
from packages.domain.asset_scoring import (
|
||||||
MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX,
|
|
||||||
MIN_QUALITY_SCORE as _MIN_QUALITY_SCORE,
|
|
||||||
OPTIMAL_DURATION_MAX as _OPTIMAL_DURATION_MAX,
|
|
||||||
OPTIMAL_DURATION_MIN as _OPTIMAL_DURATION_MIN,
|
|
||||||
SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX,
|
|
||||||
TARGET_HEIGHT as _TARGET_HEIGHT,
|
|
||||||
TARGET_WIDTH as _TARGET_WIDTH,
|
|
||||||
AssetScoreDetail,
|
AssetScoreDetail,
|
||||||
SmartSelectResult,
|
SmartSelectResult,
|
||||||
diverse_selection,
|
diverse_selection,
|
||||||
|
|||||||
@@ -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 import EditPlanStatus
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
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__)
|
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_CODEC = "libx264"
|
||||||
DEFAULT_CRF = 23
|
DEFAULT_CRF = 23
|
||||||
DEFAULT_PRESET = "medium"
|
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)
|
@dataclass(frozen=True)
|
||||||
class ComposeCommand:
|
class ComposeCommand:
|
||||||
"""完整的 FFmpeg 合成命令描述。"""
|
"""完整的 FFmpeg 合成命令描述。"""
|
||||||
@@ -401,62 +387,8 @@ class VideoComposeService:
|
|||||||
output_height: int,
|
output_height: int,
|
||||||
fps: int,
|
fps: int,
|
||||||
) -> ClipFilterChain:
|
) -> ClipFilterChain:
|
||||||
"""为单个片段构建滤镜链。
|
"""向后兼容:委托给 video_filter_builder.build_clip_filter。"""
|
||||||
|
return build_clip_filter(clip, input_index, output_width, output_height, fps)
|
||||||
滤镜顺序:
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_filter_complex(
|
def _build_filter_complex(
|
||||||
@@ -466,102 +398,30 @@ class VideoComposeService:
|
|||||||
transition_duration: float,
|
transition_duration: float,
|
||||||
transitions: list[str],
|
transitions: list[str],
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""构建完整的 filter_complex 字符串。
|
"""向后兼容:委托给 video_filter_builder.build_filter_complex。"""
|
||||||
|
return _build_filter_complex(clip_chains, output_width, output_height, transition_duration, transitions)
|
||||||
策略:
|
|
||||||
- 单片段:直接输出
|
|
||||||
- 多片段 + 全 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
|
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
|
||||||
"""是否有任何片段包含音频流。"""
|
"""向后兼容:委托给 video_filter_builder.has_audio。"""
|
||||||
return any(c.audio_label is not None for c in clip_chains)
|
return _has_audio_func(clip_chains)
|
||||||
|
|
||||||
|
|
||||||
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
|
# ── 模块级辅助函数(向后兼容别名) ──────────────────────────────────────────
|
||||||
|
# 实际实现已迁移至 packages/domain/video_filter_builder.py
|
||||||
|
# 保留此处别名以兼容现有测试与调用方
|
||||||
|
|
||||||
|
|
||||||
def _chain_filters(filters: list[str], output_label: str) -> str:
|
def _chain_filters(filters: list[str], output_label: str) -> str:
|
||||||
"""将滤镜列表串联为 FFmpeg 滤镜字符串。"""
|
"""向后兼容:委托给 video_filter_builder.chain_filters。"""
|
||||||
filter_body = ",".join(filters)
|
return _chain_filters_func(filters, output_label)
|
||||||
return f"[0:v]{filter_body}[{output_label}]"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_concat_filter(
|
def _build_concat_filter(
|
||||||
clip_chains: list[ClipFilterChain],
|
clip_chains: list[ClipFilterChain],
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""构建 concat 滤镜(无转场,高效拼接)。
|
"""向后兼容:委托给 video_filter_builder.build_concat_filter。"""
|
||||||
|
return _build_concat_filter_func(clip_chains)
|
||||||
格式:
|
|
||||||
[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
|
|
||||||
|
|
||||||
|
|
||||||
def _build_xfade_filter(
|
def _build_xfade_filter(
|
||||||
@@ -569,80 +429,5 @@ def _build_xfade_filter(
|
|||||||
transition_duration: float,
|
transition_duration: float,
|
||||||
transitions: list[str],
|
transitions: list[str],
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""构建 xfade 转场滤镜链。
|
"""向后兼容:委托给 video_filter_builder.build_xfade_filter。"""
|
||||||
|
return _build_xfade_filter_func(clip_chains, transition_duration, transitions)
|
||||||
每两个相邻片段之间插入 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)
|
|
||||||
|
|||||||
@@ -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"
|
export type DuplicationStatus = "pending" | "processing" | "completed" | "failed"
|
||||||
@@ -62,38 +60,3 @@ export interface DuplicationUploadResponse {
|
|||||||
/** 消息 */
|
/** 消息 */
|
||||||
message: string
|
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,前端自动管理默认项目
|
* 素材库需要 project_id,前端自动管理默认项目
|
||||||
*/
|
*/
|
||||||
import apiClient from "./client"
|
import apiClient from "../client"
|
||||||
|
import type { BackendListProjectsResponse, BackendProjectResponse, ProjectItem } from "./types"
|
||||||
export interface ProjectItem {
|
import { toProjectItem } from "./utils"
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 获取当前用户的项目列表 */
|
/** 获取当前用户的项目列表 */
|
||||||
export const getProjects = async (): Promise<ProjectItem[]> => {
|
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"
|
export type PlanType = "free" | "standard" | "pro" | "enterprise"
|
||||||
@@ -65,42 +63,3 @@ export interface ChangePlanResponse {
|
|||||||
message: string
|
message: string
|
||||||
new_subscription?: SubscriptionInfo
|
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 标签体系:对接后端标签表
|
* P3 标签体系:对接后端标签表
|
||||||
*/
|
*/
|
||||||
import apiClient from "./client"
|
import apiClient from "../client"
|
||||||
|
import type { TagItem } from "./types"
|
||||||
export interface TagItem {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
created_at?: string
|
|
||||||
usage_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取当前用户所有标签 */
|
/** 获取当前用户所有标签 */
|
||||||
export const getTags = async (): Promise<TagItem[]> => {
|
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"
|
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
|
||||||
@@ -85,39 +77,3 @@ export interface CreateGenerationTaskResponse {
|
|||||||
result_count: number
|
result_count: number
|
||||||
error_message: string
|
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 新增:全局标题库
|
* Phase 1 新增:全局标题库
|
||||||
* 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示
|
* 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示
|
||||||
*/
|
*/
|
||||||
import apiClient from "./client"
|
import apiClient from "../client"
|
||||||
|
import type {
|
||||||
/** 标题条目(前端展示用) */
|
BackendCreateTitleRequest,
|
||||||
export interface TitleItem {
|
BackendTitleResponse,
|
||||||
id: string
|
BackendUpdateTitleRequest,
|
||||||
content: string
|
CreateTitleRequest,
|
||||||
category?: string
|
TitleItem,
|
||||||
source?: string
|
} from "./types"
|
||||||
word_count?: number
|
import { toTitleItem } from "./utils"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取当前用户的所有标题 */
|
/** 获取当前用户的所有标题 */
|
||||||
export const getTitles = async (): Promise<TitleItem[]> => {
|
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
|
* 配音相关 API 函数
|
||||||
* Phase 1 新增:全局配音库
|
|
||||||
*
|
|
||||||
* 任务 3.11:新增统一音色 API(对接后端 3.04),保留旧接口向后兼容
|
* 任务 3.11:新增统一音色 API(对接后端 3.04),保留旧接口向后兼容
|
||||||
*/
|
*/
|
||||||
import apiClient from "./client"
|
import apiClient from "../client"
|
||||||
|
import type {
|
||||||
/* ── 统一音色 API(后端 3.04) ─────────────────────────── */
|
CreateVoiceRequest,
|
||||||
|
PresetVoiceListResponse,
|
||||||
/** 统一音色条目(preset + clone 混合) */
|
UnifiedVoiceListParams,
|
||||||
export interface UnifiedVoiceItem {
|
UnifiedVoiceListResponse,
|
||||||
id: string
|
VoiceItem,
|
||||||
type: "preset" | "clone"
|
} from "./types"
|
||||||
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 const fetchVoices = async (
|
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) */
|
/** 获取当前用户的所有配音(旧 → /voices/legacy) */
|
||||||
export const getVoices = async (): Promise<VoiceItem[]> => {
|
export const getVoices = async (): Promise<VoiceItem[]> => {
|
||||||
const response = await apiClient.get("/voices/legacy")
|
const response = await apiClient.get("/voices/legacy")
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||||
|
import { formatSize, formatDuration, getQualityLevel } from "./utils"
|
||||||
|
|
||||||
|
interface AssetCardProps {
|
||||||
|
asset: MediaAsset
|
||||||
|
isSelected: boolean
|
||||||
|
isDragging: boolean
|
||||||
|
isDragOver: boolean
|
||||||
|
showCheckbox: boolean
|
||||||
|
compact?: boolean
|
||||||
|
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
|
||||||
|
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
|
||||||
|
onDragStart: (e: React.DragEvent) => void
|
||||||
|
onDragOver: (e: React.DragEvent) => void
|
||||||
|
onDrop: (e: React.DragEvent) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
onMouseEnter: (e: React.MouseEvent) => void
|
||||||
|
onMouseLeave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssetCard: React.FC<AssetCardProps> = ({
|
||||||
|
asset,
|
||||||
|
isSelected,
|
||||||
|
isDragging,
|
||||||
|
isDragOver,
|
||||||
|
showCheckbox,
|
||||||
|
onToggleSelect,
|
||||||
|
onCardClick,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onDragEnd,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
}) => {
|
||||||
|
const qualityLevel = getQualityLevel(asset.quality_score)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"as-card",
|
||||||
|
isSelected ? "selected" : "",
|
||||||
|
isDragging ? "dragging" : "",
|
||||||
|
isDragOver ? "drag-over" : "",
|
||||||
|
showCheckbox ? "has-checkbox" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onClick={(e) => onCardClick(asset, e)}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
|
{/* 缩略图 */}
|
||||||
|
<div className="as-card-thumb">
|
||||||
|
{asset.thumbnail_url ? (
|
||||||
|
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Checkbox */}
|
||||||
|
{showCheckbox && (
|
||||||
|
<span
|
||||||
|
data-checkbox
|
||||||
|
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleSelect(asset, e.shiftKey)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 类型角标 */}
|
||||||
|
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||||
|
|
||||||
|
{/* 时长角标 */}
|
||||||
|
{asset.duration != null && (
|
||||||
|
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 质量分角标 */}
|
||||||
|
{asset.quality_score != null && (
|
||||||
|
<span
|
||||||
|
className={`as-card-quality ${qualityLevel}`}
|
||||||
|
title={`质量分: ${asset.quality_score}`}
|
||||||
|
>
|
||||||
|
{asset.quality_score}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 信息 */}
|
||||||
|
<div className="as-card-info">
|
||||||
|
<p className="as-card-name" title={asset.name}>
|
||||||
|
{asset.name}
|
||||||
|
</p>
|
||||||
|
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AssetCard
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||||
|
import { formatSize, formatDuration, getQualityColor } from "./utils"
|
||||||
|
|
||||||
|
interface AssetListItemProps {
|
||||||
|
asset: MediaAsset
|
||||||
|
isSelected: boolean
|
||||||
|
isDragging: boolean
|
||||||
|
isDragOver: boolean
|
||||||
|
showCheckbox: boolean
|
||||||
|
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
|
||||||
|
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
|
||||||
|
onDragStart: (e: React.DragEvent) => void
|
||||||
|
onDragOver: (e: React.DragEvent) => void
|
||||||
|
onDrop: (e: React.DragEvent) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
onMouseEnter: (e: React.MouseEvent) => void
|
||||||
|
onMouseLeave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssetListItem: React.FC<AssetListItemProps> = ({
|
||||||
|
asset,
|
||||||
|
isSelected,
|
||||||
|
isDragging,
|
||||||
|
isDragOver,
|
||||||
|
showCheckbox,
|
||||||
|
onToggleSelect,
|
||||||
|
onCardClick,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onDragEnd,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"as-list-item",
|
||||||
|
isSelected ? "selected" : "",
|
||||||
|
isDragging ? "dragging" : "",
|
||||||
|
isDragOver ? "drag-over" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onClick={(e) => onCardClick(asset, e)}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
|
{/* 拖拽手柄 */}
|
||||||
|
<span className="as-list-item-drag" title="拖拽排序">
|
||||||
|
⠿
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Checkbox */}
|
||||||
|
{showCheckbox && (
|
||||||
|
<span
|
||||||
|
data-checkbox
|
||||||
|
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleSelect(asset, e.shiftKey)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图标 */}
|
||||||
|
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||||
|
|
||||||
|
{/* 信息 */}
|
||||||
|
<div className="as-list-item-info">
|
||||||
|
<div className="as-list-item-name">{asset.name}</div>
|
||||||
|
<div className="as-list-item-meta">
|
||||||
|
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||||
|
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
||||||
|
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 质量分 */}
|
||||||
|
{asset.quality_score != null && (
|
||||||
|
<span
|
||||||
|
className="as-list-item-quality"
|
||||||
|
style={{ color: getQualityColor(asset.quality_score) }}
|
||||||
|
>
|
||||||
|
{asset.quality_score}分
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AssetListItem
|
||||||
@@ -9,73 +9,20 @@
|
|||||||
* - 筛选增强:类型筛选 + 质量分筛选
|
* - 筛选增强:类型筛选 + 质量分筛选
|
||||||
* - 视图切换:网格视图 / 列表视图
|
* - 视图切换:网格视图 / 列表视图
|
||||||
*/
|
*/
|
||||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
|
import React, { useCallback } from "react"
|
||||||
import "./AssetSelector.css"
|
import "./AssetSelector.css"
|
||||||
import { Input, Select, Button } from "@/components/ui"
|
import type { AssetSelectorProps } from "./types"
|
||||||
|
import useAssetFilter from "./hooks/useAssetFilter"
|
||||||
|
import useAssetSelection from "./hooks/useAssetSelection"
|
||||||
|
import useDragReorder from "./hooks/useDragReorder"
|
||||||
|
import useAssetPreview from "./hooks/useAssetPreview"
|
||||||
|
import SelectorToolbar from "./SelectorToolbar"
|
||||||
|
import SelectorBatchBar from "./SelectorBatchBar"
|
||||||
|
import AssetCard from "./AssetCard"
|
||||||
|
import AssetListItem from "./AssetListItem"
|
||||||
|
import PreviewOverlay from "./PreviewOverlay"
|
||||||
|
import EmptyState from "./EmptyState"
|
||||||
import type { MediaAsset } from "@/api/template-editor"
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/template-editor"
|
|
||||||
|
|
||||||
/* ──────────── 类型 ──────────── */
|
|
||||||
|
|
||||||
export interface AssetSelectorProps {
|
|
||||||
assets: MediaAsset[]
|
|
||||||
selectedIds?: string[]
|
|
||||||
onSelectionChange?: (ids: string[]) => void
|
|
||||||
onAssetDragStart?: (asset: MediaAsset) => void
|
|
||||||
onReorder?: (fromIdx: number, toIdx: number) => void
|
|
||||||
showQualityFilter?: boolean
|
|
||||||
showBatchSelect?: boolean
|
|
||||||
compact?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type ViewMode = "grid" | "list"
|
|
||||||
|
|
||||||
/* ──────────── 工具函数 ──────────── */
|
|
||||||
|
|
||||||
/** 格式化文件大小 */
|
|
||||||
const formatSize = (bytes?: number): string => {
|
|
||||||
if (!bytes) return ""
|
|
||||||
if (bytes < 1024) return `${bytes}B`
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化时长 */
|
|
||||||
const formatDuration = (seconds?: number): string => {
|
|
||||||
if (!seconds) return ""
|
|
||||||
const m = Math.floor(seconds / 60)
|
|
||||||
const s = Math.floor(seconds % 60)
|
|
||||||
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取质量分等级 */
|
|
||||||
const getQualityLevel = (score?: number): string => {
|
|
||||||
if (score == null) return "none"
|
|
||||||
if (score >= 90) return "excellent"
|
|
||||||
if (score >= 70) return "good"
|
|
||||||
if (score >= 50) return "fair"
|
|
||||||
return "poor"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 质量分颜色 */
|
|
||||||
const getQualityColor = (score?: number): string => {
|
|
||||||
if (score == null) return "var(--text-secondary)"
|
|
||||||
if (score >= 90) return "var(--success-color, #10b981)"
|
|
||||||
if (score >= 70) return "var(--primary-color, #6366f1)"
|
|
||||||
if (score >= 50) return "var(--warning-color, #f59e0b)"
|
|
||||||
return "var(--error-color, #ef4444)"
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────────── 类型筛选选项 ──────────── */
|
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
|
||||||
{ value: "", label: "全部类型" },
|
|
||||||
{ value: "video", label: "🎬 视频" },
|
|
||||||
{ value: "image", label: "🖼️ 图片" },
|
|
||||||
{ value: "audio", label: "🎵 音频" },
|
|
||||||
]
|
|
||||||
|
|
||||||
/* ──────────── 组件 ──────────── */
|
|
||||||
|
|
||||||
const AssetSelector: React.FC<AssetSelectorProps> = ({
|
const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||||
assets,
|
assets,
|
||||||
@@ -87,162 +34,41 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
|||||||
showBatchSelect = true,
|
showBatchSelect = true,
|
||||||
compact = false,
|
compact = false,
|
||||||
}) => {
|
}) => {
|
||||||
/* ── 搜索 & 筛选 ── */
|
// 搜索 & 筛选
|
||||||
const [searchText, setSearchText] = useState("")
|
const {
|
||||||
const [filterType, setFilterType] = useState("")
|
searchText,
|
||||||
const [filterQuality, setFilterQuality] = useState("")
|
filterType,
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("grid")
|
filterQuality,
|
||||||
|
viewMode,
|
||||||
|
filteredAssets,
|
||||||
|
setSearchText,
|
||||||
|
setFilterType,
|
||||||
|
setFilterQuality,
|
||||||
|
setViewMode,
|
||||||
|
} = useAssetFilter(assets)
|
||||||
|
|
||||||
/* ── 拖拽状态 ── */
|
// 选中操作
|
||||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
const { selectedSet, toggleSelect, clearSelection } = useAssetSelection({
|
||||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
filteredAssets,
|
||||||
|
selectedIds,
|
||||||
|
onSelectionChange,
|
||||||
|
})
|
||||||
|
|
||||||
/* ── 悬浮预览 ── */
|
// 拖拽排序
|
||||||
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
|
const { dragIdx, dragOverIdx, handleDragStart, handleDragOver, handleDrop, handleDragEnd } =
|
||||||
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
|
useDragReorder({
|
||||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
filteredAssets,
|
||||||
|
selectedIds,
|
||||||
|
onAssetDragStart,
|
||||||
|
onReorder,
|
||||||
|
})
|
||||||
|
|
||||||
/* ── Shift 连选 ── */
|
// 悬浮预览
|
||||||
const lastClickedIdx = useRef<number | null>(null)
|
const { previewAsset, previewPos, handleMouseEnter, handleMouseLeave } = useAssetPreview()
|
||||||
|
|
||||||
/* ── 过滤后的素材列表 ── */
|
|
||||||
const filteredAssets = useMemo(() => {
|
|
||||||
let list = assets
|
|
||||||
if (searchText) {
|
|
||||||
const q = searchText.toLowerCase()
|
|
||||||
list = list.filter(
|
|
||||||
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (filterType) {
|
|
||||||
list = list.filter((a) => a.type === filterType)
|
|
||||||
}
|
|
||||||
if (filterQuality) {
|
|
||||||
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
|
|
||||||
if (opt?.min != null && opt?.max != null) {
|
|
||||||
list = list.filter(
|
|
||||||
(a) =>
|
|
||||||
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}, [assets, searchText, filterType, filterQuality])
|
|
||||||
|
|
||||||
/* ── 选中状态 ── */
|
|
||||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
|
||||||
|
|
||||||
/* ── 选择操作 ── */
|
|
||||||
const toggleSelect = useCallback(
|
|
||||||
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
|
|
||||||
if (!onSelectionChange) return
|
|
||||||
|
|
||||||
if (shiftKey && lastClickedIdx.current !== null) {
|
|
||||||
// Shift 连选
|
|
||||||
const start = Math.min(lastClickedIdx.current, idx)
|
|
||||||
const end = Math.max(lastClickedIdx.current, idx)
|
|
||||||
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
|
|
||||||
const newSet = new Set(selectedIds)
|
|
||||||
rangeIds.forEach((id) => newSet.add(id))
|
|
||||||
onSelectionChange(Array.from(newSet))
|
|
||||||
} else {
|
|
||||||
const newSet = new Set(selectedIds)
|
|
||||||
if (newSet.has(asset.id)) {
|
|
||||||
newSet.delete(asset.id)
|
|
||||||
} else {
|
|
||||||
newSet.add(asset.id)
|
|
||||||
}
|
|
||||||
onSelectionChange(Array.from(newSet))
|
|
||||||
}
|
|
||||||
lastClickedIdx.current = idx
|
|
||||||
},
|
|
||||||
[onSelectionChange, selectedIds, filteredAssets],
|
|
||||||
)
|
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
|
||||||
onSelectionChange?.([])
|
|
||||||
}, [onSelectionChange])
|
|
||||||
|
|
||||||
/* ── 拖拽排序 ── */
|
|
||||||
const handleDragStart = useCallback(
|
|
||||||
(e: React.DragEvent, idx: number) => {
|
|
||||||
setDragIdx(idx)
|
|
||||||
e.dataTransfer.effectAllowed = "move"
|
|
||||||
e.dataTransfer.setData("text/plain", String(idx))
|
|
||||||
// 设置素材数据,供 TimelinePanel 接收(P1-2 修复)
|
|
||||||
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
|
|
||||||
// 批量拖拽:如果有多个选中素材,一起携带
|
|
||||||
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
|
|
||||||
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
|
|
||||||
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
|
|
||||||
}
|
|
||||||
// 通知父组件素材拖拽开始
|
|
||||||
if (onAssetDragStart) {
|
|
||||||
onAssetDragStart(filteredAssets[idx])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[filteredAssets, selectedIds, onAssetDragStart],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDragOver = useCallback(
|
|
||||||
(e: React.DragEvent, idx: number) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.dataTransfer.dropEffect = "move"
|
|
||||||
if (dragIdx === null || dragIdx === idx) return
|
|
||||||
setDragOverIdx(idx)
|
|
||||||
},
|
|
||||||
[dragIdx],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
|
||||||
(e: React.DragEvent, toIdx: number) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
|
|
||||||
onReorder(dragIdx, toIdx)
|
|
||||||
}
|
|
||||||
setDragIdx(null)
|
|
||||||
setDragOverIdx(null)
|
|
||||||
},
|
|
||||||
[dragIdx, onReorder],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
|
||||||
setDragIdx(null)
|
|
||||||
setDragOverIdx(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* ── 悬浮预览 ── */
|
|
||||||
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
|
|
||||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
|
||||||
previewTimer.current = setTimeout(() => {
|
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
||||||
setPreviewAsset(asset)
|
|
||||||
setPreviewPos({
|
|
||||||
x: rect.right + 12,
|
|
||||||
y: Math.max(8, rect.top - 20),
|
|
||||||
})
|
|
||||||
}, 400)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
|
||||||
if (previewTimer.current) {
|
|
||||||
clearTimeout(previewTimer.current)
|
|
||||||
previewTimer.current = null
|
|
||||||
}
|
|
||||||
setPreviewAsset(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 清理定时器
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* ── 点击卡片 ── */
|
/* ── 点击卡片 ── */
|
||||||
const handleCardClick = useCallback(
|
const handleCardClick = useCallback(
|
||||||
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
|
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
|
||||||
// 如果点击的是 checkbox 区域,不触发卡片点击
|
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
if (target.closest("[data-checkbox]")) return
|
if (target.closest("[data-checkbox]")) return
|
||||||
toggleSelect(asset, idx, e.shiftKey)
|
toggleSelect(asset, idx, e.shiftKey)
|
||||||
@@ -250,253 +76,82 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
|||||||
[toggleSelect],
|
[toggleSelect],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ──────────── 渲染 ──────────── */
|
|
||||||
|
|
||||||
const hasSelection = selectedIds.length > 0
|
const hasSelection = selectedIds.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="as-container">
|
<div className="as-container">
|
||||||
{/* ═══ 工具栏 ═══ */}
|
{/* 工具栏 */}
|
||||||
<div className="as-toolbar">
|
<SelectorToolbar
|
||||||
<div className="as-toolbar-left">
|
searchText={searchText}
|
||||||
<div className="as-search">
|
filterType={filterType}
|
||||||
<Input
|
filterQuality={filterQuality}
|
||||||
placeholder="搜索素材..."
|
viewMode={viewMode}
|
||||||
value={searchText}
|
showQualityFilter={showQualityFilter}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onSearchChange={setSearchText}
|
||||||
prefix="🔍"
|
onTypeChange={setFilterType}
|
||||||
/>
|
onQualityChange={setFilterQuality}
|
||||||
</div>
|
onViewModeChange={setViewMode}
|
||||||
<Select
|
/>
|
||||||
value={filterType}
|
|
||||||
onChange={(v: string) => setFilterType(v)}
|
|
||||||
options={TYPE_OPTIONS}
|
|
||||||
/>
|
|
||||||
{showQualityFilter && (
|
|
||||||
<Select
|
|
||||||
value={filterQuality}
|
|
||||||
onChange={(v: string) => setFilterQuality(v)}
|
|
||||||
options={QUALITY_OPTIONS}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="as-toolbar-right">
|
|
||||||
<button
|
|
||||||
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
|
|
||||||
onClick={() => setViewMode("grid")}
|
|
||||||
title="网格视图"
|
|
||||||
>
|
|
||||||
⊞
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
|
|
||||||
onClick={() => setViewMode("list")}
|
|
||||||
title="列表视图"
|
|
||||||
>
|
|
||||||
☰
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ═══ 批量操作栏 ═══ */}
|
{/* 批量操作栏 */}
|
||||||
{showBatchSelect && hasSelection && (
|
{showBatchSelect && hasSelection && (
|
||||||
<div className="as-batch-bar">
|
<SelectorBatchBar selectedCount={selectedIds.length} onClear={clearSelection} />
|
||||||
<span className="as-batch-bar-count">已选 {selectedIds.length} 项</span>
|
|
||||||
<div className="as-batch-bar-actions">
|
|
||||||
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
|
|
||||||
取消选择
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ═══ 素材列表 ═══ */}
|
{/* 素材列表 */}
|
||||||
<div className="as-body">
|
<div className="as-body">
|
||||||
{filteredAssets.length === 0 ? (
|
{filteredAssets.length === 0 ? (
|
||||||
<div className="as-empty">
|
<EmptyState />
|
||||||
<div className="as-empty-icon">📂</div>
|
|
||||||
<p>暂无素材</p>
|
|
||||||
</div>
|
|
||||||
) : viewMode === "grid" ? (
|
) : viewMode === "grid" ? (
|
||||||
/* ── 网格视图 ── */
|
/* 网格视图 */
|
||||||
<div className={`as-grid${compact ? " compact" : ""}`}>
|
<div className={`as-grid${compact ? " compact" : ""}`}>
|
||||||
{filteredAssets.map((asset, idx) => {
|
{filteredAssets.map((asset, idx) => (
|
||||||
const isSelected = selectedSet.has(asset.id)
|
<AssetCard
|
||||||
const isDragging = dragIdx === idx
|
key={asset.id}
|
||||||
const isDragOver = dragOverIdx === idx
|
asset={asset}
|
||||||
const qualityLevel = getQualityLevel(asset.quality_score)
|
isSelected={selectedSet.has(asset.id)}
|
||||||
|
isDragging={dragIdx === idx}
|
||||||
return (
|
isDragOver={dragOverIdx === idx}
|
||||||
<div
|
showCheckbox={showBatchSelect}
|
||||||
key={asset.id}
|
compact={compact}
|
||||||
className={[
|
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
|
||||||
"as-card",
|
onCardClick={(a, e) => handleCardClick(a, idx, e)}
|
||||||
isSelected ? "selected" : "",
|
onDragStart={(e) => handleDragStart(e, idx)}
|
||||||
isDragging ? "dragging" : "",
|
onDragOver={(e) => handleDragOver(e, idx)}
|
||||||
isDragOver ? "drag-over" : "",
|
onDrop={(e) => handleDrop(e, idx)}
|
||||||
showBatchSelect ? "has-checkbox" : "",
|
onDragEnd={handleDragEnd}
|
||||||
]
|
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||||
.filter(Boolean)
|
onMouseLeave={handleMouseLeave}
|
||||||
.join(" ")}
|
/>
|
||||||
draggable
|
))}
|
||||||
onDragStart={(e) => handleDragStart(e, idx)}
|
|
||||||
onDragOver={(e) => handleDragOver(e, idx)}
|
|
||||||
onDrop={(e) => handleDrop(e, idx)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onClick={(e) => handleCardClick(asset, idx, e)}
|
|
||||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
{/* 缩略图 */}
|
|
||||||
<div className="as-card-thumb">
|
|
||||||
{asset.thumbnail_url ? (
|
|
||||||
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
|
|
||||||
) : (
|
|
||||||
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Checkbox */}
|
|
||||||
{showBatchSelect && (
|
|
||||||
<span
|
|
||||||
data-checkbox
|
|
||||||
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
toggleSelect(asset, idx, e.shiftKey)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 类型角标 */}
|
|
||||||
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
|
|
||||||
|
|
||||||
{/* 时长角标 */}
|
|
||||||
{asset.duration != null && (
|
|
||||||
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 质量分角标 */}
|
|
||||||
{asset.quality_score != null && (
|
|
||||||
<span
|
|
||||||
className={`as-card-quality ${qualityLevel}`}
|
|
||||||
title={`质量分: ${asset.quality_score}`}
|
|
||||||
>
|
|
||||||
{asset.quality_score}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 信息 */}
|
|
||||||
<div className="as-card-info">
|
|
||||||
<p className="as-card-name" title={asset.name}>
|
|
||||||
{asset.name}
|
|
||||||
</p>
|
|
||||||
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ── 列表视图 ── */
|
/* 列表视图 */
|
||||||
<div className="as-list">
|
<div className="as-list">
|
||||||
{filteredAssets.map((asset, idx) => {
|
{filteredAssets.map((asset, idx) => (
|
||||||
const isSelected = selectedSet.has(asset.id)
|
<AssetListItem
|
||||||
const isDragging = dragIdx === idx
|
key={asset.id}
|
||||||
const isDragOver = dragOverIdx === idx
|
asset={asset}
|
||||||
|
isSelected={selectedSet.has(asset.id)}
|
||||||
return (
|
isDragging={dragIdx === idx}
|
||||||
<div
|
isDragOver={dragOverIdx === idx}
|
||||||
key={asset.id}
|
showCheckbox={showBatchSelect}
|
||||||
className={[
|
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
|
||||||
"as-list-item",
|
onCardClick={(a, e) => handleCardClick(a, idx, e)}
|
||||||
isSelected ? "selected" : "",
|
onDragStart={(e) => handleDragStart(e, idx)}
|
||||||
isDragging ? "dragging" : "",
|
onDragOver={(e) => handleDragOver(e, idx)}
|
||||||
isDragOver ? "drag-over" : "",
|
onDrop={(e) => handleDrop(e, idx)}
|
||||||
]
|
onDragEnd={handleDragEnd}
|
||||||
.filter(Boolean)
|
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||||
.join(" ")}
|
onMouseLeave={handleMouseLeave}
|
||||||
draggable
|
/>
|
||||||
onDragStart={(e) => handleDragStart(e, idx)}
|
))}
|
||||||
onDragOver={(e) => handleDragOver(e, idx)}
|
|
||||||
onDrop={(e) => handleDrop(e, idx)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onClick={(e) => handleCardClick(asset, idx, e)}
|
|
||||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
{/* 拖拽手柄 */}
|
|
||||||
<span className="as-list-item-drag" title="拖拽排序">
|
|
||||||
⠿
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Checkbox */}
|
|
||||||
{showBatchSelect && (
|
|
||||||
<span
|
|
||||||
data-checkbox
|
|
||||||
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
toggleSelect(asset, idx, e.shiftKey)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 图标 */}
|
|
||||||
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
|
||||||
|
|
||||||
{/* 信息 */}
|
|
||||||
<div className="as-list-item-info">
|
|
||||||
<div className="as-list-item-name">{asset.name}</div>
|
|
||||||
<div className="as-list-item-meta">
|
|
||||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
|
||||||
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
|
||||||
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 质量分 */}
|
|
||||||
{asset.quality_score != null && (
|
|
||||||
<span
|
|
||||||
className="as-list-item-quality"
|
|
||||||
style={{ color: getQualityColor(asset.quality_score) }}
|
|
||||||
>
|
|
||||||
{asset.quality_score}分
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══ 悬浮预览 ═══ */}
|
{/* 悬浮预览 */}
|
||||||
{previewAsset && (
|
{previewAsset && <PreviewOverlay asset={previewAsset} position={previewPos} />}
|
||||||
<div className="as-preview-overlay" style={{ left: previewPos.x, top: previewPos.y }}>
|
|
||||||
<div className="as-preview-overlay-thumb">
|
|
||||||
{previewAsset.thumbnail_url ? (
|
|
||||||
<img src={previewAsset.thumbnail_url} alt={previewAsset.name} />
|
|
||||||
) : (
|
|
||||||
<span className="as-preview-overlay-thumb-icon">
|
|
||||||
{MATERIAL_TYPE_ICONS[previewAsset.type]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="as-preview-overlay-name">{previewAsset.name}</p>
|
|
||||||
<div className="as-preview-overlay-meta">
|
|
||||||
<span>类型: {MATERIAL_TYPE_LABELS[previewAsset.type]}</span>
|
|
||||||
{previewAsset.duration != null && (
|
|
||||||
<span>时长: {formatDuration(previewAsset.duration)}</span>
|
|
||||||
)}
|
|
||||||
{previewAsset.size != null && <span>大小: {formatSize(previewAsset.size)}</span>}
|
|
||||||
{previewAsset.quality_score != null && (
|
|
||||||
<span>质量分: {previewAsset.quality_score}</span>
|
|
||||||
)}
|
|
||||||
{previewAsset.tags.length > 0 && <span>标签: {previewAsset.tags.join(", ")}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
const EmptyState: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="as-empty">
|
||||||
|
<div className="as-empty-icon">📂</div>
|
||||||
|
<p>暂无素材</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmptyState
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||||
|
import { formatSize, formatDuration } from "./utils"
|
||||||
|
|
||||||
|
interface PreviewOverlayProps {
|
||||||
|
asset: MediaAsset
|
||||||
|
position: { x: number; y: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PreviewOverlay: React.FC<PreviewOverlayProps> = ({ asset, position }) => {
|
||||||
|
return (
|
||||||
|
<div className="as-preview-overlay" style={{ left: position.x, top: position.y }}>
|
||||||
|
<div className="as-preview-overlay-thumb">
|
||||||
|
{asset.thumbnail_url ? (
|
||||||
|
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||||
|
) : (
|
||||||
|
<span className="as-preview-overlay-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="as-preview-overlay-name">{asset.name}</p>
|
||||||
|
<div className="as-preview-overlay-meta">
|
||||||
|
<span>类型: {MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||||
|
{asset.duration != null && <span>时长: {formatDuration(asset.duration)}</span>}
|
||||||
|
{asset.size != null && <span>大小: {formatSize(asset.size)}</span>}
|
||||||
|
{asset.quality_score != null && <span>质量分: {asset.quality_score}</span>}
|
||||||
|
{asset.tags.length > 0 && <span>标签: {asset.tags.join(", ")}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PreviewOverlay
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
|
||||||
|
interface SelectorBatchBarProps {
|
||||||
|
selectedCount: number
|
||||||
|
onClear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectorBatchBar: React.FC<SelectorBatchBarProps> = ({ selectedCount, onClear }) => {
|
||||||
|
return (
|
||||||
|
<div className="as-batch-bar">
|
||||||
|
<span className="as-batch-bar-count">已选 {selectedCount} 项</span>
|
||||||
|
<div className="as-batch-bar-actions">
|
||||||
|
<Button buttonType="ghost" buttonSize="sm" onClick={onClear}>
|
||||||
|
取消选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectorBatchBar
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Input, Select } from "@/components/ui"
|
||||||
|
import { TYPE_OPTIONS } from "./constants"
|
||||||
|
import { QUALITY_OPTIONS } from "@/api/template-editor"
|
||||||
|
import type { ViewMode } from "./types"
|
||||||
|
|
||||||
|
interface SelectorToolbarProps {
|
||||||
|
searchText: string
|
||||||
|
filterType: string
|
||||||
|
filterQuality: string
|
||||||
|
viewMode: ViewMode
|
||||||
|
showQualityFilter: boolean
|
||||||
|
onSearchChange: (value: string) => void
|
||||||
|
onTypeChange: (value: string) => void
|
||||||
|
onQualityChange: (value: string) => void
|
||||||
|
onViewModeChange: (mode: ViewMode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectorToolbar: React.FC<SelectorToolbarProps> = ({
|
||||||
|
searchText,
|
||||||
|
filterType,
|
||||||
|
filterQuality,
|
||||||
|
viewMode,
|
||||||
|
showQualityFilter,
|
||||||
|
onSearchChange,
|
||||||
|
onTypeChange,
|
||||||
|
onQualityChange,
|
||||||
|
onViewModeChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="as-toolbar">
|
||||||
|
<div className="as-toolbar-left">
|
||||||
|
<div className="as-search">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索素材..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
prefix="🔍"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={filterType}
|
||||||
|
onChange={(v: string) => onTypeChange(v)}
|
||||||
|
options={TYPE_OPTIONS}
|
||||||
|
/>
|
||||||
|
{showQualityFilter && (
|
||||||
|
<Select
|
||||||
|
value={filterQuality}
|
||||||
|
onChange={(v: string) => onQualityChange(v)}
|
||||||
|
options={QUALITY_OPTIONS}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="as-toolbar-right">
|
||||||
|
<button
|
||||||
|
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
|
||||||
|
onClick={() => onViewModeChange("grid")}
|
||||||
|
title="网格视图"
|
||||||
|
>
|
||||||
|
⊞
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
|
||||||
|
onClick={() => onViewModeChange("list")}
|
||||||
|
title="列表视图"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectorToolbar
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** 类型筛选选项 */
|
||||||
|
export const TYPE_OPTIONS = [
|
||||||
|
{ value: "", label: "全部类型" },
|
||||||
|
{ value: "video", label: "🎬 视频" },
|
||||||
|
{ value: "image", label: "🖼️ 图片" },
|
||||||
|
{ value: "audio", label: "🎵 音频" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState, useMemo } from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
import { QUALITY_OPTIONS } from "@/api/template-editor"
|
||||||
|
import type { ViewMode } from "../types"
|
||||||
|
|
||||||
|
interface UseAssetFilterReturn {
|
||||||
|
searchText: string
|
||||||
|
filterType: string
|
||||||
|
filterQuality: string
|
||||||
|
viewMode: ViewMode
|
||||||
|
filteredAssets: MediaAsset[]
|
||||||
|
setSearchText: (value: string) => void
|
||||||
|
setFilterType: (value: string) => void
|
||||||
|
setFilterQuality: (value: string) => void
|
||||||
|
setViewMode: (mode: ViewMode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 素材筛选 Hook —— 搜索 + 类型 + 质量分 + 视图切换
|
||||||
|
*/
|
||||||
|
const useAssetFilter = (assets: MediaAsset[]): UseAssetFilterReturn => {
|
||||||
|
const [searchText, setSearchText] = useState("")
|
||||||
|
const [filterType, setFilterType] = useState("")
|
||||||
|
const [filterQuality, setFilterQuality] = useState("")
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("grid")
|
||||||
|
|
||||||
|
const filteredAssets = useMemo(() => {
|
||||||
|
let list = assets
|
||||||
|
if (searchText) {
|
||||||
|
const q = searchText.toLowerCase()
|
||||||
|
list = list.filter(
|
||||||
|
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (filterType) {
|
||||||
|
list = list.filter((a) => a.type === filterType)
|
||||||
|
}
|
||||||
|
if (filterQuality) {
|
||||||
|
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
|
||||||
|
if (opt?.min != null && opt?.max != null) {
|
||||||
|
list = list.filter(
|
||||||
|
(a) =>
|
||||||
|
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}, [assets, searchText, filterType, filterQuality])
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchText,
|
||||||
|
filterType,
|
||||||
|
filterQuality,
|
||||||
|
viewMode,
|
||||||
|
filteredAssets,
|
||||||
|
setSearchText,
|
||||||
|
setFilterType,
|
||||||
|
setFilterQuality,
|
||||||
|
setViewMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAssetFilter
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
|
||||||
|
interface UseAssetPreviewReturn {
|
||||||
|
previewAsset: MediaAsset | null
|
||||||
|
previewPos: { x: number; y: number }
|
||||||
|
handleMouseEnter: (asset: MediaAsset, e: React.MouseEvent) => void
|
||||||
|
handleMouseLeave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 悬浮预览 Hook —— 延迟 400ms 显示预览浮层
|
||||||
|
*/
|
||||||
|
const useAssetPreview = (): UseAssetPreviewReturn => {
|
||||||
|
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
|
||||||
|
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
|
||||||
|
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
|
||||||
|
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||||
|
previewTimer.current = setTimeout(() => {
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
setPreviewAsset(asset)
|
||||||
|
setPreviewPos({
|
||||||
|
x: rect.right + 12,
|
||||||
|
y: Math.max(8, rect.top - 20),
|
||||||
|
})
|
||||||
|
}, 400)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
if (previewTimer.current) {
|
||||||
|
clearTimeout(previewTimer.current)
|
||||||
|
previewTimer.current = null
|
||||||
|
}
|
||||||
|
setPreviewAsset(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewAsset,
|
||||||
|
previewPos,
|
||||||
|
handleMouseEnter,
|
||||||
|
handleMouseLeave,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAssetPreview
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useCallback, useRef, useMemo } from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
|
||||||
|
interface UseAssetSelectionOptions {
|
||||||
|
filteredAssets: MediaAsset[]
|
||||||
|
selectedIds: string[]
|
||||||
|
onSelectionChange?: (ids: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAssetSelectionReturn {
|
||||||
|
selectedSet: Set<string>
|
||||||
|
toggleSelect: (asset: MediaAsset, idx: number, shiftKey: boolean) => void
|
||||||
|
clearSelection: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 素材选择 Hook —— 单选/多选/Shift 连选
|
||||||
|
*/
|
||||||
|
const useAssetSelection = ({
|
||||||
|
filteredAssets,
|
||||||
|
selectedIds,
|
||||||
|
onSelectionChange,
|
||||||
|
}: UseAssetSelectionOptions): UseAssetSelectionReturn => {
|
||||||
|
const lastClickedIdx = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||||
|
|
||||||
|
const toggleSelect = useCallback(
|
||||||
|
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
|
||||||
|
if (!onSelectionChange) return
|
||||||
|
|
||||||
|
if (shiftKey && lastClickedIdx.current !== null) {
|
||||||
|
// Shift 连选
|
||||||
|
const start = Math.min(lastClickedIdx.current, idx)
|
||||||
|
const end = Math.max(lastClickedIdx.current, idx)
|
||||||
|
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
|
||||||
|
const newSet = new Set(selectedIds)
|
||||||
|
rangeIds.forEach((id) => newSet.add(id))
|
||||||
|
onSelectionChange(Array.from(newSet))
|
||||||
|
} else {
|
||||||
|
const newSet = new Set(selectedIds)
|
||||||
|
if (newSet.has(asset.id)) {
|
||||||
|
newSet.delete(asset.id)
|
||||||
|
} else {
|
||||||
|
newSet.add(asset.id)
|
||||||
|
}
|
||||||
|
onSelectionChange(Array.from(newSet))
|
||||||
|
}
|
||||||
|
lastClickedIdx.current = idx
|
||||||
|
},
|
||||||
|
[onSelectionChange, selectedIds, filteredAssets],
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
onSelectionChange?.([])
|
||||||
|
}, [onSelectionChange])
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedSet,
|
||||||
|
toggleSelect,
|
||||||
|
clearSelection,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAssetSelection
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
|
||||||
|
interface UseDragReorderOptions {
|
||||||
|
filteredAssets: MediaAsset[]
|
||||||
|
selectedIds: string[]
|
||||||
|
onAssetDragStart?: (asset: MediaAsset) => void
|
||||||
|
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDragReorderReturn {
|
||||||
|
dragIdx: number | null
|
||||||
|
dragOverIdx: number | null
|
||||||
|
handleDragStart: (e: React.DragEvent, idx: number) => void
|
||||||
|
handleDragOver: (e: React.DragEvent, idx: number) => void
|
||||||
|
handleDrop: (e: React.DragEvent, toIdx: number) => void
|
||||||
|
handleDragEnd: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖拽排序 Hook —— HTML5 DnD,支持批量拖拽携带数据
|
||||||
|
*/
|
||||||
|
const useDragReorder = ({
|
||||||
|
filteredAssets,
|
||||||
|
selectedIds,
|
||||||
|
onAssetDragStart,
|
||||||
|
onReorder,
|
||||||
|
}: UseDragReorderOptions): UseDragReorderReturn => {
|
||||||
|
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||||
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(e: React.DragEvent, idx: number) => {
|
||||||
|
setDragIdx(idx)
|
||||||
|
e.dataTransfer.effectAllowed = "move"
|
||||||
|
e.dataTransfer.setData("text/plain", String(idx))
|
||||||
|
// 设置素材数据,供 TimelinePanel 接收
|
||||||
|
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
|
||||||
|
// 批量拖拽:如果有多个选中素材,一起携带
|
||||||
|
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
|
||||||
|
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
|
||||||
|
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
|
||||||
|
}
|
||||||
|
// 通知父组件素材拖拽开始
|
||||||
|
if (onAssetDragStart) {
|
||||||
|
onAssetDragStart(filteredAssets[idx])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filteredAssets, selectedIds, onAssetDragStart],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(e: React.DragEvent, idx: number) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = "move"
|
||||||
|
if (dragIdx === null || dragIdx === idx) return
|
||||||
|
setDragOverIdx(idx)
|
||||||
|
},
|
||||||
|
[dragIdx],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent, toIdx: number) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
|
||||||
|
onReorder(dragIdx, toIdx)
|
||||||
|
}
|
||||||
|
setDragIdx(null)
|
||||||
|
setDragOverIdx(null)
|
||||||
|
},
|
||||||
|
[dragIdx, onReorder],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDragIdx(null)
|
||||||
|
setDragOverIdx(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragIdx,
|
||||||
|
dragOverIdx,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDrop,
|
||||||
|
handleDragEnd,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDragReorder
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as AssetSelector } from "./AssetSelector"
|
export { default as AssetSelector } from "./AssetSelector"
|
||||||
export type { AssetSelectorProps } from "./AssetSelector"
|
export type { AssetSelectorProps, ViewMode } from "./types"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
|
|
||||||
|
export interface AssetSelectorProps {
|
||||||
|
assets: MediaAsset[]
|
||||||
|
selectedIds?: string[]
|
||||||
|
onSelectionChange?: (ids: string[]) => void
|
||||||
|
onAssetDragStart?: (asset: MediaAsset) => void
|
||||||
|
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||||
|
showQualityFilter?: boolean
|
||||||
|
showBatchSelect?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewMode = "grid" | "list"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** 格式化文件大小 */
|
||||||
|
export const formatSize = (bytes?: number): string => {
|
||||||
|
if (!bytes) return ""
|
||||||
|
if (bytes < 1024) return `${bytes}B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化时长 */
|
||||||
|
export const formatDuration = (seconds?: number): string => {
|
||||||
|
if (!seconds) return ""
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取质量分等级 */
|
||||||
|
export const getQualityLevel = (score?: number): string => {
|
||||||
|
if (score == null) return "none"
|
||||||
|
if (score >= 90) return "excellent"
|
||||||
|
if (score >= 70) return "good"
|
||||||
|
if (score >= 50) return "fair"
|
||||||
|
return "poor"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 质量分颜色 */
|
||||||
|
export const getQualityColor = (score?: number): string => {
|
||||||
|
if (score == null) return "var(--text-secondary)"
|
||||||
|
if (score >= 90) return "var(--success-color, #10b981)"
|
||||||
|
if (score >= 70) return "var(--primary-color, #6366f1)"
|
||||||
|
if (score >= 50) return "var(--warning-color, #f59e0b)"
|
||||||
|
return "var(--error-color, #ef4444)"
|
||||||
|
}
|
||||||
@@ -10,357 +10,35 @@
|
|||||||
*
|
*
|
||||||
* V21 Design System — 零 antd 直接导入
|
* V21 Design System — 零 antd 直接导入
|
||||||
*/
|
*/
|
||||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
import React from "react"
|
||||||
import { Modal, Button } from "@/components/ui"
|
import { Modal } from "@/components/ui"
|
||||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
import type { CloneModalProps } from "./types/cloneModal"
|
||||||
import type { VoiceClone } from "@/api/voice-clone"
|
import useCloneModal from "./hooks/useCloneModal"
|
||||||
import { uploadAsset } from "@/api/assets"
|
import InputView from "./clone-modal/InputView"
|
||||||
|
import ProgressView from "./clone-modal/ProgressView"
|
||||||
import "./clone-modal.css"
|
import "./clone-modal.css"
|
||||||
|
|
||||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
|
||||||
|
|
||||||
export interface CloneModalProps {
|
|
||||||
/** 弹窗是否可见 */
|
|
||||||
open: boolean
|
|
||||||
/** 关闭弹窗回调 */
|
|
||||||
onClose: () => void
|
|
||||||
/** 克隆成功回调(返回新创建的音色) */
|
|
||||||
onSuccess?: (voice: VoiceClone) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 进度阶段配置 ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const PROGRESS_STEPS: { key: string; label: string; icon: string }[] = [
|
|
||||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
|
||||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
|
||||||
{ key: "done", label: "完成", icon: "✅" },
|
|
||||||
]
|
|
||||||
|
|
||||||
/* ── 常量 ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
|
|
||||||
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
|
||||||
|
|
||||||
/** 最长录制时长:5 分钟(秒) */
|
|
||||||
const MAX_RECORD_SECONDS = 5 * 60
|
|
||||||
|
|
||||||
/* ── 组件 ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
||||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
const {
|
||||||
const [voiceName, setVoiceName] = useState("")
|
phase,
|
||||||
const [voiceDescription, setVoiceDescription] = useState("")
|
voiceName,
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
voiceDescription,
|
||||||
const [dragActive, setDragActive] = useState(false)
|
selectedFile,
|
||||||
const [errorMessage, setErrorMessage] = useState("")
|
dragActive,
|
||||||
|
errorMessage,
|
||||||
// 录音状态
|
isRecording,
|
||||||
const [isRecording, setIsRecording] = useState(false)
|
recordTime,
|
||||||
const [recordTime, setRecordTime] = useState(0)
|
recordedBlob,
|
||||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
canSubmit,
|
||||||
|
isProcessing,
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
setVoiceName,
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
setVoiceDescription,
|
||||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
setDragActive,
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
handleFileSelect,
|
||||||
const audioChunksRef = useRef<Blob[]>([])
|
handleRecordToggle,
|
||||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
handleClose,
|
||||||
const cloneCounterRef = useRef(1)
|
handleSubmit,
|
||||||
|
} = useCloneModal({ open, onClose, onSuccess })
|
||||||
/** 生成下一个默认音色名称 */
|
|
||||||
const getNextDefaultName = useCallback((): string => {
|
|
||||||
const name = `我的声音 ${cloneCounterRef.current}`
|
|
||||||
cloneCounterRef.current += 1
|
|
||||||
return name
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** 组件卸载时清理定时器和 MediaRecorder */
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
|
||||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
||||||
mediaRecorderRef.current.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** 重置弹窗状态 */
|
|
||||||
const resetState = useCallback(() => {
|
|
||||||
setPhase("input")
|
|
||||||
setVoiceName(getNextDefaultName())
|
|
||||||
setVoiceDescription("")
|
|
||||||
setSelectedFile(null)
|
|
||||||
setDragActive(false)
|
|
||||||
setErrorMessage("")
|
|
||||||
setIsRecording(false)
|
|
||||||
setRecordTime(0)
|
|
||||||
setRecordedBlob(null)
|
|
||||||
audioChunksRef.current = []
|
|
||||||
if (recordTimerRef.current) {
|
|
||||||
clearInterval(recordTimerRef.current)
|
|
||||||
recordTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
||||||
mediaRecorderRef.current.stop()
|
|
||||||
}
|
|
||||||
mediaRecorderRef.current = null
|
|
||||||
}, [getNextDefaultName])
|
|
||||||
|
|
||||||
/** 关闭弹窗 */
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
resetState()
|
|
||||||
onClose()
|
|
||||||
}, [resetState, onClose])
|
|
||||||
|
|
||||||
/** 弹窗打开时重置状态 */
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
resetState()
|
|
||||||
}
|
|
||||||
}, [open, resetState])
|
|
||||||
|
|
||||||
/* ── 文件验证 ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
const validateFile = (file: File): string | null => {
|
|
||||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
|
||||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
|
||||||
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
|
|
||||||
}
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
return "文件大小超过 10MB,请压缩后重试"
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 文件上传 ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
|
||||||
fileInputRef.current?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
const error = validateFile(file)
|
|
||||||
if (error) {
|
|
||||||
setErrorMessage(error)
|
|
||||||
setSelectedFile(null)
|
|
||||||
} else {
|
|
||||||
setErrorMessage("")
|
|
||||||
setSelectedFile(file)
|
|
||||||
// 清除录音
|
|
||||||
setRecordedBlob(null)
|
|
||||||
setIsRecording(false)
|
|
||||||
setRecordTime(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.target.value = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 拖拽 ──────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const handleDrag = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
if (e.type === "dragenter" || e.type === "dragover") {
|
|
||||||
setDragActive(true)
|
|
||||||
} else if (e.type === "dragleave") {
|
|
||||||
setDragActive(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setDragActive(false)
|
|
||||||
const file = e.dataTransfer.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
const error = validateFile(file)
|
|
||||||
if (error) {
|
|
||||||
setErrorMessage(error)
|
|
||||||
setSelectedFile(null)
|
|
||||||
} else {
|
|
||||||
setErrorMessage("")
|
|
||||||
setSelectedFile(file)
|
|
||||||
setRecordedBlob(null)
|
|
||||||
setIsRecording(false)
|
|
||||||
setRecordTime(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 录音(真实 MediaRecorder) ───────────────────── */
|
|
||||||
|
|
||||||
const handleRecord = async () => {
|
|
||||||
if (isRecording) {
|
|
||||||
// 停止录制
|
|
||||||
setIsRecording(false)
|
|
||||||
if (recordTimerRef.current) {
|
|
||||||
clearInterval(recordTimerRef.current)
|
|
||||||
recordTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
||||||
mediaRecorderRef.current.stop()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 开始录制
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: true,
|
|
||||||
})
|
|
||||||
const mediaRecorder = new MediaRecorder(stream)
|
|
||||||
mediaRecorderRef.current = mediaRecorder
|
|
||||||
audioChunksRef.current = []
|
|
||||||
|
|
||||||
mediaRecorder.ondataavailable = (event) => {
|
|
||||||
if (event.data.size > 0) {
|
|
||||||
audioChunksRef.current.push(event.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaRecorder.onstop = () => {
|
|
||||||
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
|
|
||||||
setRecordedBlob(blob)
|
|
||||||
// 清除上传的文件
|
|
||||||
setSelectedFile(null)
|
|
||||||
// 停止音轨
|
|
||||||
stream.getTracks().forEach((track) => track.stop())
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaRecorder.start()
|
|
||||||
setIsRecording(true)
|
|
||||||
setRecordTime(0)
|
|
||||||
setRecordedBlob(null)
|
|
||||||
setErrorMessage("")
|
|
||||||
|
|
||||||
recordTimerRef.current = setInterval(() => {
|
|
||||||
setRecordTime((prev) => {
|
|
||||||
const next = prev + 1
|
|
||||||
if (next >= MAX_RECORD_SECONDS) {
|
|
||||||
// 达到 5 分钟上限,自动停止录制
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsRecording(false)
|
|
||||||
if (recordTimerRef.current) {
|
|
||||||
clearInterval(recordTimerRef.current)
|
|
||||||
recordTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
||||||
mediaRecorderRef.current.stop()
|
|
||||||
}
|
|
||||||
setErrorMessage("已达最长录制时长(5分钟),已自动停止")
|
|
||||||
}, 0)
|
|
||||||
return MAX_RECORD_SECONDS
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, 1000)
|
|
||||||
} catch {
|
|
||||||
setErrorMessage("无法访问麦克风,请检查浏览器权限设置")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化录制时间 mm:ss */
|
|
||||||
const formatRecordTime = (seconds: number): string => {
|
|
||||||
const m = Math.floor(seconds / 60)
|
|
||||||
const s = seconds % 60
|
|
||||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 表单验证 ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
|
||||||
|
|
||||||
const validateForm = (): string | null => {
|
|
||||||
const name = voiceName.trim()
|
|
||||||
if (!name) {
|
|
||||||
return "请输入音色名称"
|
|
||||||
}
|
|
||||||
if (name.length < 2 || name.length > 20) {
|
|
||||||
return "音色名称需在 2-20 个字符之间"
|
|
||||||
}
|
|
||||||
if (!hasAudio) {
|
|
||||||
return "请上传音频文件或录制一段声音"
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 提交克隆 ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const formError = validateForm()
|
|
||||||
if (formError) {
|
|
||||||
setErrorMessage(formError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorMessage("")
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 阶段 1:上传音频
|
|
||||||
setPhase("uploading")
|
|
||||||
|
|
||||||
let fileToUpload: File
|
|
||||||
if (selectedFile) {
|
|
||||||
fileToUpload = selectedFile
|
|
||||||
} else {
|
|
||||||
// 将录音 Blob 转为 File
|
|
||||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
|
||||||
type: "audio/webm",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append("file", fileToUpload)
|
|
||||||
const uploadResult = await uploadAsset(formData)
|
|
||||||
|
|
||||||
// 阶段 2:克隆
|
|
||||||
setPhase("cloning")
|
|
||||||
const result = await createVoiceClone({
|
|
||||||
name: voiceName.trim(),
|
|
||||||
description: voiceDescription.trim() || undefined,
|
|
||||||
audio_url: uploadResult.url,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 阶段 3:完成
|
|
||||||
setPhase("done")
|
|
||||||
|
|
||||||
// 2秒后自动关闭
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
onSuccess?.(toVoiceClone(result))
|
|
||||||
handleClose()
|
|
||||||
}, 2000)
|
|
||||||
} catch (err) {
|
|
||||||
setPhase("input")
|
|
||||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 计算属性 ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
const canSubmit = voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio
|
|
||||||
|
|
||||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
|
||||||
|
|
||||||
/** 当前进度索引 */
|
|
||||||
const getProgressIndex = (): number => {
|
|
||||||
switch (phase) {
|
|
||||||
case "uploading":
|
|
||||||
return 0
|
|
||||||
case "cloning":
|
|
||||||
return 1
|
|
||||||
case "done":
|
|
||||||
return 2
|
|
||||||
default:
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressIndex = getProgressIndex()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -373,228 +51,30 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
|||||||
maskClosable={!isProcessing}
|
maskClosable={!isProcessing}
|
||||||
keyboard={!isProcessing}
|
keyboard={!isProcessing}
|
||||||
>
|
>
|
||||||
{/* ── 输入阶段 ──────────────────────────────────── */}
|
{/* 输入阶段 */}
|
||||||
{phase === "input" && (
|
{phase === "input" && (
|
||||||
<div className="xx-clonemodal-body">
|
<InputView
|
||||||
{/* 步骤引导 */}
|
voiceName={voiceName}
|
||||||
<div className="xx-clonemodal-steps">
|
voiceDescription={voiceDescription}
|
||||||
<div className="xx-clonemodal-step xx-clonemodal-step--active">
|
selectedFile={selectedFile}
|
||||||
<div className="xx-clonemodal-step-number">1</div>
|
dragActive={dragActive}
|
||||||
<span className="xx-clonemodal-step-label">上传/录制音频</span>
|
isRecording={isRecording}
|
||||||
</div>
|
recordTime={recordTime}
|
||||||
<div className="xx-clonemodal-step-connector" />
|
recordedBlob={recordedBlob}
|
||||||
<div className="xx-clonemodal-step">
|
errorMessage={errorMessage}
|
||||||
<div className="xx-clonemodal-step-number">2</div>
|
canSubmit={canSubmit}
|
||||||
<span className="xx-clonemodal-step-label">填写信息</span>
|
onVoiceNameChange={setVoiceName}
|
||||||
</div>
|
onVoiceDescChange={setVoiceDescription}
|
||||||
<div className="xx-clonemodal-step-connector" />
|
onDragActiveChange={setDragActive}
|
||||||
<div className="xx-clonemodal-step">
|
onFileSelect={handleFileSelect}
|
||||||
<div className="xx-clonemodal-step-number">3</div>
|
onRecordToggle={handleRecordToggle}
|
||||||
<span className="xx-clonemodal-step-label">提交克隆</span>
|
onClose={handleClose}
|
||||||
</div>
|
onSubmit={handleSubmit}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* 音色名称 */}
|
|
||||||
<div className="xx-clonemodal-field">
|
|
||||||
<label className="xx-clonemodal-label">
|
|
||||||
音色名称 <span className="xx-clonemodal-required">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="xx-clonemodal-input"
|
|
||||||
value={voiceName}
|
|
||||||
onChange={(e) => setVoiceName(e.target.value)}
|
|
||||||
placeholder="输入音色名称(2-20字符)"
|
|
||||||
maxLength={20}
|
|
||||||
/>
|
|
||||||
<div className="xx-clonemodal-char-count">{voiceName.length}/20</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 上传区域 */}
|
|
||||||
<div className="xx-clonemodal-field">
|
|
||||||
<label className="xx-clonemodal-label">上传音频</label>
|
|
||||||
<div
|
|
||||||
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
|
|
||||||
onClick={handleUploadClick}
|
|
||||||
onDragEnter={handleDrag}
|
|
||||||
onDragOver={handleDrag}
|
|
||||||
onDragLeave={handleDrag}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
|
|
||||||
<p className="xx-clonemodal-upload-title">
|
|
||||||
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
|
|
||||||
</p>
|
|
||||||
<p className="xx-clonemodal-upload-hint">支持 MP3、WAV、M4A 格式,最大 10MB</p>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={ACCEPTED_MIME}
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 或分隔 */}
|
|
||||||
<div className="xx-clonemodal-divider">
|
|
||||||
<div className="xx-clonemodal-divider-line" />
|
|
||||||
<span className="xx-clonemodal-divider-text">或</span>
|
|
||||||
<div className="xx-clonemodal-divider-line" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 录制区域 */}
|
|
||||||
<div className="xx-clonemodal-field">
|
|
||||||
<label className="xx-clonemodal-label">直接录制</label>
|
|
||||||
<div className="xx-clonemodal-record-area">
|
|
||||||
<div className="xx-clonemodal-record-info">
|
|
||||||
<p className="xx-clonemodal-record-hint">
|
|
||||||
{isRecording
|
|
||||||
? `录制中 ${formatRecordTime(recordTime)}`
|
|
||||||
: recordedBlob
|
|
||||||
? `已录制 ${formatRecordTime(recordTime)}`
|
|
||||||
: "点击按钮开始录制(最长 5 分钟)"}
|
|
||||||
</p>
|
|
||||||
{isRecording && (
|
|
||||||
<div className="xx-clonemodal-record-wave">
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
|
||||||
onClick={handleRecord}
|
|
||||||
title={isRecording ? "停止录制" : "开始录制"}
|
|
||||||
>
|
|
||||||
{isRecording ? "⏹" : "🎙️"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 音色描述 */}
|
|
||||||
<div className="xx-clonemodal-field">
|
|
||||||
<label className="xx-clonemodal-label">音色描述</label>
|
|
||||||
<textarea
|
|
||||||
className="xx-clonemodal-textarea"
|
|
||||||
value={voiceDescription}
|
|
||||||
onChange={(e) => setVoiceDescription(e.target.value)}
|
|
||||||
placeholder="可选,描述这个音色的特点(最多100字符)"
|
|
||||||
maxLength={100}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<div className="xx-clonemodal-char-count">{voiceDescription.length}/100</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 错误提示 */}
|
|
||||||
{errorMessage && (
|
|
||||||
<div className="xx-clonemodal-error">
|
|
||||||
<span className="xx-clonemodal-error-icon">⚠️</span>
|
|
||||||
<span>{errorMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 提示 */}
|
|
||||||
<div className="xx-clonemodal-tip">
|
|
||||||
<span className="xx-clonemodal-tip-icon">💡</span>
|
|
||||||
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
|
||||||
<div className="xx-clonemodal-footer">
|
|
||||||
<Button buttonType="ghost" onClick={handleClose}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button buttonType="primary" disabled={!canSubmit} onClick={handleSubmit}>
|
|
||||||
🎤 开始克隆
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 进度阶段(上传中 / 克隆中) ──────────────── */}
|
{/* 进度 / 完成阶段 */}
|
||||||
{isProcessing && (
|
{phase !== "input" && <ProgressView phase={phase} />}
|
||||||
<div className="xx-clonemodal-progress-body">
|
|
||||||
{/* 步骤指示器 */}
|
|
||||||
<div className="xx-clonemodal-steps-progress">
|
|
||||||
{PROGRESS_STEPS.map((step, idx) => {
|
|
||||||
const isActive = idx === progressIndex
|
|
||||||
const isDone = idx < progressIndex
|
|
||||||
const stepClass = [
|
|
||||||
"xx-clonemodal-step-progress",
|
|
||||||
isActive ? "xx-clonemodal-step-progress--active" : "",
|
|
||||||
isDone ? "xx-clonemodal-step-progress--done" : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={step.key}>
|
|
||||||
{idx > 0 && (
|
|
||||||
<div
|
|
||||||
className={`xx-clonemodal-step-connector${isDone ? " xx-clonemodal-step-connector--done" : ""}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className={stepClass}>
|
|
||||||
<div className="xx-clonemodal-step-icon">{isDone ? "✓" : step.icon}</div>
|
|
||||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 当前阶段描述 */}
|
|
||||||
<div className="xx-clonemodal-progress-info">
|
|
||||||
{phase === "uploading" && (
|
|
||||||
<>
|
|
||||||
<div className="xx-clonemodal-progress-spinner" />
|
|
||||||
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
|
||||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{phase === "cloning" && (
|
|
||||||
<>
|
|
||||||
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
|
||||||
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
|
||||||
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── 完成阶段 ──────────────────────────────────── */}
|
|
||||||
{phase === "done" && (
|
|
||||||
<div className="xx-clonemodal-progress-body">
|
|
||||||
{/* 步骤指示器(全部完成) */}
|
|
||||||
<div className="xx-clonemodal-steps-progress">
|
|
||||||
{PROGRESS_STEPS.map((step, idx) => (
|
|
||||||
<React.Fragment key={step.key}>
|
|
||||||
{idx > 0 && (
|
|
||||||
<div className="xx-clonemodal-step-connector xx-clonemodal-step-connector--done" />
|
|
||||||
)}
|
|
||||||
<div className="xx-clonemodal-step-progress xx-clonemodal-step-progress--done">
|
|
||||||
<div className="xx-clonemodal-step-icon">✓</div>
|
|
||||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="xx-clonemodal-success">
|
|
||||||
<div className="xx-clonemodal-success-icon">🎉</div>
|
|
||||||
<h3 className="xx-clonemodal-success-title">克隆已提交</h3>
|
|
||||||
<p className="xx-clonemodal-success-desc">
|
|
||||||
音色正在生成中,完成后将出现在「我的克隆」列表中
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
import UploadZone from "./UploadZone"
|
||||||
|
import RecordArea from "./RecordArea"
|
||||||
|
import StepIndicator from "./StepIndicator"
|
||||||
|
import { MAX_VOICE_NAME_LENGTH, MAX_VOICE_DESC_LENGTH } from "../constants/cloneModal"
|
||||||
|
|
||||||
|
interface InputViewProps {
|
||||||
|
voiceName: string
|
||||||
|
voiceDescription: string
|
||||||
|
selectedFile: File | null
|
||||||
|
dragActive: boolean
|
||||||
|
isRecording: boolean
|
||||||
|
recordTime: number
|
||||||
|
recordedBlob: Blob | null
|
||||||
|
errorMessage: string
|
||||||
|
canSubmit: boolean
|
||||||
|
onVoiceNameChange: (value: string) => void
|
||||||
|
onVoiceDescChange: (value: string) => void
|
||||||
|
onDragActiveChange: (active: boolean) => void
|
||||||
|
onFileSelect: (file: File | null, error: string) => void
|
||||||
|
onRecordToggle: () => void
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const INPUT_STEPS = ["上传/录制音频", "填写信息", "提交克隆"]
|
||||||
|
|
||||||
|
const InputView: React.FC<InputViewProps> = ({
|
||||||
|
voiceName,
|
||||||
|
voiceDescription,
|
||||||
|
selectedFile,
|
||||||
|
dragActive,
|
||||||
|
isRecording,
|
||||||
|
recordTime,
|
||||||
|
recordedBlob,
|
||||||
|
errorMessage,
|
||||||
|
canSubmit,
|
||||||
|
onVoiceNameChange,
|
||||||
|
onVoiceDescChange,
|
||||||
|
onDragActiveChange,
|
||||||
|
onFileSelect,
|
||||||
|
onRecordToggle,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-clonemodal-body">
|
||||||
|
{/* 步骤引导 */}
|
||||||
|
<StepIndicator currentStep={0} steps={INPUT_STEPS} />
|
||||||
|
|
||||||
|
{/* 音色名称 */}
|
||||||
|
<div className="xx-clonemodal-field">
|
||||||
|
<label className="xx-clonemodal-label">
|
||||||
|
音色名称 <span className="xx-clonemodal-required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="xx-clonemodal-input"
|
||||||
|
value={voiceName}
|
||||||
|
onChange={(e) => onVoiceNameChange(e.target.value)}
|
||||||
|
placeholder="输入音色名称(2-20字符)"
|
||||||
|
maxLength={MAX_VOICE_NAME_LENGTH}
|
||||||
|
/>
|
||||||
|
<div className="xx-clonemodal-char-count">
|
||||||
|
{voiceName.length}/{MAX_VOICE_NAME_LENGTH}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上传区域 */}
|
||||||
|
<div className="xx-clonemodal-field">
|
||||||
|
<label className="xx-clonemodal-label">上传音频</label>
|
||||||
|
<UploadZone
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
dragActive={dragActive}
|
||||||
|
onDragActiveChange={onDragActiveChange}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 或分隔 */}
|
||||||
|
<div className="xx-clonemodal-divider">
|
||||||
|
<div className="xx-clonemodal-divider-line" />
|
||||||
|
<span className="xx-clonemodal-divider-text">或</span>
|
||||||
|
<div className="xx-clonemodal-divider-line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 录制区域 */}
|
||||||
|
<div className="xx-clonemodal-field">
|
||||||
|
<label className="xx-clonemodal-label">直接录制</label>
|
||||||
|
<RecordArea
|
||||||
|
isRecording={isRecording}
|
||||||
|
recordTime={recordTime}
|
||||||
|
recordedBlob={recordedBlob}
|
||||||
|
onRecordToggle={onRecordToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 音色描述 */}
|
||||||
|
<div className="xx-clonemodal-field">
|
||||||
|
<label className="xx-clonemodal-label">音色描述</label>
|
||||||
|
<textarea
|
||||||
|
className="xx-clonemodal-textarea"
|
||||||
|
value={voiceDescription}
|
||||||
|
onChange={(e) => onVoiceDescChange(e.target.value)}
|
||||||
|
placeholder="可选,描述这个音色的特点(最多100字符)"
|
||||||
|
maxLength={MAX_VOICE_DESC_LENGTH}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="xx-clonemodal-char-count">
|
||||||
|
{voiceDescription.length}/{MAX_VOICE_DESC_LENGTH}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="xx-clonemodal-error">
|
||||||
|
<span className="xx-clonemodal-error-icon">⚠️</span>
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提示 */}
|
||||||
|
<div className="xx-clonemodal-tip">
|
||||||
|
<span className="xx-clonemodal-tip-icon">💡</span>
|
||||||
|
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<div className="xx-clonemodal-footer">
|
||||||
|
<Button buttonType="ghost" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button buttonType="primary" disabled={!canSubmit} onClick={onSubmit}>
|
||||||
|
🎤 开始克隆
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InputView
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { PROGRESS_STEPS } from "../constants/cloneModal"
|
||||||
|
import type { ProgressStep } from "../types/cloneModal"
|
||||||
|
import type { ModalPhase } from "../types/cloneModal"
|
||||||
|
|
||||||
|
interface ProgressViewProps {
|
||||||
|
phase: ModalPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProgressIndex = (phase: ModalPhase): number => {
|
||||||
|
switch (phase) {
|
||||||
|
case "uploading":
|
||||||
|
return 0
|
||||||
|
case "cloning":
|
||||||
|
return 1
|
||||||
|
case "done":
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProgressView: React.FC<ProgressViewProps> = ({ phase }) => {
|
||||||
|
const progressIndex = getProgressIndex(phase)
|
||||||
|
const isDone = phase === "done"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="xx-clonemodal-progress-body">
|
||||||
|
{/* 步骤指示器 */}
|
||||||
|
<div className="xx-clonemodal-steps-progress">
|
||||||
|
{PROGRESS_STEPS.map((step: ProgressStep, idx: number) => {
|
||||||
|
const isActive = idx === progressIndex && !isDone
|
||||||
|
const stepDone = idx < progressIndex || isDone
|
||||||
|
const stepClass = [
|
||||||
|
"xx-clonemodal-step-progress",
|
||||||
|
isActive ? "xx-clonemodal-step-progress--active" : "",
|
||||||
|
stepDone ? "xx-clonemodal-step-progress--done" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={step.key}>
|
||||||
|
{idx > 0 && (
|
||||||
|
<div
|
||||||
|
className={`xx-clonemodal-step-connector${stepDone ? " xx-clonemodal-step-connector--done" : ""}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={stepClass}>
|
||||||
|
<div className="xx-clonemodal-step-icon">{stepDone ? "✓" : step.icon}</div>
|
||||||
|
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 完成阶段 */}
|
||||||
|
{isDone && (
|
||||||
|
<div className="xx-clonemodal-success">
|
||||||
|
<div className="xx-clonemodal-success-icon">🎉</div>
|
||||||
|
<h3 className="xx-clonemodal-success-title">克隆已提交</h3>
|
||||||
|
<p className="xx-clonemodal-success-desc">
|
||||||
|
音色正在生成中,完成后将出现在「我的克隆」列表中
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 进行中阶段 */}
|
||||||
|
{!isDone && (
|
||||||
|
<div className="xx-clonemodal-progress-info">
|
||||||
|
{phase === "uploading" && (
|
||||||
|
<>
|
||||||
|
<div className="xx-clonemodal-progress-spinner" />
|
||||||
|
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
||||||
|
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{phase === "cloning" && (
|
||||||
|
<>
|
||||||
|
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
||||||
|
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
||||||
|
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProgressView
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { formatRecordTime } from "../utils/cloneModal"
|
||||||
|
|
||||||
|
interface RecordAreaProps {
|
||||||
|
isRecording: boolean
|
||||||
|
recordTime: number
|
||||||
|
recordedBlob: Blob | null
|
||||||
|
onRecordToggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecordArea: React.FC<RecordAreaProps> = ({
|
||||||
|
isRecording,
|
||||||
|
recordTime,
|
||||||
|
recordedBlob,
|
||||||
|
onRecordToggle,
|
||||||
|
}) => {
|
||||||
|
const getHintText = () => {
|
||||||
|
if (isRecording) return `录制中 ${formatRecordTime(recordTime)}`
|
||||||
|
if (recordedBlob) return `已录制 ${formatRecordTime(recordTime)}`
|
||||||
|
return "点击按钮开始录制(最长 5 分钟)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="xx-clonemodal-record-area">
|
||||||
|
<div className="xx-clonemodal-record-info">
|
||||||
|
<p className="xx-clonemodal-record-hint">{getHintText()}</p>
|
||||||
|
{isRecording && (
|
||||||
|
<div className="xx-clonemodal-record-wave">
|
||||||
|
<span className="xx-clonemodal-record-wave-bar" />
|
||||||
|
<span className="xx-clonemodal-record-wave-bar" />
|
||||||
|
<span className="xx-clonemodal-record-wave-bar" />
|
||||||
|
<span className="xx-clonemodal-record-wave-bar" />
|
||||||
|
<span className="xx-clonemodal-record-wave-bar" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
||||||
|
onClick={onRecordToggle}
|
||||||
|
title={isRecording ? "停止录制" : "开始录制"}
|
||||||
|
>
|
||||||
|
{isRecording ? "⏹" : "🎙️"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RecordArea
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
interface StepIndicatorProps {
|
||||||
|
currentStep: number
|
||||||
|
steps: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输入阶段顶部的步骤引导(数字步骤)
|
||||||
|
*/
|
||||||
|
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, steps }) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-clonemodal-steps">
|
||||||
|
{steps.map((label, idx) => {
|
||||||
|
const isActive = idx <= currentStep
|
||||||
|
return (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
{idx > 0 && <div className="xx-clonemodal-step-connector" />}
|
||||||
|
<div className={`xx-clonemodal-step${isActive ? " xx-clonemodal-step--active" : ""}`}>
|
||||||
|
<div className="xx-clonemodal-step-number">{idx + 1}</div>
|
||||||
|
<span className="xx-clonemodal-step-label">{label}</span>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StepIndicator
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import React, { useRef } from "react"
|
||||||
|
import { ACCEPTED_MIME } from "../constants/cloneModal"
|
||||||
|
import { validateFile } from "../utils/cloneModal"
|
||||||
|
|
||||||
|
interface UploadZoneProps {
|
||||||
|
selectedFile: File | null
|
||||||
|
dragActive: boolean
|
||||||
|
onDragActiveChange: (active: boolean) => void
|
||||||
|
onFileSelect: (file: File | null, error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UploadZone: React.FC<UploadZoneProps> = ({
|
||||||
|
selectedFile,
|
||||||
|
dragActive,
|
||||||
|
onDragActiveChange,
|
||||||
|
onFileSelect,
|
||||||
|
}) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleUploadClick = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
const error = validateFile(file)
|
||||||
|
onFileSelect(error ? null : file, error || "")
|
||||||
|
}
|
||||||
|
e.target.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") {
|
||||||
|
onDragActiveChange(true)
|
||||||
|
} else if (e.type === "dragleave") {
|
||||||
|
onDragActiveChange(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
onDragActiveChange(false)
|
||||||
|
const file = e.dataTransfer.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
const error = validateFile(file)
|
||||||
|
onFileSelect(error ? null : file, error || "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
|
||||||
|
onClick={handleUploadClick}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
|
||||||
|
<p className="xx-clonemodal-upload-title">
|
||||||
|
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
|
||||||
|
</p>
|
||||||
|
<p className="xx-clonemodal-upload-hint">支持 MP3、WAV、M4A 格式,最大 10MB</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_MIME}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UploadZone
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user