Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a5bb1ab43 | |||
| bf47e69fa4 | |||
| b947bf7569 | |||
| f110bc6f16 | |||
| 0c9375ff32 | |||
| ef344e9ffc | |||
| 0d4904433e | |||
| 708662394f | |||
| 5bc3440370 | |||
| 7dc92191e0 | |||
| 5028956cea | |||
| d72450f42d | |||
| 1ed0d5aa75 | |||
| aef4febd1c | |||
| d99ee6fc84 | |||
| 9b034764ad | |||
| bfb11c3526 | |||
| 9e37c7b73d | |||
| f7a945d417 | |||
| 8748b43070 | |||
| a28395c318 | |||
| 9f0c064f2a | |||
| 1ea8fd3989 | |||
| 4fee87c5e8 | |||
| a74d25e414 | |||
| d213a055a1 | |||
| 8219d2445d | |||
| 0d871d9734 | |||
| 608ddbf9f7 | |||
| 6cb146693e | |||
| 18571451ed | |||
| 2bcbd54ed7 | |||
| d1c83de698 | |||
| 2849123bd7 | |||
| d6ab413dcd | |||
| c47aed95fc | |||
| 8e2e1e1357 | |||
| b0f2e4712a | |||
| d60a963b62 | |||
| d698d78b35 | |||
| f8bc252ded | |||
| 2371860f82 | |||
| d1970bd44b | |||
| dbd956fc6e | |||
| 734d7eb377 | |||
| 3e1284d130 | |||
| bcda5a2581 | |||
| 25a9352794 | |||
| ae9a79f01d | |||
| 30c5cd4bbe | |||
| 32e78f31e2 | |||
| 2832109b35 | |||
| 1d59ee5336 | |||
| 653be7755b | |||
| 6a853fef3c | |||
| e33bfe71bf | |||
| 9d2f4ec0f2 | |||
| 3242e71ec5 | |||
| 1b4532cd9c | |||
| 3620e29f41 | |||
| ed6150fa52 | |||
| dc9d84d015 | |||
| 67d5f899b3 | |||
| 1c83122711 | |||
| 46d98f369d | |||
| a568c97439 | |||
| f94ffa77bf | |||
| b3118b05ab | |||
| a48740195d | |||
| 7ec8e4355e | |||
| edf6a5501b | |||
| 9005135868 | |||
| c454a31e1b | |||
| e6dfeafb85 | |||
| cd14f0e0ac | |||
| 6e30a96f6d | |||
| 4fe9fc0c95 | |||
| 8a386f6172 | |||
| bbe15ca87f | |||
| 02755953aa | |||
| 4071d3f03f | |||
| 6fd5debe5a | |||
| 66d791e6dd | |||
| 7c77c50bc5 | |||
| 5986da8fe8 | |||
| b057c2d478 | |||
| 7b34547f04 | |||
| 87357c8bde | |||
| ed11fc20e7 | |||
| 065e383f3c | |||
| b625077f74 | |||
| f99ee8a2aa | |||
| 414a4a9020 | |||
| 14cf4a4d86 | |||
| d8770ba57f | |||
| a0e9441f82 | |||
| 1a32d7e81f | |||
| 0780491105 | |||
| 60e8318661 | |||
| c6df51b4e3 | |||
| 646be3d8f1 | |||
| 4fa0c45c52 | |||
| 7462f8d42c | |||
| 072416d3a2 | |||
| 0d207c1d95 | |||
| 220f4ef112 | |||
| 8e86979069 | |||
| 2324620a78 | |||
| 80b650dc6d | |||
| 8a3609f303 | |||
| c67aadcb2b | |||
| b86a9c2af2 | |||
| 2d05f37651 | |||
| 289b5b6e1c | |||
| c00ddef943 | |||
| 430e16edc6 | |||
| 5dde1243b5 | |||
| 3b92e72aa4 | |||
| d18bbc4a17 | |||
| 72718cc6a3 | |||
| 971a3923d1 | |||
| 89c04c4f71 | |||
| e7a0ba3fce | |||
| 25bd215c0c | |||
| f5a0d9966b | |||
| 93f21e639f | |||
| d312a9c2fe | |||
| 3aad5c193d | |||
| 89b7931b38 | |||
| 8020e68833 | |||
| 24f1e47bf5 | |||
| 8e60c412f7 | |||
| a101b2170b | |||
| 1f8bbe37dd | |||
| 6a7fde9d90 | |||
| ff660b1438 | |||
| f06c7f9d33 | |||
| bf6ae9c430 | |||
| ea40b41c05 | |||
| c9c7c36b1d | |||
| e795f924ea | |||
| dd1a83ec51 | |||
| 4adb082819 | |||
| a6e809600e | |||
| 92b87cc836 | |||
| ecc058abe4 | |||
| b3e1902b65 | |||
| b0e9266571 | |||
| 50f5a51426 | |||
| a57a26cb67 | |||
| bc111fe08a | |||
| 4b4e1834ac | |||
| 8c2d9947e2 | |||
| b4f4d4ffad | |||
| 8b109de871 | |||
| 67678cda58 | |||
| 356df4663e | |||
| 230d610a6d | |||
| f447048dc7 | |||
| 0e94adf7b6 | |||
| eef1f18e05 | |||
| 48d7e01498 | |||
| ffd533b552 | |||
| dd2db817bc | |||
| fb47512f9c | |||
| 319dd2c839 | |||
| efb9d6c57f | |||
| 254ffd5391 | |||
| fa640ca537 | |||
| 680a9c10c2 |
+385
-6
File diff suppressed because one or more lines are too long
@@ -1,569 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop, "feature/**" ]
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy-staging:
|
||||
name: Deploy Staging
|
||||
runs-on: saas
|
||||
if: github.ref_name == 'main' || github.ref_name == 'develop' || startsWith(github.ref_name, 'feature/')
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
# Retry up to 5 times with backoff for transient 5xx errors
|
||||
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: Install SSH client
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
|
||||
echo "openssh-client installed"
|
||||
|
||||
- name: Build staging web artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npm ci && npm run build'
|
||||
docker build --pull=false \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
--build-arg NGINX_CONF=infra/docker/nginx-staging.conf \
|
||||
-t "xiaoxia-saas-web:staging-${GITHUB_SHA}" \
|
||||
.
|
||||
test -f apps/web/dist/index.html
|
||||
|
||||
|
||||
- name: Build and push staging API/Worker images
|
||||
shell: bash
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="172.30.18.198:5000"
|
||||
CACHE_REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas"
|
||||
CACHE_BRANCH="${GITHUB_REF_NAME//\//-}"
|
||||
|
||||
# 登录 Gitea Registry(用于构建缓存,PAT 带 packages 权限)
|
||||
printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin
|
||||
|
||||
# 登录内网 Registry(运行时镜像推送目标)
|
||||
printf '%s' "Xiaoxia2026" | docker login 172.30.18.198:5000 -u admin --password-stdin 2>/dev/null || true
|
||||
|
||||
# 用默认 docker driver builder(共享 docker daemon 凭证,解决 buildx 认证问题)
|
||||
docker buildx use default 2>/dev/null || {
|
||||
echo "Warning: default builder not available, buildx cache may not work"
|
||||
}
|
||||
|
||||
# 验证 builder 状态
|
||||
docker buildx ls
|
||||
|
||||
# 构建 API(带分布式缓存,缓存存 Gitea Registry,镜像推内网 Registry)
|
||||
docker buildx build \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_BRANCH},ignore-error=true" \
|
||||
--cache-to "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_BRANCH},mode=max" \
|
||||
-f infra/docker/api.Dockerfile \
|
||||
-t "${REGISTRY}/xiaoxia-saas-api:dev" \
|
||||
--push \
|
||||
.
|
||||
|
||||
# 构建 Worker(带分布式缓存)
|
||||
docker buildx build \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_BRANCH},ignore-error=true" \
|
||||
--cache-to "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_BRANCH},mode=max" \
|
||||
-f infra/docker/worker.Dockerfile \
|
||||
-t "${REGISTRY}/xiaoxia-saas-worker:dev" \
|
||||
--push \
|
||||
.
|
||||
|
||||
- name: Package staging release artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
rm -rf dist/staging-artifacts
|
||||
mkdir -p dist/staging-artifacts
|
||||
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
|
||||
-czf dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz .
|
||||
docker save -o "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" "xiaoxia-saas-web:staging-${GITHUB_SHA}"
|
||||
|
||||
- name: Upload staging artifact to business host
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
mkdir -p ~/.ssh
|
||||
# Prefer host key if available (more reliable), fallback to secrets
|
||||
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
echo "Using host SSH key: $key_path"
|
||||
elif [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$STAGING_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
echo "Using secret SSH key: $key_path"
|
||||
else
|
||||
echo "ERROR: No SSH key available"
|
||||
exit 1
|
||||
fi
|
||||
ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts
|
||||
ssh -i "$key_path" "$staging_user@$staging_host" "mkdir -p /var/lib/xiaoxia-saas-staging/artifacts"
|
||||
scp -i "$key_path" "dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz" \
|
||||
"$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz"
|
||||
scp -i "$key_path" "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" \
|
||||
"$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar"
|
||||
|
||||
- name: Deploy staging stack on business host
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
else
|
||||
echo "ERROR: No SSH key available"
|
||||
exit 1
|
||||
fi
|
||||
echo 'c2V0IC1ldQphcnRpZmFjdD0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvYXJ0aWZhY3RzL3hpYW94aWEtc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhci5neiIKaW1hZ2VfdGFyPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9hcnRpZmFjdHMveGlhb3hpYS13ZWItc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhciIKdGVzdCAtZiAiJGFydGlmYWN0Igp0ZXN0IC1mICIkaW1hZ2VfdGFyIgp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYKZG9ja2VyIGxvYWQgLWkgIiRpbWFnZV90YXIiCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8KdGFyIC14emYgIiRhcnRpZmFjdCIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby8uZW52CmNobW9kICt4IC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vaW5mcmEvZG9ja2VyL2RlcGxveS1zdGFnaW5nLnNoCiMgRW5zdXJlIGlzb2xhdGVkIHN0YWdpbmcgbmV0d29yayBleGlzdHMgYmVmb3JlIGRlcGxveQpkb2NrZXIgbmV0d29yayBjcmVhdGUgeGlhb3hpYS1uZXQtc3RhZ2luZyAyPi9kZXYvbnVsbCB8fCB0cnVlClJFR0lTVFJZPSIxNzIuMzAuMTguMTk4OjUwMDAiIEFQSV9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLWFwaTpkZXYiIFdPUktFUl9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLXdvcmtlcjpkZXYiIFdFQl9JTUFHRT0ieGlhb3hpYS1zYWFzLXdlYjpzdGFnaW5nLSR7R0lUSFVCX1NIQX0iIEhPU1RfUFJFRklYPSBFTlY9c3RhZ2luZyBXRUJfUE9SVD0zMDAxIFJFQlVJTERfQkFDS0VORD0wIEJVSUxEX1dFQj0wIFJVTl9NSUdSQVRJT05TPTEgUkVHSVNUUllfUEFTUz0iWGlhb3hpYVJlZ2lzdHJ5MjAyNiIgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXN0YWdpbmcuc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$staging_user@$staging_host" "GITHUB_SHA='${GITHUB_SHA}' sh"
|
||||
|
||||
- name: Post-deploy smoke test
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
else
|
||||
echo "ERROR: No SSH key available"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running post-deploy smoke tests on staging..."
|
||||
|
||||
# Wait for service to fully start
|
||||
sleep 5
|
||||
|
||||
# Run smoke tests via SSH on the business host
|
||||
ssh -i "$key_path" "$staging_user@$staging_host" '
|
||||
echo "--- Smoke test 1: Health check ---"
|
||||
HEALTH=$(curl -sf --max-time 10 http://127.0.0.1:8000/health) || {
|
||||
echo "FAIL: health endpoint unreachable"
|
||||
exit 1
|
||||
}
|
||||
echo "Health OK: $HEALTH"
|
||||
|
||||
echo "--- Smoke test 2: Login API (expect 401) ---"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \
|
||||
http://127.0.0.1:8000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"smoke@test.com\",\"password\":\"wrong\"}")
|
||||
|
||||
if [ "$HTTP_CODE" != "401" ] && [ "$HTTP_CODE" != "422" ]; then
|
||||
echo "FAIL: login returned HTTP $HTTP_CODE (expected 401 or 422)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Login API OK: HTTP $HTTP_CODE"
|
||||
|
||||
echo "--- Smoke test 3: API docs endpoint ---"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://127.0.0.1:8000/docs)
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "FAIL: /docs returned HTTP $HTTP_CODE (expected 200)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Docs endpoint OK: HTTP $HTTP_CODE"
|
||||
|
||||
echo "--- Smoke test 4: Network isolation verification ---"
|
||||
# Verify staging containers are on the staging network
|
||||
STAGING_NET=$(docker inspect xiaoxia-api-staging --format="{{json .NetworkSettings.Networks}}" 2>/dev/null)
|
||||
if [ -z "$STAGING_NET" ]; then
|
||||
echo "WARN: Could not inspect staging container networks (container may not exist yet)"
|
||||
else
|
||||
echo "Staging API container networks: $STAGING_NET"
|
||||
if echo "$STAGING_NET" | grep -q "xiaoxia-net-staging"; then
|
||||
echo "Network isolation OK: staging containers on xiaoxia-net-staging"
|
||||
else
|
||||
echo "WARN: staging containers not on expected xiaoxia-net-staging network"
|
||||
echo " Current networks: $STAGING_NET"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Verify cross-environment DNS isolation
|
||||
# staging API should resolve to staging container, not production
|
||||
STAGING_API_IP=$(docker exec xiaoxia-web-staging getent hosts xiaoxia-api-staging 2>/dev/null | awk "{print \$1}" || true)
|
||||
PRODUCTION_API_IP=$(docker exec xiaoxia-web-staging getent hosts xiaoxia-api-production 2>/dev/null | awk "{print \$1}" || true)
|
||||
if [ -n "$STAGING_API_IP" ]; then
|
||||
echo "Staging API resolves to: $STAGING_API_IP (from web container)"
|
||||
fi
|
||||
if [ -n "$PRODUCTION_API_IP" ]; then
|
||||
echo "FAIL: staging web container can resolve production API address ($PRODUCTION_API_IP) - network isolation broken!"
|
||||
exit 1
|
||||
else
|
||||
echo "Network isolation OK: staging web cannot resolve xiaoxia-api-production"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== All smoke tests passed! ==="
|
||||
'
|
||||
|
||||
staging-e2e:
|
||||
name: Staging E2E Tests
|
||||
runs-on: saas
|
||||
if: github.ref_name == 'develop' || github.ref_name == 'main'
|
||||
needs: deploy-staging
|
||||
|
||||
steps:
|
||||
- name: Install SSH client
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
|
||||
echo "openssh-client installed"
|
||||
|
||||
- name: Run Playwright E2E on staging server
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$STAGING_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
else
|
||||
echo "ERROR: No SSH key available"
|
||||
exit 1
|
||||
fi
|
||||
ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts
|
||||
|
||||
# 在业务服务器上跑 Playwright E2E(用 host 网络访问 staging 3001/8000 端口)
|
||||
ssh -i "$key_path" "$staging_user@$staging_host" '
|
||||
cd /var/lib/xiaoxia-saas-staging/repo
|
||||
docker run --rm \
|
||||
-e E2E_BASE_URL=http://127.0.0.1:3001 \
|
||||
-e E2E_API_BASE=http://127.0.0.1:8000/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
--network host \
|
||||
mcr.microsoft.com/playwright:v1.45.0-jammy \
|
||||
sh -lc "npm ci && npx playwright test --reporter=line --project=chromium"
|
||||
'
|
||||
|
||||
build-production-runtime-images:
|
||||
name: Build Production Runtime Images
|
||||
runs-on: saas
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
# Retry up to 5 times with backoff for transient 5xx errors
|
||||
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: Build runtime image artifact
|
||||
shell: sh
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
chmod +x scripts/build_release_images.sh
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN}" scripts/build_release_images.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Build production web artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npm ci && npm run build'
|
||||
docker build --pull=false \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
|
||||
-t "xiaoxia-saas-web:${GITHUB_REF_NAME}" \
|
||||
.
|
||||
test -f apps/web/dist/index.html
|
||||
|
||||
- name: Package release source artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist/release-artifacts
|
||||
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
|
||||
-czf "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" .
|
||||
docker save -o "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" "xiaoxia-saas-web:${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Upload runtime image and release artifacts
|
||||
shell: sh
|
||||
env:
|
||||
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}"
|
||||
production_user="${PRODUCTION_SSH_USER:-root}"
|
||||
mkdir -p ~/.ssh
|
||||
# Prefer host key if available (more reliable), fallback to secrets
|
||||
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
echo "Using host SSH key: $key_path"
|
||||
elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
echo "Using secret SSH key: $key_path"
|
||||
else
|
||||
echo "ERROR: No SSH key available"
|
||||
exit 1
|
||||
fi
|
||||
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
|
||||
scp -i "$key_path" "dist/release-images/xiaoxia-runtime-images-${GITHUB_REF_NAME}.tar" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/runtime-images-${GITHUB_REF_NAME}.tar"
|
||||
scp -i "$key_path" "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/release-${GITHUB_REF_NAME}.tar.gz"
|
||||
scp -i "$key_path" "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/web-${GITHUB_REF_NAME}.tar"
|
||||
|
||||
- name: Cleanup old Docker images
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
if [ -f scripts/cleanup_old_images.sh ]; then
|
||||
chmod +x scripts/cleanup_old_images.sh
|
||||
scripts/cleanup_old_images.sh
|
||||
else
|
||||
echo "Cleanup script not found, doing basic prune..."
|
||||
docker image prune -f 2>/dev/null || true
|
||||
fi
|
||||
echo "Disk usage after cleanup:"
|
||||
df -h / | tail -1
|
||||
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: saas
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: build-production-runtime-images
|
||||
|
||||
steps:
|
||||
- name: Install SSH client
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
|
||||
echo "openssh-client installed"
|
||||
|
||||
- name: Deploy production over SSH
|
||||
shell: sh
|
||||
env:
|
||||
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}"
|
||||
production_user="${PRODUCTION_SSH_USER:-root}"
|
||||
mkdir -p ~/.ssh
|
||||
# Prefer host key if available (more reliable), fallback to secrets
|
||||
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
echo "Using host SSH key: $key_path"
|
||||
elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
echo "Using secret SSH key: $key_path"
|
||||
else
|
||||
echo "ERROR: No SSH key available"
|
||||
exit 1
|
||||
fi
|
||||
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
|
||||
echo 'c2V0IC1ldQpyZWxlYXNlX3Rhcj0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVsZWFzZS0ke1JFTEVBU0VfVkVSU0lPTn0udGFyLmd6Igp0ZXN0IC1mICIkcmVsZWFzZV90YXIiCnRlc3QgLWYgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3J1bnRpbWUtaW1hZ2VzLSR7UkVMRUFTRV9WRVJTSU9OfS50YXIiCnRlc3QgLWYgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3dlYi0ke1JFTEVBU0VfVkVSU0lPTn0udGFyIgpta2RpciAtcCAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbgpvbGRfYXNzZXRzX2Rpcj0iL3RtcC94aWFveGlhLXByZXZpb3VzLXdlYi1hc3NldHMtJHtSRUxFQVNFX1ZFUlNJT059IgpybSAtcmYgIiRvbGRfYXNzZXRzX2RpciIKbWtkaXIgLXAgIiRvbGRfYXNzZXRzX2RpciIKaWYgZG9ja2VyIGluc3BlY3QgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBkb2NrZXIgY3AgeGlhb3hpYS13ZWItcHJvZHVjdGlvbjovdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLy4gIiRvbGRfYXNzZXRzX2RpciIvIDI+L2Rldi9udWxsIHx8IHRydWUKZmkKaWYgWyAtZCAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvL2FwcHMvd2ViL2Rpc3QvYXNzZXRzIF07IHRoZW4KICBjcCAtYSAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvL2FwcHMvd2ViL2Rpc3QvYXNzZXRzLy4gIiRvbGRfYXNzZXRzX2RpciIvCmZpCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8KdGFyIC14emYgIiRyZWxlYXNlX3RhciIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmlmIFsgLWQgIiRvbGRfYXNzZXRzX2RpciIgXTsgdGhlbgogIG1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMKICBmb3IgYXNzZXQgaW4gIiRvbGRfYXNzZXRzX2RpciIvKjsgZG8KICAgIFsgLWUgIiRhc3NldCIgXSB8fCBjb250aW51ZQogICAgbmFtZT0iJChiYXNlbmFtZSAiJGFzc2V0IikiCiAgICBpZiBbICEgLWUgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvJG5hbWUiIF07IHRoZW4KICAgICAgY3AgLWEgIiRhc3NldCIgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvJG5hbWUiCiAgICBmaQogIGRvbmUKICBybSAtcmYgIiRvbGRfYXNzZXRzX2RpciIKZmkKdGVzdCAtZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi8uZW52CmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby8uZW52CiMgRW5zdXJlIGlzb2xhdGVkIHByb2R1Y3Rpb24gbmV0d29yayBleGlzdHMgYmVmb3JlIGRlcGxveQpkb2NrZXIgbmV0d29yayBjcmVhdGUgeGlhb3hpYS1uZXQtcHJvZHVjdGlvbiAyPi9kZXYvbnVsbCB8fCB0cnVlCkhPU1RfUFJFRklYPSBFTlY9cHJvZHVjdGlvbiBXRUJfSU1BR0U9InhpYW94aWEtc2Fhcy13ZWI6JHtSRUxFQVNFX1ZFUlNJT059IiBXRUJfSU1BR0VfVEFSPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi93ZWItJHtSRUxFQVNFX1ZFUlNJT059LnRhciIgc2ggL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXByb2R1Y3Rpb24uc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAxL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$production_user@$production_host" "RELEASE_VERSION='${GITHUB_REF_NAME}' sh"
|
||||
|
||||
production-e2e:
|
||||
name: Production Browser E2E
|
||||
runs-on: saas
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: deploy-production
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
# Retry up to 5 times with backoff for transient 5xx errors
|
||||
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 production browser E2E
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
-e E2E_BASE_URL=https://saas.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
mcr.microsoft.com/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||
@@ -1,69 +0,0 @@
|
||||
name: Test SSH Secret
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- '.gitea/workflows/test-ssh-secret.yml'
|
||||
|
||||
jobs:
|
||||
test-ssh:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Install SSH client
|
||||
run: |
|
||||
which ssh || (apt-get update && apt-get install -y openssh-client)
|
||||
ssh -V
|
||||
|
||||
- name: Debug environment
|
||||
run: |
|
||||
echo "=== Environment ==="
|
||||
echo "Runner hostname: $(hostname)"
|
||||
echo "Runner IP: $(hostname -i || echo 'unknown')"
|
||||
echo "Current user: $(whoami)"
|
||||
echo "=== Secrets check ==="
|
||||
if [ -n "$STAGING_SSH_HOST" ]; then
|
||||
echo "STAGING_SSH_HOST: [SET] value_length=${#STAGING_SSH_HOST}"
|
||||
else
|
||||
echo "STAGING_SSH_HOST: [EMPTY]"
|
||||
fi
|
||||
if [ -n "$STAGING_SSH_USER" ]; then
|
||||
echo "STAGING_SSH_USER: [SET] value_length=${#STAGING_SSH_USER}"
|
||||
else
|
||||
echo "STAGING_SSH_USER: [EMPTY]"
|
||||
fi
|
||||
if [ -n "$STAGING_SSH_KEY" ]; then
|
||||
echo "STAGING_SSH_KEY: [SET] value_length=${#STAGING_SSH_KEY}"
|
||||
else
|
||||
echo "STAGING_SSH_KEY: [EMPTY]"
|
||||
fi
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "$STAGING_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub 2>/dev/null || echo "No public key generated"
|
||||
echo "=== SSH Key fingerprint ==="
|
||||
ssh-keygen -lf ~/.ssh/id_ed25519 || echo "Key fingerprint failed"
|
||||
env:
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
echo "Attempting SSH connection to $STAGING_SSH_HOST..."
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=10 \
|
||||
-o BatchMode=yes \
|
||||
-v \
|
||||
$STAGING_SSH_USER@$STAGING_SSH_HOST "echo 'SSH_CONNECTION_SUCCESS' && hostname && whoami"
|
||||
echo "=== SSH Test Complete ==="
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
@@ -47,3 +47,4 @@ build/
|
||||
# Tracker temp files
|
||||
tracker_tasks.json
|
||||
|
||||
frontend-v21-ui-prototype-final.html
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Task: Add source_edit_plan_id to edit_plans and generation_tasks
|
||||
|
||||
Revision ID: 022
|
||||
Revises: 021
|
||||
Create Date: 2026-07-04
|
||||
|
||||
新增 source_edit_plan_id 字段到 edit_plans 和 generation_tasks 表,
|
||||
用于关联生成记录到其来源的剪辑计划。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "022"
|
||||
down_revision = "021"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_plans",
|
||||
sa.Column("source_edit_plan_id", sa.String(32), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_edit_plans_source_edit_plan_id"),
|
||||
"edit_plans",
|
||||
["source_edit_plan_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("source_edit_plan_id", sa.String(32), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generation_tasks_source_edit_plan_id"),
|
||||
"generation_tasks",
|
||||
["source_edit_plan_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_generation_tasks_source_edit_plan_id"),
|
||||
table_name="generation_tasks",
|
||||
)
|
||||
op.drop_column("generation_tasks", "source_edit_plan_id")
|
||||
|
||||
op.drop_index(
|
||||
op.f("ix_edit_plans_source_edit_plan_id"),
|
||||
table_name="edit_plans",
|
||||
)
|
||||
op.drop_column("edit_plans", "source_edit_plan_id")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Task: Add project_id and created_by_user_id to edit_plans
|
||||
|
||||
Revision ID: 023
|
||||
Revises: 022
|
||||
Create Date: 2026-07-05
|
||||
|
||||
新增 project_id 和 created_by_user_id 字段到 edit_plans 表,
|
||||
用于项目归属鉴权和用户归属追踪,修复审计发现的 P1 越权漏洞。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "023"
|
||||
down_revision = "022"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_plans",
|
||||
sa.Column("project_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_edit_plans_project_id"),
|
||||
"edit_plans",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"edit_plans",
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_edit_plans_created_by_user_id"),
|
||||
"edit_plans",
|
||||
["created_by_user_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_edit_plans_created_by_user_id"),
|
||||
table_name="edit_plans",
|
||||
)
|
||||
op.drop_column("edit_plans", "created_by_user_id")
|
||||
|
||||
op.drop_index(
|
||||
op.f("ix_edit_plans_project_id"),
|
||||
table_name="edit_plans",
|
||||
)
|
||||
op.drop_column("edit_plans", "project_id")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Task: Add is_admin to users
|
||||
|
||||
Revision ID: 024
|
||||
Revises: 023
|
||||
Create Date: 2026-07-05
|
||||
|
||||
新增 is_admin 字段到 users 表,用于模板管理等管理员权限校验。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "024"
|
||||
down_revision = "023"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "is_admin")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Task: Add wechat_openid / wechat_unionid to users
|
||||
|
||||
Revision ID: 025
|
||||
Revises: 024
|
||||
Create Date: 2026-07-05
|
||||
|
||||
补录微信小程序登录所需的 wechat 字段。
|
||||
生产数据库已手动添加过这些字段和索引,因此 upgrade 做幂等检查,
|
||||
避免在已有字段的库上执行报错。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "025"
|
||||
down_revision = "024"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> bool:
|
||||
"""检查列是否已存在。离线模式下返回 False。"""
|
||||
conn = op.get_bind()
|
||||
try:
|
||||
result = conn.execute(
|
||||
sa.text("SELECT 1 FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
if result is None:
|
||||
return False
|
||||
return result.scalar() is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _index_exists(index: str) -> bool:
|
||||
"""检查索引是否已存在。离线模式下返回 False。"""
|
||||
conn = op.get_bind()
|
||||
try:
|
||||
result = conn.execute(
|
||||
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :index"),
|
||||
{"index": index},
|
||||
)
|
||||
if result is None:
|
||||
return False
|
||||
return result.scalar() is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# wechat_openid
|
||||
if not _column_exists("users", "wechat_openid"):
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("wechat_openid", sa.String(length=128), nullable=True),
|
||||
)
|
||||
|
||||
# wechat_unionid
|
||||
if not _column_exists("users", "wechat_unionid"):
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("wechat_unionid", sa.String(length=128), nullable=True),
|
||||
)
|
||||
|
||||
# 唯一索引
|
||||
if not _index_exists("ix_users_wechat_openid"):
|
||||
op.create_index("ix_users_wechat_openid", "users", ["wechat_openid"], unique=True)
|
||||
|
||||
if not _index_exists("ix_users_wechat_unionid"):
|
||||
op.create_index("ix_users_wechat_unionid", "users", ["wechat_unionid"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_users_wechat_unionid", table_name="users")
|
||||
op.drop_index("ix_users_wechat_openid", table_name="users")
|
||||
op.drop_column("users", "wechat_unionid")
|
||||
op.drop_column("users", "wechat_openid")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Add user profile fields (name, avatar, updated_at)
|
||||
|
||||
Revision ID: 026
|
||||
Revises: 025
|
||||
Create Date: 2026-07-05
|
||||
|
||||
补录用户资料字段。生产数据库已手动添加过这些字段,
|
||||
因此 upgrade 做幂等检查,避免在已有字段的库上执行报错。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "026"
|
||||
down_revision = "025"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> bool:
|
||||
if context.is_offline_mode():
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _column_exists("users", "name"):
|
||||
op.add_column("users", sa.Column("name", sa.String(100), nullable=True))
|
||||
|
||||
if not _column_exists("users", "avatar"):
|
||||
op.add_column("users", sa.Column("avatar", sa.String(500), nullable=True))
|
||||
|
||||
if not _column_exists("users", "updated_at"):
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "updated_at")
|
||||
op.drop_column("users", "avatar")
|
||||
op.drop_column("users", "name")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Add user ban fields (ban_reason, ban_at)
|
||||
|
||||
Revision ID: 027
|
||||
Revises: 026
|
||||
Create Date: 2026-07-05
|
||||
|
||||
补录用户封禁字段。生产数据库已手动添加过这些字段,
|
||||
因此 upgrade 做幂等检查。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "027"
|
||||
down_revision = "026"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> bool:
|
||||
if context.is_offline_mode():
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _column_exists("users", "ban_reason"):
|
||||
op.add_column("users", sa.Column("ban_reason", sa.Text(), nullable=True))
|
||||
|
||||
if not _column_exists("users", "ban_at"):
|
||||
op.add_column("users", sa.Column("ban_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "ban_at")
|
||||
op.drop_column("users", "ban_reason")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Add user admin fields (admin_status, admin_remarks)
|
||||
|
||||
Revision ID: 028
|
||||
Revises: 027
|
||||
Create Date: 2026-07-05
|
||||
|
||||
补录管理员备注字段。生产数据库已手动添加过这些字段,
|
||||
因此 upgrade 做幂等检查。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "028"
|
||||
down_revision = "027"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> bool:
|
||||
if context.is_offline_mode():
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _column_exists("users", "admin_status"):
|
||||
op.add_column("users", sa.Column("admin_status", sa.String(50), nullable=True))
|
||||
|
||||
if not _column_exists("users", "admin_remarks"):
|
||||
op.add_column("users", sa.Column("admin_remarks", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "admin_remarks")
|
||||
op.drop_column("users", "admin_status")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Add user phone field
|
||||
|
||||
Revision ID: 029
|
||||
Revises: 028
|
||||
Create Date: 2026-07-05
|
||||
|
||||
补录用户手机号字段。生产数据库已手动添加过该字段,
|
||||
因此 upgrade 做幂等检查。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "029"
|
||||
down_revision = "028"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> bool:
|
||||
if context.is_offline_mode():
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _column_exists("users", "phone"):
|
||||
op.add_column("users", sa.Column("phone", sa.String(20), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "phone")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Add tags and asset_tags tables
|
||||
|
||||
Revision ID: 030
|
||||
Revises: 029
|
||||
Create Date: 2026-07-07
|
||||
|
||||
新增标签表和素材-标签关联表,支持规范化多对多标签管理。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "030"
|
||||
down_revision = "029"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(table: str) -> bool:
|
||||
ctx = op.get_context()
|
||||
if ctx.as_sql:
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text("SELECT COUNT(*) FROM information_schema.tables WHERE table_name = :table"),
|
||||
{"table": table},
|
||||
)
|
||||
return (result.scalar() or 0) > 0
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _table_exists("tags"):
|
||||
op.create_table(
|
||||
"tags",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "name", name="uq_tags_user_name"),
|
||||
)
|
||||
op.create_index("ix_tags_user_id", "tags", ["user_id"])
|
||||
|
||||
if not _table_exists("asset_tags"):
|
||||
op.create_table(
|
||||
"asset_tags",
|
||||
sa.Column("asset_id", sa.String(36), primary_key=True),
|
||||
sa.Column("tag_id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_asset_tags_tag_id", "asset_tags", ["tag_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_asset_tags_tag_id", table_name="asset_tags")
|
||||
op.drop_table("asset_tags")
|
||||
op.drop_index("ix_tags_user_id", table_name="tags")
|
||||
op.drop_table("tags")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Add file_hash to assets and ingest_jobs
|
||||
|
||||
Revision ID: 031
|
||||
Revises: 030
|
||||
Create Date: 2026-07-07
|
||||
|
||||
为素材去重检测功能添加 file_hash 字段。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "031"
|
||||
down_revision = "030"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("assets", sa.Column("file_hash", sa.String(64), nullable=True))
|
||||
op.create_index(op.f("ix_assets_file_hash"), "assets", ["file_hash"])
|
||||
|
||||
op.add_column("ingest_jobs", sa.Column("file_hash", sa.String(64), nullable=True))
|
||||
op.create_index(op.f("ix_ingest_jobs_file_hash"), "ingest_jobs", ["file_hash"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_ingest_jobs_file_hash"), table_name="ingest_jobs")
|
||||
op.drop_column("ingest_jobs", "file_hash")
|
||||
|
||||
op.drop_index(op.f("ix_assets_file_hash"), table_name="assets")
|
||||
op.drop_column("assets", "file_hash")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add asset_select_mode to generation_tasks
|
||||
|
||||
Revision ID: 032
|
||||
Revises: 031
|
||||
Create Date: 2026-07-07
|
||||
|
||||
素材库自动匹配功能:为 generation_tasks 表添加 asset_select_mode 字段。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "032"
|
||||
down_revision = "031"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("asset_select_mode", sa.String(20), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generation_tasks", "asset_select_mode")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Add batch_id to generation_tasks
|
||||
|
||||
Revision ID: 033
|
||||
Revises: 032
|
||||
Create Date: 2026-07-07
|
||||
|
||||
视频查重功能:为 generation_tasks 表添加 batch_id 字段,
|
||||
用于关联同一次批量生成请求中的多个任务。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "033"
|
||||
down_revision = "032"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("batch_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.create_index(op.f("ix_generation_tasks_batch_id"), "generation_tasks", ["batch_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_generation_tasks_batch_id"), table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "batch_id")
|
||||
@@ -16,6 +16,7 @@ from app.api.routes.jobs import router as jobs_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.recipes import router as recipes_router
|
||||
from app.api.routes.subscription import router as subscription_router
|
||||
from app.api.routes.tags import router as tags_router
|
||||
from app.api.routes.task_center import router as task_center_router
|
||||
from app.api.routes.templates import router as templates_router
|
||||
from app.api.routes.titles import router as titles_router
|
||||
@@ -38,6 +39,11 @@ api_router.include_router(
|
||||
prefix="/projects",
|
||||
tags=["Project"],
|
||||
)
|
||||
api_router.include_router(
|
||||
tags_router,
|
||||
prefix="/tags",
|
||||
tags=["Tag"],
|
||||
)
|
||||
api_router.include_router(
|
||||
task_center_router,
|
||||
tags=["TaskCenter"],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Any
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
@@ -7,10 +8,12 @@ from app.dependencies import (
|
||||
get_project_repository,
|
||||
)
|
||||
from app.schemas.asset_diagnosis import AssetGapItem, AssetSmartViewItem, ProjectAssetDiagnosisResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from packages.domain import Asset, AssetLibraryKind, AssetStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -190,21 +193,152 @@ def _build_diagnosis(project_id: str, assets: list[Asset]) -> ProjectAssetDiagno
|
||||
)
|
||||
|
||||
|
||||
def _build_single_asset_diagnosis(project_id: str, asset: Asset) -> ProjectAssetDiagnosisResponse:
|
||||
"""为单个素材构建诊断结果"""
|
||||
kind = _asset_kind(asset)
|
||||
is_ready = asset.status == AssetStatus.READY
|
||||
is_problem = asset.status in {AssetStatus.ERROR, AssetStatus.UPLOADING, AssetStatus.PROCESSING}
|
||||
is_risky = is_ready and (
|
||||
(asset.quality_score is not None and asset.quality_score < 60)
|
||||
or asset.metadata.get("review_status") == "rejected"
|
||||
or asset.status == AssetStatus.ERROR
|
||||
)
|
||||
is_unclassified = is_ready and asset.classification_status.value in {"pending", "failed"}
|
||||
|
||||
# 单素材评分
|
||||
score = 0
|
||||
if is_ready:
|
||||
score = 60
|
||||
if kind == "video":
|
||||
score += 20
|
||||
if asset.duration and asset.duration >= 5:
|
||||
score += 10
|
||||
if asset.quality_score and asset.quality_score >= 60:
|
||||
score += 10
|
||||
if is_problem:
|
||||
score = max(score - 30, 0)
|
||||
if is_risky:
|
||||
score = max(score - 20, 0)
|
||||
score = max(0, min(100, score))
|
||||
|
||||
gaps: list[AssetGapItem] = []
|
||||
if not is_ready:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="asset_not_ready",
|
||||
severity="critical",
|
||||
message=f"素材状态为 {asset.status.value},尚未就绪",
|
||||
recommendation="等待素材导入完成后再使用。",
|
||||
)
|
||||
)
|
||||
if is_risky:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="asset_low_quality",
|
||||
severity="warning",
|
||||
message="素材质量分偏低或已被拒绝",
|
||||
recommendation="建议使用更清晰、稳定的素材替代。",
|
||||
)
|
||||
)
|
||||
if is_unclassified:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="asset_unclassified",
|
||||
severity="info",
|
||||
message="素材尚未完成分类",
|
||||
recommendation="等待分类完成或手动检查素材类型。",
|
||||
)
|
||||
)
|
||||
if kind == "video" and (asset.duration is None or asset.duration < 5):
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="short_video",
|
||||
severity="warning",
|
||||
message="视频时长偏短",
|
||||
recommendation="建议使用时长 5 秒以上的视频素材。",
|
||||
)
|
||||
)
|
||||
|
||||
used_count = int(asset.metadata.get("generation_use_count") or 0)
|
||||
smart_views = [
|
||||
AssetSmartViewItem(
|
||||
key="asset_info",
|
||||
label="素材信息",
|
||||
count=1,
|
||||
description=f"类型: {kind},状态: {asset.status.value}",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="asset_quality",
|
||||
label="质量评分",
|
||||
count=int(asset.quality_score or 0),
|
||||
description=f"质量分: {asset.quality_score or '未评分'}",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="asset_usage",
|
||||
label="使用次数",
|
||||
count=used_count,
|
||||
description=f"参与生成 {used_count} 次",
|
||||
),
|
||||
]
|
||||
|
||||
video_count = 1 if kind == "video" and is_ready else 0
|
||||
image_count = 1 if kind == "image" and is_ready else 0
|
||||
voice_count = 1 if kind == "voice" and is_ready else 0
|
||||
total_duration = round(float(asset.duration or 0), 2) if kind == "video" else 0.0
|
||||
|
||||
return ProjectAssetDiagnosisResponse(
|
||||
project_id=project_id,
|
||||
readiness_score=score,
|
||||
readiness_label=_readiness_label(score),
|
||||
total_assets=1,
|
||||
ready_assets=1 if is_ready else 0,
|
||||
video_assets=video_count,
|
||||
image_assets=image_count,
|
||||
voice_assets=voice_count,
|
||||
total_duration_seconds=total_duration,
|
||||
estimated_video_count=1 if video_count and total_duration >= 5 else 0,
|
||||
used_assets=1 if used_count > 0 else 0,
|
||||
unused_assets=1 if used_count == 0 and is_ready else 0,
|
||||
pending_review_assets=1 if asset.metadata.get("review_status") == "pending_review" else 0,
|
||||
smart_views=smart_views,
|
||||
gaps=gaps,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/asset-diagnosis", response_model=ProjectAssetDiagnosisResponse)
|
||||
def get_project_asset_diagnosis(
|
||||
project_id: str,
|
||||
asset_id: Optional[str] = Query(None),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> ProjectAssetDiagnosisResponse:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
try:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
libraries = asset_library_repository.find_by_project(project_id)
|
||||
assets: list[Asset] = []
|
||||
for library in libraries:
|
||||
assets.extend(asset_repository.list_by_library(library.id))
|
||||
# 单素材诊断模式
|
||||
if asset_id:
|
||||
asset = asset_repository.get(asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
if asset.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="Asset does not belong to this project")
|
||||
return _build_single_asset_diagnosis(project_id, asset)
|
||||
|
||||
libraries = asset_library_repository.find_by_project(project_id)
|
||||
assets: list[Asset] = []
|
||||
for library in libraries:
|
||||
assets.extend(asset_repository.list_by_library(library.id))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("素材诊断查询失败: project_id=%s", project_id)
|
||||
# 返回空诊断结果,避免 500
|
||||
return _build_diagnosis(project_id, [])
|
||||
|
||||
return _build_diagnosis(project_id, assets)
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.dependencies import get_asset_library_repository, get_project_repositor
|
||||
from app.schemas.asset_library import (
|
||||
AssetLibraryResponse,
|
||||
CreateAssetLibraryRequest,
|
||||
EnsureDefaultLibraryRequest,
|
||||
ListAssetLibrariesResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -15,7 +16,7 @@ from packages.application import (
|
||||
GetProjectUseCase,
|
||||
ListAssetLibrariesUseCase,
|
||||
)
|
||||
from packages.domain import AssetLibraryKind
|
||||
from packages.domain import AssetLibrary, AssetLibraryKind
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -43,6 +44,7 @@ def _to_asset_library_response(item) -> AssetLibraryResponse:
|
||||
@router.get("", response_model=ListAssetLibrariesResponse)
|
||||
def list_asset_libraries(
|
||||
project_id: str | None = Query(None),
|
||||
kind: str | None = Query(None, pattern="^(video|voice|image)$"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
@@ -66,6 +68,11 @@ def list_asset_libraries(
|
||||
all_items.extend(use_case.execute(proj.id))
|
||||
items = all_items
|
||||
|
||||
# 按 kind 过滤(可选)
|
||||
if kind:
|
||||
kind_enum = AssetLibraryKind(kind)
|
||||
items = [item for item in items if item.kind == kind_enum]
|
||||
|
||||
return ListAssetLibrariesResponse(items=[_to_asset_library_response(item) for item in items])
|
||||
|
||||
|
||||
@@ -90,3 +97,53 @@ def create_asset_library(
|
||||
)
|
||||
)
|
||||
return _to_asset_library_response(item)
|
||||
|
||||
|
||||
# 默认素材库名称映射
|
||||
_DEFAULT_LIBRARY_NAMES = {
|
||||
"video": "视频素材库",
|
||||
"voice": "配音素材库",
|
||||
"image": "图片素材库",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ensure-default", response_model=AssetLibraryResponse)
|
||||
def ensure_default_library(
|
||||
request: EnsureDefaultLibraryRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetLibraryResponse:
|
||||
"""确保项目下指定 kind 的默认素材库存在,已存在则直接返回,不存在则自动创建。"""
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
|
||||
|
||||
kind = AssetLibraryKind(request.kind)
|
||||
|
||||
# 查找该项目下同 kind 的素材库,返回第一个
|
||||
existing = asset_library_repository.find_by_project(request.project_id)
|
||||
for lib in existing:
|
||||
if lib.kind == kind:
|
||||
return _to_asset_library_response(lib)
|
||||
|
||||
# 不存在 → 自动创建
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
default_name = _DEFAULT_LIBRARY_NAMES.get(request.kind, f"{request.kind}素材库")
|
||||
library = AssetLibrary(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=request.project_id,
|
||||
name=default_name,
|
||||
kind=kind,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
created = asset_library_repository.create(library)
|
||||
return _to_asset_library_response(created)
|
||||
|
||||
@@ -1,25 +1,53 @@
|
||||
from typing import Any
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
get_tag_repository,
|
||||
)
|
||||
from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetReviewRequest
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from app.schemas.asset import (
|
||||
AssetResponse,
|
||||
BatchDeleteRequest,
|
||||
BatchDeleteResponse,
|
||||
CreateAssetRequest,
|
||||
ListAssetsResponse,
|
||||
UpdateAssetRequest,
|
||||
UpdateAssetReviewRequest,
|
||||
)
|
||||
from app.schemas.tag import TagAssetsRequest
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetCommand,
|
||||
CreateAssetUseCase,
|
||||
ListAssetsUseCase,
|
||||
)
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_asset_response(item) -> AssetResponse:
|
||||
def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
||||
# 生成签名文件 URL(用于视频播放 / 文件下载)
|
||||
file_url = None
|
||||
if item.storage_key:
|
||||
try:
|
||||
svc = storage_service or get_storage_service()
|
||||
file_url = svc.get_download_url(item.storage_key)
|
||||
except Exception:
|
||||
logger.warning("生成签名URL失败: storage_key=%s", item.storage_key, exc_info=True)
|
||||
file_url = None
|
||||
|
||||
# 缩略图:优先用已有 thumbnail_url,否则对视频素材复用文件签名 URL
|
||||
thumbnail_url = item.thumbnail_url
|
||||
if not thumbnail_url and item.mime_type and item.mime_type.startswith("video") and file_url:
|
||||
thumbnail_url = file_url
|
||||
|
||||
return AssetResponse(
|
||||
id=item.id,
|
||||
project_id=item.project_id,
|
||||
@@ -29,7 +57,8 @@ def _to_asset_response(item) -> AssetResponse:
|
||||
mime_type=item.mime_type,
|
||||
metadata=item.metadata,
|
||||
file_size=item.file_size,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
file_url=file_url,
|
||||
thumbnail_url=thumbnail_url,
|
||||
duration=item.duration,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
@@ -39,6 +68,7 @@ def _to_asset_response(item) -> AssetResponse:
|
||||
classification_status=item.classification_status.value,
|
||||
quality_score=item.quality_score,
|
||||
uploaded_by_user_id=item.uploaded_by_user_id,
|
||||
tag_ids=getattr(item, "tag_ids", []),
|
||||
)
|
||||
|
||||
|
||||
@@ -53,19 +83,164 @@ def _check_project_access(project_id: str, user_id: str, project_repository) ->
|
||||
|
||||
@router.get("", response_model=ListAssetsResponse)
|
||||
def list_assets(
|
||||
library_id: str,
|
||||
library_id: Optional[str] = Query(None),
|
||||
project_id: Optional[str] = Query(None),
|
||||
kind: Optional[str] = Query(None, pattern="^(video|voice|image)$"),
|
||||
keyword: Optional[str] = Query(None, description="按名称模糊匹配"),
|
||||
gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"),
|
||||
style: Optional[str] = Query(None, description="按 metadata.style 筛选"),
|
||||
tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ListAssetsResponse:
|
||||
library = asset_library_repository.get(library_id)
|
||||
if library is None:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
||||
_check_project_access(library.project_id, authenticated_user.user.id, project_repository)
|
||||
use_case = ListAssetsUseCase(asset_repository)
|
||||
items = use_case.execute(library_id)
|
||||
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# kind → file_type 映射(voice 对应 audio)
|
||||
kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"}
|
||||
|
||||
# 解析 tag_ids 参数(逗号分隔)
|
||||
filter_tag_ids: list[str] | None = None
|
||||
if tag_ids:
|
||||
filter_tag_ids = [t.strip() for t in tag_ids.split(",") if t.strip()]
|
||||
if not filter_tag_ids:
|
||||
filter_tag_ids = None
|
||||
|
||||
# 需要内存过滤的标志(keyword/gender/style/tag_ids 无法在 DB 层过滤)
|
||||
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids)
|
||||
|
||||
def _apply_memory_filters(items):
|
||||
"""应用 keyword / gender / style / tag_ids 内存过滤。"""
|
||||
result = items
|
||||
if keyword:
|
||||
kw = keyword.lower()
|
||||
result = [i for i in result if kw in (i.name or "").lower()]
|
||||
if gender:
|
||||
result = [i for i in result if (i.metadata or {}).get("gender") == gender]
|
||||
if style:
|
||||
result = [i for i in result if (i.metadata or {}).get("style") == style]
|
||||
if filter_tag_ids:
|
||||
tag_set = set(filter_tag_ids)
|
||||
result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))]
|
||||
return result
|
||||
|
||||
# ── 优化路径:无内存过滤时,使用 DB 级分页 ──
|
||||
if not needs_memory_filter:
|
||||
ft = kind_to_file_type.get(kind) if kind else None
|
||||
|
||||
# 模式1:指定 library_id
|
||||
if library_id:
|
||||
library = asset_library_repository.get(library_id)
|
||||
if library is None:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
||||
_check_project_access(library.project_id, user_id, project_repository)
|
||||
if ft:
|
||||
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
|
||||
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
|
||||
else:
|
||||
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit)
|
||||
total = asset_repository.count_by_project(library.project_id)
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in items],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 模式2:指定 project_id
|
||||
if project_id:
|
||||
_check_project_access(project_id, user_id, project_repository)
|
||||
if ft:
|
||||
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
|
||||
all_items = asset_repository.find_by_project(project_id)
|
||||
items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft)]
|
||||
total = len(items)
|
||||
paged = items[skip : skip + limit]
|
||||
else:
|
||||
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit)
|
||||
total = asset_repository.count_by_project(project_id)
|
||||
paged = items
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in paged],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 模式3:跨项目(无 library_id/project_id)
|
||||
try:
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
except Exception:
|
||||
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
|
||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||
|
||||
project_ids = [p.id for p in projects]
|
||||
if not project_ids:
|
||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||
|
||||
total = asset_repository.count_by_project_ids(project_ids)
|
||||
# 跨项目分页:逐项目累积直到凑够一页
|
||||
paged_items: list = []
|
||||
offset = skip
|
||||
remaining = limit
|
||||
for pid in project_ids:
|
||||
proj_total = asset_repository.count_by_project(pid)
|
||||
if offset >= proj_total:
|
||||
offset -= proj_total
|
||||
continue
|
||||
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining)
|
||||
paged_items.extend(proj_items)
|
||||
remaining -= len(proj_items)
|
||||
offset = 0
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in paged_items],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# ── 内存过滤路径:有 keyword/gender/style 时,加载全量后内存过滤 ──
|
||||
if library_id:
|
||||
library = asset_library_repository.get(library_id)
|
||||
if library is None:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
||||
_check_project_access(library.project_id, user_id, project_repository)
|
||||
if kind:
|
||||
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
|
||||
else:
|
||||
all_items = asset_repository.find_by_library(library_id)
|
||||
elif project_id:
|
||||
_check_project_access(project_id, user_id, project_repository)
|
||||
all_items = asset_repository.find_by_project(project_id)
|
||||
else:
|
||||
try:
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
except Exception:
|
||||
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
|
||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||
all_items = []
|
||||
for proj in projects:
|
||||
all_items.extend(asset_repository.find_by_project(proj.id))
|
||||
|
||||
# 应用 kind 过滤(如果有)+ keyword/gender/style
|
||||
if kind:
|
||||
ft = kind_to_file_type.get(kind)
|
||||
all_items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft or "")]
|
||||
filtered = _apply_memory_filters(all_items)
|
||||
total = len(filtered)
|
||||
paged = filtered[skip : skip + limit]
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in paged],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
def _apply_asset_review_status(item, review_status: str):
|
||||
@@ -93,6 +268,130 @@ def update_asset_review_status(
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=BatchDeleteResponse)
|
||||
def batch_delete_assets(
|
||||
request: BatchDeleteRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchDeleteResponse:
|
||||
"""批量删除素材(配音素材等),需逐项校验项目权限。"""
|
||||
user_id = authenticated_user.user.id
|
||||
deleted_ids: list[str] = []
|
||||
failed_ids: list[str] = []
|
||||
|
||||
for asset_id in request.ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_ids.append(asset_id)
|
||||
continue
|
||||
try:
|
||||
_check_project_access(item.project_id, user_id, project_repository)
|
||||
deleted_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_ids.append(asset_id)
|
||||
|
||||
if deleted_ids:
|
||||
asset_repository.batch_delete(deleted_ids)
|
||||
|
||||
return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_ids)
|
||||
|
||||
|
||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||
def get_asset(
|
||||
asset_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetResponse:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
return _to_asset_response(item)
|
||||
|
||||
|
||||
@router.put("/{asset_id}", response_model=AssetResponse)
|
||||
def update_asset(
|
||||
asset_id: str,
|
||||
request: UpdateAssetRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetResponse:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 合并可修改字段
|
||||
if request.name is not None:
|
||||
item.name = request.name
|
||||
if request.metadata is not None:
|
||||
item.metadata = {**item.metadata, **request.metadata}
|
||||
if request.tags is not None:
|
||||
item.metadata = {**item.metadata, "tags": request.tags}
|
||||
|
||||
updated = asset_repository.update(item)
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.delete("/{asset_id}", status_code=204)
|
||||
def delete_asset(
|
||||
asset_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
asset_repository.delete(asset_id)
|
||||
|
||||
|
||||
@router.post("/{asset_id}/tags", response_model=AssetResponse)
|
||||
def tag_asset(
|
||||
asset_id: str,
|
||||
request: TagAssetsRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
tag_repository: Any = Depends(get_tag_repository),
|
||||
) -> AssetResponse:
|
||||
"""给素材打标签。"""
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
for tag_id in request.tag_ids:
|
||||
tag = tag_repository.get(tag_id)
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found")
|
||||
if tag.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail=f"无权使用标签 {tag_id}")
|
||||
item.add_tag(tag_id)
|
||||
updated = asset_repository.update(item)
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204)
|
||||
def untag_asset(
|
||||
asset_id: str,
|
||||
tag_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
"""取消素材的标签。"""
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
item.remove_tag(tag_id)
|
||||
asset_repository.update(item)
|
||||
|
||||
|
||||
@router.post("", response_model=AssetResponse)
|
||||
def create_asset(
|
||||
request: CreateAssetRequest,
|
||||
|
||||
@@ -6,13 +6,14 @@ app.dependencies and authentication behavior lives in application use cases.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
from app.auth import AuthenticatedUser, blacklist_token, get_current_user
|
||||
from app.config import settings
|
||||
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
@@ -284,3 +285,99 @@ def _translate_auth_error(error: str | None) -> str:
|
||||
"Display name is required": "显示名称不能为空",
|
||||
}
|
||||
return translations.get(error or "", error or "注册失败")
|
||||
|
||||
|
||||
class WechatSyncRequest(BaseModel):
|
||||
openid: str
|
||||
unionid: Optional[str] = None
|
||||
nickname: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
source: str = "miniapp"
|
||||
|
||||
|
||||
class WechatSyncResponse(BaseModel):
|
||||
access_token: str
|
||||
token: str
|
||||
refresh_token: str
|
||||
user_id: str
|
||||
user: dict
|
||||
user_info: dict
|
||||
is_new_user: bool
|
||||
expires_in: int
|
||||
|
||||
|
||||
def _get_internal_api_keys() -> list[str]:
|
||||
"""获取内部 API Key 列表
|
||||
|
||||
优先级:
|
||||
1. INTERNAL_API_KEYS 环境变量
|
||||
2. /app/generated/internal_api_keys.txt 文件 (volume 持久化)
|
||||
"""
|
||||
env_keys = os.environ.get("INTERNAL_API_KEYS", "")
|
||||
if env_keys:
|
||||
return [k.strip() for k in env_keys.split(",") if k.strip()]
|
||||
|
||||
# 从持久化文件读取
|
||||
try:
|
||||
with open("/app/generated/internal_api_keys.txt", "r") as f:
|
||||
content = f.read().strip()
|
||||
if content:
|
||||
return [k.strip() for k in content.split(",") if k.strip()]
|
||||
except Exception:
|
||||
logger.debug("Failed to read internal API keys from file", exc_info=True)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _verify_internal_api_key(x_api_key: str | None = Header(None)) -> bool:
|
||||
"""验证内部 API Key
|
||||
|
||||
- 已配置时:必须匹配 INTERNAL_API_KEYS 中的 key
|
||||
- 未配置且非生产环境:放行(方便开发)
|
||||
- 未配置且生产环境:拒绝
|
||||
"""
|
||||
env = os.environ.get("APP_ENV", os.environ.get("ENV", "development")).lower()
|
||||
key_list = _get_internal_api_keys()
|
||||
|
||||
if not key_list:
|
||||
if env in ("production", "prod"):
|
||||
raise HTTPException(status_code=401, detail="内部接口未配置 API Key")
|
||||
return True
|
||||
|
||||
if x_api_key and x_api_key.strip() in key_list:
|
||||
return True
|
||||
|
||||
raise HTTPException(status_code=401, detail="无效的 API Key")
|
||||
|
||||
|
||||
@router.post("/wechat-sync", response_model=WechatSyncResponse, include_in_schema=False)
|
||||
async def wechat_sync(
|
||||
request: WechatSyncRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
):
|
||||
"""
|
||||
微信同步登录/注册(系统级内部接口)
|
||||
|
||||
由 BFF 层通过 API Key 调用,不直接面向终端用户。
|
||||
根据 openid 查找或创建用户,返回 SaaS token。
|
||||
"""
|
||||
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as UseCaseRequest
|
||||
from packages.application.auth.wechat_sync_use_case import (
|
||||
WechatSyncUseCase,
|
||||
)
|
||||
|
||||
use_case = WechatSyncUseCase(user_repository=user_repository)
|
||||
use_case_request = UseCaseRequest(
|
||||
openid=request.openid,
|
||||
unionid=request.unionid,
|
||||
nickname=request.nickname,
|
||||
avatar_url=request.avatar_url,
|
||||
source=request.source,
|
||||
)
|
||||
|
||||
response, error = use_case.execute(use_case_request)
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
return WechatSyncResponse(**response.to_dict())
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
@@ -42,20 +43,33 @@ DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB
|
||||
CHUNK_EXPIRY_HOURS = 24
|
||||
|
||||
# Allowed file types (consistent with existing upload.py)
|
||||
# Allowed file types — must stay in sync with upload.py ALLOWED_MIME_TYPES
|
||||
ALLOWED_MIME_TYPES = {
|
||||
# Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/svg+xml",
|
||||
# Video
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/mpeg",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/3gpp",
|
||||
# Audio
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/mp3",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/x-m4a",
|
||||
"audio/webm",
|
||||
}
|
||||
|
||||
# Chunk storage root directory
|
||||
@@ -360,6 +374,7 @@ async def complete_chunked_upload(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> ChunkedUploadCompleteResponse:
|
||||
@@ -423,6 +438,29 @@ async def complete_chunked_upload(
|
||||
content_type=meta["content_type"],
|
||||
)
|
||||
|
||||
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
|
||||
if request.file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中(chunked): library=%s hash=%s existing_asset=%s",
|
||||
request.library_id,
|
||||
request.file_hash,
|
||||
existing.id,
|
||||
)
|
||||
meta["status"] = "completed"
|
||||
_save_upload_meta(upload_id, meta)
|
||||
return ChunkedUploadCompleteResponse(
|
||||
storage_key=storage_key,
|
||||
ingest_job_id="",
|
||||
url=file_url,
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
# Create ingest job
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
@@ -430,6 +468,7 @@ async def complete_chunked_upload(
|
||||
project_id=meta["project_id"],
|
||||
library_id=meta["library_id"],
|
||||
storage_key=storage_key,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
|
||||
@@ -8,6 +8,8 @@ RESTful CRUD for EditPlan:
|
||||
- DELETE /api/v1/edit-plans/{id} 删除
|
||||
- POST /api/v1/edit-plans/{id}/generate 触发剪辑渲染生成(任务 2.05)
|
||||
- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05)
|
||||
- POST /api/v1/edit-plans/{id}/ai-recommend AI 推荐片段方案(任务 3.09)
|
||||
- POST /api/v1/edit-plans/{id}/generate-cover AI 生成封面(任务 3.09)
|
||||
|
||||
业务逻辑委托给 EditPlanService 服务层。
|
||||
"""
|
||||
@@ -20,10 +22,10 @@ from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_db_session
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -34,6 +36,7 @@ from packages.application.generation_tasks import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
)
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -51,6 +54,7 @@ class EditPlanCreateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200, description="计划名称")
|
||||
config: dict[str, Any] = Field(default_factory=dict, description="计划配置 (JSON)")
|
||||
total_duration: float = Field(default=0.0, ge=0.0, description="总时长 (秒)")
|
||||
project_id: str = Field(default="", description="所属项目 ID")
|
||||
|
||||
|
||||
class EditPlanUpdateRequest(BaseModel):
|
||||
@@ -73,6 +77,8 @@ class EditPlanResponse(BaseModel):
|
||||
name: str
|
||||
status: str
|
||||
total_duration: float
|
||||
project_id: str = ""
|
||||
created_by_user_id: str = ""
|
||||
config: dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -119,9 +125,86 @@ class EditPlanGenerateResponse(BaseModel):
|
||||
clip_count: int
|
||||
|
||||
|
||||
class EditPlanGenerationsResponse(BaseModel):
|
||||
"""剪辑计划关联的生成记录列表响应体"""
|
||||
|
||||
items: List[GenerationTaskResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── AI 推荐片段方案 Schemas(任务 3.09) ──────────────────────────────────────
|
||||
|
||||
|
||||
class AIRecommendRequest(BaseModel):
|
||||
"""AI 推荐片段方案请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip")
|
||||
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)")
|
||||
|
||||
|
||||
class AIRecommendClipItem(BaseModel):
|
||||
"""AI 推荐的单个片段"""
|
||||
|
||||
clip_type: str = Field(..., description="片段类型: intro / showcase / title / subtitle / cta / outro")
|
||||
order: int = Field(..., ge=0, description="片段顺序")
|
||||
text_content: str = Field(default="", description="文字内容")
|
||||
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
|
||||
transition_effect: str = Field(default="cut", description="转场效果")
|
||||
asset_id: str = Field(default="", description="关联素材 ID")
|
||||
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
|
||||
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
|
||||
|
||||
|
||||
class AIRecommendResponse(BaseModel):
|
||||
"""AI 推荐片段方案响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
|
||||
config: dict[str, Any] = Field(..., description="推荐的 plan config(cover/title/subtitle/bgm)")
|
||||
total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)")
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||
|
||||
|
||||
# ── AI 封面生成 Schemas(任务 3.09) ─────────────────────────────────────────
|
||||
|
||||
|
||||
class GenerateCoverRequest(BaseModel):
|
||||
"""AI 封面生成请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
cover_type: str = Field(
|
||||
default="ai_frame",
|
||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||
)
|
||||
frame_time: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
"""AI 封面生成响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_project_access(project_id: str, user_id: str, project_repository: Any) -> None:
|
||||
"""校验用户对项目的访问权限(参照 assets.py 的 can_access 模式)"""
|
||||
if not project_id or not project_id.strip():
|
||||
return
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
|
||||
def _to_response(p: EditPlan) -> EditPlanResponse:
|
||||
return EditPlanResponse(
|
||||
id=p.id,
|
||||
@@ -129,6 +212,8 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
|
||||
name=p.name,
|
||||
status=p.status.value if hasattr(p.status, "value") else p.status,
|
||||
total_duration=p.total_duration,
|
||||
project_id=p.project_id or "",
|
||||
created_by_user_id=p.created_by_user_id or "",
|
||||
config=p.config,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
@@ -143,6 +228,7 @@ def list_plans(
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
template_id: Optional[str] = Query(default=None, description="按模板 ID 筛选"),
|
||||
project_id: Optional[str] = Query(default=None, description="按项目 ID 筛选"),
|
||||
status_filter: Optional[str] = Query(
|
||||
default=None,
|
||||
alias="status",
|
||||
@@ -150,10 +236,15 @@ def list_plans(
|
||||
),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanListResponse:
|
||||
"""获取剪辑计划列表(支持分页、按模板/状态筛选)"""
|
||||
"""获取剪辑计划列表(支持分页、按模板/状态/项目筛选)"""
|
||||
svc = EditPlanService(db)
|
||||
|
||||
# 空串 project_id 视为未传(避免 DB 中匹配到空串记录)
|
||||
if project_id is not None and not project_id.strip():
|
||||
project_id = None
|
||||
|
||||
# 解析状态筛选
|
||||
status_enum: Optional[EditPlanStatus] = None
|
||||
if status_filter:
|
||||
@@ -165,15 +256,21 @@ def list_plans(
|
||||
detail=(f"无效的状态值: {status_filter}," f"可选值: draft, editing, rendering, completed, failed"),
|
||||
)
|
||||
|
||||
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
|
||||
if project_id:
|
||||
_check_project_access(project_id, current_user.user.id, project_repository)
|
||||
|
||||
skip = (page - 1) * page_size
|
||||
plans = svc.list_plans(
|
||||
template_id=template_id,
|
||||
project_id=project_id,
|
||||
status=status_enum,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
)
|
||||
total = svc.count_plans(
|
||||
template_id=template_id,
|
||||
project_id=project_id,
|
||||
status=status_enum,
|
||||
)
|
||||
|
||||
@@ -190,6 +287,7 @@ def get_plan(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanResponse:
|
||||
"""获取单个剪辑计划详情"""
|
||||
svc = EditPlanService(db)
|
||||
@@ -200,6 +298,9 @@ def get_plan(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
# 项目鉴权
|
||||
if plan.project_id:
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
return _to_response(plan)
|
||||
|
||||
|
||||
@@ -208,15 +309,25 @@ def create_plan(
|
||||
body: EditPlanCreateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanResponse:
|
||||
"""创建剪辑计划"""
|
||||
# 空串 project_id 统一为 ""
|
||||
project_id = (body.project_id or "").strip()
|
||||
# 项目鉴权
|
||||
if project_id:
|
||||
_check_project_access(project_id, current_user.user.id, project_repository)
|
||||
svc = EditPlanService(db)
|
||||
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
|
||||
normalized_config = normalize_plan_config(body.config)
|
||||
try:
|
||||
created = svc.create_plan(
|
||||
template_id=body.template_id,
|
||||
name=body.name,
|
||||
config=body.config,
|
||||
config=normalized_config,
|
||||
total_duration=body.total_duration,
|
||||
project_id=project_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
@@ -238,17 +349,26 @@ def update_plan(
|
||||
body: EditPlanUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanResponse:
|
||||
"""更新剪辑计划(支持状态机流转)"""
|
||||
svc = EditPlanService(db)
|
||||
# 项目鉴权
|
||||
existing = svc.get_plan(plan_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if existing.project_id:
|
||||
_check_project_access(existing.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 基础字段更新
|
||||
try:
|
||||
if body.name is not None or body.config is not None or body.total_duration is not None:
|
||||
# 标准化 config(如果提供了)
|
||||
config_to_update = normalize_plan_config(body.config) if body.config is not None else None
|
||||
svc.update_plan(
|
||||
plan_id,
|
||||
name=body.name,
|
||||
config=body.config,
|
||||
config=config_to_update,
|
||||
total_duration=body.total_duration,
|
||||
)
|
||||
|
||||
@@ -285,9 +405,14 @@ def delete_plan(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> Response:
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
"""删除剪辑计划"""
|
||||
svc = EditPlanService(db)
|
||||
# 项目鉴权
|
||||
existing = svc.get_plan(plan_id)
|
||||
if existing and existing.project_id:
|
||||
_check_project_access(existing.project_id, current_user.user.id, project_repository)
|
||||
deleted = svc.delete_plan(plan_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
@@ -299,7 +424,6 @@ def delete_plan(
|
||||
plan_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
# ── 生成相关端点(任务 2.05) ─────────────────────────────────────────────────
|
||||
@@ -310,6 +434,7 @@ def generate_plan(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanGenerateResponse:
|
||||
"""触发剪辑计划渲染生成
|
||||
|
||||
@@ -322,6 +447,12 @@ def generate_plan(
|
||||
5. 将计划状态流转为 rendering
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
# 项目鉴权
|
||||
plan_check = svc.get_plan(plan_id)
|
||||
if plan_check is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if plan_check.project_id:
|
||||
_check_project_access(plan_check.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 检查是否可生成
|
||||
try:
|
||||
@@ -349,6 +480,7 @@ def generate_plan(
|
||||
project_id="",
|
||||
template_id=plan.template_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
source_edit_plan_id=plan_id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -388,6 +520,7 @@ def get_generation_status(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanGenerationStatusResponse:
|
||||
"""查询剪辑计划生成进度
|
||||
|
||||
@@ -403,6 +536,9 @@ def get_generation_status(
|
||||
)
|
||||
|
||||
plan = gen_status["plan"]
|
||||
# 项目鉴权
|
||||
if plan.project_id:
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
clips = gen_status["clips"]
|
||||
|
||||
clip_items = [
|
||||
@@ -426,6 +562,254 @@ def get_generation_status(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{plan_id}/generations",
|
||||
response_model=EditPlanGenerationsResponse,
|
||||
)
|
||||
def list_plan_generations(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanGenerationsResponse:
|
||||
"""查询剪辑计划关联的所有生成记录
|
||||
|
||||
返回该剪辑计划触发的所有 GenerationTask,按创建时间倒序。
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
# 验证计划存在 + 项目鉴权
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
if plan.project_id:
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
items = [
|
||||
GenerationTaskResponse(
|
||||
id=t.id,
|
||||
project_id=t.project_id,
|
||||
asset_library_id=t.asset_library_id,
|
||||
strategy_id=t.strategy_id,
|
||||
voice_library_id=t.voice_library_id,
|
||||
template_id=t.template_id,
|
||||
asset_ids=t.asset_ids,
|
||||
title_ids=t.title_ids,
|
||||
voice_ids=t.voice_ids,
|
||||
source_edit_plan_id=t.source_edit_plan_id or "",
|
||||
status=t.status.value if hasattr(t.status, "value") else t.status,
|
||||
progress=t.progress,
|
||||
result_count=t.result_count,
|
||||
error_message=t.error_message,
|
||||
)
|
||||
for t in tasks
|
||||
]
|
||||
return EditPlanGenerationsResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
# ── AI 推荐 & 封面生成端点(任务 3.09) ────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{plan_id}/ai-recommend",
|
||||
response_model=AIRecommendResponse,
|
||||
)
|
||||
def ai_recommend_clips(
|
||||
plan_id: str,
|
||||
body: AIRecommendRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AIRecommendResponse:
|
||||
"""AI 推荐片段方案
|
||||
|
||||
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
|
||||
|
||||
流程:
|
||||
1. 验证计划存在且状态为 draft/editing
|
||||
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
|
||||
3. 清除计划现有片段,按推荐方案重新创建
|
||||
4. 更新计划 config(cover/title/subtitle/bgm)和 total_duration
|
||||
5. 返回推荐方案详情
|
||||
|
||||
前端对接:
|
||||
- 请求体只需传 asset_ids(必填),editing_mode 和 target_duration 可选
|
||||
- 返回的 clips 可直接渲染到时间线
|
||||
- 返回的 config 包含推荐的封面/标题/字幕/BGM 配置
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
|
||||
# 验证计划存在
|
||||
try:
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
# 项目鉴权
|
||||
if plan.project_id:
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 验证状态:只允许 draft 或 editing
|
||||
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
if plan_status not in ("draft", "editing"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"AI 推荐仅支持 draft/editing 状态的计划,当前状态: {plan_status}",
|
||||
)
|
||||
|
||||
# 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步)
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||||
|
||||
result = run_ai_recommend(
|
||||
plan_id=plan_id,
|
||||
template_id=plan.template_id,
|
||||
asset_ids=body.asset_ids,
|
||||
editing_mode=body.editing_mode,
|
||||
target_duration=body.target_duration,
|
||||
)
|
||||
|
||||
# ── 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中 ──
|
||||
# TODO: 当前各 repo 方法内部 commit(),无法真正回滚。
|
||||
# 后续重构 repo 为 flush() 模式后,此处改为统一 commit。
|
||||
try:
|
||||
# 清除现有片段
|
||||
svc.delete_all_clips(plan_id)
|
||||
|
||||
# 按推荐方案创建新片段
|
||||
for clip_data in result["clips"]:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_data["clip_type"],
|
||||
order=clip_data["order"],
|
||||
text_content=clip_data.get("text_content", ""),
|
||||
duration=clip_data["duration"],
|
||||
transition_effect=clip_data.get("transition_effect", "cut"),
|
||||
asset_id=clip_data.get("asset_id", ""),
|
||||
start_time=clip_data.get("start_time", 0.0),
|
||||
config=clip_data.get("config", {}),
|
||||
)
|
||||
|
||||
# 更新计划 config 和 total_duration
|
||||
normalized_config = normalize_plan_config(result.get("config", {}))
|
||||
svc.update_plan(
|
||||
plan_id,
|
||||
config=normalized_config,
|
||||
total_duration=result["total_duration"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
|
||||
# 尝试回滚未提交的变更
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"AI 推荐结果写入失败: {exc}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
|
||||
plan_id,
|
||||
len(result["clips"]),
|
||||
result["total_duration"],
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return AIRecommendResponse(
|
||||
plan_id=plan_id,
|
||||
clips=[
|
||||
AIRecommendClipItem(
|
||||
clip_type=c["clip_type"],
|
||||
order=c["order"],
|
||||
text_content=c.get("text_content", ""),
|
||||
duration=c["duration"],
|
||||
transition_effect=c.get("transition_effect", "cut"),
|
||||
asset_id=c.get("asset_id", ""),
|
||||
start_time=c.get("start_time", 0.0),
|
||||
config=c.get("config", {}),
|
||||
)
|
||||
for c in result["clips"]
|
||||
],
|
||||
config=normalized_config,
|
||||
total_duration=result["total_duration"],
|
||||
confidence=result["confidence"],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{plan_id}/generate-cover",
|
||||
response_model=GenerateCoverResponse,
|
||||
)
|
||||
def generate_cover(
|
||||
plan_id: str,
|
||||
body: GenerateCoverRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面
|
||||
|
||||
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
|
||||
|
||||
流程:
|
||||
1. 验证计划存在
|
||||
2. 调用 AI 封面生成服务(当前为 stub,后续接入真实 AI)
|
||||
3. 更新 plan.config["cover"] 为生成的封面数据
|
||||
4. 返回封面数据
|
||||
|
||||
前端对接:
|
||||
- cover_type=ai_frame: AI 智能选帧(默认)
|
||||
- cover_type=manual: 手动选帧,需传 frame_time
|
||||
- cover_type=upload: 用户上传,接口返回空 image_url,前端自行上传后更新
|
||||
- cover_type=ai_regenerate: AI 重新生成
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
|
||||
# 验证计划存在
|
||||
try:
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
# 项目鉴权
|
||||
if plan.project_id:
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 调用 AI 封面生成服务
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
)
|
||||
|
||||
# 更新 plan.config["cover"]
|
||||
current_config = dict(plan.config)
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"AI 封面生成: plan_id=%s type=%s by user=%s",
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateCoverResponse(
|
||||
plan_id=plan_id,
|
||||
cover=cover_data,
|
||||
)
|
||||
|
||||
|
||||
# ── Timeline / Scene 端点(P2-6) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -505,6 +889,7 @@ def get_plan_timeline(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> TimelineResponse:
|
||||
"""获取剪辑计划的时间线场景数据
|
||||
|
||||
@@ -512,6 +897,9 @@ def get_plan_timeline(
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
# 项目鉴权
|
||||
if plan.project_id:
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
|
||||
# 按 order 排序
|
||||
|
||||
@@ -24,6 +24,7 @@ from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_template_config
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -86,6 +87,15 @@ class EditTemplateListResponse(BaseModel):
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _require_admin(current_user: AuthenticatedUser) -> None:
|
||||
"""校验当前用户是否为管理员,非管理员返回 403"""
|
||||
if not getattr(current_user.user, "is_admin", False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅管理员可执行此操作",
|
||||
)
|
||||
|
||||
|
||||
def _to_response(t: EditTemplate) -> EditTemplateResponse:
|
||||
return EditTemplateResponse(
|
||||
id=t.id,
|
||||
@@ -176,13 +186,16 @@ def create_template(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditTemplateResponse:
|
||||
"""创建模板(管理员)"""
|
||||
_require_admin(current_user)
|
||||
svc = EditTemplateService(db)
|
||||
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
|
||||
normalized_config = normalize_template_config(body.config)
|
||||
try:
|
||||
created = svc.create_template(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
template_type=body.template_type,
|
||||
config=body.config,
|
||||
config=normalized_config,
|
||||
preview_url=body.preview_url,
|
||||
sort_weight=body.sort_weight,
|
||||
)
|
||||
@@ -203,6 +216,7 @@ def update_template(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditTemplateResponse:
|
||||
"""更新模板"""
|
||||
_require_admin(current_user)
|
||||
svc = EditTemplateService(db)
|
||||
|
||||
# 解析状态
|
||||
@@ -216,13 +230,16 @@ def update_template(
|
||||
detail=f"无效的状态值: {body.status},可选值: active, inactive",
|
||||
)
|
||||
|
||||
# 标准化 config(如果提供了)
|
||||
config_to_update = normalize_template_config(body.config) if body.config is not None else None
|
||||
|
||||
try:
|
||||
result = svc.update_template(
|
||||
template_id,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
template_type=body.template_type,
|
||||
config=body.config,
|
||||
config=config_to_update,
|
||||
preview_url=body.preview_url,
|
||||
sort_weight=body.sort_weight,
|
||||
status=status_enum,
|
||||
@@ -249,6 +266,7 @@ def delete_template(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""删除模板(软删除 → 设为 inactive)"""
|
||||
_require_admin(current_user)
|
||||
svc = EditTemplateService(db)
|
||||
try:
|
||||
svc.deactivate_template(template_id)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import random
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -14,6 +16,7 @@ from app.schemas.generated_video import (
|
||||
ListGeneratedVideosResponse,
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
CreateGenerationTaskRequest,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
@@ -50,6 +53,9 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
batch_id=getattr(task, "batch_id", ""),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
@@ -84,6 +90,49 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _select_assets_from_library(
|
||||
assets: list,
|
||||
mode: str,
|
||||
count: int,
|
||||
) -> list[str]:
|
||||
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
|
||||
|
||||
Args:
|
||||
assets: 素材库中所有素材(Asset 实体列表)
|
||||
mode: 选取模式 — all=全部, random=随机, smart=按质量评分
|
||||
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表
|
||||
"""
|
||||
ready_video_assets = [a for a in assets if a.status.value == "ready" and a.mime_type.startswith("video")]
|
||||
|
||||
if not ready_video_assets:
|
||||
return []
|
||||
|
||||
if mode == "random":
|
||||
selected = (
|
||||
ready_video_assets if count <= 0 else random.sample(ready_video_assets, min(count, len(ready_video_assets)))
|
||||
)
|
||||
return [a.id for a in selected]
|
||||
|
||||
if mode == "smart":
|
||||
# 按质量分降序排列(质量分高的优先),质量分相同时按时长降序
|
||||
sorted_assets = sorted(
|
||||
ready_video_assets,
|
||||
key=lambda a: (
|
||||
a.quality_score if a.quality_score is not None else 0.0,
|
||||
a.duration if a.duration is not None else 0.0,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
selected = sorted_assets if count <= 0 else sorted_assets[:count]
|
||||
return [a.id for a in selected]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
return [a.id for a in ready_video_assets]
|
||||
|
||||
|
||||
def _resolve_project_and_library(
|
||||
request: CreateGenerationTaskRequest,
|
||||
project_repository: Any,
|
||||
@@ -121,7 +170,7 @@ def _resolve_project_and_library(
|
||||
return project_id, asset_library_id
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=GenerationTaskResponse)
|
||||
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
|
||||
def create_generation_task(
|
||||
request: CreateGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -129,12 +178,13 @@ def create_generation_task(
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> GenerationTaskResponse:
|
||||
) -> BatchGenerationTaskResponse:
|
||||
project_id, asset_library_id = _resolve_project_and_library(
|
||||
request, project_repository, asset_library_repository, asset_repository, authenticated_user
|
||||
)
|
||||
|
||||
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
|
||||
resolved_asset_ids: list[str] = list(request.asset_ids)
|
||||
if asset_library_id:
|
||||
library = asset_library_repository.get(asset_library_id)
|
||||
if library is None or (project_id and library.project_id != project_id):
|
||||
@@ -143,22 +193,42 @@ def create_generation_task(
|
||||
assets = asset_repository.find_by_library(asset_library_id)
|
||||
_ensure_library_has_ready_video_assets(assets)
|
||||
|
||||
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
|
||||
if not resolved_asset_ids:
|
||||
resolved_asset_ids = _select_assets_from_library(
|
||||
assets,
|
||||
mode=request.asset_select_mode,
|
||||
count=request.asset_select_count,
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=project_id,
|
||||
asset_library_id=asset_library_id,
|
||||
strategy_id=request.strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=request.asset_ids,
|
||||
title_ids=request.title_ids,
|
||||
voice_ids=request.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
count = request.count
|
||||
created_tasks = []
|
||||
# 同批次任务共享 batch_id,用于视频查重时批次内比对
|
||||
batch_id = uuid.uuid4().hex if count > 1 else ""
|
||||
|
||||
for _ in range(count):
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=project_id,
|
||||
asset_library_id=asset_library_id,
|
||||
strategy_id=request.strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=resolved_asset_ids,
|
||||
title_ids=request.title_ids,
|
||||
voice_ids=request.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
return _to_generation_task_response(task)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
created_tasks.append(task)
|
||||
|
||||
items = [_to_generation_task_response(t) for t in created_tasks]
|
||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||
@@ -234,6 +304,8 @@ def retry_generation_task(
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""标签 CRUD 路由。"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_tag_repository
|
||||
from app.schemas.tag import (
|
||||
CreateTagRequest,
|
||||
ListTagsResponse,
|
||||
TagResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.domain import Tag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=ListTagsResponse)
|
||||
def list_tags(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
tag_repository: Any = Depends(get_tag_repository),
|
||||
) -> ListTagsResponse:
|
||||
"""列出当前用户的标签。"""
|
||||
user_id = authenticated_user.user.id
|
||||
items = tag_repository.list_by_user(user_id, skip=skip, limit=limit)
|
||||
total = tag_repository.count_by_user(user_id)
|
||||
return ListTagsResponse(
|
||||
items=[TagResponse(id=t.id, name=t.name, created_at=t.created_at) for t in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TagResponse, status_code=201)
|
||||
def create_tag(
|
||||
request: CreateTagRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
tag_repository: Any = Depends(get_tag_repository),
|
||||
) -> TagResponse:
|
||||
"""创建标签(同用户同名去重,返回 409)。"""
|
||||
user_id = authenticated_user.user.id
|
||||
existing = tag_repository.find_by_name(user_id, request.name)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="标签名称已存在")
|
||||
tag = Tag.create(user_id=user_id, name=request.name)
|
||||
created = tag_repository.create(tag)
|
||||
return TagResponse(id=created.id, name=created.name, created_at=created.created_at)
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=204)
|
||||
def delete_tag(
|
||||
tag_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
tag_repository: Any = Depends(get_tag_repository),
|
||||
) -> None:
|
||||
"""删除标签(同时清理素材关联)。"""
|
||||
tag = tag_repository.get(tag_id)
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=404, detail="标签不存在")
|
||||
if tag.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权删除该标签")
|
||||
tag_repository.delete(tag_id)
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.template import (
|
||||
@@ -13,6 +15,7 @@ from app.schemas.template import (
|
||||
ListTemplatesResponse,
|
||||
SegmentResponse,
|
||||
TemplateResponse,
|
||||
ToggleFavoriteResponse,
|
||||
UpdateTemplateRequest,
|
||||
ValidateTemplateRequest,
|
||||
ValidateTemplateResponse,
|
||||
@@ -20,6 +23,8 @@ from app.schemas.template import (
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
|
||||
from packages.application.template.commands import (
|
||||
CreateCategoryCommand,
|
||||
@@ -92,9 +97,13 @@ def list_templates(
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ListTemplatesResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = ListTemplatesUseCase(template_repository)
|
||||
templates = use_case.execute(user_id, skip=skip, limit=limit)
|
||||
total = template_repository.count_by_user(user_id)
|
||||
try:
|
||||
use_case = ListTemplatesUseCase(template_repository)
|
||||
templates = use_case.execute(user_id, skip=skip, limit=limit)
|
||||
total = template_repository.count_by_user(user_id)
|
||||
except Exception:
|
||||
logger.exception("list_templates 查询失败: user_id=%s", user_id)
|
||||
return ListTemplatesResponse(items=[], total=0)
|
||||
return ListTemplatesResponse(
|
||||
items=[_to_response(t) for t in templates],
|
||||
total=total,
|
||||
@@ -108,8 +117,12 @@ def get_template(
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> TemplateResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = GetTemplateUseCase(template_repository)
|
||||
template = use_case.execute(template_id, user_id)
|
||||
try:
|
||||
use_case = GetTemplateUseCase(template_repository)
|
||||
template = use_case.execute(template_id, user_id)
|
||||
except Exception:
|
||||
logger.exception("get_template 查询失败: template_id=%s", template_id)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
|
||||
if template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
return _to_response(template)
|
||||
@@ -207,6 +220,25 @@ def delete_template(
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse)
|
||||
def toggle_favorite(
|
||||
template_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ToggleFavoriteResponse:
|
||||
"""切换模板收藏状态(当前为兼容端点,始终返回 false)"""
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = GetTemplateUseCase(template_repository)
|
||||
try:
|
||||
template = use_case.execute(template_id, user_id)
|
||||
except Exception:
|
||||
logger.exception("toggle_favorite 查询失败: template_id=%s", template_id)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
if template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
return ToggleFavoriteResponse(id=template_id, is_favorite=False)
|
||||
|
||||
|
||||
# ── Validate template ──
|
||||
|
||||
|
||||
@@ -246,8 +278,12 @@ def list_categories(
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ListCategoriesResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = ListCategoriesUseCase(template_repository)
|
||||
categories = use_case.execute(user_id)
|
||||
try:
|
||||
use_case = ListCategoriesUseCase(template_repository)
|
||||
categories = use_case.execute(user_id)
|
||||
except Exception:
|
||||
logger.exception("list_categories 查询失败: user_id=%s", user_id)
|
||||
return ListCategoriesResponse(items=[])
|
||||
return ListCategoriesResponse(
|
||||
items=[CategoryResponse(id=c.id, user_id=c.user_id, name=c.name, created_at=c.created_at) for c in categories],
|
||||
)
|
||||
|
||||
@@ -2,24 +2,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_cosyvoice_service, get_db_session
|
||||
from app.dependencies import (
|
||||
get_cosyvoice_service,
|
||||
get_db_session,
|
||||
get_user_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
get_voice_library_repository,
|
||||
)
|
||||
from app.schemas.tts import (
|
||||
ListTTSJobResponse,
|
||||
SaveToLibraryRequest,
|
||||
SaveToLibraryResponse,
|
||||
TTSJobResponse,
|
||||
TTSStatusResponse,
|
||||
TTSSynthesizeRequest,
|
||||
TTSSynthesizeResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.tts_job_repository import (
|
||||
SQLAlchemyTTSJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
from packages.application.tts_job.use_cases import (
|
||||
CreateTTSJobUseCase,
|
||||
DeleteTTSJobUseCase,
|
||||
@@ -29,6 +40,14 @@ from packages.application.tts_job.use_cases import (
|
||||
TTSJobNotFoundError,
|
||||
)
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
|
||||
from packages.application.voice_library.use_cases import (
|
||||
CreateVoiceLibraryUseCase,
|
||||
QuotaExceededError,
|
||||
)
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -70,12 +89,29 @@ def synthesize(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
) -> TTSSynthesizeResponse:
|
||||
"""发起 TTS 合成任务。
|
||||
|
||||
创建 TTS 任务 → 提交 CosyVoice 合成 → 触发 Celery 异步轮询。
|
||||
与音色克隆接口保持一致:CosyVoice 失败时不抛 500,而是返回 201 + failed 状态任务记录。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 校验 voice_clone_profile_id 归属(防止越权使用他人克隆音色)
|
||||
if request.voice_clone_profile_id:
|
||||
profile = voice_clone_repo.get(request.voice_clone_profile_id)
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Voice clone profile not found",
|
||||
)
|
||||
if profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to voice clone profile",
|
||||
)
|
||||
|
||||
use_case = CreateTTSJobUseCase(repository)
|
||||
job = use_case.execute(
|
||||
user_id=user_id,
|
||||
@@ -91,19 +127,40 @@ def synthesize(
|
||||
repository=repository,
|
||||
cosyvoice_service=cosyvoice_service,
|
||||
)
|
||||
job = workflow.start_synthesis(job.id)
|
||||
|
||||
try:
|
||||
job = workflow.start_synthesis(job.id)
|
||||
except Exception as e:
|
||||
# 兜底:workflow 内部已捕获 CosyVoiceError / ValueError,
|
||||
# 但 DB 异常、网络异常等意外错误可能逃逸。
|
||||
# 与音色克隆接口保持一致:标记 failed,返回 201,不抛 500。
|
||||
logger.error(f"TTS 合成异常: job_id={job.id}, error={e}", exc_info=True)
|
||||
try:
|
||||
job = workflow.process_synthesis_failure(job.id, str(e))
|
||||
except Exception as inner_e:
|
||||
logger.error(f"标记 TTS job 失败时出错: job_id={job.id}, error={inner_e}")
|
||||
|
||||
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
|
||||
if job.status.value == "processing":
|
||||
task_id = (job.metadata or {}).get("cosyvoice_task_id", "")
|
||||
if task_id:
|
||||
try:
|
||||
# 分段合成任务 vs 普通单段任务
|
||||
segment_task_ids = (job.metadata or {}).get("segment_task_ids", [])
|
||||
is_segment = len(segment_task_ids) > 0
|
||||
|
||||
try:
|
||||
if is_segment:
|
||||
from worker_app.tasks import process_tts_segment_synthesis
|
||||
|
||||
process_tts_segment_synthesis.delay(job.id)
|
||||
else:
|
||||
from worker_app.tasks import process_tts_synthesis
|
||||
|
||||
process_tts_synthesis.delay(job.id)
|
||||
except Exception as e:
|
||||
# Celery 调度失败,标记 job 为 failed
|
||||
except Exception as e:
|
||||
# Celery 调度失败,标记 job 为 failed
|
||||
try:
|
||||
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
|
||||
|
||||
return TTSSynthesizeResponse(
|
||||
job_id=job.id,
|
||||
@@ -187,3 +244,127 @@ def delete_tts_job(
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/jobs/{job_id}/save-to-library",
|
||||
response_model=SaveToLibraryResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def save_tts_job_to_library(
|
||||
job_id: str,
|
||||
request: SaveToLibraryRequest = SaveToLibraryRequest(),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> SaveToLibraryResponse:
|
||||
"""将已完成的 TTS 合成结果保存到配音库。
|
||||
|
||||
自动携带音色名、时长、语速等元信息。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 获取 TTS job
|
||||
get_use_case = GetTTSJobUseCase(tts_repository)
|
||||
try:
|
||||
job = get_use_case.execute(job_id, user_id)
|
||||
except TTSJobNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
|
||||
# 校验已完成
|
||||
if not job.is_completed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="TTS job is not completed yet",
|
||||
)
|
||||
|
||||
# 构建配音素材名称
|
||||
name = request.name or f"TTS-{job.id[:8]}"
|
||||
|
||||
# 构建元信息
|
||||
metadata_ = {
|
||||
"source": "tts_job",
|
||||
"tts_job_id": job.id,
|
||||
"format": job.format,
|
||||
"sample_rate": job.sample_rate,
|
||||
}
|
||||
if job.metadata:
|
||||
# 保留原始 job 的有用元信息
|
||||
for key in ("speed", "language"):
|
||||
if key in job.metadata:
|
||||
metadata_[key] = job.metadata[key]
|
||||
|
||||
# 获取用户套餐(用于配额检查)
|
||||
user = user_repository.find_by_id(user_id)
|
||||
plan_name = getattr(user, "subscription_plan", "free") if user else "free"
|
||||
|
||||
# 构建命令并执行
|
||||
command = CreateVoiceLibraryCommand(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
text=job.input_text,
|
||||
voice_provider="cosyvoice",
|
||||
voice_id=job.voice_id,
|
||||
voice_name=job.voice_model or "",
|
||||
audio_url=job.output_audio_url,
|
||||
duration=job.duration,
|
||||
file_size=job.file_size,
|
||||
status="completed",
|
||||
project_id=job.project_id or "",
|
||||
tags=[],
|
||||
metadata_=metadata_,
|
||||
)
|
||||
|
||||
use_case = CreateVoiceLibraryUseCase(voice_library_repository)
|
||||
try:
|
||||
item = use_case.execute(command, plan_name=plan_name or "free")
|
||||
except QuotaExceededError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
)
|
||||
|
||||
return SaveToLibraryResponse(
|
||||
id=item.id,
|
||||
name=item.name,
|
||||
audio_url=item.audio_url,
|
||||
duration=item.duration,
|
||||
voice_id=item.voice_id,
|
||||
voice_name=item.voice_name,
|
||||
status=item.status,
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/ws/tts/stream")
|
||||
async def tts_websocket_stream(
|
||||
websocket: WebSocket,
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> None:
|
||||
"""WebSocket 流式 TTS 合成。
|
||||
|
||||
协议:
|
||||
1. 客户端发送 JSON 文本帧: {"text": "...", "voice_id": "...", ...}
|
||||
2. 服务端发送 JSON 状态帧 + 二进制音频帧
|
||||
3. 完成时发送 JSON 结束帧
|
||||
"""
|
||||
await websocket.accept()
|
||||
try:
|
||||
message = await websocket.receive_json()
|
||||
params = {
|
||||
"text": message.get("text", ""),
|
||||
"voice_id": message.get("voice_id", ""),
|
||||
"sample_rate": message.get("sample_rate", 0),
|
||||
"format": message.get("format", "mp3"),
|
||||
"speed": message.get("speed", 1.0),
|
||||
}
|
||||
streaming_service = TTSStreamingService(cosyvoice_service)
|
||||
await streaming_service.synthesize_and_stream(websocket, params)
|
||||
except WebSocketDisconnect:
|
||||
logger.info("WebSocket 客户端断开连接")
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket 流式合成异常: {e}", exc_info=True)
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": f"服务异常: {e}"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
@@ -99,6 +100,7 @@ def _submit_ingest_job(
|
||||
library_id: str,
|
||||
storage_key: str,
|
||||
ingest_job_repository: Any,
|
||||
file_hash: str = "",
|
||||
) -> Any:
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
@@ -106,6 +108,7 @@ def _submit_ingest_job(
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
@@ -176,6 +179,7 @@ async def complete_direct_upload(
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DirectUploadCompleteResponse:
|
||||
"""确认浏览器直传完成并创建导入任务。"""
|
||||
@@ -199,11 +203,32 @@ async def complete_direct_upload(
|
||||
if not file_exists:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
|
||||
|
||||
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
|
||||
if request.file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中: library=%s hash=%s existing_asset=%s",
|
||||
request.library_id,
|
||||
request.file_hash,
|
||||
existing.id,
|
||||
)
|
||||
return DirectUploadCompleteResponse(
|
||||
storage_key=normalized_key,
|
||||
ingest_job_id="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
job = _submit_ingest_job(
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=normalized_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
|
||||
|
||||
@@ -218,15 +243,38 @@ async def upload_asset(
|
||||
project_id: str = Form(..., min_length=1, description="项目 ID"),
|
||||
library_id: str = Form(..., min_length=1, description="素材库 ID"),
|
||||
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
|
||||
file_hash: str = Form(default="", description="文件 MD5 哈希,用于去重检测"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> UploadAssetResponse:
|
||||
"""上传素材文件并触发导入流水线。"""
|
||||
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
|
||||
|
||||
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
|
||||
if file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=library_id,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中(multipart): library=%s hash=%s existing_asset=%s",
|
||||
library_id,
|
||||
file_hash,
|
||||
existing.id,
|
||||
)
|
||||
return UploadAssetResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id="",
|
||||
url="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
# P2-5: 服务端验证 MIME 类型
|
||||
validated_content_type = _validate_mime_type(file.content_type)
|
||||
|
||||
@@ -255,6 +303,7 @@ async def upload_asset(
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
|
||||
return UploadAssetResponse(
|
||||
|
||||
@@ -39,6 +39,7 @@ from packages.adapters.sqlalchemy_impl.project_repository import (
|
||||
SQLAlchemyProjectRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.session import build_session_factory
|
||||
from packages.adapters.sqlalchemy_impl.tag_repository import SQLAlchemyTagRepository
|
||||
from packages.adapters.sqlalchemy_impl.title_library_repository import (
|
||||
SQLAlchemyTitleLibraryRepository,
|
||||
)
|
||||
@@ -58,6 +59,7 @@ from packages.ports.generation_task_repository import GenerationTaskRepository
|
||||
from packages.ports.ingest_job_repository import IngestJobRepository
|
||||
from packages.ports.job_repository import JobRepository
|
||||
from packages.ports.project_repository import ProjectRepository
|
||||
from packages.ports.tag_repository import TagRepository
|
||||
from packages.ports.title_library_repository import TitleLibraryRepository
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
|
||||
@@ -138,6 +140,13 @@ def get_project_repository(
|
||||
return SQLAlchemyProjectRepository(session)
|
||||
|
||||
|
||||
def get_tag_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> TagRepository:
|
||||
"""Provide the SQLAlchemy tag repository implementation."""
|
||||
return SQLAlchemyTagRepository(session)
|
||||
|
||||
|
||||
def get_user_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> UserRepository:
|
||||
|
||||
@@ -25,6 +25,12 @@ class UpdateAssetReviewRequest(BaseModel):
|
||||
review_status: str = Field(..., pattern="^(pending_review|approved|rejected)$")
|
||||
|
||||
|
||||
class UpdateAssetRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
metadata: dict[str, object] | None = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
@@ -34,6 +40,7 @@ class AssetResponse(BaseModel):
|
||||
mime_type: str
|
||||
metadata: dict[str, object]
|
||||
file_size: int
|
||||
file_url: str | None = None
|
||||
thumbnail_url: str | None = None
|
||||
duration: float | None = None
|
||||
width: int | None = None
|
||||
@@ -44,7 +51,24 @@ class AssetResponse(BaseModel):
|
||||
classification_status: str
|
||||
quality_score: float | None = None
|
||||
uploaded_by_user_id: str
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
"""批量删除请求。"""
|
||||
|
||||
ids: list[str] = Field(..., min_length=1, max_length=100, description="要删除的素材 ID 列表")
|
||||
|
||||
|
||||
class BatchDeleteResponse(BaseModel):
|
||||
"""批量删除响应。"""
|
||||
|
||||
deleted_count: int = Field(..., ge=0, description="实际删除数量")
|
||||
failed_ids: list[str] = Field(default_factory=list, description="删除失败的 ID 列表")
|
||||
|
||||
|
||||
class ListAssetsResponse(BaseModel):
|
||||
items: list[AssetResponse]
|
||||
total: int = Field(default=0, ge=0)
|
||||
skip: int = Field(default=0, ge=0)
|
||||
limit: int = Field(default=100, ge=1)
|
||||
|
||||
@@ -18,3 +18,8 @@ class AssetLibraryResponse(BaseModel):
|
||||
|
||||
class ListAssetLibrariesResponse(BaseModel):
|
||||
items: list[AssetLibraryResponse]
|
||||
|
||||
|
||||
class EnsureDefaultLibraryRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
kind: str = Field(..., pattern="^(video|voice|image)$")
|
||||
|
||||
@@ -36,9 +36,12 @@ class ChunkedUploadStatusResponse(BaseModel):
|
||||
class ChunkedUploadCompleteRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1, description="Project ID")
|
||||
library_id: str = Field(..., min_length=1, description="Asset library ID")
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class ChunkedUploadCompleteResponse(BaseModel):
|
||||
storage_key: str = Field(..., description="Storage key")
|
||||
ingest_job_id: str = Field(..., description="Ingest job ID")
|
||||
url: str = Field(..., description="File URL")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
@@ -19,6 +19,18 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
asset_ids: list[str] = Field(default_factory=list)
|
||||
title_ids: list[str] = Field(default_factory=list)
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
# ── 来源剪辑计划 ──
|
||||
source_edit_plan_id: str = ""
|
||||
# ── 批量生成 ──
|
||||
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
|
||||
# ── 素材库自动匹配 ──
|
||||
asset_select_mode: str = Field(
|
||||
default="all",
|
||||
description="素材选取模式:all=全部ready视频, random=随机选取, smart=智能匹配(按质量/时长评分)",
|
||||
)
|
||||
asset_select_count: int = Field(
|
||||
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -43,12 +55,22 @@ class GenerationTaskResponse(BaseModel):
|
||||
asset_ids: list[str] = Field(default_factory=list)
|
||||
title_ids: list[str] = Field(default_factory=list)
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
source_edit_plan_id: str = ""
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
error_message: str
|
||||
|
||||
|
||||
class BatchGenerationTaskResponse(BaseModel):
|
||||
"""批量生成任务响应。"""
|
||||
|
||||
items: list[GenerationTaskResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ListGenerationTasksResponse(BaseModel):
|
||||
"""用户级生成任务列表响应(跨 project)。"""
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""标签相关 Schema。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateTagRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ListTagsResponse(BaseModel):
|
||||
items: list[TagResponse]
|
||||
total: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class TagAssetsRequest(BaseModel):
|
||||
tag_ids: list[str] = Field(..., min_length=1, max_length=50)
|
||||
@@ -44,10 +44,16 @@ class TemplateResponse(BaseModel):
|
||||
estimated_duration: float = 0.0
|
||||
segments: List[SegmentResponse] = Field(default_factory=list)
|
||||
is_active: bool = True
|
||||
is_favorite: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ToggleFavoriteResponse(BaseModel):
|
||||
id: str
|
||||
is_favorite: bool
|
||||
|
||||
|
||||
class ListTemplatesResponse(BaseModel):
|
||||
items: List[TemplateResponse]
|
||||
total: int = 0
|
||||
|
||||
@@ -83,3 +83,21 @@ class ListTTSJobResponse(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class SaveToLibraryRequest(BaseModel):
|
||||
"""保存到配音库请求。"""
|
||||
|
||||
name: Optional[str] = Field(None, description="配音素材名称,留空则自动生成")
|
||||
|
||||
|
||||
class SaveToLibraryResponse(BaseModel):
|
||||
"""保存到配音库响应。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
audio_url: str
|
||||
duration: float
|
||||
voice_id: str
|
||||
voice_name: str
|
||||
status: str
|
||||
|
||||
@@ -6,12 +6,7 @@ class UploadAssetRequest(BaseModel):
|
||||
|
||||
project_id: str = Field(..., min_length=1, description="项目 ID")
|
||||
library_id: str = Field(..., min_length=1, description="素材库 ID")
|
||||
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
url: str = Field(..., description="Public URL of uploaded file")
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class DirectUploadPrepareRequest(BaseModel):
|
||||
@@ -20,6 +15,7 @@ class DirectUploadPrepareRequest(BaseModel):
|
||||
filename: str = Field(..., min_length=1, max_length=255)
|
||||
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
|
||||
file_size: int = Field(..., gt=0)
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class DirectUploadPrepareResponse(BaseModel):
|
||||
@@ -35,8 +31,19 @@ class DirectUploadCompleteRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class DirectUploadCompleteResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
url: str = Field(..., description="Public URL of uploaded file")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
@@ -44,6 +44,7 @@ class EditPlanService:
|
||||
self,
|
||||
*,
|
||||
template_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
@@ -52,10 +53,18 @@ class EditPlanService:
|
||||
|
||||
Args:
|
||||
template_id: 按模板 ID 筛选
|
||||
project_id: 按项目 ID 筛选
|
||||
status: 按状态筛选
|
||||
skip: 分页偏移
|
||||
limit: 每页数量
|
||||
"""
|
||||
if project_id:
|
||||
return self._plan_repo.list_by_project(
|
||||
project_id,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
if template_id:
|
||||
return self._plan_repo.list_by_template(
|
||||
template_id,
|
||||
@@ -69,13 +78,22 @@ class EditPlanService:
|
||||
self,
|
||||
*,
|
||||
template_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
) -> int:
|
||||
"""统计计划数量
|
||||
|
||||
Note:
|
||||
当指定 template_id 时,通过全量查询计算 total(repo 限制)。
|
||||
当指定 template_id/project_id 时,通过全量查询计算 total(repo 限制)。
|
||||
"""
|
||||
if project_id:
|
||||
all_matching = self._plan_repo.list_by_project(
|
||||
project_id,
|
||||
status=status,
|
||||
skip=0,
|
||||
limit=10000,
|
||||
)
|
||||
return len(all_matching)
|
||||
if template_id:
|
||||
all_matching = self._plan_repo.list_by_template(
|
||||
template_id,
|
||||
@@ -104,6 +122,8 @@ class EditPlanService:
|
||||
*,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
total_duration: float = 0.0,
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
) -> EditPlan:
|
||||
"""创建剪辑计划
|
||||
|
||||
@@ -115,6 +135,8 @@ class EditPlanService:
|
||||
name=name,
|
||||
config=config,
|
||||
total_duration=total_duration,
|
||||
project_id=project_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
created = self._plan_repo.create(plan)
|
||||
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
|
||||
@@ -141,6 +163,9 @@ class EditPlanService:
|
||||
name=name.strip() if name is not None else existing.name,
|
||||
status=existing.status,
|
||||
total_duration=total_duration if total_duration is not None else existing.total_duration,
|
||||
source_edit_plan_id=existing.source_edit_plan_id,
|
||||
project_id=existing.project_id,
|
||||
created_by_user_id=existing.created_by_user_id,
|
||||
config=config if config is not None else existing.config,
|
||||
created_at=existing.created_at,
|
||||
updated_at=existing.updated_at,
|
||||
@@ -468,6 +493,9 @@ class EditPlanService:
|
||||
name=plan.name,
|
||||
status=plan.status,
|
||||
total_duration=plan.total_duration,
|
||||
source_edit_plan_id=plan.source_edit_plan_id,
|
||||
project_id=plan.project_id,
|
||||
created_by_user_id=plan.created_by_user_id,
|
||||
config=new_config,
|
||||
created_at=plan.created_at,
|
||||
updated_at=plan.updated_at,
|
||||
|
||||
@@ -5,6 +5,6 @@ test.describe("Authentication page", () => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(page.getByLabel("密码")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /登\s*录/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -19,9 +19,8 @@ const routeBrowserApiToTestApi = async (
|
||||
});
|
||||
};
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
@@ -41,27 +40,19 @@ async function loginWithRetry(
|
||||
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
type TemplateResponse = { id: string };
|
||||
type AssetListResponse = {
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
mime_type?: string;
|
||||
file_type?: string;
|
||||
}>;
|
||||
};
|
||||
type GenerationTaskResponse = {
|
||||
id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
result_count: number;
|
||||
error_message?: string | null;
|
||||
edit_plan_id?: string | null;
|
||||
};
|
||||
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test("generates a video and shows result in product library", async ({
|
||||
|
||||
test("walks through 5-step wizard and starts generation", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
@@ -69,29 +60,29 @@ test.describe("Core generation flow", () => {
|
||||
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-generation-${suffix}@example.com`;
|
||||
const username = `e2e_generation_${suffix}`;
|
||||
const libraryName = `E2E Gen Library ${suffix}`;
|
||||
const email = `e2e-gen-${suffix}@example.com`;
|
||||
const username = `e2e_gen_${suffix}`;
|
||||
const libraryName = `E2E Gen Lib ${suffix}`;
|
||||
|
||||
// Register
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
});
|
||||
expect(register.status(), await register.text()).toBe(201);
|
||||
expect(register.status()).toBe(201);
|
||||
const registerData = (await register.json()) as { user_id: string };
|
||||
|
||||
// Login
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
expect(login.status()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
// Create project
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `E2E Gen Project ${suffix}` },
|
||||
data: { name: `E2E Gen Proj ${suffix}` },
|
||||
});
|
||||
expect(project.status(), await project.text()).toBe(200);
|
||||
expect(project.status()).toBe(200);
|
||||
const projectData = (await project.json()) as ProjectResponse;
|
||||
|
||||
// Create asset library
|
||||
@@ -99,11 +90,11 @@ test.describe("Core generation flow", () => {
|
||||
headers,
|
||||
data: { project_id: projectData.id, name: libraryName, kind: "video" },
|
||||
});
|
||||
expect(library.status(), await library.text()).toBe(200);
|
||||
expect(library.status()).toBe(200);
|
||||
const libraryData = (await library.json()) as LibraryResponse;
|
||||
|
||||
// Upload source video
|
||||
const sourceFileName = "e2e-generation-source.mp4";
|
||||
const sourceFileName = "e2e-gen-source.mp4";
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
@@ -112,14 +103,13 @@ test.describe("Core generation flow", () => {
|
||||
file: {
|
||||
name: sourceFileName,
|
||||
mimeType: "video/mp4",
|
||||
buffer: Buffer.from("e2e generation source video data"),
|
||||
buffer: Buffer.from("e2e source data"),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(upload.status(), await upload.text()).toBe(200);
|
||||
expect(upload.status()).toBe(200);
|
||||
|
||||
// Wait for asset to be ready
|
||||
let sourceAssetId = "";
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -131,13 +121,35 @@ test.describe("Core generation flow", () => {
|
||||
const data = (await assets.json()) as AssetListResponse;
|
||||
const asset = data.items.find((a) => a.name === sourceFileName);
|
||||
if (!asset) return "missing";
|
||||
sourceAssetId = asset.id;
|
||||
return asset.status;
|
||||
},
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
|
||||
)
|
||||
.toBe("ready");
|
||||
|
||||
// Create an editing template so the generate page has at least one template
|
||||
// (templates are now loaded from API; new users have none by default)
|
||||
const template = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 测试模板 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 30,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
tags: ["e2e"],
|
||||
},
|
||||
});
|
||||
expect(template.status(), await template.text()).toBe(201);
|
||||
const templateData = (await template.json()) as TemplateResponse;
|
||||
expect(templateData.id).toBeTruthy();
|
||||
|
||||
// Set auth in localStorage
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
@@ -166,74 +178,79 @@ test.describe("Core generation flow", () => {
|
||||
|
||||
// Navigate to generate page
|
||||
await page.goto("/app/generate");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "一键生成" }),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Fill in title
|
||||
const titleText = `E2E 生成测试 ${suffix}`;
|
||||
await page.getByPlaceholder("请输入视频标题").fill(titleText);
|
||||
|
||||
// Select the uploaded material
|
||||
await expect(page.locator(".xx-material-card").first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const materialCard = page
|
||||
.locator(".xx-material-card")
|
||||
.filter({ hasText: sourceFileName });
|
||||
await materialCard.click();
|
||||
await expect(materialCard).toHaveClass(/xx-material-card-selected/, {
|
||||
timeout: 5_000,
|
||||
await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// Click generate
|
||||
const generateButton = page.getByRole("button", {
|
||||
name: "开始生成视频",
|
||||
});
|
||||
await expect(generateButton).toBeEnabled();
|
||||
// Step 1: template - default selected, click next
|
||||
await expect(page.locator(".xx-choice-item.selected")).toBeVisible();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// Wait for the generation task to be created
|
||||
const createTaskResponsePromise = page.waitForResponse(
|
||||
// Step 2: select material
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible();
|
||||
const librarySelect = page.locator("select").first();
|
||||
await librarySelect.selectOption({ label: libraryName });
|
||||
const materialLabel = page.getByText(sourceFileName).locator("..");
|
||||
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await materialLabel.locator("input[type='checkbox']").check();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// Step 3: title
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible();
|
||||
const titleText = `E2E Test ${suffix}`;
|
||||
await page.getByPlaceholder("输入自定义标题…").fill(titleText);
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// Step 4: voice
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible();
|
||||
const firstVoiceCard = page.locator(".xx-voice-choice-item").first();
|
||||
await firstVoiceCard.click();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// Step 5: confirm and generate
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible();
|
||||
|
||||
// Wait for plan creation API to be called
|
||||
const createPlanPromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/edit-plans") &&
|
||||
response.request().method() === "POST" &&
|
||||
!response.url().includes("/generate"),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await generateButton.click();
|
||||
|
||||
// Verify plan creation
|
||||
try {
|
||||
const planResponse = await createTaskResponsePromise;
|
||||
expect(planResponse.ok()).toBe(true);
|
||||
const planData = (await planResponse.json()) as { id: string };
|
||||
expect(planData.id).toBeTruthy();
|
||||
// Click generate button
|
||||
await page
|
||||
.locator(".xx-btn-primary")
|
||||
.filter({ hasText: "确认生成" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Wait for generation to show progress or completion
|
||||
await expect(page.getByText(/正在生成视频|视频生成完成/)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch (e) {
|
||||
// If plan creation response not caught, just check that generation started
|
||||
const hasError = await page.getByText(/生成失败/).isVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
if (hasError) {
|
||||
// It's OK if generation fails quickly (e.g., no worker), test the flow
|
||||
}
|
||||
}
|
||||
// Verify plan was created successfully
|
||||
const planResp = await createPlanPromise;
|
||||
expect(planResp.ok()).toBeTruthy();
|
||||
const planData = (await planResp.json()) as { id: string };
|
||||
expect(planData.id).toBeTruthy();
|
||||
|
||||
// Navigate to products page (verify page renders, not necessarily with products)
|
||||
// Generation is async and may not complete in test environment; just verify the page loads
|
||||
// Generation may fail in test env (no worker), that's OK
|
||||
// Just verify the flow started - check page shows generation-related UI
|
||||
await page
|
||||
.getByText(/生成中|生成完成|生成失败/)
|
||||
.isVisible({ timeout: 15_000 })
|
||||
.catch(() => false);
|
||||
// If we see progress or result, great; if not, flow still reached the end
|
||||
// which is sufficient for an E2E smoke test
|
||||
|
||||
// Verify product library page loads (smoke: just verify page renders)
|
||||
await page.goto("/app/products");
|
||||
await expect(page).toHaveURL(/\/app\/products/);
|
||||
// Verify page container exists = page rendered correctly
|
||||
// (works in all states: loading/error/success - more reliable than checking search input)
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Navigate to history page
|
||||
await page.goto("/app/history");
|
||||
await expect(page.getByRole("heading", { name: "任务历史" })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText("全部")).toBeVisible();
|
||||
});
|
||||
|
||||
test("generation task API creates and lists tasks", async ({ request }) => {
|
||||
@@ -251,19 +268,16 @@ test.describe("Core generation flow", () => {
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
// Create project
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `API Gen Test ${suffix}` },
|
||||
data: { name: `E2E API Proj ${suffix}` },
|
||||
});
|
||||
expect(project.status()).toBe(200);
|
||||
|
||||
// Get user tasks
|
||||
// List generation tasks via task center API
|
||||
const tasks = await request.get(`${apiBase}/tasks`, { headers });
|
||||
expect(tasks.status()).toBe(200);
|
||||
const tasksData = (await tasks.json()) as {
|
||||
items: Array<{ id: string; task_type: string }>;
|
||||
};
|
||||
const tasksData = await tasks.json();
|
||||
expect(Array.isArray(tasksData.items)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -21,7 +21,7 @@ const routeBrowserApiToTestApi = async (
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
@@ -60,6 +60,19 @@ test.describe("Title library flow", () => {
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
// Create a title so the titles page has at least one title card to display
|
||||
// (titles are loaded from API; new users have none by default)
|
||||
const createTitle = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 测试标题 ${suffix}`,
|
||||
text: `E2E 测试标题内容 ${suffix}`,
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
expect(createTitle.status(), await createTitle.text()).toBe(201);
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
@@ -100,7 +113,7 @@ test.describe("Title library flow", () => {
|
||||
.first()
|
||||
.textContent();
|
||||
if (firstTitleText) {
|
||||
await page.getByPlaceholder("搜索标题关键词").fill(firstTitleText);
|
||||
await page.getByPlaceholder("搜索标题关键词...").fill(firstTitleText);
|
||||
await expect(page.getByText(firstTitleText)).toBeVisible();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -21,7 +21,7 @@ const routeBrowserApiToTestApi = async (
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
@@ -174,7 +174,7 @@ test.describe("Core media upload flow", () => {
|
||||
await page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: `E2E Video Library ${suffix}` })
|
||||
.click();
|
||||
.click({ force: true });
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible({
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 覆盖:路由守卫、订阅降级、过期处理、订阅状态检查
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -18,7 +18,7 @@ function uniqueUsername(prefix: string): string {
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
@@ -37,7 +37,7 @@ async function loginWithRetry(
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: any, label: string) {
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 覆盖:创建素材库、列出素材库、创建素材记录
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -19,7 +19,7 @@ function uniqueUsername(prefix: string): string {
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
@@ -38,7 +38,7 @@ async function loginWithRetry(
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: any, label: string) {
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
@@ -62,7 +62,7 @@ async function createAuthedUser(request: any, label: string) {
|
||||
|
||||
/** 创建一个项目并返回 project id */
|
||||
async function createProject(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
suffix: string,
|
||||
): Promise<string> {
|
||||
@@ -77,7 +77,7 @@ async function createProject(
|
||||
|
||||
test.describe("素材库流程", () => {
|
||||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("创建素材库", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-create");
|
||||
@@ -185,7 +185,7 @@ test.describe("素材库流程", () => {
|
||||
const items = data.items || [];
|
||||
expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2);
|
||||
|
||||
const kinds = items.map((i: any) => i.kind);
|
||||
const kinds = items.map((i: { kind: string }) => i.kind);
|
||||
expect(kinds).toContain("video");
|
||||
expect(kinds).toContain("image");
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息
|
||||
* 每个测试独立,使用随机邮箱避免冲突。
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -18,6 +18,7 @@ function uniqueUsername(prefix: string): string {
|
||||
}
|
||||
|
||||
/** 从错误响应中提取错误消息文本,兼容新老格式 */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractErrorMessage(body: any): string {
|
||||
if (!body) return "";
|
||||
// 新格式: { error: { code: "...", message: "..." } }
|
||||
@@ -30,7 +31,7 @@ function extractErrorMessage(body: any): string {
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
@@ -52,7 +53,7 @@ async function loginWithRetry(
|
||||
|
||||
test.describe("认证流程", () => {
|
||||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
// ─── 注册 ────────────────────────────────────────────
|
||||
|
||||
@@ -150,6 +151,36 @@ test.describe("认证流程", () => {
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("注册用户名为空 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
email: uniqueEmail("emptyuser"),
|
||||
password: PASSWORD,
|
||||
username: "",
|
||||
display_name: "Empty Username",
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 422], "用户名为空应返回 4xx 校验错误").toContain(
|
||||
response.status(),
|
||||
);
|
||||
});
|
||||
|
||||
test("注册缺用户名字段 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
email: uniqueEmail("nouser"),
|
||||
password: PASSWORD,
|
||||
display_name: "No Username Field",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
[400, 422],
|
||||
"缺少用户名字段应返回 4xx 校验错误",
|
||||
).toContain(response.status());
|
||||
});
|
||||
|
||||
// ─── 登录 ────────────────────────────────────────────
|
||||
|
||||
test("登录成功 - 正向", async ({ request }) => {
|
||||
@@ -192,9 +223,16 @@ test.describe("认证流程", () => {
|
||||
});
|
||||
|
||||
test("登录不存在的邮箱 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
|
||||
});
|
||||
// 带限流重试的反向登录测试
|
||||
let response;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
|
||||
});
|
||||
if (response.status() !== 429) break;
|
||||
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`);
|
||||
await new Promise((r) => setTimeout(r, 65_000));
|
||||
}
|
||||
|
||||
expect(response.status(), "不存在的用户应返回 401").toBe(401);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 覆盖:创建项目、列出项目、获取项目详情
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -19,7 +19,7 @@ function uniqueUsername(prefix: string): string {
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
@@ -38,7 +38,7 @@ async function loginWithRetry(
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: any, label: string) {
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
@@ -62,7 +62,7 @@ async function createAuthedUser(request: any, label: string) {
|
||||
|
||||
test.describe("项目流程", () => {
|
||||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("创建项目", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-create");
|
||||
|
||||
+82
-10
@@ -14,9 +14,12 @@ export interface AssetItem {
|
||||
mime_type: string;
|
||||
metadata: Record<string, unknown>;
|
||||
file_size?: number;
|
||||
file_url?: string;
|
||||
thumbnail_url?: string;
|
||||
status?: string;
|
||||
classification_status?: string | null;
|
||||
quality_score?: number | null;
|
||||
tag_ids?: string[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
@@ -80,9 +83,13 @@ export interface AssetDiagnosis {
|
||||
|
||||
// ─── 素材诊断 ──────────────────────────────────────────────
|
||||
|
||||
/** 获取当前用户的素材诊断信息 */
|
||||
export const getAssetDiagnosis = async (): Promise<AssetDiagnosis> => {
|
||||
const response = await apiClient.get("/asset-diagnosis");
|
||||
/** 获取素材诊断信息(可选 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;
|
||||
};
|
||||
|
||||
@@ -108,6 +115,18 @@ export const createAssetLibrary = async (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}`);
|
||||
@@ -123,6 +142,46 @@ export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
|
||||
export const getAssetsByKind = async (
|
||||
kind: string,
|
||||
filters?: {
|
||||
keyword?: string;
|
||||
gender?: string;
|
||||
style?: string;
|
||||
tag_ids?: string[];
|
||||
},
|
||||
): Promise<AssetItem[]> => {
|
||||
const params: Record<string, string> = { 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(",");
|
||||
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?: Record<string, unknown>;
|
||||
}): Promise<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
data: { name?: string; metadata?: Record<string, unknown> },
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 更新素材审核状态 */
|
||||
export const updateAssetReviewStatus = async (
|
||||
assetId: string,
|
||||
@@ -181,10 +240,11 @@ export const completeDirectUpload = async (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();
|
||||
@@ -203,13 +263,25 @@ export const uploadAssetDirect = async (data: {
|
||||
);
|
||||
directForm.append("file", data.file);
|
||||
|
||||
const uploadResponse = await fetch(prepared.upload_url, {
|
||||
method: prepared.method,
|
||||
body: directForm,
|
||||
// 使用 XMLHttpRequest 以获取上传进度(fetch 不支持)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(prepared.method, prepared.upload_url);
|
||||
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 {
|
||||
reject(new Error(`OSS direct upload failed: ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("OSS direct upload failed"));
|
||||
xhr.send(directForm);
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`OSS direct upload failed: ${uploadResponse.status}`);
|
||||
}
|
||||
|
||||
return completeDirectUpload({
|
||||
project_id: project.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
import axios from "axios";
|
||||
import apiClient from "./client";
|
||||
|
||||
// 类型定义
|
||||
@@ -71,6 +72,17 @@ export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
export const refreshAccessToken = async (
|
||||
refreshToken: string,
|
||||
): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? "";
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 注册
|
||||
export const register = async (
|
||||
data: RegisterRequest,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
import { message } from "antd";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { refreshAccessToken } from "./auth";
|
||||
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
@@ -15,6 +16,24 @@ const apiClient = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// ── Token 刷新队列 ─────────────────────────────────────────
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (value?: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
const processQueue = (error: unknown | null, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
// 请求拦截器:添加 Token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
@@ -29,15 +48,77 @@ apiClient.interceptors.request.use(
|
||||
},
|
||||
);
|
||||
|
||||
// 响应拦截器:统一错误提示 + 处理未授权状态
|
||||
// 响应拦截器:统一错误提示 + 401 自动刷新 Token
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (
|
||||
error: AxiosError<{ detail?: string; message?: string; msg?: string }>,
|
||||
) => {
|
||||
// 401 → 清除登录态
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().clearAuth();
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
// 401 → 尝试刷新 Token
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
originalRequest &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
const refreshToken = useAuthStore.getState().refreshToken;
|
||||
|
||||
// 无 refresh_token → 直接登出
|
||||
if (!refreshToken) {
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = "/";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 已在刷新中 → 排队等待
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const data = await refreshAccessToken(refreshToken);
|
||||
const newAccessToken = data.access_token;
|
||||
const newRefreshToken = data.refresh_token ?? refreshToken;
|
||||
|
||||
// 更新 Zustand + localStorage
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setAuth(
|
||||
useAuthStore.getState().user!,
|
||||
newAccessToken,
|
||||
newRefreshToken,
|
||||
);
|
||||
|
||||
// 处理排队的请求
|
||||
processQueue(null, newAccessToken);
|
||||
|
||||
// 重试原始请求
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// 刷新失败 → 登出
|
||||
processQueue(refreshError, null);
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = "/";
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 提取后端返回的错误信息(detail / message / msg)
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface CreateEditPlanRequest {
|
||||
name: string;
|
||||
config?: Record<string, unknown>;
|
||||
total_duration?: number;
|
||||
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
|
||||
source_edit_plan_id?: string;
|
||||
}
|
||||
|
||||
/** 更新剪辑计划请求 */
|
||||
@@ -49,6 +51,16 @@ export interface GenerateResponse {
|
||||
clip_count: number;
|
||||
}
|
||||
|
||||
/** 剪辑计划关联的生成记录 */
|
||||
export interface EditPlanGeneration {
|
||||
id: string;
|
||||
edit_plan_id: string;
|
||||
generation_task_id: string;
|
||||
status: EditPlanStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 片段生成状态 */
|
||||
export interface ClipStatusItem {
|
||||
clip_id: string;
|
||||
@@ -68,6 +80,51 @@ export interface GenerationStatusResponse {
|
||||
clips: ClipStatusItem[];
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 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: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** AI 推荐响应 */
|
||||
export interface AIRecommendResponse {
|
||||
plan_id: string;
|
||||
clips: AIRecommendClipItem[];
|
||||
config: Record<string, unknown>;
|
||||
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: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 前端 UI 类型(EditingPlanner 组件依赖,保留兼容)
|
||||
* ============================================================ */
|
||||
@@ -177,6 +234,38 @@ export async function getGenerationStatus(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** AI 推荐片段方案 */
|
||||
export async function aiRecommendClips(
|
||||
planId: string,
|
||||
data: AIRecommendRequest,
|
||||
): Promise<AIRecommendResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/edit-plans/${planId}/ai-recommend`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** AI 生成封面 */
|
||||
export async function generateCover(
|
||||
planId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/edit-plans/${planId}/generate-cover`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 获取剪辑计划关联的生成记录 */
|
||||
export async function getEditPlanGenerations(
|
||||
planId: string,
|
||||
): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}/generations`);
|
||||
return response.data.items || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
||||
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 标签 CRUD API
|
||||
* P3 标签体系:对接后端标签表
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
|
||||
export interface TagItem {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at?: string;
|
||||
usage_count?: number;
|
||||
}
|
||||
|
||||
/** 获取当前用户所有标签 */
|
||||
export const getTags = async (): Promise<TagItem[]> => {
|
||||
const response = await apiClient.get("/tags");
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/** 创建标签(同名返回 409) */
|
||||
export const createTag = async (name: string): Promise<TagItem> => {
|
||||
const response = await apiClient.post("/tags", { name });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除标签(同时清理素材关联) */
|
||||
export const deleteTag = async (tagId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tags/${tagId}`);
|
||||
};
|
||||
|
||||
/** 为素材添加标签(最多 50 个) */
|
||||
export const tagAsset = async (
|
||||
assetId: string,
|
||||
tagIds: string[],
|
||||
): Promise<void> => {
|
||||
if (tagIds.length === 0) return;
|
||||
await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds });
|
||||
};
|
||||
|
||||
/** 移除素材的某个标签 */
|
||||
export const untagAsset = async (
|
||||
assetId: string,
|
||||
tagId: string,
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`);
|
||||
};
|
||||
@@ -122,6 +122,20 @@ export const getTTSJobs = async (
|
||||
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}`);
|
||||
|
||||
@@ -30,13 +30,13 @@ const Header: React.FC = () => {
|
||||
key: "profile",
|
||||
icon: <UserOutlined />,
|
||||
label: "个人设置",
|
||||
onClick: () => navigate("/profile"),
|
||||
onClick: () => navigate("/app/profile"),
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
icon: <SettingOutlined />,
|
||||
label: "订阅管理",
|
||||
onClick: () => navigate("/subscription"),
|
||||
onClick: () => navigate("/app/subscription"),
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
@@ -49,8 +49,13 @@ const Header: React.FC = () => {
|
||||
|
||||
/** 判断导航项是否激活 */
|
||||
const isActive = (path: string) => {
|
||||
if (path === "/dashboard") {
|
||||
return location.pathname === "/" || location.pathname === "/dashboard";
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return (
|
||||
location.pathname === "/" ||
|
||||
location.pathname === "/app" ||
|
||||
location.pathname === "/app/dashboard"
|
||||
);
|
||||
}
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
@@ -61,7 +66,7 @@ const Header: React.FC = () => {
|
||||
<button
|
||||
className="xx-brand"
|
||||
type="button"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
onClick={() => navigate("/app/dashboard")}
|
||||
>
|
||||
<span className="xx-logo">🦐</span>
|
||||
<span className="xx-brand-text">小虾自动剪辑</span>
|
||||
|
||||
Regular → Executable
+7
-2
@@ -33,8 +33,13 @@
|
||||
.xx-app-sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-primary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border-right: 1px solid rgba(226, 232, 240, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width var(--transition-slow);
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface SidebarContextValue {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const SidebarContext = React.createContext<SidebarContextValue>({
|
||||
collapsed: false,
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/editing-planner": "剪辑规划",
|
||||
"/app/my-templates": "我的模板",
|
||||
"/app/voice-clone": "我的音色",
|
||||
"/app/voice-materials": "配音素材库",
|
||||
"/app/accounts": "账号管理",
|
||||
"/app/duplication": "查重",
|
||||
"/app/duplication/results": "查重结果",
|
||||
|
||||
Regular → Executable
+18
-4
@@ -75,7 +75,7 @@
|
||||
|
||||
.xx-sidebar-menu-item:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
/* 激活态 —— Indigo 高亮 */
|
||||
@@ -83,6 +83,7 @@
|
||||
color: var(--primary-color);
|
||||
background: var(--primary-soft);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
box-shadow: inset 3px 0 0 var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-sidebar-menu-item.xx-active:hover {
|
||||
@@ -96,10 +97,23 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: var(--font-size-md);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
transition: 0.15s ease;
|
||||
}
|
||||
|
||||
.xx-sidebar-menu-item.xx-active .xx-sidebar-menu-icon {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.xx-sidebar-menu-item:hover .xx-sidebar-menu-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* ── 菜单项文字 ───────────────────────────────────────────── */
|
||||
|
||||
@@ -17,8 +17,11 @@ import "./Sidebar.css";
|
||||
|
||||
/** 判断菜单项是否激活 */
|
||||
const isMenuItemActive = (pathname: string, path: string): boolean => {
|
||||
if (path === "/dashboard") {
|
||||
return pathname === "/" || pathname === "/dashboard";
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return (
|
||||
pathname === "/" || pathname === "/app" || pathname === "/app/dashboard"
|
||||
);
|
||||
}
|
||||
return pathname.startsWith(path);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, { useState, useCallback, useRef } from "react";
|
||||
import { Modal, Button } from "@/components/ui";
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
|
||||
import type { VoiceClone } from "@/api/voiceClone";
|
||||
import { uploadAsset } from "@/api/assets";
|
||||
import "./clone-voice-modal.css";
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
@@ -121,12 +122,24 @@ const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
|
||||
setStep("uploading");
|
||||
|
||||
try {
|
||||
// Mock:模拟上传 + 克隆过程
|
||||
// 先上传音频文件获取真实 URL
|
||||
let audioUrl: string;
|
||||
if (selectedFile) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile);
|
||||
formData.append("kind", "voice");
|
||||
const uploadResult = await uploadAsset(formData);
|
||||
audioUrl = uploadResult.url;
|
||||
} else {
|
||||
// 录制功能暂未实现,提示用户上传
|
||||
setStep("input");
|
||||
return;
|
||||
}
|
||||
|
||||
// 提交克隆请求
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
audio_url: selectedFile
|
||||
? `mock://${selectedFile.name}`
|
||||
: "mock://recorded-audio",
|
||||
audio_url: audioUrl,
|
||||
});
|
||||
|
||||
setStep("success");
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
/**
|
||||
* VoiceCloneModal — 音色克隆弹窗
|
||||
*
|
||||
* 功能:上传音频文件 / 录制音频、填写音色名称、提交克隆任务
|
||||
* 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
|
||||
* 对接 API(Mock):POST /api/v1/voice-clones
|
||||
*
|
||||
* CSS 变量统一,与配音库 / 我的音色页面风格一致
|
||||
* V21 Design System — 零 antd 直接导入
|
||||
*/
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Modal, Button } from "@/components/ui";
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
|
||||
import type { VoiceClone } from "@/api/voiceClone";
|
||||
import "./voice-clone-modal.css";
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
/** 弹窗阶段:input=输入 | uploading=上传中 | cloning=克隆中 | done=完成 */
|
||||
type ModalPhase = "input" | "uploading" | "cloning" | "done";
|
||||
|
||||
export interface VoiceCloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean;
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void;
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void;
|
||||
}
|
||||
|
||||
/* ── 进度阶段配置 ─────────────────────────────────────────── */
|
||||
|
||||
const PROGRESS_STEPS: { key: ModalPhase; label: string; icon: string }[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
];
|
||||
|
||||
/* ── 默认音色名称计数器 ─────────────────────────────────── */
|
||||
|
||||
let cloneCounter = 1;
|
||||
|
||||
const getNextDefaultName = (): string => {
|
||||
const name = `我的声音 ${cloneCounter}`;
|
||||
cloneCounter += 1;
|
||||
return name;
|
||||
};
|
||||
|
||||
/* ── 支持的文件扩展名 ─────────────────────────────────── */
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "aac", "ogg"];
|
||||
const ACCEPTED_MIME =
|
||||
".mp3,.wav,.m4a,.aac,.ogg,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg";
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const VoiceCloneModal: React.FC<VoiceCloneModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input");
|
||||
const [voiceName, setVoiceName] = useState("");
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [recordTime, setRecordTime] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input");
|
||||
setVoiceName("");
|
||||
setSelectedFile(null);
|
||||
setIsRecording(false);
|
||||
setDragActive(false);
|
||||
setRecordTime(0);
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
|
||||
/** 弹窗打开时初始化默认名称 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setVoiceName(getNextDefaultName());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
/** 清理录制计时器 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* ── 文件上传 ──────────────────────────────────────── */
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
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 ext = file.name.split(".").pop()?.toLowerCase();
|
||||
if (ext && ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
setSelectedFile(file);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 录制(mock) ──────────────────────────────────── */
|
||||
|
||||
const handleRecord = () => {
|
||||
if (isRecording) {
|
||||
// 停止录制
|
||||
setIsRecording(false);
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
} else {
|
||||
// 开始录制
|
||||
setIsRecording(true);
|
||||
setSelectedFile(null);
|
||||
setRecordTime(0);
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
/** 格式化录制时间 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 handleStartClone = async () => {
|
||||
const name = voiceName.trim() || getNextDefaultName();
|
||||
|
||||
// 阶段 1:上传中
|
||||
setPhase("uploading");
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
|
||||
// 阶段 2:克隆中
|
||||
setPhase("cloning");
|
||||
try {
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
audio_url: selectedFile
|
||||
? `mock://${selectedFile.name}`
|
||||
: "mock://recorded-audio",
|
||||
});
|
||||
|
||||
// 阶段 3:完成
|
||||
setPhase("done");
|
||||
|
||||
// 2秒后自动关闭
|
||||
setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result));
|
||||
handleClose();
|
||||
}, 2000);
|
||||
} catch {
|
||||
setPhase("input");
|
||||
}
|
||||
};
|
||||
|
||||
/** 当前进度索引(用于进度条展示) */
|
||||
const getProgressIndex = (): number => {
|
||||
switch (phase) {
|
||||
case "uploading":
|
||||
return 0;
|
||||
case "cloning":
|
||||
return 1;
|
||||
case "done":
|
||||
return 2;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
const canStart = selectedFile || isRecording;
|
||||
const progressIndex = getProgressIndex();
|
||||
const isProcessing = phase === "uploading" || phase === "cloning";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
title="🎤 克隆新音色"
|
||||
width={540}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
maskClosable={!isProcessing}
|
||||
keyboard={!isProcessing}
|
||||
>
|
||||
{/* ── 输入阶段 ──────────────────────────────────── */}
|
||||
{phase === "input" && (
|
||||
<div className="xx-vcmodal-body">
|
||||
{/* 音色名称 */}
|
||||
<div className="xx-vcmodal-field">
|
||||
<label className="xx-vcmodal-label">音色名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className="xx-vcmodal-input"
|
||||
value={voiceName}
|
||||
onChange={(e) => setVoiceName(e.target.value)}
|
||||
placeholder="输入音色名称"
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<div className="xx-vcmodal-field">
|
||||
<label className="xx-vcmodal-label">上传音频</label>
|
||||
<div
|
||||
className={`xx-vcmodal-upload-zone${dragActive ? " xx-vcmodal-upload-zone--active" : ""}${selectedFile ? " xx-vcmodal-upload-zone--has-file" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="xx-vcmodal-upload-icon">
|
||||
{selectedFile ? "📄" : "🎵"}
|
||||
</div>
|
||||
<p className="xx-vcmodal-upload-title">
|
||||
{selectedFile
|
||||
? selectedFile.name
|
||||
: "拖拽音频文件到此处,或点击上传"}
|
||||
</p>
|
||||
<p className="xx-vcmodal-upload-hint">
|
||||
支持 MP3、WAV、M4A、AAC、OGG 格式
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_MIME}
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 或分隔 */}
|
||||
<div className="xx-vcmodal-divider">
|
||||
<div className="xx-vcmodal-divider-line" />
|
||||
<span className="xx-vcmodal-divider-text">或</span>
|
||||
<div className="xx-vcmodal-divider-line" />
|
||||
</div>
|
||||
|
||||
{/* 录制区域 */}
|
||||
<div className="xx-vcmodal-field">
|
||||
<label className="xx-vcmodal-label">直接录制</label>
|
||||
<div className="xx-vcmodal-record-area">
|
||||
<div className="xx-vcmodal-record-info">
|
||||
<p className="xx-vcmodal-record-hint">
|
||||
{isRecording
|
||||
? `录制中 ${formatRecordTime(recordTime)}`
|
||||
: "点击按钮开始录制你的声音"}
|
||||
</p>
|
||||
{isRecording && (
|
||||
<div className="xx-vcmodal-record-wave">
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-vcmodal-record-btn${isRecording ? " xx-vcmodal-record-btn--recording" : ""}`}
|
||||
onClick={handleRecord}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示 */}
|
||||
<div className="xx-vcmodal-tip">
|
||||
<span className="xx-vcmodal-tip-icon">💡</span>
|
||||
<span>
|
||||
建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="xx-vcmodal-footer">
|
||||
<Button buttonType="ghost" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
disabled={!canStart}
|
||||
onClick={handleStartClone}
|
||||
>
|
||||
🎤 开始克隆
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────── */}
|
||||
{isProcessing && (
|
||||
<div className="xx-vcmodal-progress-body">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="xx-vcmodal-steps">
|
||||
{PROGRESS_STEPS.map((step, idx) => {
|
||||
const isActive = idx === progressIndex;
|
||||
const isDone = idx < progressIndex;
|
||||
const stepClass = [
|
||||
"xx-vcmodal-step",
|
||||
isActive ? "xx-vcmodal-step--active" : "",
|
||||
isDone ? "xx-vcmodal-step--done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div
|
||||
className={`xx-vcmodal-step-connector${isDone ? " xx-vcmodal-step-connector--done" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<div className={stepClass}>
|
||||
<div className="xx-vcmodal-step-icon">
|
||||
{isDone ? "✓" : step.icon}
|
||||
</div>
|
||||
<span className="xx-vcmodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前阶段描述 */}
|
||||
<div className="xx-vcmodal-progress-info">
|
||||
{phase === "uploading" && (
|
||||
<>
|
||||
<div className="xx-vcmodal-progress-spinner" />
|
||||
<p className="xx-vcmodal-progress-text">正在上传音频文件…</p>
|
||||
<p className="xx-vcmodal-progress-sub">
|
||||
请稍候,正在将音频上传至服务器
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div className="xx-vcmodal-progress-spinner xx-vcmodal-progress-spinner--cloning" />
|
||||
<p className="xx-vcmodal-progress-text">AI 正在克隆你的声音…</p>
|
||||
<p className="xx-vcmodal-progress-sub">
|
||||
正在分析声音特征,生成专属音色模型
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 完成阶段 ──────────────────────────────────── */}
|
||||
{phase === "done" && (
|
||||
<div className="xx-vcmodal-progress-body">
|
||||
{/* 步骤指示器(全部完成) */}
|
||||
<div className="xx-vcmodal-steps">
|
||||
{PROGRESS_STEPS.map((step, idx) => (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div className="xx-vcmodal-step-connector xx-vcmodal-step-connector--done" />
|
||||
)}
|
||||
<div className="xx-vcmodal-step xx-vcmodal-step--done">
|
||||
<div className="xx-vcmodal-step-icon">✓</div>
|
||||
<span className="xx-vcmodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="xx-vcmodal-success">
|
||||
<div className="xx-vcmodal-success-icon">🎉</div>
|
||||
<h3 className="xx-vcmodal-success-title">克隆已提交</h3>
|
||||
<p className="xx-vcmodal-success-desc">
|
||||
音色正在生成中,完成后将出现在「我的音色库」列表中
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceCloneModal;
|
||||
@@ -31,7 +31,7 @@
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--line, #e4e7ec);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-primary, #101828);
|
||||
font-size: 14px;
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
.cvm-upload-zone {
|
||||
border: 2px dashed var(--line, #e4e7ec);
|
||||
border-radius: var(--radius-md, 12px);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
background: var(--bg-subtle, #f8fafc);
|
||||
@@ -120,7 +120,7 @@
|
||||
|
||||
.cvm-record-area {
|
||||
border: 1px solid var(--line, #e4e7ec);
|
||||
border-radius: var(--radius-md, 12px);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -190,7 +190,7 @@
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--warning-soft, #fef3c7);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--warning-color, #92400e);
|
||||
line-height: 1.5;
|
||||
|
||||
@@ -1,512 +0,0 @@
|
||||
/**
|
||||
* VoiceCloneModal 样式 — V21 设计系统
|
||||
* CSS 变量统一,与配音库 / 我的音色页面风格一致
|
||||
* 命名规范:xx-vcmodal-*
|
||||
*/
|
||||
@import "../../styles/global.css";
|
||||
|
||||
/* ── 弹窗内容区 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
/* ── 字段容器 ─────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-vcmodal-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: var(--letter-spacing-wide);
|
||||
}
|
||||
|
||||
/* ── 输入框 ───────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-vcmodal-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-vcmodal-input:focus {
|
||||
border-color: var(--color-primary-400);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-100);
|
||||
}
|
||||
|
||||
/* ── 上传区域 ─────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-upload-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-zone:hover {
|
||||
border-color: var(--color-primary-300);
|
||||
background: var(--color-primary-50);
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-zone--active {
|
||||
border-color: var(--color-primary-500);
|
||||
background: var(--color-primary-100);
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-zone--has-file {
|
||||
border-style: solid;
|
||||
border-color: var(--color-primary-400);
|
||||
background: var(--color-primary-50);
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-icon {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── 分隔线 ───────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.xx-vcmodal-divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.xx-vcmodal-divider-text {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* ── 录制区域 ─────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-record-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 录制波形动画 */
|
||||
.xx-vcmodal-record-wave {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-wave-bar {
|
||||
width: 3px;
|
||||
background: var(--color-primary-500);
|
||||
border-radius: var(--radius-full);
|
||||
animation: vcmodal-wave 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-wave-bar:nth-child(1) {
|
||||
height: 40%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.xx-vcmodal-record-wave-bar:nth-child(2) {
|
||||
height: 70%;
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.xx-vcmodal-record-wave-bar:nth-child(3) {
|
||||
height: 100%;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.xx-vcmodal-record-wave-bar:nth-child(4) {
|
||||
height: 60%;
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
.xx-vcmodal-record-wave-bar:nth-child(5) {
|
||||
height: 30%;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes vcmodal-wave {
|
||||
from {
|
||||
transform: scaleY(0.4);
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 录制按钮 */
|
||||
.xx-vcmodal-record-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-btn:hover {
|
||||
background: var(--color-primary-50);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-btn--recording {
|
||||
background: var(--error-color);
|
||||
color: var(--text-inverse);
|
||||
animation: vcmodal-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-btn--recording:hover {
|
||||
background: var(--error-hover);
|
||||
}
|
||||
|
||||
@keyframes vcmodal-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 提示 ─────────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-tip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--info-soft);
|
||||
border: 1px solid var(--info-border);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.xx-vcmodal-tip-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
/* ── 底部按钮 ─────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
padding-top: var(--space-sm);
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* ── 进度阶段通用 ─────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-progress-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-xl);
|
||||
padding: var(--space-lg) 0 var(--space-md);
|
||||
}
|
||||
|
||||
/* ── 步骤指示器 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-tertiary);
|
||||
border: 2px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step--active .xx-vcmodal-step-icon {
|
||||
background: var(--color-primary-50);
|
||||
border-color: var(--color-primary-500);
|
||||
color: var(--color-primary-600);
|
||||
box-shadow: 0 0 0 4px var(--color-primary-100);
|
||||
}
|
||||
|
||||
.xx-vcmodal-step--done .xx-vcmodal-step-icon {
|
||||
background: var(--color-secondary-50);
|
||||
border-color: var(--color-secondary-500);
|
||||
color: var(--color-secondary-600);
|
||||
}
|
||||
|
||||
.xx-vcmodal-step-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-tertiary);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step--active .xx-vcmodal-step-label {
|
||||
color: var(--color-primary-600);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.xx-vcmodal-step--done .xx-vcmodal-step-label {
|
||||
color: var(--color-secondary-600);
|
||||
}
|
||||
|
||||
/* 步骤连接线 */
|
||||
.xx-vcmodal-step-connector {
|
||||
flex: 0 0 40px;
|
||||
height: 2px;
|
||||
background: var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step-connector--done {
|
||||
background: var(--color-secondary-400);
|
||||
}
|
||||
|
||||
/* ── 进度信息 ─────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-progress-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.xx-vcmodal-progress-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--color-primary-100);
|
||||
border-top-color: var(--color-primary-500);
|
||||
border-radius: var(--radius-full);
|
||||
animation: vcmodal-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.xx-vcmodal-progress-spinner--cloning {
|
||||
border-color: var(--color-secondary-100);
|
||||
border-top-color: var(--color-secondary-500);
|
||||
}
|
||||
|
||||
@keyframes vcmodal-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-vcmodal-progress-text {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-vcmodal-progress-sub {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── 完成阶段 ─────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-vcmodal-success-icon {
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
animation: vcmodal-bounce 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes vcmodal-bounce {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-vcmodal-success-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-vcmodal-success-desc {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
max-width: 280px;
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
|
||||
/* ── 响应式 ───────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-vcmodal-body {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-zone {
|
||||
padding: var(--space-md) var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-area {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-btn {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.xx-vcmodal-steps {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step-connector {
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-footer {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.xx-vcmodal-footer > * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.xx-vcmodal-step-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step-connector {
|
||||
flex: 0 0 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-progress-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-success-icon {
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
* V21 Input 输入框
|
||||
* 封装 Ant Design Input,应用 V21 设计系统样式
|
||||
*/
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React from "react";
|
||||
import { Input as AntInput } from "antd";
|
||||
import type { InputProps as AntInputProps } from "antd";
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/**
|
||||
* CloneModal — 音色克隆弹窗
|
||||
* 任务 3.13:实现克隆 Modal,用户点击「克隆音色」按钮后弹出
|
||||
* CloneModal — 音色克隆弹窗(共享组件)
|
||||
*
|
||||
* 功能:
|
||||
* - 步骤引导:上传音频 → 填写信息 → 提交克隆
|
||||
* - 表单字段:音频文件上传(wav/mp3/m4a,≤10MB)、音色名称(必填,2-20字符)、音色描述(可选,≤100字符)
|
||||
* - 提交后调用 POST /api/v1/voice-clones 创建克隆
|
||||
* - 创建成功后关闭 Modal,刷新配音列表
|
||||
* - 错误处理:上传失败、格式错误、大小超限等提示
|
||||
* - 步骤引导:上传/录制音频 → 填写信息 → 提交克隆
|
||||
* - 两种输入方式:上传文件(wav/mp3/m4a,≤10MB)或浏览器原生录音(MediaRecorder)
|
||||
* - 提交:先 uploadAsset 获取真实 URL,再 createVoiceClone
|
||||
* - 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
|
||||
* - 错误处理:上传失败、格式错误、大小超限、录音权限拒绝等提示
|
||||
*
|
||||
* V21 Design System — 零 antd 直接导入
|
||||
*/
|
||||
@@ -20,7 +19,7 @@ import "./clone-modal.css";
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
type ModalPhase = "input" | "uploading" | "success";
|
||||
type ModalPhase = "input" | "uploading" | "cloning" | "done";
|
||||
|
||||
export interface CloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
@@ -31,12 +30,23 @@ export interface CloneModalProps {
|
||||
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> = ({
|
||||
@@ -50,14 +60,37 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
// 录音状态
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordTime, setRecordTime] = useState(0);
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
||||
const cloneCounterRef = useRef(1);
|
||||
|
||||
/** 组件卸载时清理定时器(P2-2 修复) */
|
||||
/** 生成下一个默认音色名称 */
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
const name = `我的声音 ${cloneCounterRef.current}`;
|
||||
cloneCounterRef.current += 1;
|
||||
return name;
|
||||
}, []);
|
||||
|
||||
/** 组件卸载时清理定时器和 MediaRecorder */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -65,12 +98,27 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input");
|
||||
setVoiceName("");
|
||||
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(() => {
|
||||
@@ -114,6 +162,10 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
} else {
|
||||
setErrorMessage("");
|
||||
setSelectedFile(file);
|
||||
// 清除录音
|
||||
setRecordedBlob(null);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
}
|
||||
}
|
||||
e.target.value = "";
|
||||
@@ -144,12 +196,101 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
} 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) {
|
||||
@@ -158,8 +299,8 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
if (name.length < 2 || name.length > 20) {
|
||||
return "音色名称需在 2-20 个字符之间";
|
||||
}
|
||||
if (!selectedFile) {
|
||||
return "请上传音频文件";
|
||||
if (!hasAudio) {
|
||||
return "请上传音频文件或录制一段声音";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -173,24 +314,42 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setPhase("uploading");
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
// P1 修复:先上传音频文件获取真实 URL,再调用克隆 API
|
||||
// 阶段 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", selectedFile!);
|
||||
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,
|
||||
});
|
||||
|
||||
setPhase("success");
|
||||
// 阶段 3:完成
|
||||
setPhase("done");
|
||||
|
||||
// 2秒后自动关闭(P2-2 修复:使用 timerRef 以便 cleanup)
|
||||
// 2秒后自动关闭
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result));
|
||||
handleClose();
|
||||
@@ -204,11 +363,25 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
/* ── 计算属性 ──────────────────────────────────────── */
|
||||
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= 2 &&
|
||||
voiceName.trim().length <= 20 &&
|
||||
selectedFile !== null;
|
||||
voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio;
|
||||
|
||||
const isProcessing = phase === "uploading";
|
||||
const isProcessing = phase === "uploading" || phase === "cloning";
|
||||
|
||||
/** 当前进度索引 */
|
||||
const getProgressIndex = (): number => {
|
||||
switch (phase) {
|
||||
case "uploading":
|
||||
return 0;
|
||||
case "cloning":
|
||||
return 1;
|
||||
case "done":
|
||||
return 2;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
const progressIndex = getProgressIndex();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -228,7 +401,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
<div className="xx-clonemodal-steps">
|
||||
<div className="xx-clonemodal-step xx-clonemodal-step--active">
|
||||
<div className="xx-clonemodal-step-number">1</div>
|
||||
<span className="xx-clonemodal-step-label">上传音频</span>
|
||||
<span className="xx-clonemodal-step-label">上传/录制音频</span>
|
||||
</div>
|
||||
<div className="xx-clonemodal-step-connector" />
|
||||
<div className="xx-clonemodal-step">
|
||||
@@ -242,11 +415,27 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
{/* 音色名称 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">
|
||||
音频文件 <span className="xx-clonemodal-required">*</span>
|
||||
音色名称 <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}
|
||||
@@ -276,21 +465,43 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
</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">
|
||||
音色名称 <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
|
||||
<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>
|
||||
|
||||
@@ -342,25 +553,93 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 上传中阶段 ────────────────────────────────── */}
|
||||
{phase === "uploading" && (
|
||||
<div className="xx-clonemodal-uploading">
|
||||
<div className="xx-clonemodal-uploading-spinner" />
|
||||
<p className="xx-clonemodal-uploading-text">正在克隆你的音色…</p>
|
||||
<p className="xx-clonemodal-uploading-sub">
|
||||
AI 正在分析你的声音特征,请稍候
|
||||
</p>
|
||||
{/* ── 进度阶段(上传中 / 克隆中) ──────────────── */}
|
||||
{isProcessing && (
|
||||
<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 === "success" && (
|
||||
<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>
|
||||
{/* ── 完成阶段 ──────────────────────────────────── */}
|
||||
{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>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--xx-color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 14px;
|
||||
color: var(--xx-color-text, #111827);
|
||||
background: var(--xx-color-bg-secondary, #f9fafb);
|
||||
@@ -118,7 +118,7 @@
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--xx-color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 14px;
|
||||
color: var(--xx-color-text, #111827);
|
||||
background: var(--xx-color-bg-secondary, #f9fafb);
|
||||
@@ -152,7 +152,7 @@
|
||||
|
||||
.xx-clonemodal-upload-zone {
|
||||
border: 2px dashed var(--xx-color-border, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
@@ -202,7 +202,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: var(--xx-color-error, #ef4444);
|
||||
@@ -222,7 +222,7 @@
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
font-size: 12px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
@@ -284,41 +284,297 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 分隔线 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.xx-clonemodal-divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--xx-color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.xx-clonemodal-divider-text {
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── 录制区域 ─────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-record-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--xx-color-border, #e5e7eb);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--xx-color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* 录制波形动画 */
|
||||
.xx-clonemodal-record-wave {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-wave-bar {
|
||||
width: 3px;
|
||||
background: var(--xx-color-primary, #6366f1);
|
||||
border-radius: 999px;
|
||||
animation: xx-clonemodal-wave 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-wave-bar:nth-child(1) {
|
||||
height: 40%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.xx-clonemodal-record-wave-bar:nth-child(2) {
|
||||
height: 70%;
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.xx-clonemodal-record-wave-bar:nth-child(3) {
|
||||
height: 100%;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.xx-clonemodal-record-wave-bar:nth-child(4) {
|
||||
height: 60%;
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
.xx-clonemodal-record-wave-bar:nth-child(5) {
|
||||
height: 30%;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes xx-clonemodal-wave {
|
||||
from {
|
||||
transform: scaleY(0.4);
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 录制按钮 */
|
||||
.xx-clonemodal-record-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--xx-color-bg, #fff);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-btn--recording {
|
||||
background: var(--xx-color-error, #ef4444);
|
||||
color: #fff;
|
||||
animation: xx-clonemodal-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-btn--recording:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
@keyframes xx-clonemodal-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────────────── */
|
||||
|
||||
.xx-clonemodal-progress-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px 0 16px;
|
||||
}
|
||||
|
||||
/* 进度步骤指示器 */
|
||||
.xx-clonemodal-steps-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--xx-color-bg-tertiary, #f3f4f6);
|
||||
border: 2px solid var(--xx-color-border, #e5e7eb);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-progress--active .xx-clonemodal-step-icon {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
color: var(--xx-color-primary, #6366f1);
|
||||
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-progress--done .xx-clonemodal-step-icon {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-progress--active .xx-clonemodal-step-label {
|
||||
color: var(--xx-color-primary, #6366f1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-progress--done .xx-clonemodal-step-label {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-connector {
|
||||
flex: 0 0 40px;
|
||||
height: 2px;
|
||||
background: var(--xx-color-border, #e5e7eb);
|
||||
margin-bottom: 20px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-connector--done {
|
||||
background: #86efac;
|
||||
}
|
||||
|
||||
/* 进度信息 */
|
||||
.xx-clonemodal-progress-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-clonemodal-progress-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(99, 102, 241, 0.15);
|
||||
border-top-color: var(--xx-color-primary, #6366f1);
|
||||
border-radius: 50%;
|
||||
animation: xx-clonemodal-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.xx-clonemodal-progress-spinner--cloning {
|
||||
border-color: rgba(34, 197, 94, 0.15);
|
||||
border-top-color: #22c55e;
|
||||
}
|
||||
|
||||
.xx-clonemodal-progress-text {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--xx-color-text, #111827);
|
||||
}
|
||||
|
||||
.xx-clonemodal-progress-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ── 成功状态 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
animation: xx-clonemodal-bounce 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes xx-clonemodal-bounce {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: var(--xx-color-text, #111827);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
max-width: 280px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── 响应式 ─────────────────────────────────────────────── */
|
||||
@@ -342,4 +598,48 @@
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-area {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-btn {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.xx-clonemodal-steps-progress {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-footer {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.xx-clonemodal-footer > * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.xx-clonemodal-step-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-connector {
|
||||
flex: 0 0 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-progress-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-icon {
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,72 +33,75 @@ export interface NavGroup {
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
/** 全量导航项(Header 扁平列表使用) */
|
||||
/**
|
||||
* 全量导航项(Header 扁平列表使用)
|
||||
* 所有路径统一使用 /app 前缀,与路由配置保持一致
|
||||
*/
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "概览",
|
||||
path: "/dashboard",
|
||||
path: "/app/dashboard",
|
||||
icon: React.createElement(DashboardOutlined),
|
||||
},
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/assets",
|
||||
path: "/app/assets",
|
||||
icon: React.createElement(FileOutlined),
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/titles",
|
||||
path: "/app/titles",
|
||||
icon: React.createElement(FileTextOutlined),
|
||||
},
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/voices",
|
||||
path: "/app/voices",
|
||||
icon: React.createElement(AudioOutlined),
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/templates",
|
||||
path: "/app/templates",
|
||||
icon: React.createElement(AppstoreOutlined),
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
path: "/editing-planner",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/my-templates",
|
||||
path: "/app/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
path: "/generate",
|
||||
path: "/app/generate",
|
||||
icon: React.createElement(VideoCameraOutlined),
|
||||
},
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
path: "/history",
|
||||
path: "/app/history",
|
||||
icon: React.createElement(HistoryOutlined),
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成品库",
|
||||
path: "/products",
|
||||
path: "/app/products",
|
||||
icon: React.createElement(TrophyOutlined),
|
||||
},
|
||||
{
|
||||
key: "duplication",
|
||||
label: "查重",
|
||||
path: "/duplication",
|
||||
path: "/app/duplication",
|
||||
icon: React.createElement(ScanOutlined),
|
||||
},
|
||||
];
|
||||
@@ -111,19 +114,19 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "概览",
|
||||
path: "/dashboard",
|
||||
path: "/app/dashboard",
|
||||
icon: React.createElement(DashboardOutlined),
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
path: "/generate",
|
||||
path: "/app/generate",
|
||||
icon: React.createElement(VideoCameraOutlined),
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
path: "/editing-planner",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
],
|
||||
@@ -134,37 +137,37 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/assets",
|
||||
path: "/app/assets",
|
||||
icon: React.createElement(FileOutlined),
|
||||
},
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/voices",
|
||||
path: "/app/voices",
|
||||
icon: React.createElement(AudioOutlined),
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/titles",
|
||||
path: "/app/titles",
|
||||
icon: React.createElement(FileTextOutlined),
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成品库",
|
||||
path: "/products",
|
||||
path: "/app/products",
|
||||
icon: React.createElement(TrophyOutlined),
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/templates",
|
||||
path: "/app/templates",
|
||||
icon: React.createElement(AppstoreOutlined),
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/my-templates",
|
||||
path: "/app/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
],
|
||||
@@ -175,25 +178,25 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
path: "/history",
|
||||
path: "/app/history",
|
||||
icon: React.createElement(HistoryOutlined),
|
||||
},
|
||||
{
|
||||
key: "duplication",
|
||||
label: "查重",
|
||||
path: "/duplication",
|
||||
path: "/app/duplication",
|
||||
icon: React.createElement(ScanOutlined),
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
label: "控制台",
|
||||
path: "/admin",
|
||||
path: "/app/admin",
|
||||
icon: React.createElement(ControlOutlined),
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "订阅管理",
|
||||
path: "/subscription",
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -40,63 +40,79 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "概览",
|
||||
path: "/dashboard",
|
||||
path: "/app/dashboard",
|
||||
icon: <DashboardOutlined />,
|
||||
},
|
||||
{ key: "assets", label: "素材库", path: "/assets", icon: <FileOutlined /> },
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/app/assets",
|
||||
icon: <FileOutlined />,
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/titles",
|
||||
path: "/app/titles",
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
{ key: "voices", label: "配音库", path: "/voices", icon: <AudioOutlined /> },
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/app/voices",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voice-clone",
|
||||
label: "我的音色",
|
||||
path: "/voice-clone",
|
||||
path: "/app/voice-clone",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voice-materials",
|
||||
label: "配音素材库",
|
||||
path: "/app/voice-materials",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/templates",
|
||||
path: "/app/templates",
|
||||
icon: <AppstoreOutlined />,
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
path: "/editing-planner",
|
||||
path: "/app/editing-planner",
|
||||
icon: <EditOutlined />,
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/my-templates",
|
||||
path: "/app/my-templates",
|
||||
icon: <FolderOutlined />,
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
path: "/generate",
|
||||
path: "/app/generate",
|
||||
icon: <VideoCameraOutlined />,
|
||||
},
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
path: "/history",
|
||||
path: "/app/history",
|
||||
icon: <HistoryOutlined />,
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成品库",
|
||||
path: "/products",
|
||||
path: "/app/products",
|
||||
icon: <TrophyOutlined />,
|
||||
},
|
||||
{
|
||||
key: "duplication",
|
||||
label: "查重",
|
||||
path: "/duplication",
|
||||
path: "/app/duplication",
|
||||
icon: <ScanOutlined />,
|
||||
},
|
||||
];
|
||||
@@ -111,13 +127,13 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "首页",
|
||||
path: "/dashboard",
|
||||
path: "/app/dashboard",
|
||||
icon: <DashboardOutlined />,
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
path: "/generate",
|
||||
path: "/app/generate",
|
||||
icon: <VideoCameraOutlined />,
|
||||
},
|
||||
],
|
||||
@@ -128,37 +144,43 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/assets",
|
||||
path: "/app/assets",
|
||||
icon: <FileOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/voices",
|
||||
path: "/app/voices",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voice-clone",
|
||||
label: "我的音色",
|
||||
path: "/voice-clone",
|
||||
path: "/app/voice-clone",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voice-materials",
|
||||
label: "配音素材库",
|
||||
path: "/app/voice-materials",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/titles",
|
||||
path: "/app/titles",
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成片库",
|
||||
path: "/products",
|
||||
path: "/app/products",
|
||||
icon: <TrophyOutlined />,
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/templates",
|
||||
path: "/app/templates",
|
||||
icon: <AppstoreOutlined />,
|
||||
},
|
||||
],
|
||||
@@ -169,19 +191,19 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
path: "/history",
|
||||
path: "/app/history",
|
||||
icon: <HistoryOutlined />,
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
label: "控制台",
|
||||
path: "/admin",
|
||||
path: "/app/admin",
|
||||
icon: <ControlOutlined />,
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "订阅管理",
|
||||
path: "/subscription",
|
||||
path: "/app/subscription",
|
||||
icon: <CrownOutlined />,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -75,7 +75,7 @@ export const useLogout = () => {
|
||||
} finally {
|
||||
clearAuth();
|
||||
queryClient.clear();
|
||||
navigate("/login");
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+11
-2
@@ -21,8 +21,17 @@ body {
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #0f172a;
|
||||
background-color: #f8fafc;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ── 自定义字体 ── */
|
||||
@font-face {
|
||||
font-family: "华康俪金黑";
|
||||
src: url("/fonts/DFLiJinHei-W8.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* 滚动条 - V21 样式 */
|
||||
|
||||
@@ -36,8 +36,8 @@ const theme = {
|
||||
|
||||
// 圆角 - V21 大圆角
|
||||
borderRadius: 14,
|
||||
borderRadiusLG: 22,
|
||||
borderRadiusSM: 10,
|
||||
borderRadiusLG: 24,
|
||||
borderRadiusSM: 8,
|
||||
|
||||
// 文字色
|
||||
colorText: "#0f172a",
|
||||
|
||||
Regular → Executable
+58
-56
@@ -4,13 +4,15 @@
|
||||
* 多平台账号绑定状态展示,2列网格布局
|
||||
* 支持绑定/解绑操作
|
||||
*/
|
||||
@import "../../styles/global.css";
|
||||
|
||||
/* ── 页面容器 ───────────────────────────────────────────── */
|
||||
|
||||
.acc-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
/* ── 平台卡片网格 ───────────────────────────────────────── */
|
||||
@@ -18,24 +20,24 @@
|
||||
.acc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* ── 平台卡片 ───────────────────────────────────────────── */
|
||||
|
||||
.acc-card {
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--line, #e4e7ec);
|
||||
border-radius: var(--radius-lg, 14px);
|
||||
padding: 24px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
transition: box-shadow 0.2s;
|
||||
gap: var(--space-md);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.acc-card:hover {
|
||||
box-shadow: 0 4px 20px var(--shadow-sm, rgba(0, 0, 0, 0.06));
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* ── 卡片头部 ───────────────────────────────────────────── */
|
||||
@@ -49,7 +51,7 @@
|
||||
.acc-card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md, 12px);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 24px;
|
||||
@@ -59,16 +61,16 @@
|
||||
}
|
||||
|
||||
.acc-card-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #101828);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.acc-card-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--muted, #98a2b3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
@@ -87,13 +89,13 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: var(--bg-subtle, #f8fafc);
|
||||
border-radius: var(--radius-md, 12px);
|
||||
transition: background 0.15s;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.acc-account-row:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 4%, transparent);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.acc-account-avatar {
|
||||
@@ -103,8 +105,8 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -114,9 +116,9 @@
|
||||
}
|
||||
|
||||
.acc-account-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #101828);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -129,73 +131,73 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.acc-status--active {
|
||||
background: var(--success-soft, #ecfdf5);
|
||||
color: var(--secondary-color, #059669);
|
||||
background: var(--success-soft);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.acc-status--expired {
|
||||
background: var(--error-soft, #fef2f2);
|
||||
color: var(--error-color, #dc2626);
|
||||
background: var(--error-soft);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.acc-status--limited {
|
||||
background: var(--warning-soft, #fffbeb);
|
||||
color: var(--accent-color, #d97706);
|
||||
background: var(--warning-soft);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* ── 空状态 ─────────────────────────────────────────────── */
|
||||
|
||||
.acc-empty {
|
||||
padding: 24px 16px;
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
text-align: center;
|
||||
color: var(--muted, #98a2b3);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.acc-empty-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: var(--space-sm);
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.acc-empty-text {
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-base);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 底部统计栏 ─────────────────────────────────────────── */
|
||||
|
||||
.acc-stats-bar {
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-subtle, #f8fafc);
|
||||
border-radius: var(--radius-md, 12px);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #475467);
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.acc-stats-icon {
|
||||
font-size: 16px;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.acc-stats-text {
|
||||
font-weight: 600;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.acc-stats-highlight {
|
||||
color: var(--primary, #6366f1);
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* ── 响应式:平板 ───────────────────────────────────────── */
|
||||
@@ -203,11 +205,11 @@
|
||||
@media (max-width: 768px) {
|
||||
.acc-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.acc-card {
|
||||
padding: 20px;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,12 +217,12 @@
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.acc-page {
|
||||
gap: 16px;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.acc-card {
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
padding: var(--space-md);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.acc-card-icon {
|
||||
@@ -230,7 +232,7 @@
|
||||
}
|
||||
|
||||
.acc-card-title {
|
||||
font-size: 15px;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.acc-account-row {
|
||||
@@ -241,11 +243,11 @@
|
||||
.acc-account-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.acc-stats-bar {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
.xx-page-head {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 28px;
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 32px 40px;
|
||||
margin-bottom: 32px;
|
||||
@@ -55,7 +55,7 @@
|
||||
.xx-page-head-simple {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 28px;
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 32px 40px;
|
||||
margin-bottom: 32px;
|
||||
@@ -79,7 +79,7 @@
|
||||
.xx-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
padding: 24px;
|
||||
transition: all 0.3s;
|
||||
@@ -128,7 +128,7 @@
|
||||
.xx-stat-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
padding: 24px;
|
||||
transition: all 0.3s;
|
||||
@@ -149,7 +149,7 @@
|
||||
.xx-stat-card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -231,7 +231,7 @@
|
||||
.xx-table-wrapper {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -264,7 +264,7 @@
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
border-radius: 14px !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
font-weight: 700 !important;
|
||||
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.25) !important;
|
||||
transition: all 0.2s !important;
|
||||
@@ -279,7 +279,7 @@
|
||||
background: white !important;
|
||||
border: 1px solid rgba(226, 232, 240, 0.95) !important;
|
||||
color: var(--slate, #0f172a) !important;
|
||||
border-radius: 14px !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.2s !important;
|
||||
}
|
||||
@@ -291,7 +291,7 @@
|
||||
|
||||
/* V21 输入框 */
|
||||
.xx-search-input {
|
||||
border-radius: 14px !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
border: 1px solid rgba(226, 232, 240, 0.95) !important;
|
||||
padding: 8px 16px !important;
|
||||
}
|
||||
@@ -304,7 +304,7 @@
|
||||
|
||||
/* V21 Select */
|
||||
.xx-select {
|
||||
border-radius: 14px !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.xx-select:hover,
|
||||
@@ -315,7 +315,7 @@
|
||||
|
||||
/* V21 Tag */
|
||||
.xx-tag {
|
||||
border-radius: 8px !important;
|
||||
border-radius: var(--radius-xs) !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 12px !important;
|
||||
padding: 4px 10px !important;
|
||||
@@ -371,7 +371,7 @@
|
||||
.xx-chart-container {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -391,7 +391,7 @@
|
||||
.xx-filter-bar {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 24px;
|
||||
@@ -405,7 +405,7 @@
|
||||
.xx-resource-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
@@ -414,7 +414,7 @@
|
||||
.xx-resource-card-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -431,7 +431,7 @@
|
||||
|
||||
/* 日期选择器 */
|
||||
.xx-date-picker {
|
||||
border-radius: 14px !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
/* Drawer */
|
||||
@@ -463,7 +463,7 @@
|
||||
color: var(--muted, #64748b);
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -471,7 +471,7 @@
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const AdminComingSoon: React.FC = () => {
|
||||
extra={[
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate("/")}
|
||||
onClick={() => navigate("/app/dashboard")}
|
||||
className="xx-primary-btn"
|
||||
>
|
||||
返回首页
|
||||
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
SearchOutlined,
|
||||
InboxOutlined,
|
||||
VideoCameraOutlined,
|
||||
SoundOutlined,
|
||||
PictureOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
ExperimentOutlined,
|
||||
LoadingOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -35,7 +36,7 @@ import "./assets.css";
|
||||
/* ============================================================
|
||||
* 类型
|
||||
* ============================================================ */
|
||||
type AssetKind = "video" | "voice" | "image";
|
||||
type AssetKind = "video" | "image";
|
||||
type StatusType = "ok" | "warn" | "bad" | "info";
|
||||
|
||||
interface LibraryItem {
|
||||
@@ -50,6 +51,7 @@ interface AssetItem {
|
||||
name: string;
|
||||
kind: AssetKind;
|
||||
thumbUrl?: string;
|
||||
fileUrl?: string;
|
||||
status: StatusType;
|
||||
statusLabel: string;
|
||||
duration?: string;
|
||||
@@ -64,7 +66,6 @@ interface AssetItem {
|
||||
/** 根据 mime_type 推断前端 AssetKind */
|
||||
const inferKind = (mimeType: string): AssetKind => {
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("audio/")) return "voice";
|
||||
return "image";
|
||||
};
|
||||
|
||||
@@ -96,7 +97,7 @@ const formatDuration = (seconds: number): string => {
|
||||
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: item.kind || inferKind("video"),
|
||||
kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"),
|
||||
count: item.asset_count ?? 0,
|
||||
});
|
||||
|
||||
@@ -107,11 +108,19 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
item.classification_status ?? undefined,
|
||||
);
|
||||
const metadata = item.metadata || {};
|
||||
const kind = inferKind(item.mime_type || "");
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: inferKind(item.mime_type || ""),
|
||||
thumbUrl: metadata.thumbnail_url as string | undefined,
|
||||
kind,
|
||||
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
|
||||
thumbUrl:
|
||||
(item.thumbnail_url as string | undefined) ||
|
||||
(metadata.thumbnail_url as string | undefined) ||
|
||||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
|
||||
fileUrl:
|
||||
(item.file_url as string | undefined) ||
|
||||
(metadata.file_url as string | undefined),
|
||||
status,
|
||||
statusLabel: label,
|
||||
duration:
|
||||
@@ -138,8 +147,6 @@ const kindIcon = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return <VideoCameraOutlined />;
|
||||
case "voice":
|
||||
return <SoundOutlined />;
|
||||
case "image":
|
||||
return <PictureOutlined />;
|
||||
}
|
||||
@@ -149,8 +156,6 @@ const kindLabel = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "视频";
|
||||
case "voice":
|
||||
return "配音";
|
||||
case "image":
|
||||
return "图片";
|
||||
}
|
||||
@@ -161,8 +166,6 @@ const thumbGradient = (kind: AssetKind): string => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)";
|
||||
case "voice":
|
||||
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)";
|
||||
case "image":
|
||||
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)";
|
||||
}
|
||||
@@ -178,15 +181,45 @@ const StatusPill: React.FC<{ status: StatusType; label: string }> = ({
|
||||
<span className={`xx-status-pill xx-status-pill-${status}`}>{label}</span>
|
||||
);
|
||||
|
||||
/* ============================================================
|
||||
* SkeletonCard — 骨架屏卡片(素材列表加载时占位)
|
||||
* ============================================================ */
|
||||
const SkeletonCard: React.FC = () => (
|
||||
<div className="xx-asset-card xx-asset-skeleton">
|
||||
<div className="xx-asset-thumb xx-skeleton-pulse" />
|
||||
<div className="xx-asset-info">
|
||||
<div
|
||||
className="xx-skeleton-line xx-skeleton-pulse"
|
||||
style={{ width: "70%" }}
|
||||
/>
|
||||
<div
|
||||
className="xx-skeleton-line xx-skeleton-pulse"
|
||||
style={{ width: "40%", marginTop: 8 }}
|
||||
/>
|
||||
<div
|
||||
className="xx-skeleton-line xx-skeleton-pulse"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 28,
|
||||
marginTop: 8,
|
||||
borderRadius: "var(--radius-xs)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ============================================================
|
||||
* AssetCard 组件
|
||||
* ============================================================ */
|
||||
const AssetCard: React.FC<{
|
||||
asset: AssetItem;
|
||||
selected: boolean;
|
||||
diagnosing?: boolean;
|
||||
onToggle: () => void;
|
||||
onDiagnose: () => void;
|
||||
}> = ({ asset, selected, onToggle, onDiagnose }) => (
|
||||
onPlay: () => void;
|
||||
}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay }) => (
|
||||
<div
|
||||
className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`}
|
||||
onClick={onToggle}
|
||||
@@ -205,8 +238,14 @@ const AssetCard: React.FC<{
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮 */}
|
||||
{(asset.kind === "video" || asset.kind === "voice") && (
|
||||
<span className="xx-asset-play">
|
||||
{asset.kind === "video" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlay();
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</span>
|
||||
)}
|
||||
@@ -229,14 +268,15 @@ const AssetCard: React.FC<{
|
||||
{asset.duration && <span>{asset.duration}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="xx-asset-diagnose-btn"
|
||||
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
|
||||
disabled={diagnosing}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDiagnose();
|
||||
}}
|
||||
>
|
||||
<ExperimentOutlined />
|
||||
诊断
|
||||
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
|
||||
{diagnosing ? "诊断中..." : "诊断"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,12 +355,19 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
/* 上传 */
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
/* 新建素材库 */
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [newLibName, setNewLibName] = useState("");
|
||||
const [newLibKind, setNewLibKind] = useState<AssetKind>("video");
|
||||
|
||||
/* 视频播放 */
|
||||
const [playingAsset, setPlayingAsset] = useState<AssetItem | null>(null);
|
||||
|
||||
/* 诊断中状态 — 记录正在诊断的素材 ID */
|
||||
const [diagnosingId, setDiagnosingId] = useState<string | null>(null);
|
||||
|
||||
/* 派生数据 */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets;
|
||||
@@ -382,18 +429,25 @@ const AssetLibrary: React.FC = () => {
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||||
message.info(`大文件 "${file.name}" 将使用直传上传`);
|
||||
}
|
||||
await uploadAssetDirect({ file, library_id: effectiveLibId });
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: effectiveLibId,
|
||||
onProgress: (pct) => setUploadProgress(pct),
|
||||
});
|
||||
message.success(`"${file.name}" 上传成功`);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
} catch {
|
||||
message.error(`"${file.name}" 上传失败`);
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : "";
|
||||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -432,15 +486,18 @@ const AssetLibrary: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/* 诊断 — 调用真实 API */
|
||||
/* 诊断 — 调用真实 API,带 loading 状态 */
|
||||
const handleDiagnose = async (asset: AssetItem) => {
|
||||
setDiagnosingId(asset.id);
|
||||
try {
|
||||
const result = await getAssetDiagnosis();
|
||||
const result = await getAssetDiagnosis(asset.id);
|
||||
const score = result.readiness_score ?? "-";
|
||||
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
} catch {
|
||||
message.error(`"${asset.name}" 诊断失败`);
|
||||
} finally {
|
||||
setDiagnosingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -466,9 +523,10 @@ const AssetLibrary: React.FC = () => {
|
||||
if (libLoading) {
|
||||
return (
|
||||
<div className="xx-assets-page">
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
<div className="xx-assets-skeleton-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -476,6 +534,54 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="xx-assets-page">
|
||||
{/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */}
|
||||
<AntModal
|
||||
open={uploading}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={260}
|
||||
maskClosable={false}
|
||||
className="xx-upload-progress-modal"
|
||||
>
|
||||
<div className="xx-upload-progress-body">
|
||||
<svg
|
||||
className="xx-upload-progress-ring"
|
||||
viewBox="0 0 120 120"
|
||||
width={120}
|
||||
height={120}
|
||||
>
|
||||
{/* 背景圆环 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--border-primary, #e5e7eb)"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
{/* 进度圆弧 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--primary-color, #6366f1)"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 52}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 52 * (1 - uploadProgress / 100)}`}
|
||||
transform="rotate(-90 60 60)"
|
||||
style={{ transition: "stroke-dashoffset 0.3s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="xx-upload-progress-text">
|
||||
<span className="xx-upload-progress-pct">{uploadProgress}%</span>
|
||||
<span className="xx-upload-progress-label">上传中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* 两栏布局 */}
|
||||
<div className="xx-assets-layout">
|
||||
{/* ─── 左侧:素材库列表 ─── */}
|
||||
@@ -486,14 +592,8 @@ const AssetLibrary: React.FC = () => {
|
||||
className={`xx-asset-library-item${lib.id === effectiveLibId ? " active" : ""}`}
|
||||
onClick={() => setActiveLibId(lib.id)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<h4 style={{ flex: 1, margin: 0 }}>
|
||||
<div className="xx-asset-library-header">
|
||||
<h4>
|
||||
{kindIcon(lib.kind)} {lib.name}
|
||||
</h4>
|
||||
<Popconfirm
|
||||
@@ -538,7 +638,7 @@ const AssetLibrary: React.FC = () => {
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept="video/*,audio/*,image/*"
|
||||
accept="video/*,image/*"
|
||||
>
|
||||
<div className="xx-asset-upload-zone">
|
||||
<p className="xx-asset-upload-icon">
|
||||
@@ -548,7 +648,7 @@ const AssetLibrary: React.FC = () => {
|
||||
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">
|
||||
支持视频、音频、图片,单文件不超过 2GB
|
||||
支持视频、图片,单文件不超过 2GB
|
||||
</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
@@ -571,7 +671,6 @@ const AssetLibrary: React.FC = () => {
|
||||
options={[
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "voice", label: "配音" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
@@ -591,12 +690,7 @@ const AssetLibrary: React.FC = () => {
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={selectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span className="xx-assets-filter-count">
|
||||
共 {filteredAssets.length} 个素材
|
||||
</span>
|
||||
</div>
|
||||
@@ -630,14 +724,19 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
{/* 素材网格 */}
|
||||
{assetsLoading ? (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">⏳</div>
|
||||
<p>加载素材中...</p>
|
||||
<div className="xx-asset-grid">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : assetsError ? (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon">❌</div>
|
||||
<p>{assetsErrorObj?.message || "加载失败"}</p>
|
||||
<div className="xx-assets-empty-icon">
|
||||
<ExclamationCircleOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">
|
||||
{assetsErrorObj?.message || "加载失败"}
|
||||
</p>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
@@ -653,8 +752,10 @@ const AssetLibrary: React.FC = () => {
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
selected={selectedIds.has(asset.id)}
|
||||
diagnosing={diagnosingId === asset.id}
|
||||
onToggle={() => toggleSelect(asset.id)}
|
||||
onDiagnose={() => handleDiagnose(asset)}
|
||||
onPlay={() => setPlayingAsset(asset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -663,7 +764,9 @@ const AssetLibrary: React.FC = () => {
|
||||
<div className="xx-assets-empty-icon">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<p>暂无素材,请上传或切换素材库</p>
|
||||
<p className="xx-assets-empty-title">
|
||||
暂无素材,请上传或切换素材库
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -680,24 +783,9 @@ const AssetLibrary: React.FC = () => {
|
||||
destroyOnClose
|
||||
confirmLoading={createLibMutation.isPending}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<div className="xx-asset-form-body">
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
名称
|
||||
</div>
|
||||
<div className="xx-asset-form-label">名称</div>
|
||||
<Input
|
||||
placeholder="请输入素材库名称"
|
||||
value={newLibName}
|
||||
@@ -706,28 +794,45 @@ const AssetLibrary: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
类型
|
||||
</div>
|
||||
<div className="xx-asset-form-label">类型</div>
|
||||
<Select
|
||||
value={newLibKind}
|
||||
onChange={(v) => setNewLibKind(v)}
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "voice", label: "配音" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 视频/音频播放弹窗 ─── */}
|
||||
<AntModal
|
||||
title={playingAsset?.name ?? "播放"}
|
||||
open={!!playingAsset}
|
||||
onCancel={() => setPlayingAsset(null)}
|
||||
footer={null}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
{playingAsset?.fileUrl ? (
|
||||
<video
|
||||
src={playingAsset.fileUrl}
|
||||
controls
|
||||
autoPlay
|
||||
className="xx-asset-video-player"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-asset-empty-fallback">
|
||||
<p>暂无可播放的文件地址</p>
|
||||
<p className="xx-asset-empty-fallback-id">
|
||||
素材 ID: {playingAsset?.id}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AntModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -54,8 +54,13 @@
|
||||
box-shadow: var(--shadow-primary);
|
||||
}
|
||||
|
||||
.xx-asset-library-item:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.xx-asset-library-item h4 {
|
||||
margin: 0 0 4px;
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
@@ -94,6 +99,12 @@
|
||||
background: var(--error-soft);
|
||||
}
|
||||
|
||||
.xx-asset-library-delete:active {
|
||||
background: var(--error-color);
|
||||
color: var(--text-inverse);
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.xx-asset-library-add {
|
||||
border: 1px dashed var(--border-color);
|
||||
background: transparent;
|
||||
@@ -116,6 +127,12 @@
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-asset-library-add:active {
|
||||
transform: scale(0.98);
|
||||
background: var(--primary-soft);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
右侧内容区
|
||||
============================================================ */
|
||||
@@ -143,6 +160,12 @@
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-asset-upload-zone:active {
|
||||
border-style: solid;
|
||||
transform: scale(0.99);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.xx-asset-upload-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: var(--space-sm);
|
||||
@@ -212,6 +235,11 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.xx-asset-card:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* 缩略图 */
|
||||
.xx-asset-thumb {
|
||||
aspect-ratio: 9 / 16;
|
||||
@@ -219,7 +247,7 @@
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
}
|
||||
|
||||
@@ -244,7 +272,7 @@
|
||||
backdrop-filter: blur(4px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
font-size: var(--font-size-md);
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
@@ -283,7 +311,7 @@
|
||||
|
||||
.xx-asset-name {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
@@ -323,14 +351,26 @@
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-asset-diagnose-btn:active {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-inverse);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.xx-asset-diagnose-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
状态标签(StatusPill)
|
||||
============================================================ */
|
||||
.xx-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xxs) var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
@@ -357,6 +397,45 @@
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
骨架屏(素材列表加载占位)
|
||||
============================================================ */
|
||||
@keyframes xx-skeleton-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-asset-skeleton {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xx-asset-skeleton .xx-asset-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.xx-skeleton-pulse {
|
||||
animation: xx-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.xx-skeleton-line {
|
||||
height: 14px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* 诊断按钮 loading 态 */
|
||||
.xx-asset-diagnose-btn-loading {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
空状态
|
||||
============================================================ */
|
||||
@@ -367,7 +446,7 @@
|
||||
}
|
||||
|
||||
.xx-assets-empty-icon {
|
||||
font-size: 48px;
|
||||
font-size: var(--font-size-3xl);
|
||||
margin-bottom: var(--space-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -391,6 +470,62 @@
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
内联样式迁移类
|
||||
============================================================ */
|
||||
.xx-asset-library-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.xx-asset-library-header h4 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-asset-form-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.xx-asset-form-label {
|
||||
margin-bottom: 6px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-asset-video-player {
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.xx-asset-empty-fallback {
|
||||
text-align: center;
|
||||
padding: var(--space-2xl) 0;
|
||||
}
|
||||
|
||||
.xx-asset-empty-fallback-id {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
骨架屏网格(加载态)
|
||||
============================================================ */
|
||||
.xx-assets-skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.xx-assets-skeleton-card .xx-asset-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@@ -452,3 +587,40 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 上传进度弹窗 ─── */
|
||||
.xx-upload-progress-modal .ant-modal-content {
|
||||
padding: 24px 16px 20px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.xx-upload-progress-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.xx-upload-progress-ring {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xx-upload-progress-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-upload-progress-pct {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color, #6366f1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-upload-progress-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 认证页面共享样式 — V21 Design System
|
||||
* Login / Register 共用
|
||||
*/
|
||||
|
||||
/* ── 页面容器 ───────────────────────────────────────────── */
|
||||
.xx-auth-page {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--primary-color) 8%, transparent),
|
||||
color-mix(in srgb, var(--secondary-color) 6%, transparent)
|
||||
);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ── 卡片 ───────────────────────────────────────────────── */
|
||||
.xx-auth-card {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 48px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* ── 头部 ───────────────────────────────────────────────── */
|
||||
.xx-auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.xx-logo-big {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-auth-header h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.xx-auth-header p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── 底部链接 ───────────────────────────────────────────── */
|
||||
.xx-auth-footer {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.xx-auth-footer a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.xx-auth-footer a:hover {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* ── 表单标签 ───────────────────────────────────────────── */
|
||||
.xx-auth-page .ant-form-item-label > label {
|
||||
font-weight: 850;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── 输入框 ─────────────────────────────────────────────── */
|
||||
.xx-auth-page .ant-input,
|
||||
.xx-auth-page .ant-input-password input {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.xx-auth-page .ant-input:focus,
|
||||
.xx-auth-page .ant-input-password input:focus,
|
||||
.xx-auth-page .ant-input-focused,
|
||||
.xx-auth-page .ant-input-password-focused .ant-input {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px var(--primary-soft);
|
||||
}
|
||||
|
||||
/* ── 复选框 ─────────────────────────────────────────────── */
|
||||
.xx-auth-page .ant-checkbox-wrapper {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-auth-page .ant-checkbox-inner {
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
/* ── 分隔线 ─────────────────────────────────────────────── */
|
||||
.xx-auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-auth-divider::before,
|
||||
.xx-auth-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
/* ── 第三方登录按钮 ─────────────────────────────────────── */
|
||||
.xx-auth-third-party {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-btn-wechat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.xx-btn-wechat:hover {
|
||||
background: var(--secondary-color);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.xx-btn-wechat .xx-wechat-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ── 响应式 ─────────────────────────────────────────────── */
|
||||
@media (max-width: 480px) {
|
||||
.xx-auth-card {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.xx-auth-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.xx-logo-big {
|
||||
font-size: 48px;
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
.xx-auth-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
@@ -34,12 +34,12 @@
|
||||
.xx-auth-header h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 8px;
|
||||
color: #0f172a;
|
||||
color: var(--text-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.xx-auth-header p {
|
||||
color: #64748b;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -47,37 +47,37 @@
|
||||
.xx-auth-footer {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.xx-auth-footer a {
|
||||
color: #4f46e5;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.xx-auth-footer a:hover {
|
||||
color: #4338ca;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Form 样式覆盖 */
|
||||
.ant-form-item-label > label {
|
||||
font-weight: 850;
|
||||
color: #0f172a;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-password input {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ant-input:focus,
|
||||
.ant-input-password input:focus {
|
||||
border-color: #4f46e5;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
@@ -96,8 +96,8 @@
|
||||
|
||||
.xx-result-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,91 +1,61 @@
|
||||
/* V21 登录页 */
|
||||
.xx-auth-page {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
/**
|
||||
* 登录页样式 — V21 Design System
|
||||
* 共享样式在 Auth.shared.css,此处仅保留登录页特有样式
|
||||
*/
|
||||
@import "./Auth.shared.css";
|
||||
|
||||
/* ── 品牌展示 ─────────────────────────────────────────────── */
|
||||
.xx-auth-brand {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.xx-auth-logo {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-auth-brand-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── 登录按钮 ───────────────────────────────────────────── */
|
||||
.xx-auth-card .xx-btn-primary {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(79, 70, 229, 0.08),
|
||||
rgba(16, 185, 129, 0.06)
|
||||
var(--color-primary-500),
|
||||
var(--color-primary-600)
|
||||
);
|
||||
padding: 24px;
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.xx-auth-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
.xx-auth-card .xx-btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px
|
||||
color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
}
|
||||
|
||||
.xx-auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.xx-logo-big {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-auth-header h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 8px;
|
||||
color: #0f172a;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.xx-auth-header p {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.xx-auth-footer {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.xx-auth-footer a {
|
||||
color: #4f46e5;
|
||||
/* ── 忘记密码链接 ───────────────────────────────────────── */
|
||||
.xx-auth-forgot {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.xx-auth-footer a:hover {
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
/* Form 样式覆盖 */
|
||||
.ant-form-item-label > label {
|
||||
font-weight: 850;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-password input {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.ant-input:focus,
|
||||
.ant-input-password input:focus {
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.ant-checkbox-wrapper {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.ant-checkbox-inner {
|
||||
border-radius: 6px;
|
||||
.xx-auth-forgot:hover {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
Regular → Executable
+43
-21
@@ -5,6 +5,7 @@ import React from "react";
|
||||
import { Form, Input, Checkbox, message } from "antd";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useLogin } from "@/hooks/useAuth";
|
||||
import Button from "@/components/ui/Button";
|
||||
import "./Login.css";
|
||||
|
||||
interface LoginFormValues {
|
||||
@@ -25,7 +26,7 @@ const Login: React.FC = () => {
|
||||
password: values.password,
|
||||
});
|
||||
message.success("登录成功!");
|
||||
navigate("/");
|
||||
navigate("/app/dashboard");
|
||||
} catch (error: unknown) {
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown)
|
||||
message.error("登录失败,请检查邮箱和密码");
|
||||
@@ -36,9 +37,11 @@ const Login: React.FC = () => {
|
||||
<div className="xx-auth-page">
|
||||
<div className="xx-auth-card">
|
||||
<div className="xx-auth-header">
|
||||
<div className="xx-logo-big">🦐</div>
|
||||
<h1>小虾自动剪辑</h1>
|
||||
<p>登录您的账户</p>
|
||||
<div className="xx-auth-brand">
|
||||
<span className="xx-auth-logo">🦐</span>
|
||||
<span className="xx-auth-brand-name">小虾智剪</span>
|
||||
</div>
|
||||
<p>登录到您的账户,开始智能视频创作</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
@@ -68,32 +71,51 @@ const Login: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Form.Item name="remember" valuePropName="checked" noStyle>
|
||||
<Checkbox>记住我</Checkbox>
|
||||
</Form.Item>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
style={{ float: "right", fontSize: "12px" }}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
忘记密码?
|
||||
</Link>
|
||||
<Form.Item name="remember" valuePropName="checked" noStyle>
|
||||
<Checkbox>记住我</Checkbox>
|
||||
</Form.Item>
|
||||
<Link className="xx-auth-forgot" to="/forgot-password">
|
||||
忘记密码?
|
||||
</Link>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="lg"
|
||||
htmlType="submit"
|
||||
loading={loginMutation.isPending}
|
||||
style={{ width: "100%" }}
|
||||
disabled={loginMutation.isPending}
|
||||
>
|
||||
{loginMutation.isPending ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div className="xx-auth-footer">
|
||||
还没有账号? <Link to="/register">立即注册</Link>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className="xx-auth-divider">或</div>
|
||||
|
||||
<div className="xx-auth-third-party">
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn-wechat"
|
||||
onClick={() => message.info("微信登录功能开发中")}
|
||||
>
|
||||
<span className="xx-wechat-icon">💬</span>
|
||||
微信登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="xx-auth-footer">
|
||||
还没有账号? <Link to="/register">立即注册</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,91 +1,49 @@
|
||||
/* V21 注册页 - 与 Login 保持一致 */
|
||||
.xx-auth-page {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
/**
|
||||
* 注册页样式 — V21 Design System
|
||||
* 共享样式在 Auth.shared.css,此处仅保留注册页特有样式
|
||||
*/
|
||||
@import "./Auth.shared.css";
|
||||
|
||||
/* ── 品牌展示 ─────────────────────────────────────────────── */
|
||||
.xx-auth-brand {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.xx-auth-logo {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-auth-brand-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── 注册按钮 ───────────────────────────────────────────── */
|
||||
.xx-auth-card .xx-btn-primary {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(79, 70, 229, 0.08),
|
||||
rgba(16, 185, 129, 0.06)
|
||||
var(--color-primary-500),
|
||||
var(--color-primary-600)
|
||||
);
|
||||
padding: 24px;
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.xx-auth-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.xx-auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.xx-logo-big {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-auth-header h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 8px;
|
||||
color: #0f172a;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.xx-auth-header p {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.xx-auth-footer {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.xx-auth-footer a {
|
||||
color: #4f46e5;
|
||||
text-decoration: none;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.xx-auth-footer a:hover {
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
/* Form 样式覆盖 */
|
||||
.ant-form-item-label > label {
|
||||
font-weight: 850;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-password input {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.ant-input:focus,
|
||||
.ant-input-password input:focus {
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.ant-checkbox-wrapper {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.ant-checkbox-inner {
|
||||
border-radius: 6px;
|
||||
.xx-auth-card .xx-btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px
|
||||
color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
}
|
||||
|
||||
Regular → Executable
+12
-8
@@ -6,6 +6,7 @@ import { Form, Input, message } from "antd";
|
||||
import { UserOutlined, LockOutlined, MailOutlined } from "@ant-design/icons";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useRegister } from "@/hooks/useAuth";
|
||||
import Button from "@/components/ui/Button";
|
||||
import "./Register.css";
|
||||
|
||||
interface RegisterFormValues {
|
||||
@@ -38,9 +39,11 @@ const Register: React.FC = () => {
|
||||
<div className="xx-auth-page">
|
||||
<div className="xx-auth-card">
|
||||
<div className="xx-auth-header">
|
||||
<div className="xx-logo-big">🦐</div>
|
||||
<h1>小虾自动剪辑</h1>
|
||||
<p>注册您的账户</p>
|
||||
<div className="xx-auth-brand">
|
||||
<span className="xx-auth-logo">🦐</span>
|
||||
<span className="xx-auth-brand-name">小虾智剪</span>
|
||||
</div>
|
||||
<p>创建账户,开启智能视频创作之旅</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
@@ -128,14 +131,15 @@ const Register: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="lg"
|
||||
htmlType="submit"
|
||||
loading={registerMutation.isPending}
|
||||
style={{ width: "100%" }}
|
||||
disabled={registerMutation.isPending}
|
||||
>
|
||||
{registerMutation.isPending ? "注册中..." : "注册"}
|
||||
</button>
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div className="xx-auth-footer">
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
.xx-auth-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
@@ -34,12 +34,12 @@
|
||||
.xx-auth-header h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 8px;
|
||||
color: #0f172a;
|
||||
color: var(--text-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.xx-auth-header p {
|
||||
color: #64748b;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -47,37 +47,37 @@
|
||||
.xx-auth-footer {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.xx-auth-footer a {
|
||||
color: #4f46e5;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.xx-auth-footer a:hover {
|
||||
color: #4338ca;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Form 样式覆盖 */
|
||||
.ant-form-item-label > label {
|
||||
font-weight: 850;
|
||||
color: #0f172a;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-password input {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ant-input:focus,
|
||||
.ant-input-password input:focus {
|
||||
border-color: #4f46e5;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
@@ -96,8 +96,8 @@
|
||||
|
||||
.xx-result-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Tag } from "@/components/ui";
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
AppstoreOutlined,
|
||||
ThunderboltOutlined,
|
||||
DatabaseOutlined,
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import "./dashboard.css";
|
||||
|
||||
/* ============================================================
|
||||
@@ -25,7 +32,7 @@ interface KpiItem {
|
||||
const kpiData: KpiItem[] = [
|
||||
{
|
||||
key: "projects",
|
||||
icon: "🎬",
|
||||
icon: "video",
|
||||
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
||||
value: "12",
|
||||
label: "项目总数",
|
||||
@@ -35,7 +42,7 @@ const kpiData: KpiItem[] = [
|
||||
},
|
||||
{
|
||||
key: "assets",
|
||||
icon: "📦",
|
||||
icon: "appstore",
|
||||
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
value: "486",
|
||||
label: "素材总数",
|
||||
@@ -45,7 +52,7 @@ const kpiData: KpiItem[] = [
|
||||
},
|
||||
{
|
||||
key: "generations",
|
||||
icon: "✨",
|
||||
icon: "thunderbolt",
|
||||
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
|
||||
value: "156",
|
||||
label: "本月生成数",
|
||||
@@ -55,7 +62,7 @@ const kpiData: KpiItem[] = [
|
||||
},
|
||||
{
|
||||
key: "storage",
|
||||
icon: "💾",
|
||||
icon: "database",
|
||||
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
value: "2.4GB",
|
||||
label: "存储空间",
|
||||
@@ -77,35 +84,35 @@ interface QuickEntry {
|
||||
const quickEntries: QuickEntry[] = [
|
||||
{
|
||||
id: "titles",
|
||||
icon: "📝",
|
||||
icon: "filetext",
|
||||
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
||||
title: "标题库",
|
||||
description: "24条标题 · 5个分类",
|
||||
path: "/titles",
|
||||
path: "/app/titles",
|
||||
},
|
||||
{
|
||||
id: "assets",
|
||||
icon: "📦",
|
||||
icon: "appstore",
|
||||
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
title: "素材库",
|
||||
description: "486个素材 · 3个素材库",
|
||||
path: "/assets",
|
||||
path: "/app/assets",
|
||||
},
|
||||
{
|
||||
id: "generate",
|
||||
icon: "✨",
|
||||
icon: "thunderbolt",
|
||||
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
|
||||
title: "一键生成",
|
||||
description: "开始创作新视频",
|
||||
path: "/generate",
|
||||
path: "/app/generate",
|
||||
},
|
||||
{
|
||||
id: "products",
|
||||
icon: "🎬",
|
||||
icon: "video",
|
||||
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
title: "成片库",
|
||||
description: "89个成片 · 3个待复核",
|
||||
path: "/products",
|
||||
path: "/app/products",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -238,6 +245,15 @@ const formatDate = () => {
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 星期${weekDays[d.getDay()]}`;
|
||||
};
|
||||
|
||||
/** 图标名称 → Ant Design 组件映射 */
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
video: <VideoCameraOutlined />,
|
||||
appstore: <AppstoreOutlined />,
|
||||
thunderbolt: <ThunderboltOutlined />,
|
||||
database: <DatabaseOutlined />,
|
||||
filetext: <FileTextOutlined />,
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 组件
|
||||
* ============================================================ */
|
||||
@@ -250,7 +266,7 @@ const Dashboard: React.FC = () => {
|
||||
<div className="xx-dashboard-page">
|
||||
{/* ── 欢迎头部 ─────────────────────────────────────────── */}
|
||||
<div className="xx-dashboard-welcome">
|
||||
<h2>{getGreeting()},创作者 👋</h2>
|
||||
<h2>{getGreeting()},创作者</h2>
|
||||
<p>{formatDate()} — 欢迎回到小小剪辑控制台</p>
|
||||
</div>
|
||||
|
||||
@@ -266,7 +282,7 @@ const Dashboard: React.FC = () => {
|
||||
className="xx-kpi-icon"
|
||||
style={{ background: item.iconGradient }}
|
||||
>
|
||||
{item.icon}
|
||||
{iconMap[item.icon] ?? item.icon}
|
||||
</div>
|
||||
<div className="xx-kpi-value">{item.value}</div>
|
||||
<div className="xx-kpi-label">{item.label}</div>
|
||||
@@ -282,18 +298,12 @@ const Dashboard: React.FC = () => {
|
||||
{/* ── 主内容区:左侧任务+图表 / 右侧公告 ──────────────── */}
|
||||
<div className="xx-dashboard-main">
|
||||
{/* 左列 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "var(--space-md)",
|
||||
}}
|
||||
>
|
||||
<div className="xx-dashboard-left-col">
|
||||
{/* 最近任务 */}
|
||||
<div className="xx-dashboard-section">
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>最近任务</h3>
|
||||
<button onClick={() => navigate("/history")}>查看全部</button>
|
||||
<button onClick={() => navigate("/app/history")}>查看全部</button>
|
||||
</div>
|
||||
<div className="xx-task-list">
|
||||
{recentTasks.map((task) => (
|
||||
@@ -331,7 +341,7 @@ const Dashboard: React.FC = () => {
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => navigate("/history")}
|
||||
onClick={() => navigate("/app/history")}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
@@ -345,12 +355,7 @@ const Dashboard: React.FC = () => {
|
||||
<div className="xx-dashboard-section">
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>本周生成趋势</h3>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span className="xx-chart-total">
|
||||
共 {weeklyData.reduce((s, d) => s + d.value, 0)} 次
|
||||
</span>
|
||||
</div>
|
||||
@@ -381,7 +386,7 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 右列 — 公告 + 存储用量 */}
|
||||
<div className="xx-dashboard-section" style={{ alignSelf: "start" }}>
|
||||
<div className="xx-dashboard-section xx-dashboard-section--start">
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>系统公告</h3>
|
||||
</div>
|
||||
@@ -402,17 +407,8 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 存储用量 */}
|
||||
<div style={{ marginTop: "var(--space-md)" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-primary)",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
存储用量
|
||||
</div>
|
||||
<div className="xx-storage-section">
|
||||
<div className="xx-storage-section-title">存储用量</div>
|
||||
<div className="xx-storage-bar">
|
||||
<div className="xx-storage-bar-track">
|
||||
<div className="xx-storage-bar-fill" style={{ width: "24%" }} />
|
||||
@@ -427,25 +423,9 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* ── 快速入口 ─────────────────────────────────────────── */}
|
||||
<div style={{ marginBottom: "var(--space-md)" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "var(--space-md)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "var(--font-size-base)",
|
||||
fontWeight: "var(--font-weight-semibold)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
快速入口
|
||||
</h3>
|
||||
<div className="xx-quick-entry-section">
|
||||
<div className="xx-quick-entry-header">
|
||||
<h3 className="xx-quick-entry-title">快速入口</h3>
|
||||
</div>
|
||||
<div className="xx-quick-grid">
|
||||
{quickEntries.map((entry) => (
|
||||
@@ -458,7 +438,7 @@ const Dashboard: React.FC = () => {
|
||||
className="xx-quick-card-icon"
|
||||
style={{ background: entry.iconGradient }}
|
||||
>
|
||||
{entry.icon}
|
||||
{iconMap[entry.icon] ?? entry.icon}
|
||||
</div>
|
||||
<h3>{entry.title}</h3>
|
||||
<p>{entry.description}</p>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
|
||||
.xx-dashboard-welcome h2 {
|
||||
margin: 0 0 4px;
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
@@ -44,34 +44,18 @@
|
||||
}
|
||||
|
||||
.xx-kpi-card {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--bg-primary),
|
||||
var(--bg-secondary, #f8fafc)
|
||||
);
|
||||
background: linear-gradient(180deg, var(--bg-primary), var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
transition: var(--transition-all);
|
||||
transition: 0.18s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-kpi-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--kpi-accent, var(--primary-color));
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
|
||||
.xx-kpi-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.xx-kpi-icon {
|
||||
@@ -81,8 +65,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 12px;
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-kpi-value {
|
||||
@@ -90,37 +74,33 @@
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.xx-kpi-label {
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-kpi-trend {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.xx-kpi-trend--up {
|
||||
color: var(--success-color);
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.xx-kpi-trend--down {
|
||||
color: var(--error-color);
|
||||
background: var(--error-soft);
|
||||
}
|
||||
|
||||
.xx-kpi-trend--neutral {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary, #f1f5f9);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -175,23 +155,28 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.xx-dashboard-section-header a:active,
|
||||
.xx-dashboard-section-header button:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
最近任务卡片列表
|
||||
============================================================ */
|
||||
.xx-task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-task-item {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
gap: 16px;
|
||||
gap: var(--space-md);
|
||||
align-items: center;
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
@@ -202,24 +187,24 @@
|
||||
}
|
||||
|
||||
.xx-task-info h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-task-info span {
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-task-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: var(--space-xs);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -238,7 +223,7 @@
|
||||
|
||||
.xx-task-status--pending {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary, #f1f5f9);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
@@ -250,18 +235,18 @@
|
||||
|
||||
.xx-task-time {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.xx-task-time span {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: var(--space-xxs);
|
||||
}
|
||||
|
||||
.xx-task-action {
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -274,7 +259,7 @@
|
||||
.xx-chart-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
height: 160px;
|
||||
padding-top: var(--space-sm);
|
||||
}
|
||||
@@ -303,12 +288,18 @@
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.xx-chart-bar:active {
|
||||
opacity: 0.7;
|
||||
transform: scaleY(0.97);
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.xx-chart-bar-value {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
@@ -322,14 +313,14 @@
|
||||
|
||||
.xx-chart-labels {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-chart-label {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -347,7 +338,7 @@
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
padding: var(--space-lg);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
text-align: center;
|
||||
@@ -359,6 +350,12 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.xx-quick-card:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
.xx-quick-card-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
@@ -366,14 +363,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
margin: 0 auto 14px;
|
||||
color: #fff;
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0 auto var(--space-md);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-quick-card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
margin: 0 0 var(--space-sm);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -381,7 +378,7 @@
|
||||
.xx-quick-card p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -390,15 +387,15 @@
|
||||
.xx-announcement-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-announcement-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
@@ -408,11 +405,15 @@
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-announcement-item:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.xx-announcement-tag {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
padding: var(--space-xxs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
height: fit-content;
|
||||
}
|
||||
@@ -438,15 +439,15 @@
|
||||
}
|
||||
|
||||
.xx-announcement-content h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 13px;
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.xx-announcement-content time {
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -454,19 +455,19 @@
|
||||
存储用量条
|
||||
============================================================ */
|
||||
.xx-storage-bar {
|
||||
margin-top: 12px;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-storage-bar-track {
|
||||
height: 8px;
|
||||
background: var(--bg-secondary, #e2e8f0);
|
||||
border-radius: 4px;
|
||||
height: var(--space-sm);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--space-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-storage-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--space-xs);
|
||||
background: var(--gradient-primary);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
@@ -474,9 +475,56 @@
|
||||
.xx-storage-bar-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
迁移自内联样式的工具类
|
||||
============================================================ */
|
||||
.xx-dashboard-left-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-chart-total {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-dashboard-section--start {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.xx-storage-section {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-storage-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-quick-entry-section {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-quick-entry-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-quick-entry-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -511,7 +559,7 @@
|
||||
|
||||
.xx-task-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-task-time {
|
||||
|
||||
@@ -170,7 +170,7 @@ const DuplicationDetail: React.FC = () => {
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/duplication/results")}
|
||||
onClick={() => navigate("/app/duplication/results")}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
返回列表
|
||||
|
||||
@@ -158,7 +158,7 @@ const DuplicationResults: React.FC = () => {
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/duplication")}
|
||||
onClick={() => navigate("/app/duplication")}
|
||||
>
|
||||
📤 上传查重
|
||||
</Button>
|
||||
|
||||
Regular → Executable
+1
-1
@@ -196,7 +196,7 @@ const DuplicationUpload: React.FC = () => {
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/duplication/results")}
|
||||
onClick={() => navigate("/app/duplication/results")}
|
||||
>
|
||||
查看结果
|
||||
</Button>
|
||||
|
||||
Regular → Executable
+2
-2
@@ -9,7 +9,7 @@
|
||||
通用页面容器
|
||||
============================================================ */
|
||||
.dup-page {
|
||||
padding: var(--space-lg);
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -68,7 +68,7 @@
|
||||
.dup-upload-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--primary-soft);
|
||||
margin: 0 auto var(--space-md);
|
||||
display: grid;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Regular → Executable
+661
-548
File diff suppressed because it is too large
Load Diff
@@ -1,204 +1,812 @@
|
||||
/**
|
||||
* 右侧片段属性面板 — V21 设计系统
|
||||
* 选中片段后编辑:文案、时长、转场效果
|
||||
* 未选中时显示全局设置(标题/字幕/BGM)
|
||||
* 右栏设置面板 — V8 原型 1:1 还原
|
||||
* 标题设置(AI toggle) + 字幕设置 + BGM设置 + 片段详情
|
||||
*/
|
||||
import React from "react";
|
||||
import type { EditPlanClip, TransitionEffect } from "@/api/editPlans";
|
||||
import { TRANSITION_OPTIONS, MATERIAL_TYPE_ICONS } from "@/api/editPlans";
|
||||
import React, { useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { TemplateMode } from "@/api/editingPlanner";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
import type { AssetItem } from "@/api/assets";
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
}
|
||||
|
||||
interface BgmSettings {
|
||||
music: string;
|
||||
}
|
||||
|
||||
interface ClipPropertiesPanelProps {
|
||||
/** 当前选中的片段 */
|
||||
selectedClip: EditPlanClip | null;
|
||||
/** 更新片段属性 */
|
||||
onUpdateClip: (clipId: string, updates: Partial<EditPlanClip>) => void;
|
||||
/** 所有片段列表(用于显示上下文) */
|
||||
clips: EditPlanClip[];
|
||||
selectedClip: ClipData | null;
|
||||
titleSettings: TitleSettings;
|
||||
subtitleSettings: SubtitleSettings;
|
||||
bgmSettings: BgmSettings;
|
||||
clipsCount: number;
|
||||
totalDuration: number;
|
||||
currentMode: TemplateMode;
|
||||
onTitleSettingsChange: (partial: Partial<TitleSettings>) => void;
|
||||
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
|
||||
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
|
||||
/** 配音素材列表(从配音素材库 API 获取) */
|
||||
voiceMaterials?: AssetItem[];
|
||||
/** 配音素材加载中 */
|
||||
voiceMaterialsLoading?: boolean;
|
||||
/** 刷新配音素材列表 */
|
||||
onRefreshVoiceMaterials?: () => void;
|
||||
/** 为片段选择配音素材 */
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void;
|
||||
}
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
];
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"苹方",
|
||||
"PingFang",
|
||||
"微软雅黑",
|
||||
"楷体",
|
||||
"华康俪金黑",
|
||||
];
|
||||
|
||||
const ANIMATION_OPTIONS = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade", label: "淡入淡出" },
|
||||
{ value: "slide", label: "滑动" },
|
||||
{ value: "typewriter", label: "打字机" },
|
||||
];
|
||||
|
||||
const BGM_OPTIONS = [
|
||||
{ value: "none", label: "无背景音乐" },
|
||||
{ value: "bgm_01", label: "🎵 轻快节奏" },
|
||||
{ value: "bgm_02", label: "🎵 温馨舒缓" },
|
||||
{ value: "bgm_03", label: "🎵 动感活力" },
|
||||
{ value: "bgm_04", label: "🎵 科技感" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号)
|
||||
* 不绑定字体,用户可自由搭配任意字体
|
||||
* 预览统一用系统字体展示效果
|
||||
*/
|
||||
const TITLE_PRESETS = [
|
||||
{
|
||||
key: "classic_white",
|
||||
label: "经典白字",
|
||||
style: {
|
||||
size: 28,
|
||||
color: "#ffffff",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#ffffff",
|
||||
WebkitTextStroke: "1px #000000",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "black_gold",
|
||||
label: "黑金质感",
|
||||
style: {
|
||||
size: 32,
|
||||
color: "#d4a843",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: true,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#d4a843",
|
||||
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "fresh_minimal",
|
||||
label: "清新简约",
|
||||
style: {
|
||||
size: 24,
|
||||
color: "#333333",
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: false,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#333333",
|
||||
fontSize: "18px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "variety_show",
|
||||
label: "综艺花字",
|
||||
style: {
|
||||
size: 36,
|
||||
color: "#ff4081",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: true,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 900,
|
||||
color: "#ff4081",
|
||||
WebkitTextStroke: "1.5px #ffffff",
|
||||
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
|
||||
fontSize: "22px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "business",
|
||||
label: "商务极简",
|
||||
style: {
|
||||
size: 24,
|
||||
color: "#1a1a1a",
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: false,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#1a1a1a",
|
||||
fontSize: "17px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "retro_film",
|
||||
label: "复古胶片",
|
||||
style: {
|
||||
size: 28,
|
||||
color: "#e8d5b7",
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: true,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#e8d5b7",
|
||||
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
|
||||
fontSize: "18px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "neon_glow",
|
||||
label: "霓虹发光",
|
||||
style: {
|
||||
size: 32,
|
||||
color: "#00e5ff",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: true,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#00e5ff",
|
||||
textShadow:
|
||||
"0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "handwriting",
|
||||
label: "手写字",
|
||||
style: {
|
||||
size: 28,
|
||||
color: "#333333",
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: true,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#333333",
|
||||
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 判断当前设置匹配哪个预设(比较 size + color + bold/italic/stroke/shadow,不比较字体) */
|
||||
function getActivePreset(settings: TitleSettings): string | null {
|
||||
for (const p of TITLE_PRESETS) {
|
||||
if (
|
||||
settings.size === p.style.size &&
|
||||
settings.color === p.style.color &&
|
||||
settings.bold === p.style.bold &&
|
||||
settings.italic === p.style.italic &&
|
||||
settings.stroke === p.style.stroke &&
|
||||
settings.shadow === p.style.shadow
|
||||
) {
|
||||
return p.key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 片段类型图标/标签 */
|
||||
const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" };
|
||||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "画中画",
|
||||
};
|
||||
|
||||
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
selectedClip,
|
||||
onUpdateClip,
|
||||
clips,
|
||||
titleSettings,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
clipsCount,
|
||||
totalDuration,
|
||||
currentMode,
|
||||
onTitleSettingsChange,
|
||||
onSubtitleSettingsChange,
|
||||
onBgmSettingsChange,
|
||||
onClipUpdate,
|
||||
voiceMaterials = [],
|
||||
voiceMaterialsLoading = false,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
}) => {
|
||||
if (!selectedClip) {
|
||||
return (
|
||||
<div className="ep-right">
|
||||
<div className="ep-clip-props-empty">
|
||||
<div className="ep-clip-props-empty-icon">👆</div>
|
||||
<h3>选择一个片段</h3>
|
||||
<p>点击时间线上的片段来编辑属性</p>
|
||||
<div className="ep-clip-props-summary">
|
||||
<div className="ep-clip-props-summary-item">
|
||||
<span className="ep-clip-props-summary-label">总片段数</span>
|
||||
<span className="ep-clip-props-summary-value">
|
||||
{clips.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-clip-props-summary-item">
|
||||
<span className="ep-clip-props-summary-label">总时长</span>
|
||||
<span className="ep-clip-props-summary-value">
|
||||
{clips.reduce((s, c) => s + c.duration, 0)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const navigate = useNavigate();
|
||||
|
||||
const transition = selectedClip.transition ?? {
|
||||
type: "none" as const,
|
||||
duration: 0,
|
||||
/* ── 配音试听 ── */
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
|
||||
/** 试听配音素材 */
|
||||
const handlePreviewVoice = useCallback(
|
||||
(asset: AssetItem) => {
|
||||
// 点击同一个 → 暂停
|
||||
if (previewingId === asset.id) {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
return;
|
||||
}
|
||||
// 停止上一个
|
||||
audioRef.current?.pause();
|
||||
const url = asset.file_url || (asset.metadata?.preview_url as string);
|
||||
if (!url) return;
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => {});
|
||||
audio.onended = () => setPreviewingId(null);
|
||||
setPreviewingId(asset.id);
|
||||
},
|
||||
[previewingId],
|
||||
);
|
||||
|
||||
/** 从 metadata 取性别标签 */
|
||||
const getGenderLabel = (m: AssetItem): string => {
|
||||
const g = (m.metadata?.gender as string) || "";
|
||||
if (g === "male") return "男";
|
||||
if (g === "female") return "女";
|
||||
return "";
|
||||
};
|
||||
|
||||
const handleTransitionTypeChange = (type: TransitionEffect["type"]) => {
|
||||
const duration = type === "none" ? 0 : transition.duration || 0.5;
|
||||
onUpdateClip(selectedClip.id, {
|
||||
transition: { type, duration },
|
||||
});
|
||||
};
|
||||
|
||||
const handleTransitionDurationChange = (duration: number) => {
|
||||
onUpdateClip(selectedClip.id, {
|
||||
transition: { ...transition, duration },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ep-right">
|
||||
{/* 片段信息头 */}
|
||||
<div className="ep-right-section">
|
||||
<div className="ep-clip-props-header">
|
||||
<span className="ep-clip-props-header-icon">
|
||||
{MATERIAL_TYPE_ICONS[selectedClip.material_type] || "📄"}
|
||||
</span>
|
||||
<div>
|
||||
<h3>片段 {selectedClip.order + 1}</h3>
|
||||
<span className="ep-clip-props-header-type">
|
||||
{selectedClip.material_type} · {selectedClip.duration}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-right-panel">
|
||||
{/* ═══ 标题设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">📝</span>
|
||||
标题设置
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文案编辑 */}
|
||||
<div className="ep-right-section">
|
||||
<h3>📝 文案</h3>
|
||||
<div className="ep-clip-props-field">
|
||||
<textarea
|
||||
className="ep-clip-props-textarea"
|
||||
placeholder="输入片段文案..."
|
||||
value={selectedClip.script_text}
|
||||
onChange={(e) =>
|
||||
onUpdateClip(selectedClip.id, {
|
||||
script_text: e.target.value,
|
||||
<div className="ep-toggle-row">
|
||||
<span className="ep-toggle-label">AI 自动选择</span>
|
||||
<div
|
||||
className={`ep-toggle ${titleSettings.aiAutoSelect ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({
|
||||
aiAutoSelect: !titleSettings.aiAutoSelect,
|
||||
})
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="ep-clip-props-field-hint">
|
||||
{selectedClip.script_text.length} 字
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时长调整 */}
|
||||
<div className="ep-right-section">
|
||||
<h3>⏱️ 时长</h3>
|
||||
<div className="ep-clip-props-field">
|
||||
<div className="ep-clip-props-duration-control">
|
||||
<input
|
||||
type="range"
|
||||
className="ep-clip-props-range"
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
value={selectedClip.duration}
|
||||
onChange={(e) =>
|
||||
onUpdateClip(selectedClip.id, {
|
||||
duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-props-duration-value">
|
||||
{selectedClip.duration}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转场效果 */}
|
||||
<div className="ep-right-section">
|
||||
<h3>✨ 转场效果</h3>
|
||||
<div className="ep-clip-props-field">
|
||||
<label className="ep-clip-props-label">转场类型</label>
|
||||
<div className="ep-clip-props-transition-grid">
|
||||
{TRANSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`ep-clip-props-transition-btn${transition.type === opt.value ? " active" : ""}`}
|
||||
onClick={() => handleTransitionTypeChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{transition.type !== "none" && (
|
||||
<div className="ep-clip-props-field">
|
||||
<label className="ep-clip-props-label">转场时长</label>
|
||||
<div className="ep-clip-props-duration-control">
|
||||
<input
|
||||
type="range"
|
||||
className="ep-clip-props-range"
|
||||
min={0.1}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={transition.duration}
|
||||
{!titleSettings.aiAutoSelect && (
|
||||
<>
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">位置</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={titleSettings.position}
|
||||
onChange={(e) =>
|
||||
handleTransitionDurationChange(Number(e.target.value))
|
||||
onTitleSettingsChange({ position: e.target.value })
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-props-duration-value">
|
||||
{transition.duration.toFixed(1)}s
|
||||
</span>
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">字体</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={titleSettings.font}
|
||||
onChange={(e) =>
|
||||
onTitleSettingsChange({ font: e.target.value })
|
||||
}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">大小</label>
|
||||
<div className="ep-slider-row">
|
||||
<input
|
||||
className="ep-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
value={titleSettings.size}
|
||||
onChange={(e) =>
|
||||
onTitleSettingsChange({ size: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="ep-slider-value">{titleSettings.size}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">预设样式</label>
|
||||
<div className="ep-title-presets-grid">
|
||||
{TITLE_PRESETS.map((p) => {
|
||||
const isActive = getActivePreset(titleSettings) === p.key;
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`ep-title-preset-card${isActive ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({
|
||||
size: p.style.size,
|
||||
color: p.style.color,
|
||||
bold: p.style.bold,
|
||||
italic: p.style.italic,
|
||||
stroke: p.style.stroke,
|
||||
shadow: p.style.shadow,
|
||||
})
|
||||
}
|
||||
title={p.label}
|
||||
>
|
||||
<span
|
||||
className="ep-title-preset-preview-text"
|
||||
style={p.previewStyle}
|
||||
>
|
||||
标题
|
||||
</span>
|
||||
<span className="ep-title-preset-card-label">
|
||||
{p.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">样式</label>
|
||||
<div className="ep-style-btns">
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.bold ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({ bold: !titleSettings.bold })
|
||||
}
|
||||
title="粗体"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.italic ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({ italic: !titleSettings.italic })
|
||||
}
|
||||
title="斜体"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.stroke ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({ stroke: !titleSettings.stroke })
|
||||
}
|
||||
title="描边"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.shadow ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({ shadow: !titleSettings.shadow })
|
||||
}
|
||||
title="阴影"
|
||||
>
|
||||
☁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 素材关联 */}
|
||||
<div className="ep-right-section">
|
||||
<h3>🔗 关联素材</h3>
|
||||
<div className="ep-clip-props-field">
|
||||
{selectedClip.media_asset_id ? (
|
||||
<div className="ep-clip-props-asset-linked">
|
||||
<span className="ep-clip-props-asset-icon">
|
||||
{MATERIAL_TYPE_ICONS[selectedClip.material_type]}
|
||||
</span>
|
||||
<span className="ep-clip-props-asset-name">
|
||||
{selectedClip.media_asset_id}
|
||||
</span>
|
||||
<button
|
||||
className="ep-clip-props-asset-unlink"
|
||||
onClick={() =>
|
||||
onUpdateClip(selectedClip.id, {
|
||||
media_asset_id: undefined,
|
||||
})
|
||||
{/* ═══ 字幕设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">💬</span>
|
||||
字幕设置
|
||||
</div>
|
||||
|
||||
<div className="ep-toggle-row">
|
||||
<span className="ep-toggle-label">启用字幕</span>
|
||||
<div
|
||||
className={`ep-toggle ${subtitleSettings.enabled ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onSubtitleSettingsChange({
|
||||
enabled: !subtitleSettings.enabled,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subtitleSettings.enabled && (
|
||||
<>
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">位置</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={subtitleSettings.position}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ position: e.target.value })
|
||||
}
|
||||
title="取消关联"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ep-clip-props-asset-empty">
|
||||
<p>从左侧素材库拖拽素材到此片段</p>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">字体</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={subtitleSettings.font}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ font: e.target.value })
|
||||
}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">大小</label>
|
||||
<div className="ep-slider-row">
|
||||
<input
|
||||
className="ep-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={32}
|
||||
value={subtitleSettings.size}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ size: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="ep-slider-value">
|
||||
{subtitleSettings.size}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">动画</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={subtitleSettings.animation}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ animation: e.target.value })
|
||||
}
|
||||
>
|
||||
{ANIMATION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ BGM 设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎵</span>
|
||||
BGM 设置
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">背景音乐</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={bgmSettings.music}
|
||||
onChange={(e) => onBgmSettingsChange({ music: e.target.value })}
|
||||
>
|
||||
{BGM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||||
{selectedClip && (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎞️</span>
|
||||
片段详情
|
||||
</div>
|
||||
|
||||
<div className="ep-clip-detail">
|
||||
{/* 类型选择器 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">类型</div>
|
||||
<div className="ep-clip-type-selector">
|
||||
{(["voice", "pip"] as ClipType[]).map((t) => {
|
||||
const disabled =
|
||||
currentMode === "pip"
|
||||
? t !== "pip"
|
||||
: currentMode === "voice_over"
|
||||
? t !== "voice"
|
||||
: false; // voice_pip 可切换
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
className={`ep-clip-type-btn${selectedClip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
!disabled && onClipUpdate(selectedClip.id, { type: t })
|
||||
}
|
||||
>
|
||||
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时长 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">时长</div>
|
||||
<div className="ep-clip-detail-row">
|
||||
<input
|
||||
className="ep-duration-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={selectedClip.duration}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(selectedClip.id, {
|
||||
duration: Math.max(
|
||||
1,
|
||||
Math.min(120, Number(e.target.value) || 1),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-detail-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材起始时间 — 仅 voice 类型显示 */}
|
||||
{selectedClip.type === "voice" && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">素材起始时间</div>
|
||||
<div className="ep-clip-detail-row">
|
||||
<input
|
||||
className="ep-duration-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={9999}
|
||||
step={0.1}
|
||||
value={selectedClip.startOffset}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(selectedClip.id, {
|
||||
startOffset: Math.max(
|
||||
0,
|
||||
Math.min(9999, Number(e.target.value) || 0),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-detail-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配音素材选择 — 仅 voice 类型显示 */}
|
||||
{selectedClip.type === "voice" && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">
|
||||
配音素材
|
||||
{onRefreshVoiceMaterials && (
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-refresh-btn"
|
||||
title="刷新配音列表"
|
||||
onClick={() => onRefreshVoiceMaterials()}
|
||||
disabled={voiceMaterialsLoading}
|
||||
>
|
||||
{voiceMaterialsLoading ? "⏳" : "🔄"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
|
||||
<div className="ep-voice-loading">加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ep-voice-select-row">
|
||||
<select
|
||||
className="ep-clip-detail-select"
|
||||
value={selectedClip.voice_asset_id ?? ""}
|
||||
onChange={(e) => {
|
||||
const assetId = e.target.value;
|
||||
if (!onClipVoiceSelect) return;
|
||||
if (!assetId) {
|
||||
onClipVoiceSelect(selectedClip.id, null);
|
||||
} else {
|
||||
const asset = voiceMaterials.find(
|
||||
(m) => m.id === assetId,
|
||||
);
|
||||
if (asset)
|
||||
onClipVoiceSelect(selectedClip.id, asset);
|
||||
}
|
||||
// 切换选择时停止试听
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
}}
|
||||
>
|
||||
<option value="">未选择</option>
|
||||
{voiceMaterials.map((m) => {
|
||||
const gender = getGenderLabel(m);
|
||||
const label = gender
|
||||
? `${m.name}(${gender})`
|
||||
: m.name;
|
||||
return (
|
||||
<option key={m.id} value={m.id}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{/* 试听按钮 */}
|
||||
{selectedClip.voice_asset_id && (
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-preview-btn"
|
||||
title={
|
||||
previewingId === selectedClip.voice_asset_id
|
||||
? "暂停"
|
||||
: "试听"
|
||||
}
|
||||
onClick={() => {
|
||||
const asset = voiceMaterials.find(
|
||||
(m) => m.id === selectedClip.voice_asset_id,
|
||||
);
|
||||
if (asset) handlePreviewVoice(asset);
|
||||
}}
|
||||
>
|
||||
{previewingId === selectedClip.voice_asset_id
|
||||
? "⏸"
|
||||
: "▶️"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{voiceMaterials.length === 0 && (
|
||||
<div className="ep-voice-empty">
|
||||
暂无配音素材,请先上传
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="ep-voice-upload-btn"
|
||||
onClick={() => navigate("/app/voice-materials")}
|
||||
>
|
||||
+ 上传新配音
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ 统计信息(始终显示) ═══ */}
|
||||
{!selectedClip && (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">📊</span>
|
||||
编辑统计
|
||||
</div>
|
||||
<div className="ep-clip-detail">
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">片段数</div>
|
||||
<div className="ep-clip-detail-value">{clipsCount}</div>
|
||||
</div>
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">总时长</div>
|
||||
<div className="ep-clip-detail-value">
|
||||
{totalDuration.toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">当前模式</div>
|
||||
<div className="ep-clip-detail-value">
|
||||
{currentMode === "pip"
|
||||
? "画中画"
|
||||
: currentMode === "voice_over"
|
||||
? "人物口播"
|
||||
: currentMode === "one_take"
|
||||
? "一镜到底"
|
||||
: "口播+画中画"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 生成历史弹窗 — 展示当前剪辑计划的生成任务记录
|
||||
* 从 EditingPlanner 拆分,避免主文件过大
|
||||
*/
|
||||
import React from "react";
|
||||
import { CloseOutlined, InboxOutlined } from "@ant-design/icons";
|
||||
import type { EditPlanGeneration } from "@/api/editPlans";
|
||||
import { PLAN_STATUS_LABELS } from "@/api/editPlans";
|
||||
|
||||
interface GenerationHistoryModalProps {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
history: EditPlanGeneration[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
open,
|
||||
loading,
|
||||
history,
|
||||
onClose,
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="ep-modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="ep-modal ep-gh-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="ep-modal-header">
|
||||
<h3>生成历史</h3>
|
||||
<button className="ep-modal-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ep-modal-body ep-gh-body">
|
||||
{loading ? (
|
||||
<div className="ep-gh-empty">
|
||||
<div className="ep-skeleton">
|
||||
<div className="ep-skeleton-item ep-skeleton-item--header" />
|
||||
<div className="ep-skeleton-item" />
|
||||
<div className="ep-skeleton-item" />
|
||||
<div className="ep-skeleton-item" />
|
||||
</div>
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="ep-gh-empty">
|
||||
<InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} />
|
||||
<span>暂无生成记录</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="ep-gh-table">
|
||||
<thead>
|
||||
<tr className="ep-gh-table-header-row">
|
||||
<th className="ep-gh-th">任务ID</th>
|
||||
<th className="ep-gh-th">状态</th>
|
||||
<th className="ep-gh-th">创建时间</th>
|
||||
<th className="ep-gh-th">更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((gen) => {
|
||||
const statusClass = `ep-gh-status-tag--${gen.status}`;
|
||||
return (
|
||||
<tr key={gen.id} className="ep-gh-table-row">
|
||||
<td className="ep-gh-td ep-gh-td-id">
|
||||
{gen.generation_task_id.slice(0, 8)}...
|
||||
</td>
|
||||
<td className="ep-gh-td">
|
||||
<span className={`ep-gh-status-tag ${statusClass}`}>
|
||||
{PLAN_STATUS_LABELS[gen.status] || gen.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.created_at
|
||||
? new Date(gen.created_at).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.updated_at
|
||||
? new Date(gen.updated_at).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="ep-modal-footer">
|
||||
<button className="ep-btn ep-btn-secondary" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerationHistoryModal;
|
||||
Regular → Executable
+102
-174
@@ -1,221 +1,149 @@
|
||||
/**
|
||||
* 左侧素材面板 — V21 设计系统
|
||||
* Tab 切换:模板列表 / 素材库
|
||||
* 素材 Tab 集成 AssetSelector 组件
|
||||
* 左侧面板 — V8 原型 1:1 还原
|
||||
* Tab 切换:模板列表 + 素材库
|
||||
*/
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Input, Select, Tag, Button } from "@/components/ui";
|
||||
import {
|
||||
MODE_LABELS,
|
||||
MODE_COLORS,
|
||||
type EditingTemplate,
|
||||
type TemplateCategory,
|
||||
type TemplateMode,
|
||||
} from "@/api/editingPlanner";
|
||||
import { getMediaAssets, type MediaAsset } from "@/api/editPlans";
|
||||
import { getAssetLibraries } from "@/api/assets";
|
||||
import React, { useState } from "react";
|
||||
import type { EditingTemplate } from "@/api/editingPlanner";
|
||||
import { MODE_LABELS } from "@/api/editingPlanner";
|
||||
import type { MediaAsset } from "@/api/editPlans";
|
||||
import AssetSelector from "@/components/AssetSelector/AssetSelector";
|
||||
|
||||
/** antd Tag color → V21 Tag variant */
|
||||
const modeVariantMap: Record<
|
||||
string,
|
||||
"primary" | "success" | "warning" | "info"
|
||||
> = {
|
||||
blue: "primary",
|
||||
green: "success",
|
||||
orange: "warning",
|
||||
purple: "info",
|
||||
};
|
||||
|
||||
type LeftTab = "templates" | "assets";
|
||||
|
||||
interface MediaPanelProps {
|
||||
/* 模板相关 */
|
||||
templates: EditingTemplate[];
|
||||
categories: TemplateCategory[];
|
||||
isLoadingTemplates: boolean;
|
||||
loading: boolean;
|
||||
searchQuery: string;
|
||||
currentFilter: string;
|
||||
filterCategories: string[];
|
||||
loadedTemplateId: string | null;
|
||||
onTemplateSelect: (tpl: EditingTemplate) => void;
|
||||
onNewTemplate: () => void;
|
||||
/* 素材相关 */
|
||||
onAssetDragStart?: (asset: MediaAsset) => void;
|
||||
onBatchAddAssets?: (assets: MediaAsset[]) => void;
|
||||
onLoadTemplate: (id: string) => void;
|
||||
onSearchChange: (q: string) => void;
|
||||
onFilterChange: (f: string) => void;
|
||||
// 素材相关
|
||||
mediaAssets?: MediaAsset[];
|
||||
onAssetSelect?: (ids: string[]) => void;
|
||||
selectedAssetIds?: string[];
|
||||
}
|
||||
|
||||
const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
templates,
|
||||
categories,
|
||||
isLoadingTemplates,
|
||||
loading,
|
||||
searchQuery,
|
||||
currentFilter,
|
||||
filterCategories,
|
||||
loadedTemplateId,
|
||||
onTemplateSelect,
|
||||
onNewTemplate,
|
||||
onAssetDragStart,
|
||||
onBatchAddAssets,
|
||||
onLoadTemplate,
|
||||
onSearchChange,
|
||||
onFilterChange,
|
||||
mediaAssets = [],
|
||||
onAssetSelect,
|
||||
selectedAssetIds = [],
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<LeftTab>("templates");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterCategory, setFilterCategory] = useState("");
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
|
||||
/* 先获取素材库列表,再用第一个 library_id 获取素材 */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
});
|
||||
const libraryId = libraries.length > 0 ? libraries[0].id : undefined;
|
||||
|
||||
/* 素材数据查询 */
|
||||
const { data: assets = [] } = useQuery({
|
||||
queryKey: ["media-assets", libraryId],
|
||||
queryFn: () => getMediaAssets(libraryId),
|
||||
enabled: libraryId !== undefined,
|
||||
});
|
||||
|
||||
/* 过滤模板 */
|
||||
const filteredTemplates = templates.filter((tpl) => {
|
||||
if (
|
||||
searchText &&
|
||||
!tpl.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
return false;
|
||||
if (filterCategory && tpl.category !== filterCategory) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
/* 获取选中的素材 */
|
||||
const selectedAssets = assets.filter((a) => selectedAssetIds.includes(a.id));
|
||||
|
||||
/* 批量添加到时间线 */
|
||||
const handleBatchAdd = useCallback(() => {
|
||||
if (selectedAssets.length > 0 && onBatchAddAssets) {
|
||||
onBatchAddAssets(selectedAssets);
|
||||
setSelectedAssetIds([]);
|
||||
}
|
||||
}, [selectedAssets, onBatchAddAssets]);
|
||||
const [activeTab, setActiveTab] = useState<"templates" | "assets">(
|
||||
"templates",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ep-left">
|
||||
<div className="ep-left-panel">
|
||||
{/* Tab 切换 */}
|
||||
<div className="ep-left-tabs">
|
||||
<button
|
||||
className={`ep-left-tab${activeTab === "templates" ? " active" : ""}`}
|
||||
className={`ep-left-tab ${activeTab === "templates" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("templates")}
|
||||
>
|
||||
📂 模板
|
||||
📋 模板
|
||||
</button>
|
||||
<button
|
||||
className={`ep-left-tab${activeTab === "assets" ? " active" : ""}`}
|
||||
className={`ep-left-tab ${activeTab === "assets" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("assets")}
|
||||
>
|
||||
🎬 素材
|
||||
📁 素材
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
{activeTab === "templates" ? (
|
||||
{/* 模板 Tab */}
|
||||
{activeTab === "templates" && (
|
||||
<>
|
||||
{/* 搜索栏 */}
|
||||
<div className="ep-left-header">
|
||||
<Input.Search
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap ep-media-panel-inner">
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="按分类筛选"
|
||||
value={filterCategory || undefined}
|
||||
onChange={(v: string) => setFilterCategory(v || "")}
|
||||
allowClear
|
||||
options={categories.map((c) => ({
|
||||
value: c.name,
|
||||
label: c.name,
|
||||
}))}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chip 分类筛选 */}
|
||||
<div className="ep-filter-chips">
|
||||
{filterCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
|
||||
onClick={() => onFilterChange(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-left-list">
|
||||
{isLoadingTemplates ? (
|
||||
<div className="ep-left-empty">
|
||||
<div className="ep-left-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
<div className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="ep-left-empty">
|
||||
<div className="ep-left-empty-icon">📭</div>
|
||||
<p>暂无模板</p>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map((tpl) => {
|
||||
const modeColor =
|
||||
MODE_COLORS[tpl.mode as TemplateMode] || "blue";
|
||||
const variant = modeVariantMap[modeColor] || "primary";
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card${loadedTemplateId === tpl.id ? " selected" : ""}`}
|
||||
onClick={() => onTemplateSelect(tpl)}
|
||||
>
|
||||
<div className="ep-template-card-name">{tpl.name}</div>
|
||||
templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
|
||||
onClick={() => onLoadTemplate(tpl.id)}
|
||||
>
|
||||
<div className="ep-template-card-header">
|
||||
<span className="ep-template-card-name">{tpl.name}</span>
|
||||
<span className="ep-template-card-mode">
|
||||
{MODE_LABELS[tpl.mode]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
<span>📐 {tpl.segments.length}片段</span>
|
||||
</div>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="ep-template-card-tags">
|
||||
<Tag variant={variant}>
|
||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
|
||||
</Tag>
|
||||
{tpl.tags.slice(0, 2).map((tag) => (
|
||||
<Tag key={tag} variant="info">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
</Tag>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
{tpl.segments.length} 片段 · ~{tpl.estimated_duration}s
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
{loadedTemplateId && (
|
||||
<div className="ep-left-footer">
|
||||
<button className="ep-new-template-btn" onClick={onNewTemplate}>
|
||||
✨ 新建空白模板
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 素材 Tab — 使用 AssetSelector */}
|
||||
<div className="ep-left-media">
|
||||
<AssetSelector
|
||||
assets={assets}
|
||||
selectedIds={selectedAssetIds}
|
||||
onSelectionChange={setSelectedAssetIds}
|
||||
onAssetDragStart={onAssetDragStart}
|
||||
showQualityFilter
|
||||
showBatchSelect
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 批量添加按钮 */}
|
||||
{selectedAssetIds.length > 0 && onBatchAddAssets && (
|
||||
<div className="ep-left-media-footer">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={handleBatchAdd}
|
||||
block
|
||||
>
|
||||
📦 添加选中素材到时间线 ({selectedAssetIds.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{/* 素材 Tab */}
|
||||
{activeTab === "assets" && (
|
||||
<div className="ep-assets-tab">
|
||||
<AssetSelector
|
||||
assets={mediaAssets}
|
||||
selectedIds={selectedAssetIds}
|
||||
onSelectionChange={onAssetSelect}
|
||||
showQualityFilter={false}
|
||||
showBatchSelect={false}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,416 +1,214 @@
|
||||
/**
|
||||
* 预览播放器 — V21 设计系统
|
||||
* 模拟播放 EditPlan 片段序列,支持播放/暂停、进度条拖拽、时间线点击跳转
|
||||
* 任务 2.16
|
||||
* 预览区 — V8 原型 1:1 还原
|
||||
* 手机模型预览(150x267) + 封面预览(150x267) 并排
|
||||
* 封面右侧竖排4个方案按钮
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import "./PreviewPlayer.css";
|
||||
import type { EditPlanClip } from "@/api/editPlans";
|
||||
import { MATERIAL_TYPE_ICONS } from "@/api/editPlans";
|
||||
import React from "react";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
|
||||
interface PreviewPlayerProps {
|
||||
clips: EditPlanClip[];
|
||||
totalDuration: number;
|
||||
selectedClipId: string | null;
|
||||
onSelectClip: (clipId: string | null) => void;
|
||||
interface CoverScheme {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/* ── 片段颜色(与 TimelinePanel 保持一致) ── */
|
||||
const CLIP_COLORS = [
|
||||
"#4f46e5",
|
||||
"#7c3aed",
|
||||
"#2563eb",
|
||||
"#0891b2",
|
||||
"#059669",
|
||||
"#d97706",
|
||||
];
|
||||
const getClipColor = (idx: number) => CLIP_COLORS[idx % CLIP_COLORS.length];
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
}
|
||||
|
||||
/* ── 格式化时间 mm:ss ── */
|
||||
const formatTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
}
|
||||
|
||||
interface PreviewPlayerProps {
|
||||
clips: ClipData[];
|
||||
selectedClipId: string | null;
|
||||
isPlaying: boolean;
|
||||
currentCoverScheme: string;
|
||||
coverSchemes: CoverScheme[];
|
||||
aiCoverLoading: boolean;
|
||||
titleSettings?: TitleSettings;
|
||||
subtitleSettings?: SubtitleSettings;
|
||||
onClipSelect: (clipId: string) => void;
|
||||
onCoverSchemeChange: (scheme: string) => void;
|
||||
onPlayPause: () => void;
|
||||
onAiGenerateCover: (coverType: "ai_frame" | "ai_regenerate") => void;
|
||||
}
|
||||
|
||||
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
|
||||
voice: "🎙️",
|
||||
pip: "🖼️",
|
||||
};
|
||||
|
||||
/* ── 根据播放进度计算当前片段索引 ── */
|
||||
const getClipIndexAtTime = (clips: EditPlanClip[], time: number): number => {
|
||||
let elapsed = 0;
|
||||
for (let i = 0; i < clips.length; i++) {
|
||||
elapsed += clips[i].duration;
|
||||
if (time < elapsed) return i;
|
||||
}
|
||||
return Math.max(0, clips.length - 1);
|
||||
};
|
||||
|
||||
/* ── 根据片段索引计算起始时间 ── */
|
||||
const getClipStartTime = (clips: EditPlanClip[], clipIndex: number): number => {
|
||||
let time = 0;
|
||||
for (let i = 0; i < clipIndex; i++) {
|
||||
time += clips[i].duration;
|
||||
}
|
||||
return time;
|
||||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "画中画",
|
||||
};
|
||||
|
||||
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
clips,
|
||||
totalDuration,
|
||||
selectedClipId,
|
||||
onSelectClip,
|
||||
isPlaying,
|
||||
currentCoverScheme,
|
||||
coverSchemes,
|
||||
aiCoverLoading,
|
||||
titleSettings,
|
||||
subtitleSettings,
|
||||
onCoverSchemeChange,
|
||||
onPlayPause,
|
||||
onAiGenerateCover,
|
||||
}) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
const currentClipIndex =
|
||||
clips.length > 0 ? getClipIndexAtTime(clips, currentTime) : -1;
|
||||
const currentClip = currentClipIndex >= 0 ? clips[currentClipIndex] : null;
|
||||
const clipStartTime =
|
||||
currentClipIndex >= 0 ? getClipStartTime(clips, currentClipIndex) : 0;
|
||||
const clipProgress =
|
||||
currentClip && currentClip.duration > 0
|
||||
? ((currentTime - clipStartTime) / currentClip.duration) * 100
|
||||
: 0;
|
||||
const overallProgress =
|
||||
totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0;
|
||||
|
||||
/* ── 播放控制 ── */
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setIsPlaying(false);
|
||||
}, []);
|
||||
|
||||
const startPlayback = useCallback(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
const next = prev + 0.1;
|
||||
if (next >= totalDuration) {
|
||||
// 播放结束
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
setIsPlaying(false);
|
||||
return 0; // 回到起点
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 100);
|
||||
setIsPlaying(true);
|
||||
}, [totalDuration]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (clips.length === 0) return;
|
||||
if (isPlaying) {
|
||||
stopPlayback();
|
||||
} else {
|
||||
// 如果在末尾,从头开始
|
||||
if (currentTime >= totalDuration - 0.05) {
|
||||
setCurrentTime(0);
|
||||
}
|
||||
startPlayback();
|
||||
}
|
||||
}, [
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
clips.length,
|
||||
startPlayback,
|
||||
stopPlayback,
|
||||
]);
|
||||
|
||||
/* ── 停止/重置 ── */
|
||||
const handleStop = useCallback(() => {
|
||||
stopPlayback();
|
||||
setCurrentTime(0);
|
||||
}, [stopPlayback]);
|
||||
|
||||
/* ── 上一段/下一段 ── */
|
||||
const handlePrevClip = useCallback(() => {
|
||||
if (currentClipIndex <= 0) {
|
||||
setCurrentTime(0);
|
||||
} else {
|
||||
setCurrentTime(getClipStartTime(clips, currentClipIndex - 1));
|
||||
}
|
||||
}, [currentClipIndex, clips]);
|
||||
|
||||
const handleNextClip = useCallback(() => {
|
||||
if (currentClipIndex < clips.length - 1) {
|
||||
setCurrentTime(getClipStartTime(clips, currentClipIndex + 1));
|
||||
} else {
|
||||
setCurrentTime(totalDuration);
|
||||
}
|
||||
}, [currentClipIndex, clips, totalDuration]);
|
||||
|
||||
/* ── 进度条拖拽 ── */
|
||||
const updateTimeFromMouse = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!progressRef.current || totalDuration === 0) return;
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (clientX - rect.left) / rect.width),
|
||||
);
|
||||
setCurrentTime(ratio * totalDuration);
|
||||
},
|
||||
[totalDuration],
|
||||
);
|
||||
|
||||
const handleProgressMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
wasPlayingRef.current = isPlaying;
|
||||
if (isPlaying) stopPlayback();
|
||||
updateTimeFromMouse(e.clientX);
|
||||
},
|
||||
[isPlaying, stopPlayback, updateTimeFromMouse],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
updateTimeFromMouse(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
if (wasPlayingRef.current) {
|
||||
startPlayback();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging, updateTimeFromMouse, startPlayback]);
|
||||
|
||||
/* ── 点击时间线片段跳转 ── */
|
||||
const handleTimelineSegmentClick = useCallback(
|
||||
(idx: number) => {
|
||||
setCurrentTime(getClipStartTime(clips, idx));
|
||||
onSelectClip(clips[idx].id);
|
||||
},
|
||||
[clips, onSelectClip],
|
||||
);
|
||||
|
||||
/* ── 组件卸载时清理定时器 ── */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* ── 片段变化时同步播放位置(外部选中片段跳转) ── */
|
||||
useEffect(() => {
|
||||
if (selectedClipId && !isPlaying) {
|
||||
const idx = clips.findIndex((c) => c.id === selectedClipId);
|
||||
if (idx >= 0) {
|
||||
const startTime = getClipStartTime(clips, idx);
|
||||
// 只在当前不在该片段范围内时跳转
|
||||
const endTime = startTime + clips[idx].duration;
|
||||
if (currentTime < startTime || currentTime >= endTime) {
|
||||
setCurrentTime(startTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedClipId, clips, isPlaying]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/* ── 空状态 ── */
|
||||
if (clips.length === 0) {
|
||||
return (
|
||||
<div className="ep-preview">
|
||||
<div className="ep-preview-screen">
|
||||
<div className="ep-preview-empty">
|
||||
<div className="ep-preview-empty-icon">🎬</div>
|
||||
<p>添加片段后预览</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId);
|
||||
const displayClip = selectedClip || clips[0];
|
||||
|
||||
return (
|
||||
<div className="ep-preview">
|
||||
{/* ── 预览画面 ── */}
|
||||
<div className="ep-preview-screen">
|
||||
{/* 背景渐变(模拟视频画面) */}
|
||||
<div
|
||||
className="ep-preview-visual"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${getClipColor(currentClipIndex)}33, ${getClipColor(currentClipIndex)}11)`,
|
||||
}}
|
||||
>
|
||||
{/* 片段类型图标 */}
|
||||
<div className="ep-preview-type-icon">
|
||||
{currentClip
|
||||
? MATERIAL_TYPE_ICONS[currentClip.material_type] || "📄"
|
||||
: "🎬"}
|
||||
</div>
|
||||
|
||||
{/* 文案字幕 */}
|
||||
{currentClip?.script_text && (
|
||||
<div className="ep-preview-subtitle">{currentClip.script_text}</div>
|
||||
)}
|
||||
|
||||
{/* 片段序号角标 */}
|
||||
<div
|
||||
className="ep-preview-clip-badge"
|
||||
style={{ backgroundColor: getClipColor(currentClipIndex) }}
|
||||
>
|
||||
#{currentClipIndex + 1}
|
||||
</div>
|
||||
|
||||
{/* 素材类型标签 */}
|
||||
{currentClip && (
|
||||
<div className="ep-preview-material-tag">
|
||||
{MATERIAL_TYPE_ICONS[currentClip.material_type]}{" "}
|
||||
{currentClip.material_type}
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-preview-area">
|
||||
{/* 手机模型预览 */}
|
||||
<div className="ep-phone-preview">
|
||||
<div className="ep-phone-status-bar">
|
||||
<span>9:41</span>
|
||||
<span>📶 🔋</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 控制栏 ── */}
|
||||
<div className="ep-preview-controls">
|
||||
{/* 左侧:时间 */}
|
||||
<div className="ep-preview-time">
|
||||
<span className="ep-preview-time-current">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
<span className="ep-preview-time-sep">/</span>
|
||||
<span className="ep-preview-time-total">
|
||||
{formatTime(totalDuration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 中间:播放控制按钮 */}
|
||||
<div className="ep-preview-buttons">
|
||||
<button
|
||||
className="ep-preview-btn"
|
||||
onClick={handlePrevClip}
|
||||
title="上一段"
|
||||
>
|
||||
⏮
|
||||
</button>
|
||||
<button
|
||||
className="ep-preview-btn ep-preview-btn-play"
|
||||
onClick={togglePlay}
|
||||
title={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
<button className="ep-preview-btn" onClick={handleStop} title="停止">
|
||||
⏹
|
||||
</button>
|
||||
<button
|
||||
className="ep-preview-btn"
|
||||
onClick={handleNextClip}
|
||||
title="下一段"
|
||||
>
|
||||
⏭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 右侧:片段信息 */}
|
||||
<div className="ep-preview-clip-info">
|
||||
{currentClip && (
|
||||
<div className="ep-phone-content">
|
||||
{displayClip ? (
|
||||
<>
|
||||
<span className="ep-preview-clip-idx">
|
||||
片段 {currentClipIndex + 1}/{clips.length}
|
||||
</span>
|
||||
<span className="ep-preview-clip-dur">
|
||||
{currentClip.duration}s
|
||||
</span>
|
||||
<button className="ep-phone-play-btn" onClick={onPlayPause}>
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
<div className="ep-phone-progress">
|
||||
<div
|
||||
className="ep-phone-progress-fill"
|
||||
style={{ width: isPlaying ? "45%" : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="ep-phone-clip-label">
|
||||
{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}{" "}
|
||||
{CLIP_TYPE_LABELS[displayClip.type] || "片段"}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="ep-phone-empty-hint">暂无片段</span>
|
||||
)}
|
||||
|
||||
{/* 标题实时预览 */}
|
||||
{titleSettings &&
|
||||
!titleSettings.aiAutoSelect &&
|
||||
titleSettings.title && (
|
||||
<div
|
||||
className="ep-preview-title"
|
||||
style={{
|
||||
fontSize: `${Math.min(titleSettings.size, 20)}px`,
|
||||
fontFamily: titleSettings.font,
|
||||
fontWeight: titleSettings.bold ? "bold" : "normal",
|
||||
fontStyle: titleSettings.italic ? "italic" : "normal",
|
||||
textShadow: titleSettings.shadow
|
||||
? "2px 2px 4px rgba(0,0,0,0.5)"
|
||||
: "none",
|
||||
WebkitTextStroke: titleSettings.stroke
|
||||
? "1px rgba(0,0,0,0.6)"
|
||||
: "none",
|
||||
top:
|
||||
titleSettings.position === "top"
|
||||
? "8px"
|
||||
: titleSettings.position === "center"
|
||||
? "50%"
|
||||
: "auto",
|
||||
bottom: titleSettings.position === "bottom" ? "30px" : "auto",
|
||||
transform:
|
||||
titleSettings.position === "center"
|
||||
? "translateY(-50%)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
{titleSettings.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 字幕实时预览 */}
|
||||
{subtitleSettings?.enabled && (
|
||||
<div
|
||||
className="ep-preview-subtitle"
|
||||
style={{
|
||||
fontSize: `${Math.min(subtitleSettings.size, 14)}px`,
|
||||
fontFamily: subtitleSettings.font,
|
||||
top:
|
||||
subtitleSettings.position === "top"
|
||||
? "8px"
|
||||
: subtitleSettings.position === "center"
|
||||
? "50%"
|
||||
: "auto",
|
||||
bottom: subtitleSettings.position === "bottom" ? "8px" : "auto",
|
||||
transform:
|
||||
subtitleSettings.position === "center"
|
||||
? "translateY(-50%)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
字幕预览文字
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 进度条(可拖拽) ── */}
|
||||
<div
|
||||
className="ep-preview-progress"
|
||||
ref={progressRef}
|
||||
onMouseDown={handleProgressMouseDown}
|
||||
>
|
||||
<div className="ep-preview-progress-track">
|
||||
{/* 片段色块背景 */}
|
||||
{clips.map((clip, idx) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="ep-preview-progress-segment"
|
||||
style={{
|
||||
width: `${(clip.duration / totalDuration) * 100}%`,
|
||||
backgroundColor: getClipColor(idx),
|
||||
opacity: idx === currentClipIndex ? 0.6 : 0.25,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* 已播放覆盖层 */}
|
||||
<div
|
||||
className="ep-preview-progress-fill"
|
||||
style={{ width: `${overallProgress}%` }}
|
||||
/>
|
||||
{/* 封面预览 */}
|
||||
<div className="ep-cover-preview">
|
||||
<div className="ep-cover-image">
|
||||
{displayClip ? (
|
||||
<span className="ep-cover-icon">
|
||||
{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}
|
||||
</span>
|
||||
) : (
|
||||
<span>暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ep-cover-label">
|
||||
{coverSchemes.find((s) => s.key === currentCoverScheme)?.label ||
|
||||
"封面预览"}
|
||||
</div>
|
||||
{/* AI 封面操作按钮 */}
|
||||
<div className="ep-cover-ai-btns">
|
||||
<button
|
||||
className="ep-cover-ai-btn"
|
||||
onClick={() => onAiGenerateCover("ai_frame")}
|
||||
disabled={aiCoverLoading}
|
||||
title="AI 智能选帧"
|
||||
>
|
||||
{aiCoverLoading ? "⏳" : "🤖"} AI 选帧
|
||||
</button>
|
||||
<button
|
||||
className="ep-cover-ai-btn"
|
||||
onClick={() => onAiGenerateCover("ai_regenerate")}
|
||||
disabled={aiCoverLoading}
|
||||
title="AI 重新生成封面"
|
||||
>
|
||||
{aiCoverLoading ? "⏳" : "🔄"} AI 重选
|
||||
</button>
|
||||
</div>
|
||||
{/* 拖拽手柄 */}
|
||||
<div
|
||||
className="ep-preview-progress-handle"
|
||||
style={{ left: `${overallProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 迷你时间线(可点击跳转) ── */}
|
||||
<div className="ep-preview-timeline">
|
||||
{clips.map((clip, idx) => {
|
||||
const isActive = idx === currentClipIndex;
|
||||
const isSelected = clip.id === selectedClipId;
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-preview-timeline-seg${isActive ? " active" : ""}${isSelected ? " selected" : ""}`}
|
||||
style={{
|
||||
width: `${(clip.duration / totalDuration) * 100}%`,
|
||||
backgroundColor: isActive
|
||||
? getClipColor(idx)
|
||||
: `${getClipColor(idx)}55`,
|
||||
}}
|
||||
onClick={() => handleTimelineSegmentClick(idx)}
|
||||
title={`片段 ${idx + 1}: ${clip.duration}s`}
|
||||
>
|
||||
<span className="ep-preview-timeline-seg-label">
|
||||
{MATERIAL_TYPE_ICONS[clip.material_type]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 播放头指示器 */}
|
||||
<div
|
||||
className="ep-preview-playhead"
|
||||
style={{ left: `${overallProgress}%` }}
|
||||
/>
|
||||
{/* 封面方案按钮(竖排4个) */}
|
||||
<div className="ep-cover-tags">
|
||||
{coverSchemes.map((scheme) => (
|
||||
<button
|
||||
key={scheme.key}
|
||||
className={`ep-cover-tag ${currentCoverScheme === scheme.key ? "active" : ""}`}
|
||||
onClick={() => onCoverSchemeChange(scheme.key)}
|
||||
>
|
||||
{scheme.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 片段内进度 ── */}
|
||||
{currentClip && (
|
||||
<div className="ep-preview-clip-progress">
|
||||
<div
|
||||
className="ep-preview-clip-progress-fill"
|
||||
style={{
|
||||
width: `${clipProgress}%`,
|
||||
backgroundColor: getClipColor(currentClipIndex),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Regular → Executable
+334
-280
@@ -1,324 +1,378 @@
|
||||
/**
|
||||
* 中间时间线面板 — V21 设计系统
|
||||
* 可视化时长条 + 片段卡片 + 拖拽排序 + 素材拖入
|
||||
* 水平轨道时间线 — 片段 = 时间规划 + 类型标记,不绑定素材
|
||||
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
|
||||
* "+" 卡片 → 类型+时长选择器
|
||||
*/
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui";
|
||||
import type { EditPlanClip, MediaAsset } from "@/api/editPlans";
|
||||
import { MATERIAL_TYPE_ICONS, TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
|
||||
interface TimelinePanelProps {
|
||||
clips: EditPlanClip[];
|
||||
clips: ClipData[];
|
||||
selectedClipId: string | null;
|
||||
onSelectClip: (clipId: string | null) => void;
|
||||
onRemoveClip: (clipId: string) => void;
|
||||
onReorderClips: (fromIdx: number, toIdx: number) => void;
|
||||
onAssetDrop: (asset: MediaAsset, insertIdx: number) => void;
|
||||
onBatchAssetDrop?: (assets: MediaAsset[], insertIdx: number) => void;
|
||||
onAddClip: () => void;
|
||||
totalDuration: number;
|
||||
currentMode: string;
|
||||
onClipSelect: (clipId: string) => void;
|
||||
onClipReorder: (fromIdx: number, toIdx: number) => void;
|
||||
onClipRemove: (clipId: string) => void;
|
||||
onAddClip: (type: ClipType, duration: number) => void;
|
||||
}
|
||||
|
||||
/** 片段类型图标 */
|
||||
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
|
||||
voice: "🎙️",
|
||||
pip: "🖼️",
|
||||
};
|
||||
|
||||
/** 片段类型标签 */
|
||||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "画中画",
|
||||
};
|
||||
|
||||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
onSelectClip,
|
||||
onRemoveClip,
|
||||
onReorderClips,
|
||||
onAssetDrop,
|
||||
onBatchAssetDrop,
|
||||
currentMode,
|
||||
onClipSelect,
|
||||
onClipReorder,
|
||||
onClipRemove,
|
||||
onAddClip,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
const [isDragOverEmpty, setIsDragOverEmpty] = useState(false);
|
||||
const dragIdxRef = useRef<number | null>(null);
|
||||
const dragRef = useRef<number | null>(null);
|
||||
const [showAddPicker, setShowAddPicker] = useState(false);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const addCardRef = useRef<HTMLDivElement>(null);
|
||||
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({
|
||||
top: 0,
|
||||
right: 0,
|
||||
});
|
||||
|
||||
/* ── 内部片段拖拽排序 ── */
|
||||
const handleClipDragStart = (e: React.DragEvent, idx: number) => {
|
||||
dragIdxRef.current = idx;
|
||||
e.dataTransfer.setData("application/x-clip-index", String(idx));
|
||||
/* ── "+" 卡片:类型+时长选择状态 ── */
|
||||
const [addType, setAddType] = useState<ClipType>("voice");
|
||||
const [addDuration, setAddDuration] = useState<number>(5);
|
||||
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] =
|
||||
currentMode === "voice_over"
|
||||
? ["voice"]
|
||||
: currentMode === "pip"
|
||||
? ["pip"]
|
||||
: ["voice", "pip"]; // voice_pip 或默认
|
||||
|
||||
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
|
||||
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
|
||||
const GAP = 6; // 面板与"+"卡片的间距
|
||||
|
||||
/* ── 计算 picker 初始位置(默认从"+"按钮上方弹出) ── */
|
||||
const updatePickerPosition = useCallback(() => {
|
||||
if (!addCardRef.current) return;
|
||||
const rect = addCardRef.current.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
|
||||
/* 垂直方向:默认向上弹出(上方空间永远比下方大) */
|
||||
const roughHeight = 180; // 粗略估算,useLayoutEffect 会用实际高度校正
|
||||
let top = rect.top - GAP - roughHeight;
|
||||
if (top < 8) top = 8;
|
||||
|
||||
/* 水平方向:右对齐"+"卡片;太靠右超出视口则左移 */
|
||||
let right = vw - rect.right;
|
||||
if (rect.right - PICKER_W < 8) {
|
||||
right = vw - PICKER_W - 8;
|
||||
}
|
||||
|
||||
setPickerPos({ top, right });
|
||||
}, []);
|
||||
|
||||
const handleTogglePicker = () => {
|
||||
if (!showAddPicker) {
|
||||
updatePickerPosition();
|
||||
}
|
||||
setShowAddPicker((v) => !v);
|
||||
};
|
||||
|
||||
/* ── 渲染后用实际 offsetHeight 做精确边界校正(useLayoutEffect 确保 paint 前完成) ── */
|
||||
useLayoutEffect(() => {
|
||||
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return;
|
||||
const pickerEl = pickerRef.current;
|
||||
const addRect = addCardRef.current.getBoundingClientRect();
|
||||
const pickerH = pickerEl.offsetHeight; // 实际高度,不用硬编码
|
||||
const vh = window.innerHeight;
|
||||
const vw = window.innerWidth;
|
||||
|
||||
/* 默认:面板在"+"按钮上方 */
|
||||
let top = addRect.top - GAP - pickerH;
|
||||
|
||||
/* 上方空间也不够(极端情况)→ 翻转到下方 */
|
||||
if (top < 8) {
|
||||
top = addRect.bottom + GAP;
|
||||
/* 下方也溢出 → clamp */
|
||||
if (top + pickerH > vh - 8) {
|
||||
top = vh - 8 - pickerH;
|
||||
if (top < 8) top = 8;
|
||||
}
|
||||
}
|
||||
|
||||
/* 水平方向:右对齐"+"卡片;左侧溢出保护 */
|
||||
let right = vw - addRect.right;
|
||||
const pickerRect = pickerEl.getBoundingClientRect();
|
||||
if (pickerRect.left < 8) {
|
||||
right = vw - PICKER_W - 8;
|
||||
}
|
||||
|
||||
setPickerPos({ top, right });
|
||||
}, [showAddPicker]);
|
||||
|
||||
/* ── 点击外部关闭添加面板 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||
setShowAddPicker(false);
|
||||
}
|
||||
};
|
||||
if (showAddPicker) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showAddPicker]);
|
||||
|
||||
/* ── 确认添加片段 ── */
|
||||
const handleConfirmAdd = () => {
|
||||
onAddClip(addType, addDuration);
|
||||
setShowAddPicker(false);
|
||||
};
|
||||
|
||||
/* ── 片段拖拽排序 ── */
|
||||
const handleDragStart = (e: React.DragEvent, idx: number) => {
|
||||
dragRef.current = idx;
|
||||
setDragIdx(idx);
|
||||
e.dataTransfer.setData("application/x-clip-drag", String(idx));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const handleClipDragOver = (e: React.DragEvent, idx: number) => {
|
||||
const handleDragOver = (e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverIdx(idx);
|
||||
};
|
||||
|
||||
const handleClipDragEnd = () => {
|
||||
dragIdxRef.current = null;
|
||||
const handleDragEnd = () => {
|
||||
dragRef.current = null;
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
};
|
||||
|
||||
/* ── 外部素材拖入 ── */
|
||||
const isAssetDrag = (e: React.DragEvent) =>
|
||||
e.dataTransfer.types.includes("application/x-media-asset") ||
|
||||
e.dataTransfer.types.includes("application/x-media-assets");
|
||||
|
||||
const handleAssetDragOver = (e: React.DragEvent) => {
|
||||
if (isAssetDrag(e)) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropOnClip = (e: React.DragEvent, insertIdx: number) => {
|
||||
const handleDrop = (e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverIdx(null);
|
||||
|
||||
// 内部片段排序
|
||||
const clipIdx = e.dataTransfer.getData("application/x-clip-index");
|
||||
if (clipIdx !== "") {
|
||||
const fromIdx = Number(clipIdx);
|
||||
if (fromIdx !== insertIdx && fromIdx !== insertIdx - 1) {
|
||||
const adjustedTo = fromIdx < insertIdx ? insertIdx - 1 : insertIdx;
|
||||
onReorderClips(fromIdx, adjustedTo);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量素材拖入
|
||||
const assetsJson = e.dataTransfer.getData("application/x-media-assets");
|
||||
if (assetsJson) {
|
||||
try {
|
||||
const assets: MediaAsset[] = JSON.parse(assetsJson);
|
||||
if (onBatchAssetDrop) {
|
||||
onBatchAssetDrop(assets, insertIdx);
|
||||
} else {
|
||||
assets.forEach((asset, i) => onAssetDrop(asset, insertIdx + i));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 单个素材拖入
|
||||
const assetJson = e.dataTransfer.getData("application/x-media-asset");
|
||||
if (assetJson) {
|
||||
try {
|
||||
const asset: MediaAsset = JSON.parse(assetJson);
|
||||
onAssetDrop(asset, insertIdx);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropOnEmpty = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOverEmpty(false);
|
||||
|
||||
// 批量素材拖入
|
||||
const assetsJson = e.dataTransfer.getData("application/x-media-assets");
|
||||
if (assetsJson) {
|
||||
try {
|
||||
const assets: MediaAsset[] = JSON.parse(assetsJson);
|
||||
if (onBatchAssetDrop) {
|
||||
onBatchAssetDrop(assets, clips.length);
|
||||
} else {
|
||||
assets.forEach((asset, i) => onAssetDrop(asset, clips.length + i));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 单个素材拖入
|
||||
const assetJson = e.dataTransfer.getData("application/x-media-asset");
|
||||
if (assetJson) {
|
||||
try {
|
||||
const asset: MediaAsset = JSON.parse(assetJson);
|
||||
onAssetDrop(asset, clips.length);
|
||||
} catch {
|
||||
// ignore
|
||||
const fromStr = e.dataTransfer.getData("application/x-clip-drag");
|
||||
if (fromStr !== "") {
|
||||
const fromIdx = Number(fromStr);
|
||||
if (fromIdx !== toIdx) {
|
||||
onClipReorder(fromIdx, toIdx);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 空轨道区域不接受素材拖入 ── */
|
||||
const handleEmptyDragOver = (e: React.DragEvent) => {
|
||||
if (isAssetDrag(e)) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOverEmpty(true);
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
/* ── 转场标签 ── */
|
||||
const getTransitionLabel = (clip: EditPlanClip) => {
|
||||
if (!clip.transition || clip.transition.type === "none") return null;
|
||||
const opt = TRANSITION_OPTIONS.find(
|
||||
(o) => o.value === clip.transition?.type,
|
||||
);
|
||||
return opt ? opt.label : clip.transition.type;
|
||||
};
|
||||
/* ── 时间标尺 ── */
|
||||
const totalDuration = clips.reduce((s, c) => s + c.duration, 0);
|
||||
const rulerMarks: number[] = [];
|
||||
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15;
|
||||
for (let t = 0; t <= totalDuration + step; t += step) {
|
||||
rulerMarks.push(t);
|
||||
}
|
||||
|
||||
/* ── 时长条宽度百分比 ── */
|
||||
const getClipWidth = (clip: EditPlanClip) => {
|
||||
if (totalDuration === 0) return 100 / Math.max(clips.length, 1);
|
||||
return (clip.duration / totalDuration) * 100;
|
||||
const formatTime = (sec: number) => {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/* ── 片段颜色 ── */
|
||||
const clipColors = [
|
||||
"#4f46e5",
|
||||
"#7c3aed",
|
||||
"#2563eb",
|
||||
"#0891b2",
|
||||
"#059669",
|
||||
"#d97706",
|
||||
];
|
||||
const getClipColor = (idx: number) => clipColors[idx % clipColors.length];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 可视化时长条 */}
|
||||
<div className="ep-timeline-bar">
|
||||
<div className="ep-timeline-bar-label">
|
||||
时间线{" "}
|
||||
<span className="ep-timeline-bar-duration">{totalDuration}s</span>
|
||||
</div>
|
||||
<div className="ep-timeline-bar-track">
|
||||
{clips.map((clip, idx) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="ep-timeline-bar-segment"
|
||||
style={{
|
||||
width: `${getClipWidth(clip)}%`,
|
||||
backgroundColor: getClipColor(idx),
|
||||
}}
|
||||
title={`片段 ${idx + 1}: ${clip.duration}s`}
|
||||
/>
|
||||
))}
|
||||
{clips.length === 0 && (
|
||||
<div className="ep-timeline-bar-empty">拖入素材开始编辑</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 片段列表 */}
|
||||
<div className="ep-timeline-area">
|
||||
{/* 时间线头部 */}
|
||||
<div className="ep-timeline-header">
|
||||
<h3>片段列表 ({clips.length})</h3>
|
||||
<Button buttonType="secondary" buttonSize="sm" onClick={onAddClip}>
|
||||
+ 添加片段
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ep-timeline-list">
|
||||
{clips.length === 0 ? (
|
||||
<div
|
||||
className={`ep-timeline-empty-drop${isDragOverEmpty ? " active" : ""}`}
|
||||
onDragOver={handleEmptyDragOver}
|
||||
onDragLeave={() => setIsDragOverEmpty(false)}
|
||||
onDrop={handleDropOnEmpty}
|
||||
<div className="ep-timeline-title">
|
||||
<span>🎬 时间线</span>
|
||||
<span className="ep-timeline-duration">
|
||||
总时长: {formatTime(totalDuration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-timeline-actions">
|
||||
<button
|
||||
className="ep-timeline-action-btn"
|
||||
onClick={() => {
|
||||
if (clips.length > 1) {
|
||||
const last = clips[clips.length - 1];
|
||||
onClipRemove(last.id);
|
||||
}
|
||||
}}
|
||||
title="删除最后一个片段"
|
||||
>
|
||||
<div className="ep-timeline-empty-icon">🎬</div>
|
||||
<p>从左侧拖拽素材到这里</p>
|
||||
<span>或点击「添加片段」手动创建</span>
|
||||
</div>
|
||||
) : (
|
||||
clips.map((clip, idx) => {
|
||||
const isSelected = clip.id === selectedClipId;
|
||||
const transitionLabel = getTransitionLabel(clip);
|
||||
|
||||
return (
|
||||
<React.Fragment key={clip.id}>
|
||||
{/* 拖放插入指示器 */}
|
||||
{dragOverIdx === idx && (
|
||||
<div className="ep-timeline-drop-indicator" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`ep-clip-card${isSelected ? " selected" : ""}${dragOverIdx === idx ? " drag-over" : ""}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleClipDragStart(e, idx)}
|
||||
onDragOver={(e) => {
|
||||
handleClipDragOver(e, idx);
|
||||
handleAssetDragOver(e);
|
||||
}}
|
||||
onDragEnd={handleClipDragEnd}
|
||||
onDrop={(e) => handleDropOnClip(e, idx)}
|
||||
onClick={() => onSelectClip(clip.id)}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<span className="ep-clip-drag">⠿</span>
|
||||
|
||||
{/* 序号徽标 */}
|
||||
<span
|
||||
className="ep-clip-index"
|
||||
style={{ backgroundColor: getClipColor(idx) }}
|
||||
>
|
||||
#{idx + 1}
|
||||
</span>
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<div className="ep-clip-info-top">
|
||||
<span className="ep-clip-type-icon">
|
||||
{MATERIAL_TYPE_ICONS[clip.material_type] || "📄"}
|
||||
</span>
|
||||
<span className="ep-clip-script">
|
||||
{clip.script_text || (
|
||||
<em className="ep-clip-script-empty">未填写文案</em>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-clip-info-bottom">
|
||||
<div className="ep-clip-duration-bar">
|
||||
<div
|
||||
className="ep-clip-duration-fill"
|
||||
style={{
|
||||
width: `${Math.min(100, (clip.duration / 60) * 100)}%`,
|
||||
backgroundColor: getClipColor(idx),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="ep-clip-duration-text">
|
||||
{clip.duration}s
|
||||
</span>
|
||||
{clip.media_asset_id && (
|
||||
<span
|
||||
className="ep-clip-asset-badge"
|
||||
title="已关联素材"
|
||||
>
|
||||
🔗
|
||||
</span>
|
||||
)}
|
||||
{transitionLabel && (
|
||||
<span className="ep-clip-transition-badge">
|
||||
✨ {transitionLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="ep-clip-actions">
|
||||
<button
|
||||
className="ep-clip-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveClip(clip.id);
|
||||
}}
|
||||
title="删除片段"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 末尾插入指示器 */}
|
||||
{clips.length > 0 && dragOverIdx === clips.length && (
|
||||
<div className="ep-timeline-drop-indicator" />
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
<button className="ep-timeline-action-btn" title="重做">
|
||||
↪
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 一镜到底提示 */}
|
||||
{currentMode === "one_take" && (
|
||||
<div className="ep-one-take-hint">🎥 一镜到底模式无片段</div>
|
||||
)}
|
||||
|
||||
{/* 时间标尺 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<div className="ep-time-ruler">
|
||||
<div
|
||||
className="ep-time-ruler-inner"
|
||||
style={{ width: Math.max(clips.length * 108, 300) }}
|
||||
>
|
||||
{rulerMarks.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="ep-time-mark"
|
||||
style={{
|
||||
left:
|
||||
totalDuration > 0
|
||||
? `${(t / totalDuration) * clips.length * 108}px`
|
||||
: `${t * 20}px`,
|
||||
}}
|
||||
>
|
||||
{t}s
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 水平片段轨道 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<div className="ep-clip-track" onDragOver={handleEmptyDragOver}>
|
||||
{clips.length === 0 ? (
|
||||
<div className="ep-track-empty">
|
||||
<div className="ep-track-empty-icon">🎬</div>
|
||||
<div className="ep-track-empty-text">点击右侧 + 添加片段</div>
|
||||
</div>
|
||||
) : (
|
||||
clips.map((clip, idx) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onClick={() => onClipSelect(clip.id)}
|
||||
>
|
||||
{/* 类型图标 */}
|
||||
<div className="ep-clip-thumbnail">
|
||||
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
|
||||
</div>
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<span className="ep-clip-name">
|
||||
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
|
||||
</span>
|
||||
<span className="ep-clip-duration">{clip.duration}s</span>
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="ep-clip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClipRemove(clip.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */}
|
||||
<div className="ep-track-add-card-wrapper">
|
||||
<div
|
||||
ref={addCardRef}
|
||||
className="ep-track-add-card"
|
||||
onClick={handleTogglePicker}
|
||||
title="添加片段"
|
||||
>
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */}
|
||||
{showAddPicker && (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className="ep-add-clip-picker ep-add-clip-picker--portal"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: pickerPos.top,
|
||||
right: pickerPos.right,
|
||||
}}
|
||||
>
|
||||
<div className="ep-add-clip-picker-title">添加片段</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="ep-add-clip-type-row">
|
||||
<span className="ep-add-clip-type-label">类型:</span>
|
||||
{availableTypes.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
|
||||
onClick={() => setAddType(t)}
|
||||
>
|
||||
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 时长输入 */}
|
||||
<div className="ep-add-clip-duration-row">
|
||||
<span className="ep-add-clip-type-label">时长:</span>
|
||||
<input
|
||||
type="number"
|
||||
className="ep-duration-input"
|
||||
min={1}
|
||||
max={120}
|
||||
value={addDuration}
|
||||
onChange={(e) =>
|
||||
setAddDuration(
|
||||
Math.max(1, Math.min(120, Number(e.target.value) || 1)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="ep-add-clip-duration-unit">秒</span>
|
||||
</div>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<button
|
||||
className="ep-add-clip-confirm-btn"
|
||||
onClick={handleConfirmAdd}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 撤销/重做 Hook — 通用历史栈管理
|
||||
* 支持任意状态的快照式 undo/redo
|
||||
*/
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
export function useUndoRedo<T>(initialState: T) {
|
||||
const [present, setPresent] = useState<T>(initialState);
|
||||
const pastRef = useRef<T[]>([]);
|
||||
const futureRef = useRef<T[]>([]);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
|
||||
const set = useCallback((next: T | ((prev: T) => T)) => {
|
||||
setPresent((curr) => {
|
||||
const resolved =
|
||||
typeof next === "function" ? (next as (p: T) => T)(curr) : next;
|
||||
pastRef.current = [...pastRef.current.slice(-(MAX_HISTORY - 1)), curr];
|
||||
futureRef.current = [];
|
||||
setCanUndo(true);
|
||||
setCanRedo(false);
|
||||
return resolved;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
setPresent((curr) => {
|
||||
const past = pastRef.current;
|
||||
if (past.length === 0) return curr;
|
||||
const prev = past[past.length - 1];
|
||||
pastRef.current = past.slice(0, -1);
|
||||
futureRef.current = [curr, ...futureRef.current];
|
||||
setCanUndo(pastRef.current.length > 0);
|
||||
setCanRedo(true);
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
setPresent((curr) => {
|
||||
const future = futureRef.current;
|
||||
if (future.length === 0) return curr;
|
||||
const next = future[0];
|
||||
futureRef.current = future.slice(1);
|
||||
pastRef.current = [...pastRef.current, curr];
|
||||
setCanUndo(true);
|
||||
setCanRedo(futureRef.current.length > 0);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reset = useCallback((state: T) => {
|
||||
pastRef.current = [];
|
||||
futureRef.current = [];
|
||||
setPresent(state);
|
||||
setCanUndo(false);
|
||||
setCanRedo(false);
|
||||
}, []);
|
||||
|
||||
return { state: present, set, undo, redo, canUndo, canRedo, reset };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 片段(Clip)统一类型定义
|
||||
* 片段 = 时间规划 + 类型标记,不绑定任何素材
|
||||
*/
|
||||
|
||||
export type ClipType = "voice" | "pip";
|
||||
|
||||
export interface ClipData {
|
||||
id: string;
|
||||
type: ClipType; // 片段类型:voice(口播)或 pip(画中画)
|
||||
duration: number; // 时长(秒)
|
||||
startOffset: number; // 仅 voice 类型:在口播素材中的起始时间(秒)
|
||||
// 保留兼容字段(后端序列化需要)
|
||||
template_segment_id?: string;
|
||||
script_text?: string;
|
||||
order?: number;
|
||||
/** 配音素材 ID(voice 类型片段使用) */
|
||||
voice_asset_id?: string;
|
||||
/** 配音素材文件 URL(voice 类型片段使用) */
|
||||
voice_file_url?: string;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user