Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ca2ffe272 | |||
| dfb2feef8a | |||
| b3ef7bb041 | |||
| a1a272b833 | |||
| 728db0faf8 | |||
| 7e5e412f7f | |||
| df08161630 | |||
| 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 |
@@ -0,0 +1,165 @@
|
||||
name: Auto Approve CI PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
name: Auto Approve on CI Green
|
||||
runs-on: ci-l1
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Auto approve when CI passes
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
|
||||
|
||||
# 检查是否纯前端改动
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
SKIP_BACKEND=true
|
||||
echo "✅ 纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
SKIP_BACKEND=false
|
||||
echo "🔧 包含后端/公共变更,检查全部CI"
|
||||
fi
|
||||
|
||||
# 定义需要检查的context
|
||||
# 根据目标分支决定检查哪些门禁
|
||||
TARGET_BRANCH="${GITHUB_BASE_REF}"
|
||||
echo "目标分支: ${TARGET_BRANCH}"
|
||||
|
||||
if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
elif [ "$TARGET_BRANCH" = "main" ]; then
|
||||
# main分支只检查required statuses: Validate + Frontend Lint
|
||||
# 不检查Tests/test(不是required门禁)
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"Tests / test (pull_request)"
|
||||
)
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项"
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
echo " - $ctx"
|
||||
done
|
||||
echo
|
||||
|
||||
# 轮询等待,最多20分钟(120次x10秒)
|
||||
for attempt in $(seq 1 120); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 调用辅助脚本检查每个context状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
|
||||
echo " $ctx: $STATE"
|
||||
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
|
||||
|
||||
# 检查是否已有审批(任何用户的APPROVED都算,避免重复审批)
|
||||
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
||||
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
|
||||
|
||||
if [ "$EXISTING" = "yes" ]; then
|
||||
echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第一步:创建PENDING review(Gitea API需要先创建再提交)
|
||||
echo "创建review..."
|
||||
REVIEW_CREATE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
|
||||
|
||||
if [ -z "$REVIEW_ID" ]; then
|
||||
echo "❌ 创建review失败"
|
||||
echo "$REVIEW_CREATE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 如果已经是APPROVED就不用再submit了(兼容不同Gitea版本)
|
||||
if [ "$REVIEW_STATE" = "APPROVED" ]; then
|
||||
echo "✅ 自动审批成功(直接创建为APPROVED)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第二步:submit review为APPROVED
|
||||
echo "提交review审批..."
|
||||
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
|
||||
|
||||
echo "提交API HTTP状态: $SUBMIT_CODE"
|
||||
cat /tmp/submit_resp.json 2>/dev/null || true
|
||||
echo
|
||||
|
||||
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
|
||||
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
|
||||
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 提交审批失败"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "❌ CI检查有失败项,不自动审批"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
|
||||
exit 0
|
||||
+122
-12
@@ -1,21 +1,131 @@
|
||||
name: Auto Merge PRs
|
||||
name: Auto Merge PRs (main)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *'
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review, review_requested]
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
name: Auto Merge on CI Green + Approved (main)
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'main'
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Auto merge develop PRs
|
||||
|
||||
- name: Auto merge when CI passes and approved
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh develop
|
||||
|
||||
- name: Auto merge main PRs (release only)
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh main
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
||||
echo
|
||||
|
||||
# 只合main分支
|
||||
if [ "$BASE_REF" != "main" ]; then
|
||||
echo "Skip: 目标分支不是main"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# main分支门禁:Validate + Frontend Lint
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"Tests / test (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
echo "检查门禁: ${#CONTEXTS[@]} 项"
|
||||
echo
|
||||
|
||||
# 轮询等待,最多30分钟(180次x10秒)
|
||||
for attempt in $(seq 1 180); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 检查CI状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
|
||||
echo " CI: ${ctx##*/}: $STATE"
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查审批状态
|
||||
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
|
||||
echo " 审批: $APPROVAL_RESULT"
|
||||
HAS_APPROVAL=false
|
||||
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
|
||||
HAS_APPROVAL=true
|
||||
fi
|
||||
|
||||
# 全部满足 → 合并
|
||||
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
|
||||
echo
|
||||
echo "CI全绿 + 审批通过,执行自动合并"
|
||||
|
||||
# 幂等检查:PR是否还是open
|
||||
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "PR状态为 ${PR_STATE},无需合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 执行merge(main分支用merge,保留历史)
|
||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"merge","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
echo "合并API HTTP状态: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "自动合并成功"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "405" ]; then
|
||||
echo "合并失败(405),可能有冲突或门禁未通过"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "Auto merge failed: PR may have conflicts or unresolved checks. Please review manually."}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 0
|
||||
else
|
||||
echo "自动合并失败 (HTTP $HTTP_CODE)"
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "CI有失败项,不自动合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "等待超时(30分钟)"
|
||||
exit 0
|
||||
|
||||
+395
-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 }}
|
||||
@@ -11,9 +11,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
@@ -93,9 +95,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
@@ -47,3 +47,4 @@ build/
|
||||
# Tracker temp files
|
||||
tracker_tasks.json
|
||||
|
||||
frontend-v21-ui-prototype-final.html
|
||||
|
||||
@@ -20,26 +20,33 @@ depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> bool:
|
||||
"""检查 PostgreSQL 表中某列是否已存在。"""
|
||||
"""检查列是否已存在。离线模式下返回 False。"""
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
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:
|
||||
"""检查 PostgreSQL 中某索引是否已存在。"""
|
||||
"""检查索引是否已存在。离线模式下返回 False。"""
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :index"),
|
||||
{"index": index},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
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:
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -198,13 +198,10 @@ def _build_single_asset_diagnosis(project_id: str, asset: Asset) -> ProjectAsset
|
||||
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_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"}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,14 +7,23 @@ 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 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
|
||||
|
||||
@@ -59,6 +68,7 @@ def _to_asset_response(item, storage_service=None) -> 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", []),
|
||||
)
|
||||
|
||||
|
||||
@@ -75,6 +85,13 @@ def _check_project_access(project_id: str, user_id: str, project_repository) ->
|
||||
def list_assets(
|
||||
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),
|
||||
@@ -82,32 +99,148 @@ def list_assets(
|
||||
) -> ListAssetsResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 模式1:指定 library_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)
|
||||
items = asset_repository.find_by_library(library_id)
|
||||
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
|
||||
|
||||
# 模式2:指定 project_id → 返回该项目所有素材
|
||||
if project_id:
|
||||
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)
|
||||
items = asset_repository.find_by_project(project_id)
|
||||
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
|
||||
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))
|
||||
|
||||
# 模式3:都不传 → 返回用户可访问的所有项目的所有素材
|
||||
try:
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
except Exception:
|
||||
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
|
||||
return ListAssetsResponse(items=[])
|
||||
|
||||
all_items = []
|
||||
for proj in projects:
|
||||
all_items.extend(asset_repository.find_by_project(proj.id))
|
||||
return ListAssetsResponse(items=[_to_asset_response(item) for item in all_items])
|
||||
# 应用 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):
|
||||
@@ -135,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])
|
||||
|
||||
@@ -23,12 +23,12 @@ 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, get_project_repository
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
@@ -39,7 +39,6 @@ from packages.application.generation_tasks import (
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -662,6 +661,7 @@ def ai_recommend_clips(
|
||||
|
||||
# 调用 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,
|
||||
@@ -783,6 +783,7 @@ def generate_cover(
|
||||
|
||||
# 调用 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,
|
||||
|
||||
@@ -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,
|
||||
@@ -51,6 +54,8 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
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,
|
||||
@@ -85,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,
|
||||
@@ -122,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),
|
||||
@@ -130,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):
|
||||
@@ -144,23 +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,
|
||||
source_edit_plan_id=request.source_edit_plan_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)
|
||||
@@ -237,6 +305,7 @@ def retry_generation_task(
|
||||
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,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
|
||||
@@ -45,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 时返回)")
|
||||
|
||||
@@ -21,6 +21,16 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
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":
|
||||
@@ -46,12 +56,21 @@ class GenerationTaskResponse(BaseModel):
|
||||
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)
|
||||
@@ -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 时返回)")
|
||||
|
||||
@@ -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,
|
||||
await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
const materialCard = page
|
||||
.locator(".xx-material-card")
|
||||
.filter({ hasText: sourceFileName });
|
||||
await materialCard.click({ force: true });
|
||||
await expect(materialCard).toHaveClass(/xx-material-card-selected/, {
|
||||
|
||||
// Step 1: template - default selected, click next
|
||||
await expect(page.locator(".xx-choice-item.selected")).toBeVisible();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// 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();
|
||||
|
||||
// Click generate
|
||||
const generateButton = page.getByRole("button", {
|
||||
name: "开始生成视频",
|
||||
});
|
||||
await expect(generateButton).toBeEnabled();
|
||||
// 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();
|
||||
|
||||
// Wait for the generation task to be created
|
||||
const createTaskResponsePromise = page.waitForResponse(
|
||||
// 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({ force: true });
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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> {
|
||||
@@ -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,
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface AssetItem {
|
||||
status?: string;
|
||||
classification_status?: string | null;
|
||||
quality_score?: number | null;
|
||||
tag_ids?: string[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
@@ -83,7 +84,9 @@ export interface AssetDiagnosis {
|
||||
// ─── 素材诊断 ──────────────────────────────────────────────
|
||||
|
||||
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
|
||||
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
|
||||
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 });
|
||||
@@ -112,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}`);
|
||||
@@ -127,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,
|
||||
@@ -185,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();
|
||||
@@ -207,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)
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -51,7 +51,11 @@ const Header: React.FC = () => {
|
||||
const isActive = (path: string) => {
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return location.pathname === "/" || location.pathname === "/app" || location.pathname === "/app/dashboard";
|
||||
return (
|
||||
location.pathname === "/" ||
|
||||
location.pathname === "/app" ||
|
||||
location.pathname === "/app/dashboard"
|
||||
);
|
||||
}
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/* ── 菜单项文字 ───────────────────────────────────────────── */
|
||||
|
||||
@@ -19,7 +19,9 @@ import "./Sidebar.css";
|
||||
const isMenuItemActive = (pathname: string, path: string): boolean => {
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return pathname === "/" || pathname === "/app" || pathname === "/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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -86,7 +86,10 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
};
|
||||
@@ -108,7 +111,10 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
@@ -207,13 +213,18 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
} else {
|
||||
// 开始录制
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
});
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
@@ -250,7 +261,10 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
setErrorMessage("已达最长录制时长(5分钟),已自动停止");
|
||||
@@ -311,9 +325,13 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
fileToUpload = selectedFile;
|
||||
} else {
|
||||
// 将录音 Blob 转为 File
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
});
|
||||
fileToUpload = new File(
|
||||
[recordedBlob!],
|
||||
`recorded-${Date.now()}.webm`,
|
||||
{
|
||||
type: "audio/webm",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
@@ -345,9 +363,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
/* ── 计算属性 ──────────────────────────────────────── */
|
||||
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= 2 &&
|
||||
voiceName.trim().length <= 20 &&
|
||||
hasAudio;
|
||||
voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio;
|
||||
|
||||
const isProcessing = phase === "uploading" || phase === "cloning";
|
||||
|
||||
@@ -564,7 +580,9 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
<div className="xx-clonemodal-step-icon">
|
||||
{isDone ? "✓" : step.icon}
|
||||
</div>
|
||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||
<span className="xx-clonemodal-step-label">
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -585,7 +603,9 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
{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-text">
|
||||
AI 正在克隆你的声音…
|
||||
</p>
|
||||
<p className="xx-clonemodal-progress-sub">
|
||||
正在分析声音特征,生成专属音色模型
|
||||
</p>
|
||||
|
||||
@@ -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);
|
||||
@@ -313,7 +313,7 @@
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--xx-color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--xx-color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
@@ -345,15 +345,34 @@
|
||||
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; }
|
||||
.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); }
|
||||
from {
|
||||
transform: scaleY(0.4);
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 录制按钮 */
|
||||
@@ -366,7 +385,10 @@
|
||||
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;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -390,8 +412,13 @@
|
||||
}
|
||||
|
||||
@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); }
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────────────── */
|
||||
@@ -522,9 +549,17 @@
|
||||
}
|
||||
|
||||
@keyframes xx-clonemodal-bounce {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
60% { transform: scale(1.2); opacity: 1; }
|
||||
100% { transform: scale(1); }
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-title {
|
||||
|
||||
@@ -43,20 +43,36 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/dashboard",
|
||||
icon: <DashboardOutlined />,
|
||||
},
|
||||
{ key: "assets", label: "素材库", path: "/app/assets", icon: <FileOutlined /> },
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/app/assets",
|
||||
icon: <FileOutlined />,
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/app/titles",
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
{ key: "voices", label: "配音库", path: "/app/voices", icon: <AudioOutlined /> },
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/app/voices",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voice-clone",
|
||||
label: "我的音色",
|
||||
path: "/app/voice-clone",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voice-materials",
|
||||
label: "配音素材库",
|
||||
path: "/app/voice-materials",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
@@ -143,6 +159,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/voice-clone",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voice-materials",
|
||||
label: "配音素材库",
|
||||
path: "/app/voice-materials",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
SearchOutlined,
|
||||
InboxOutlined,
|
||||
VideoCameraOutlined,
|
||||
SoundOutlined,
|
||||
PictureOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckOutlined,
|
||||
@@ -37,7 +36,7 @@ import "./assets.css";
|
||||
/* ============================================================
|
||||
* 类型
|
||||
* ============================================================ */
|
||||
type AssetKind = "video" | "voice" | "image";
|
||||
type AssetKind = "video" | "image";
|
||||
type StatusType = "ok" | "warn" | "bad" | "info";
|
||||
|
||||
interface LibraryItem {
|
||||
@@ -67,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";
|
||||
};
|
||||
|
||||
@@ -99,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,
|
||||
});
|
||||
|
||||
@@ -110,14 +108,16 @@ 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 || ""),
|
||||
kind,
|
||||
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
|
||||
thumbUrl:
|
||||
(item.thumbnail_url as string | undefined) ||
|
||||
(item.file_url as string | undefined) ||
|
||||
(metadata.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),
|
||||
@@ -147,8 +147,6 @@ const kindIcon = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return <VideoCameraOutlined />;
|
||||
case "voice":
|
||||
return <SoundOutlined />;
|
||||
case "image":
|
||||
return <PictureOutlined />;
|
||||
}
|
||||
@@ -158,8 +156,6 @@ const kindLabel = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "视频";
|
||||
case "voice":
|
||||
return "配音";
|
||||
case "image":
|
||||
return "图片";
|
||||
}
|
||||
@@ -170,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%)";
|
||||
}
|
||||
@@ -194,9 +188,23 @@ 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
|
||||
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>
|
||||
);
|
||||
@@ -230,7 +238,7 @@ const AssetCard: React.FC<{
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮 */}
|
||||
{(asset.kind === "video" || asset.kind === "voice") && (
|
||||
{asset.kind === "video" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
@@ -347,6 +355,7 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
/* 上传 */
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
/* 新建素材库 */
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
@@ -420,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;
|
||||
};
|
||||
@@ -518,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">
|
||||
{/* ─── 左侧:素材库列表 ─── */}
|
||||
@@ -574,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">
|
||||
@@ -584,7 +648,7 @@ const AssetLibrary: React.FC = () => {
|
||||
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">
|
||||
支持视频、音频、图片,单文件不超过 2GB
|
||||
支持视频、图片,单文件不超过 2GB
|
||||
</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
@@ -607,7 +671,6 @@ const AssetLibrary: React.FC = () => {
|
||||
options={[
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "voice", label: "配音" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
@@ -668,8 +731,12 @@ const AssetLibrary: React.FC = () => {
|
||||
</div>
|
||||
) : assetsError ? (
|
||||
<div className="xx-assets-empty">
|
||||
<div className="xx-assets-empty-icon"><ExclamationCircleOutlined /></div>
|
||||
<p className="xx-assets-empty-title">{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"
|
||||
@@ -697,7 +764,9 @@ const AssetLibrary: React.FC = () => {
|
||||
<div className="xx-assets-empty-icon">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">暂无素材,请上传或切换素材库</p>
|
||||
<p className="xx-assets-empty-title">
|
||||
暂无素材,请上传或切换素材库
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -732,7 +801,6 @@ const AssetLibrary: React.FC = () => {
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "voice", label: "配音" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
|
||||
.xx-asset-library-delete:active {
|
||||
background: var(--error-color);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
@@ -247,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;
|
||||
}
|
||||
|
||||
@@ -272,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);
|
||||
}
|
||||
@@ -353,7 +353,7 @@
|
||||
|
||||
.xx-asset-diagnose-btn:active {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
@@ -401,9 +401,15 @@
|
||||
骨架屏(素材列表加载占位)
|
||||
============================================================ */
|
||||
@keyframes xx-skeleton-pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
100% { opacity: 1; }
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-asset-skeleton {
|
||||
@@ -411,7 +417,7 @@
|
||||
}
|
||||
|
||||
.xx-asset-skeleton .xx-asset-thumb {
|
||||
background: var(--bg-tertiary, #e5e7eb);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.xx-skeleton-pulse {
|
||||
@@ -421,7 +427,7 @@
|
||||
.xx-skeleton-line {
|
||||
height: 14px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--bg-tertiary, #e5e7eb);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* 诊断按钮 loading 态 */
|
||||
@@ -464,7 +470,6 @@
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
内联样式迁移类
|
||||
============================================================ */
|
||||
@@ -518,7 +523,7 @@
|
||||
}
|
||||
|
||||
.xx-assets-skeleton-card .xx-asset-thumb {
|
||||
background: var(--bg-tertiary, #e5e7eb);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -582,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
+42
-20
@@ -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 {
|
||||
@@ -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%;
|
||||
|
||||
@@ -245,7 +245,6 @@ const formatDate = () => {
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 星期${weekDays[d.getDay()]}`;
|
||||
};
|
||||
|
||||
|
||||
/** 图标名称 → Ant Design 组件映射 */
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
video: <VideoCameraOutlined />,
|
||||
|
||||
@@ -44,40 +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: var(--space-lg);
|
||||
transition: var(--transition-all);
|
||||
padding: 20px;
|
||||
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);
|
||||
}
|
||||
|
||||
.xx-kpi-card:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
.xx-kpi-icon {
|
||||
@@ -106,27 +84,23 @@
|
||||
}
|
||||
|
||||
.xx-kpi-trend {
|
||||
font-size: var(--font-size-xs);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xxs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -249,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);
|
||||
}
|
||||
|
||||
@@ -391,7 +365,7 @@
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0 auto var(--space-md);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-quick-card h3 {
|
||||
@@ -421,7 +395,7 @@
|
||||
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);
|
||||
}
|
||||
@@ -486,7 +460,7 @@
|
||||
|
||||
.xx-storage-bar-track {
|
||||
height: var(--space-sm);
|
||||
background: var(--bg-secondary, #e2e8f0);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--space-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -506,7 +480,6 @@
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
迁移自内联样式的工具类
|
||||
============================================================ */
|
||||
|
||||
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
+124
-148
@@ -4,13 +4,8 @@
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
// 简易消息工具(UI 库未导出 message)
|
||||
const msg = {
|
||||
success: (s: string) => console.log(`✅ ${s}`),
|
||||
error: (s: string) => console.error(`❌ ${s}`),
|
||||
warning: (s: string) => console.warn(`⚠️ ${s}`),
|
||||
info: (s: string) => console.info(`ℹ️ ${s}`),
|
||||
};
|
||||
import { message } from "antd";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
EditingTemplate,
|
||||
TemplateCategory,
|
||||
@@ -20,22 +15,28 @@ import type {
|
||||
import {
|
||||
getEditingTemplates,
|
||||
getEditingTemplate,
|
||||
createEditingTemplate,
|
||||
updateEditingTemplate,
|
||||
getTemplateCategories,
|
||||
generateFromTemplate,
|
||||
MODE_LABELS,
|
||||
} from "@/api/editingPlanner";
|
||||
import type { EditPlanClip, EditPlanGeneration, MediaAsset } from "@/api/editPlans";
|
||||
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
|
||||
import {
|
||||
getMediaAssets,
|
||||
getEditPlanGenerations,
|
||||
MATERIAL_TYPE_LABELS,
|
||||
aiRecommendClips,
|
||||
generateCover,
|
||||
} from "@/api/editPlans";
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo";
|
||||
import type { TaskItem } from "@/api/tasks";
|
||||
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
|
||||
import type { ClipData, ClipType } from "./types";
|
||||
import {
|
||||
ensureDefaultLibrary,
|
||||
getAssetsByKind,
|
||||
type AssetItem,
|
||||
} from "@/api/assets";
|
||||
import { getOrCreateDefaultProject } from "@/api/projects";
|
||||
|
||||
import MediaPanel from "./components/MediaPanel";
|
||||
import PreviewPlayer from "./components/PreviewPlayer";
|
||||
@@ -47,14 +48,6 @@ import type { GenPhase } from "./components/GenerationProgressModal";
|
||||
import GenerationHistoryModal from "./components/GenerationHistoryModal";
|
||||
import "./EditingPlanner.css";
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
interface ClipData extends EditPlanClip {
|
||||
name: string;
|
||||
thumbnail?: string;
|
||||
assetName?: string;
|
||||
}
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
|
||||
@@ -102,7 +95,7 @@ const EditingPlanner: React.FC = () => {
|
||||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
|
||||
|
||||
/* ── AI 操作状态 ── */
|
||||
const [aiRecommendLoading, setAiRecommendLoading] = useState(false);
|
||||
|
||||
const [aiCoverLoading, setAiCoverLoading] = useState(false);
|
||||
|
||||
/* ── 封面方案 ── */
|
||||
@@ -115,7 +108,7 @@ const EditingPlanner: React.FC = () => {
|
||||
|
||||
/* ── 标题/字幕/BGM 设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState({
|
||||
aiAutoSelect: true,
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "top",
|
||||
font: "思源黑体",
|
||||
@@ -124,6 +117,7 @@ const EditingPlanner: React.FC = () => {
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: true,
|
||||
color: "#ffffff",
|
||||
});
|
||||
|
||||
const [subtitleSettings, setSubtitleSettings] = useState({
|
||||
@@ -156,6 +150,11 @@ const EditingPlanner: React.FC = () => {
|
||||
|
||||
/* ── 素材库 ── */
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([]);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
|
||||
const handleAssetSelect = (ids: string[]) => {
|
||||
setSelectedAssetIds(ids);
|
||||
};
|
||||
|
||||
/* ── 生成历史 ── */
|
||||
const [genHistoryOpen, setGenHistoryOpen] = useState(false);
|
||||
@@ -165,6 +164,37 @@ const EditingPlanner: React.FC = () => {
|
||||
/* ── 播放 ── */
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
/* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
|
||||
const voiceMaterialsQuery = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: async () => {
|
||||
const project = await getOrCreateDefaultProject();
|
||||
await ensureDefaultLibrary({ project_id: project.id, kind: "voice" });
|
||||
const assets = await getAssetsByKind("voice");
|
||||
return assets;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? [];
|
||||
|
||||
/** 为片段选择配音素材 */
|
||||
const handleClipVoiceSelect = useCallback(
|
||||
(clipId: string, asset: AssetItem | null) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === clipId
|
||||
? {
|
||||
...c,
|
||||
voice_asset_id: asset?.id ?? undefined,
|
||||
voice_file_url: asset?.file_url ?? undefined,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/* ──────────── 加载 ──────────── */
|
||||
|
||||
/**
|
||||
@@ -183,7 +213,7 @@ const EditingPlanner: React.FC = () => {
|
||||
setCategories(cats);
|
||||
setMediaAssets(assets);
|
||||
} catch {
|
||||
msg.error("加载模板失败");
|
||||
message.error("加载模板失败");
|
||||
} finally {
|
||||
setLoadingTemplates(false);
|
||||
}
|
||||
@@ -207,14 +237,13 @@ const EditingPlanner: React.FC = () => {
|
||||
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
|
||||
id: seg.id || `seg-${idx}`,
|
||||
template_segment_id: seg.id || `seg-${idx}`,
|
||||
name: `片段${idx + 1} · ${MATERIAL_TYPE_LABELS[seg.material_type || "video"] || "视频"}`,
|
||||
material_type:
|
||||
(seg.material_type as ClipData["material_type"]) || "video",
|
||||
script_text: "",
|
||||
type: (seg.material_type === "voiceover"
|
||||
? "voice"
|
||||
: "pip") as ClipType,
|
||||
duration: (seg.duration_min + seg.duration_max) / 2,
|
||||
startOffset: 0,
|
||||
script_text: "",
|
||||
order: seg.segment_order,
|
||||
assetName: undefined,
|
||||
transition: { type: "none", duration: 0 },
|
||||
}));
|
||||
resetClips(mapped);
|
||||
|
||||
@@ -225,6 +254,7 @@ const EditingPlanner: React.FC = () => {
|
||||
position: tpl.title_config.position,
|
||||
font: tpl.title_config.font_preset,
|
||||
size: tpl.title_config.font_size,
|
||||
color: tpl.title_config.font_color || "#ffffff",
|
||||
}));
|
||||
setSubtitleSettings({
|
||||
enabled: tpl.subtitle_config.enabled,
|
||||
@@ -240,8 +270,8 @@ const EditingPlanner: React.FC = () => {
|
||||
setDraftCategory(tpl.category);
|
||||
setDraftTags(tpl.tags.join(", "));
|
||||
})
|
||||
.catch(() => msg.error("加载模板详情失败"));
|
||||
}, [loadedTemplateId]);
|
||||
.catch(() => message.error("加载模板详情失败"));
|
||||
}, [loadedTemplateId, resetClips]);
|
||||
|
||||
/* ──────────── 计算 ──────────── */
|
||||
|
||||
@@ -294,91 +324,32 @@ const EditingPlanner: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleAssetDropToClip = (clipId: string, asset: MediaAsset) => {
|
||||
handleClipUpdate(clipId, {
|
||||
media_asset_id: asset.id,
|
||||
assetName: asset.name,
|
||||
thumbnail: asset.thumbnail_url,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssetDropToTimeline = (asset: MediaAsset) => {
|
||||
const newClip: ClipData = {
|
||||
id: `clip-${Date.now()}`,
|
||||
template_segment_id: "",
|
||||
name: `片段${clips.length + 1} · ${MATERIAL_TYPE_LABELS[asset.type]}`,
|
||||
material_type:
|
||||
asset.type === "audio"
|
||||
? "voiceover"
|
||||
: asset.type === "image"
|
||||
? "image"
|
||||
: "video",
|
||||
script_text: "",
|
||||
duration: asset.duration || 5,
|
||||
order: clips.length,
|
||||
media_asset_id: asset.id,
|
||||
assetName: asset.name,
|
||||
thumbnail: asset.thumbnail_url,
|
||||
transition: { type: "none", duration: 0 },
|
||||
};
|
||||
setClips((prev) => [...prev, newClip]);
|
||||
};
|
||||
|
||||
/**
|
||||
* AI 推荐片段方案
|
||||
* 将当前已绑定素材的片段 ID 列表 + 编辑模式 + 目标时长发送给后端
|
||||
* 返回的推荐结果直接替换当前片段列表(不保留原手动编排)
|
||||
* 添加片段(不绑定任何素材)
|
||||
* 片段 = 时间规划 + 类型标记
|
||||
*/
|
||||
const handleAiRecommend = async () => {
|
||||
if (!loadedTemplateId) {
|
||||
msg.warning("请先加载模板");
|
||||
return;
|
||||
}
|
||||
const assetIds = clips
|
||||
.map((c) => c.media_asset_id)
|
||||
.filter((v): v is string => !!v);
|
||||
if (assetIds.length === 0) {
|
||||
msg.warning("请先添加素材到片段");
|
||||
return;
|
||||
}
|
||||
setAiRecommendLoading(true);
|
||||
try {
|
||||
const res = await aiRecommendClips(loadedTemplateId, {
|
||||
asset_ids: assetIds,
|
||||
editing_mode: currentMode,
|
||||
target_duration: totalDuration || 30,
|
||||
});
|
||||
const mapped: ClipData[] = res.clips.map((c, i) => ({
|
||||
id: `clip-ai-${i}-${Date.now()}`,
|
||||
template_segment_id: "",
|
||||
name: `片段${i + 1} · ${c.clip_type}`,
|
||||
material_type: (c.clip_type as ClipData["material_type"]) || "video",
|
||||
script_text: c.text_content || "",
|
||||
duration: c.duration || 5,
|
||||
order: c.order ?? i,
|
||||
media_asset_id: c.asset_id || undefined,
|
||||
assetName: undefined,
|
||||
transition: { type: "none", duration: 0 },
|
||||
}));
|
||||
setClips(mapped);
|
||||
msg.success(`AI 推荐了 ${mapped.length} 个片段`);
|
||||
} catch {
|
||||
msg.error("AI 推荐失败");
|
||||
} finally {
|
||||
setAiRecommendLoading(false);
|
||||
}
|
||||
};
|
||||
const handleAddClip = useCallback(
|
||||
(type: ClipType, duration: number) => {
|
||||
const newClip: ClipData = {
|
||||
id: `clip-${Date.now()}`,
|
||||
type,
|
||||
duration,
|
||||
startOffset: 0,
|
||||
order: clips.length,
|
||||
};
|
||||
setClips((prev) => [...prev, newClip]);
|
||||
},
|
||||
[clips.length, setClips],
|
||||
);
|
||||
|
||||
/* AI 封面生成 */
|
||||
const handleAiGenerateCover = async (
|
||||
coverType: "ai_frame" | "ai_regenerate",
|
||||
) => {
|
||||
if (!loadedTemplateId) return;
|
||||
const assetIds = clips
|
||||
.map((c) => c.media_asset_id)
|
||||
.filter((v): v is string => !!v);
|
||||
const assetIds = selectedAssetIds;
|
||||
if (assetIds.length === 0) {
|
||||
msg.warning("请先添加素材");
|
||||
message.warning("请先在素材库中选择素材");
|
||||
return;
|
||||
}
|
||||
setAiCoverLoading(true);
|
||||
@@ -387,30 +358,27 @@ const EditingPlanner: React.FC = () => {
|
||||
asset_ids: assetIds,
|
||||
cover_type: coverType,
|
||||
});
|
||||
setCurrentCoverScheme(coverType === "ai_frame" ? "ai_frame" : "ai_reselect");
|
||||
msg.success("AI 封面生成成功");
|
||||
setCurrentCoverScheme(
|
||||
coverType === "ai_frame" ? "ai_frame" : "ai_reselect",
|
||||
);
|
||||
message.success("AI 封面生成成功");
|
||||
} catch {
|
||||
msg.error("AI 封面生成失败");
|
||||
message.error("AI 封面生成失败");
|
||||
} finally {
|
||||
setAiCoverLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 保存 */
|
||||
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
|
||||
const handleOpenSaveModal = () => {
|
||||
if (!loadedTemplateId) {
|
||||
msg.warning("请先加载一个模板");
|
||||
return;
|
||||
}
|
||||
setSaveModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!draftName.trim()) {
|
||||
msg.warning("请输入模板名称");
|
||||
message.warning("请输入模板名称");
|
||||
return;
|
||||
}
|
||||
if (!loadedTemplateId) return;
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
const payload: SaveTemplatePayload = {
|
||||
@@ -425,7 +393,7 @@ const EditingPlanner: React.FC = () => {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: "#ffffff",
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
position: titleSettings.position,
|
||||
},
|
||||
@@ -446,15 +414,19 @@ const EditingPlanner: React.FC = () => {
|
||||
segment_order: i,
|
||||
duration_min: Math.max(1, c.duration - 2),
|
||||
duration_max: c.duration + 2,
|
||||
material_type: c.material_type,
|
||||
material_type: c.type === "voice" ? "voiceover" : "video",
|
||||
})),
|
||||
};
|
||||
await updateEditingTemplate(loadedTemplateId, payload);
|
||||
msg.success("模板保存成功");
|
||||
if (loadedTemplateId) {
|
||||
await updateEditingTemplate(loadedTemplateId, payload);
|
||||
} else {
|
||||
await createEditingTemplate(payload);
|
||||
}
|
||||
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功");
|
||||
setSaveModalOpen(false);
|
||||
loadTemplates();
|
||||
} catch {
|
||||
msg.error("保存失败");
|
||||
message.error("保存失败");
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
@@ -466,16 +438,13 @@ const EditingPlanner: React.FC = () => {
|
||||
* 一键生成页面从 params 解析配置,无需重复请求接口
|
||||
*/
|
||||
const handleGoToGenerate = () => {
|
||||
if (!loadedTemplateId) {
|
||||
msg.warning("请先加载一个模板");
|
||||
return;
|
||||
}
|
||||
const planConfig = {
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
@@ -497,16 +466,18 @@ const EditingPlanner: React.FC = () => {
|
||||
total_duration: totalDuration,
|
||||
segments: clips.map((c, i) => ({
|
||||
order: i,
|
||||
name: c.name,
|
||||
material_type: c.material_type,
|
||||
material_type: c.type === "voice" ? "voiceover" : "video",
|
||||
duration: c.duration,
|
||||
media_asset_id: c.media_asset_id,
|
||||
template_segment_id: c.template_segment_id,
|
||||
script_text: c.script_text,
|
||||
voice_asset_id: c.voice_asset_id,
|
||||
voice_file_url: c.voice_file_url,
|
||||
})),
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
params.set("edit_plan_id", loadedTemplateId);
|
||||
if (loadedTemplateId) {
|
||||
params.set("edit_plan_id", loadedTemplateId);
|
||||
}
|
||||
params.set("plan_config", JSON.stringify(planConfig));
|
||||
navigate(`/app/generate?${params.toString()}`);
|
||||
};
|
||||
@@ -524,21 +495,23 @@ const EditingPlanner: React.FC = () => {
|
||||
await generateFromTemplate(loadedTemplateId, {
|
||||
voiceover_duration: voiceoverDuration || totalDuration,
|
||||
});
|
||||
// 收集所有 voice 类型片段的配音素材 ID
|
||||
const voiceIds = clips
|
||||
.filter((c) => c.type === "voice" && c.voice_asset_id)
|
||||
.map((c) => c.voice_asset_id as string);
|
||||
const res = await createGenerationTask({
|
||||
template_id: loadedTemplateId,
|
||||
asset_ids: clips
|
||||
.map((c) => c.media_asset_id)
|
||||
.filter((v): v is string => !!v),
|
||||
asset_ids: [],
|
||||
title_ids: [],
|
||||
voice_ids: [],
|
||||
voice_ids: voiceIds,
|
||||
});
|
||||
/* 创建接口返回的是精简响应,需查询完整 TaskItem 用于轮询 */
|
||||
const task = await getTask(res.id);
|
||||
setGenTask(task);
|
||||
setGenPhase("progress");
|
||||
msg.info("生成任务已创建");
|
||||
message.info("生成任务已创建");
|
||||
} catch {
|
||||
msg.error("创建生成任务失败");
|
||||
message.error("创建生成任务失败");
|
||||
} finally {
|
||||
setGenSubmitting(false);
|
||||
}
|
||||
@@ -577,7 +550,7 @@ const EditingPlanner: React.FC = () => {
|
||||
setGenTask(t);
|
||||
setGenPhase("progress");
|
||||
} catch {
|
||||
msg.error("重试失败");
|
||||
message.error("重试失败");
|
||||
} finally {
|
||||
setGenSubmitting(false);
|
||||
}
|
||||
@@ -593,7 +566,7 @@ const EditingPlanner: React.FC = () => {
|
||||
/* 查看生成历史 */
|
||||
const handleViewGenHistory = async () => {
|
||||
if (!loadedTemplateId) {
|
||||
msg.warning("请先加载一个模板");
|
||||
message.warning("请先加载一个模板");
|
||||
return;
|
||||
}
|
||||
setGenHistoryOpen(true);
|
||||
@@ -602,7 +575,7 @@ const EditingPlanner: React.FC = () => {
|
||||
const items = await getEditPlanGenerations(loadedTemplateId);
|
||||
setGenHistory(items);
|
||||
} catch {
|
||||
msg.error("加载生成历史失败");
|
||||
message.error("加载生成历史失败");
|
||||
} finally {
|
||||
setGenHistoryLoading(false);
|
||||
}
|
||||
@@ -654,8 +627,9 @@ const EditingPlanner: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ 第2行:模式栏 48px ═══ */}
|
||||
{/* ═══ 第2行:模式栏 56px(V21风格)═══ */}
|
||||
<div className="ep-mode-bar">
|
||||
<span className="ep-mode-bar-label">剪辑模式:</span>
|
||||
{MODE_LIST.map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
@@ -663,7 +637,7 @@ const EditingPlanner: React.FC = () => {
|
||||
onClick={() => handleModeChange(m.key)}
|
||||
>
|
||||
<span className="ep-mode-icon">{m.icon}</span>
|
||||
{m.label}
|
||||
<span className="ep-mode-label">{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -681,6 +655,9 @@ const EditingPlanner: React.FC = () => {
|
||||
onLoadTemplate={handleLoadTemplate}
|
||||
onSearchChange={setSearchQuery}
|
||||
onFilterChange={setCurrentFilter}
|
||||
mediaAssets={mediaAssets}
|
||||
onAssetSelect={handleAssetSelect}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
/>
|
||||
|
||||
{/* 中栏 flex-1 */}
|
||||
@@ -693,6 +670,8 @@ const EditingPlanner: React.FC = () => {
|
||||
currentCoverScheme={currentCoverScheme}
|
||||
coverSchemes={COVER_SCHEMES}
|
||||
aiCoverLoading={aiCoverLoading}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={subtitleSettings}
|
||||
onClipSelect={handleClipSelect}
|
||||
onCoverSchemeChange={setCurrentCoverScheme}
|
||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||||
@@ -707,9 +686,7 @@ const EditingPlanner: React.FC = () => {
|
||||
onClipSelect={handleClipSelect}
|
||||
onClipReorder={handleClipReorder}
|
||||
onClipRemove={handleClipRemove}
|
||||
onAssetDropToTimeline={handleAssetDropToTimeline}
|
||||
onAssetDropToClip={handleAssetDropToClip}
|
||||
mediaAssets={mediaAssets}
|
||||
onAddClip={handleAddClip}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -722,7 +699,6 @@ const EditingPlanner: React.FC = () => {
|
||||
clipsCount={clips.length}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
aiRecommendLoading={aiRecommendLoading}
|
||||
onTitleSettingsChange={(partial) =>
|
||||
setTitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
@@ -733,7 +709,10 @@ const EditingPlanner: React.FC = () => {
|
||||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onClipUpdate={handleClipUpdate}
|
||||
onAiRecommend={handleAiRecommend}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||||
onClipVoiceSelect={handleClipVoiceSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -749,10 +728,7 @@ const EditingPlanner: React.FC = () => {
|
||||
<span className="ep-status-sep">|</span>
|
||||
<span>📐 模板片段: {currentTemplate?.segments.length || 0}</span>
|
||||
<span className="ep-status-sep">|</span>
|
||||
<button
|
||||
className="ep-status-link"
|
||||
onClick={handleViewGenHistory}
|
||||
>
|
||||
<button className="ep-status-link" onClick={handleViewGenHistory}>
|
||||
📋 生成历史
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,21 +2,11 @@
|
||||
* 右栏设置面板 — V8 原型 1:1 还原
|
||||
* 标题设置(AI toggle) + 字幕设置 + BGM设置 + 片段详情
|
||||
*/
|
||||
import React from "react";
|
||||
import React, { useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { TemplateMode } from "@/api/editingPlanner";
|
||||
import { MATERIAL_TYPE_LABELS } from "@/api/editPlans";
|
||||
|
||||
interface ClipData {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
material_type: "video" | "image" | "audio" | "voiceover";
|
||||
thumbnail?: string;
|
||||
assetName?: string;
|
||||
media_asset_id?: string;
|
||||
template_segment_id?: string;
|
||||
script_text?: string;
|
||||
}
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
import type { AssetItem } from "@/api/assets";
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
@@ -28,6 +18,7 @@ interface TitleSettings {
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface SubtitleSettings {
|
||||
@@ -50,12 +41,18 @@ interface ClipPropertiesPanelProps {
|
||||
clipsCount: number;
|
||||
totalDuration: number;
|
||||
currentMode: TemplateMode;
|
||||
aiRecommendLoading: boolean;
|
||||
onTitleSettingsChange: (partial: Partial<TitleSettings>) => void;
|
||||
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
|
||||
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
|
||||
onAiRecommend: () => void;
|
||||
/** 配音素材列表(从配音素材库 API 获取) */
|
||||
voiceMaterials?: AssetItem[];
|
||||
/** 配音素材加载中 */
|
||||
voiceMaterialsLoading?: boolean;
|
||||
/** 刷新配音素材列表 */
|
||||
onRefreshVoiceMaterials?: () => void;
|
||||
/** 为片段选择配音素材 */
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void;
|
||||
}
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
@@ -64,7 +61,15 @@ const POSITION_OPTIONS = [
|
||||
{ value: "bottom", label: "底部" },
|
||||
];
|
||||
|
||||
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑"];
|
||||
const FONT_OPTIONS = [
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"苹方",
|
||||
"PingFang",
|
||||
"微软雅黑",
|
||||
"楷体",
|
||||
"华康俪金黑",
|
||||
];
|
||||
|
||||
const ANIMATION_OPTIONS = [
|
||||
{ value: "none", label: "无" },
|
||||
@@ -81,6 +86,182 @@ const BGM_OPTIONS = [
|
||||
{ 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,
|
||||
titleSettings,
|
||||
@@ -89,13 +270,50 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
clipsCount,
|
||||
totalDuration,
|
||||
currentMode,
|
||||
aiRecommendLoading,
|
||||
onTitleSettingsChange,
|
||||
onSubtitleSettingsChange,
|
||||
onBgmSettingsChange,
|
||||
onClipUpdate,
|
||||
onAiRecommend,
|
||||
voiceMaterials = [],
|
||||
voiceMaterialsLoading = false,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
/* ── 配音试听 ── */
|
||||
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 "";
|
||||
};
|
||||
return (
|
||||
<div className="ep-right-panel">
|
||||
{/* ═══ 标题设置 ═══ */}
|
||||
@@ -172,6 +390,42 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
</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">
|
||||
@@ -287,7 +541,9 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
onSubtitleSettingsChange({ size: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="ep-slider-value">{subtitleSettings.size}px</span>
|
||||
<span className="ep-slider-value">
|
||||
{subtitleSettings.size}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -323,9 +579,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={bgmSettings.music}
|
||||
onChange={(e) =>
|
||||
onBgmSettingsChange({ music: e.target.value })
|
||||
}
|
||||
onChange={(e) => onBgmSettingsChange({ music: e.target.value })}
|
||||
>
|
||||
{BGM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
@@ -345,15 +599,36 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="ep-clip-detail">
|
||||
<div className="ep-clip-detail-header">
|
||||
<span className="ep-clip-detail-name">{selectedClip.name}</span>
|
||||
<span className="ep-clip-detail-type">
|
||||
{MATERIAL_TYPE_LABELS[selectedClip.material_type] || "视频"}
|
||||
</span>
|
||||
{/* 类型选择器 */}
|
||||
<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-label">时长</div>
|
||||
<div className="ep-clip-detail-row">
|
||||
<input
|
||||
className="ep-duration-input"
|
||||
@@ -363,7 +638,10 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
value={selectedClip.duration}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(selectedClip.id, {
|
||||
duration: Number(e.target.value),
|
||||
duration: Math.max(
|
||||
1,
|
||||
Math.min(120, Number(e.target.value) || 1),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -371,29 +649,127 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedClip.assetName && (
|
||||
{/* 素材起始时间 — 仅 voice 类型显示 */}
|
||||
{selectedClip.type === "voice" && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">关联素材</div>
|
||||
<div className="ep-clip-detail-value">
|
||||
<span className="ep-clip-detail-linked">{selectedClip.assetName}</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">素材类型</div>
|
||||
<div className="ep-clip-detail-value">
|
||||
{MATERIAL_TYPE_LABELS[selectedClip.material_type] || "视频"}
|
||||
</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>
|
||||
|
||||
<button
|
||||
className="ep-ai-recommend-btn"
|
||||
onClick={onAiRecommend}
|
||||
disabled={aiRecommendLoading}
|
||||
>
|
||||
{aiRecommendLoading ? "⏳ AI 分析中..." : "✨ AI 推荐素材"}
|
||||
</button>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,6 @@ interface GenerationHistoryModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
||||
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
open,
|
||||
loading,
|
||||
@@ -31,7 +30,9 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
>
|
||||
<div className="ep-modal-header">
|
||||
<h3>生成历史</h3>
|
||||
<button className="ep-modal-close" onClick={onClose}><CloseOutlined /></button>
|
||||
<button className="ep-modal-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ep-modal-body ep-gh-body">
|
||||
{loading ? (
|
||||
@@ -44,7 +45,10 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="ep-gh-empty"><InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} /><span>暂无生成记录</span></div>
|
||||
<div className="ep-gh-empty">
|
||||
<InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} />
|
||||
<span>暂无生成记录</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="ep-gh-table">
|
||||
<thead>
|
||||
@@ -69,10 +73,14 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
</span>
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.created_at ? new Date(gen.created_at).toLocaleString("zh-CN") : "—"}
|
||||
{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") : "—"}
|
||||
{gen.updated_at
|
||||
? new Date(gen.updated_at).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
Regular → Executable
+109
-61
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* 左侧模板面板 — V8 原型 1:1 还原
|
||||
* 纯模板列表 + chip 分类筛选(无素材 Tab)
|
||||
* 左侧面板 — V8 原型 1:1 还原
|
||||
* Tab 切换:模板列表 + 素材库
|
||||
*/
|
||||
import React from "react";
|
||||
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";
|
||||
|
||||
interface MediaPanelProps {
|
||||
templates: EditingTemplate[];
|
||||
@@ -16,6 +18,10 @@ interface MediaPanelProps {
|
||||
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> = ({
|
||||
@@ -28,75 +34,117 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
onLoadTemplate,
|
||||
onSearchChange,
|
||||
onFilterChange,
|
||||
mediaAssets = [],
|
||||
onAssetSelect,
|
||||
selectedAssetIds = [],
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<"templates" | "assets">(
|
||||
"templates",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ep-left-panel">
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap ep-media-panel-inner">
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
{/* Tab 切换 */}
|
||||
<div className="ep-left-tabs">
|
||||
<button
|
||||
className={`ep-left-tab ${activeTab === "templates" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("templates")}
|
||||
>
|
||||
📋 模板
|
||||
</button>
|
||||
<button
|
||||
className={`ep-left-tab ${activeTab === "assets" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("assets")}
|
||||
>
|
||||
📁 素材
|
||||
</button>
|
||||
</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>
|
||||
{/* 模板 Tab */}
|
||||
{activeTab === "templates" && (
|
||||
<>
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap ep-media-panel-inner">
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
{/* 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>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
</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 className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
<span>📐 {tpl.segments.length}片段</span>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
</div>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="ep-template-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
) : (
|
||||
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">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 素材 Tab */}
|
||||
{activeTab === "assets" && (
|
||||
<div className="ep-assets-tab">
|
||||
<AssetSelector
|
||||
assets={mediaAssets}
|
||||
selectedIds={selectedAssetIds}
|
||||
onSelectionChange={onAssetSelect}
|
||||
showQualityFilter={false}
|
||||
showBatchSelect={false}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,21 +4,33 @@
|
||||
* 封面右侧竖排4个方案按钮
|
||||
*/
|
||||
import React from "react";
|
||||
|
||||
interface ClipData {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
material_type: string;
|
||||
thumbnail?: string;
|
||||
assetName?: string;
|
||||
}
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
|
||||
interface CoverScheme {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
}
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
}
|
||||
|
||||
interface PreviewPlayerProps {
|
||||
clips: ClipData[];
|
||||
selectedClipId: string | null;
|
||||
@@ -26,17 +38,22 @@ interface PreviewPlayerProps {
|
||||
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 MATERIAL_ICONS: Record<string, string> = {
|
||||
video: "🎬",
|
||||
image: "🖼️",
|
||||
audio: "🎵",
|
||||
voiceover: "🎙️",
|
||||
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
|
||||
voice: "🎙️",
|
||||
pip: "🖼️",
|
||||
};
|
||||
|
||||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "画中画",
|
||||
};
|
||||
|
||||
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
@@ -46,6 +63,8 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
currentCoverScheme,
|
||||
coverSchemes,
|
||||
aiCoverLoading,
|
||||
titleSettings,
|
||||
subtitleSettings,
|
||||
onCoverSchemeChange,
|
||||
onPlayPause,
|
||||
onAiGenerateCover,
|
||||
@@ -74,12 +93,71 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="ep-phone-clip-label">
|
||||
{displayClip.name}
|
||||
{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>
|
||||
|
||||
@@ -88,7 +166,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
<div className="ep-cover-image">
|
||||
{displayClip ? (
|
||||
<span className="ep-cover-icon">
|
||||
{MATERIAL_ICONS[displayClip.material_type] || "🎬"}
|
||||
{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}
|
||||
</span>
|
||||
) : (
|
||||
<span>暂无封面</span>
|
||||
|
||||
Regular → Executable
+277
-125
@@ -1,22 +1,16 @@
|
||||
/**
|
||||
* 水平轨道时间线 — V8 原型 1:1 还原
|
||||
* 水平轨道时间线 — 片段 = 时间规划 + 类型标记,不绑定素材
|
||||
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
|
||||
* "+" 卡片 → 类型+时长选择器
|
||||
*/
|
||||
import React, { useState, useRef } from "react";
|
||||
import type { MediaAsset } from "@/api/editPlans";
|
||||
|
||||
interface ClipData {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
material_type: string;
|
||||
thumbnail?: string;
|
||||
assetName?: string;
|
||||
media_asset_id?: string;
|
||||
template_segment_id?: string;
|
||||
script_text?: string;
|
||||
order?: number;
|
||||
}
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
|
||||
interface TimelinePanelProps {
|
||||
clips: ClipData[];
|
||||
@@ -25,16 +19,19 @@ interface TimelinePanelProps {
|
||||
onClipSelect: (clipId: string) => void;
|
||||
onClipReorder: (fromIdx: number, toIdx: number) => void;
|
||||
onClipRemove: (clipId: string) => void;
|
||||
onAssetDropToTimeline: (asset: MediaAsset) => void;
|
||||
onAssetDropToClip: (clipId: string, asset: MediaAsset) => void;
|
||||
mediaAssets: MediaAsset[];
|
||||
onAddClip: (type: ClipType, duration: number) => void;
|
||||
}
|
||||
|
||||
const MATERIAL_ICONS: Record<string, string> = {
|
||||
video: "🎬",
|
||||
image: "🖼️",
|
||||
audio: "🎵",
|
||||
voiceover: "🎙️",
|
||||
/** 片段类型图标 */
|
||||
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
|
||||
voice: "🎙️",
|
||||
pip: "🖼️",
|
||||
};
|
||||
|
||||
/** 片段类型标签 */
|
||||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "画中画",
|
||||
};
|
||||
|
||||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
@@ -44,12 +41,112 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onClipSelect,
|
||||
onClipReorder,
|
||||
onClipRemove,
|
||||
onAssetDropToTimeline,
|
||||
onAssetDropToClip,
|
||||
onAddClip,
|
||||
}) => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<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 [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) => {
|
||||
@@ -80,37 +177,12 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
if (fromIdx !== toIdx) {
|
||||
onClipReorder(fromIdx, toIdx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 素材拖到片段上
|
||||
const assetJson = e.dataTransfer.getData("application/x-media-asset");
|
||||
if (assetJson) {
|
||||
try {
|
||||
const asset: MediaAsset = JSON.parse(assetJson);
|
||||
onAssetDropToClip(clips[toIdx].id, asset);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 空轨道区域拖入 ── */
|
||||
const handleEmptyDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const assetJson = e.dataTransfer.getData("application/x-media-asset");
|
||||
if (assetJson) {
|
||||
try {
|
||||
const asset: MediaAsset = JSON.parse(assetJson);
|
||||
onAssetDropToTimeline(asset);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 空轨道区域不接受素材拖入 ── */
|
||||
const handleEmptyDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
/* ── 时间标尺 ── */
|
||||
@@ -121,13 +193,22 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
rulerMarks.push(t);
|
||||
}
|
||||
|
||||
const formatTime = (sec: number) => {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ep-timeline-area">
|
||||
{/* 时间线头部 */}
|
||||
<div className="ep-timeline-header">
|
||||
<span className="ep-timeline-title">
|
||||
时间线 · {clips.length} 个片段
|
||||
</span>
|
||||
<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"
|
||||
@@ -139,88 +220,159 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
}}
|
||||
title="删除最后一个片段"
|
||||
>
|
||||
✕ 末尾
|
||||
↩
|
||||
</button>
|
||||
<button className="ep-timeline-action-btn" title="重做">
|
||||
↪
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 一镜到底提示 */}
|
||||
{currentMode === "one_take" && (
|
||||
<div className="ep-one-take-hint">
|
||||
🎥 一镜到底模式:所有片段将无缝衔接,不可添加转场
|
||||
</div>
|
||||
<div className="ep-one-take-hint">🎥 一镜到底模式无片段</div>
|
||||
)}
|
||||
|
||||
{/* 时间标尺 */}
|
||||
<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>
|
||||
|
||||
{/* 水平片段轨道 */}
|
||||
<div
|
||||
className="ep-clip-track"
|
||||
onDrop={handleEmptyDrop}
|
||||
onDragOver={handleEmptyDragOver}
|
||||
>
|
||||
{clips.length === 0 ? (
|
||||
<div className="ep-track-empty">
|
||||
🎬 拖入素材或从模板加载片段
|
||||
</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">
|
||||
{MATERIAL_ICONS[clip.material_type] || "🎬"}
|
||||
</div>
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<span className="ep-clip-name">{clip.name}</span>
|
||||
<span className="ep-clip-duration">{clip.duration}s</span>
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="ep-clip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClipRemove(clip.id);
|
||||
{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`,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{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>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,19 +13,17 @@ export function useUndoRedo<T>(initialState: 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 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) => {
|
||||
|
||||
@@ -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
@@ -53,8 +53,8 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
@@ -63,23 +63,23 @@
|
||||
}
|
||||
|
||||
.xx-step-item:hover {
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.xx-step-item.active {
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
color: var(--primary-color, #4f46e5);
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-step-item.done {
|
||||
color: var(--secondary-color, #10b981);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.xx-step-num {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #f1f5f9;
|
||||
background: var(--bg-tertiary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
@@ -88,17 +88,17 @@
|
||||
}
|
||||
|
||||
.xx-step-item.active .xx-step-num {
|
||||
background: var(--primary-color, #4f46e5);
|
||||
color: #fff;
|
||||
background: var(--primary-color);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-step-item.done .xx-step-num {
|
||||
background: var(--secondary-color, #10b981);
|
||||
color: #fff;
|
||||
background: var(--secondary-color);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-step-arrow {
|
||||
color: #cbd5e1;
|
||||
color: var(--text-disabled);
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -124,9 +124,9 @@
|
||||
|
||||
/* ── form-section 卡片 ── */
|
||||
.xx-form-section {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-md, 14px);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@@ -158,9 +158,9 @@
|
||||
.xx-form-field input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
outline: 0;
|
||||
@@ -170,7 +170,7 @@
|
||||
|
||||
.xx-form-field select:focus,
|
||||
.xx-form-field input:focus {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
@@ -192,9 +192,9 @@
|
||||
|
||||
.xx-choice-item {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border: 2px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-md, 14px);
|
||||
background: var(--bg-primary);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: 0.18s ease;
|
||||
@@ -202,20 +202,20 @@
|
||||
}
|
||||
|
||||
.xx-choice-item:hover {
|
||||
border-color: #c7d2fe;
|
||||
border-color: var(--info-border);
|
||||
}
|
||||
|
||||
.xx-choice-item.selected {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-choice-thumb {
|
||||
height: 60px;
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
@@ -231,15 +231,15 @@
|
||||
.xx-choice-item p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-choice-check {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color, #4f46e5);
|
||||
color: #fff;
|
||||
background: var(--primary-color);
|
||||
color: var(--text-inverse);
|
||||
display: none;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
@@ -264,13 +264,13 @@
|
||||
}
|
||||
|
||||
.xx-pill-ok {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
background: var(--success-soft);
|
||||
color: var(--secondary-dark);
|
||||
}
|
||||
|
||||
.xx-pill-info {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
background: var(--info-soft);
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -284,9 +284,9 @@
|
||||
|
||||
.xx-voice-choice-item {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border: 2px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-md, 14px);
|
||||
background: var(--bg-primary);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: 0.18s ease;
|
||||
@@ -297,20 +297,20 @@
|
||||
}
|
||||
|
||||
.xx-voice-choice-item:hover {
|
||||
border-color: #c7d2fe;
|
||||
border-color: var(--info-border);
|
||||
}
|
||||
|
||||
.xx-voice-choice-item.selected {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-voice-choice-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
background: var(--gradient-primary);
|
||||
color: var(--text-inverse);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 18px;
|
||||
@@ -327,15 +327,15 @@
|
||||
.xx-voice-choice-info p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-voice-choice-check {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color, #4f46e5);
|
||||
color: #fff;
|
||||
background: var(--primary-color);
|
||||
color: var(--text-inverse);
|
||||
display: none;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
@@ -352,9 +352,9 @@
|
||||
.xx-clone-section {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-md, 14px);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.xx-clone-section-title {
|
||||
@@ -376,20 +376,20 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: 0.15s ease;
|
||||
}
|
||||
|
||||
.xx-clone-voice-row:hover {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-clone-voice-row.selected {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-clone-voice-row.disabled {
|
||||
@@ -408,24 +408,41 @@
|
||||
}
|
||||
|
||||
.xx-clone-avatar.ready {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-secondary-500),
|
||||
var(--color-secondary-600)
|
||||
);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-clone-avatar.processing {
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-accent-500),
|
||||
var(--color-accent-600)
|
||||
);
|
||||
color: var(--text-inverse);
|
||||
animation: xx-clone-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.xx-clone-avatar.failed {
|
||||
background: linear-gradient(135deg, #94a3b8, #64748b);
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-gray-400),
|
||||
var(--color-gray-500)
|
||||
);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
@keyframes xx-clone-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clone-info {
|
||||
@@ -457,7 +474,7 @@
|
||||
.xx-clone-progress {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
background: #f1f5f9;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
@@ -465,7 +482,11 @@
|
||||
|
||||
.xx-clone-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #f59e0b, #10b981);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--warning-color),
|
||||
var(--success-color)
|
||||
);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
@@ -474,17 +495,23 @@
|
||||
width: 100%;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
#f59e0b 0%, #f59e0b 25%,
|
||||
#10b981 25%, #10b981 50%,
|
||||
#f59e0b 50%
|
||||
var(--warning-color) 0%,
|
||||
var(--warning-color) 25%,
|
||||
var(--success-color) 25%,
|
||||
var(--success-color) 50%,
|
||||
var(--warning-color) 50%
|
||||
);
|
||||
background-size: 60px 100%;
|
||||
animation: xx-clone-progress-flow 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes xx-clone-progress-flow {
|
||||
from { background-position: 0 0; }
|
||||
to { background-position: 60px 0; }
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: 60px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clone-progress-text {
|
||||
@@ -492,7 +519,7 @@
|
||||
right: 0;
|
||||
top: -14px;
|
||||
font-size: 10px;
|
||||
color: #f59e0b;
|
||||
color: var(--warning-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -503,9 +530,9 @@
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: #f59e0b;
|
||||
color: var(--warning-color);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: xx-fade 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -513,26 +540,36 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
background: var(--warning-color);
|
||||
animation: xx-blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes xx-fade {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes xx-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
确认生成(步骤5)摘要
|
||||
============================================================ */
|
||||
.xx-summary-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -544,11 +581,11 @@
|
||||
}
|
||||
|
||||
.xx-summary-row + .xx-summary-row {
|
||||
border-top: 1px solid var(--border-color, #e2e8f0);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-summary-label {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -585,7 +622,7 @@
|
||||
gap: 6px;
|
||||
height: 42px;
|
||||
padding: 0 20px;
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -601,8 +638,8 @@
|
||||
}
|
||||
|
||||
.xx-btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
background: var(--gradient-primary);
|
||||
color: var(--text-inverse);
|
||||
box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22);
|
||||
}
|
||||
|
||||
@@ -612,24 +649,24 @@
|
||||
}
|
||||
|
||||
.xx-btn-ghost {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
color: #475569;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-btn-ghost:hover:not(:disabled) {
|
||||
border-color: #c7d2fe;
|
||||
color: #4338ca;
|
||||
background: #f8faff;
|
||||
border-color: var(--info-border);
|
||||
color: var(--primary-dark);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
右侧预览区 generate-preview
|
||||
============================================================ */
|
||||
.xx-generate-preview {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-md, 14px);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px;
|
||||
position: sticky;
|
||||
top: 92px;
|
||||
@@ -639,11 +676,15 @@
|
||||
.xx-preview-video {
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md, 14px);
|
||||
background: linear-gradient(135deg, #111827, #312e81);
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-gray-900),
|
||||
var(--color-primary-900)
|
||||
);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
font-size: 36px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -654,7 +695,11 @@
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 72% 28%, rgba(255, 255, 255, 0.2), transparent 40%);
|
||||
background: radial-gradient(
|
||||
circle at 72% 28%,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent 40%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -662,7 +707,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md, 14px);
|
||||
border-radius: var(--radius-md);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -678,7 +723,7 @@
|
||||
place-items: center;
|
||||
backdrop-filter: blur(8px);
|
||||
border: none;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
@@ -692,8 +737,8 @@
|
||||
/* ── 字幕预览 ── */
|
||||
.xx-preview-caption {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: var(--text-primary, #334155);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
@@ -721,17 +766,17 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-timeline-item .num {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 8px;
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
color: var(--primary-color, #4f46e5);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-color);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
@@ -740,7 +785,7 @@
|
||||
}
|
||||
|
||||
.xx-timeline-item span {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
color: var(--text-tertiary);
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -769,7 +814,7 @@
|
||||
.xx-progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #f1f5f9;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
@@ -777,7 +822,11 @@
|
||||
|
||||
.xx-progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6366f1, #4f46e5);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-primary-500),
|
||||
var(--color-primary-600)
|
||||
);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
@@ -834,3 +883,332 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 存为素材弹窗 ── */
|
||||
.xx-save-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: xxFadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.xx-save-modal {
|
||||
background: var(--bg-card, #fff);
|
||||
border-radius: var(--radius-lg, 16px);
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
animation: xxSlideUp 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-save-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-light, #f1f5f9);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.xx-save-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-save-modal-close:hover {
|
||||
background: var(--bg-hover, #f8fafc);
|
||||
color: var(--text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.xx-save-modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.xx-save-modal-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #475569);
|
||||
margin-bottom: 6px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.xx-save-modal-label:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.xx-save-modal-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-primary, #0f172a);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.xx-save-modal-input:focus {
|
||||
border-color: var(--primary-500, #6366f1);
|
||||
box-shadow: 0 0 0 2px var(--primary-100, #e0e7ff);
|
||||
}
|
||||
|
||||
.xx-save-modal-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
cursor: text;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.xx-save-modal-tags:focus-within {
|
||||
border-color: var(--primary-500, #6366f1);
|
||||
box-shadow: 0 0 0 2px var(--primary-100, #e0e7ff);
|
||||
}
|
||||
|
||||
.xx-save-modal-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--primary-50, #eef2ff);
|
||||
color: var(--primary-600, #4f46e5);
|
||||
border: 1px solid var(--primary-200, #c7d2fe);
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-remove {
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-input::placeholder {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border-light, #f1f5f9);
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-preset {
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-secondary, #475569);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-preset:hover {
|
||||
border-color: var(--primary-300, #a5b4fc);
|
||||
color: var(--primary-600, #4f46e5);
|
||||
background: var(--primary-50, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-save-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border-light, #f1f5f9);
|
||||
}
|
||||
|
||||
@keyframes xxFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes xxSlideUp {
|
||||
from {
|
||||
transform: translateY(12px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 生成数量步进器 ── */
|
||||
.xx-count-stepper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-count-stepper-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-count-stepper-btn:hover:not(:disabled) {
|
||||
border-color: var(--primary-400, #818cf8);
|
||||
color: var(--primary-600, #4f46e5);
|
||||
background: var(--primary-50, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-count-stepper-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.xx-count-stepper-value {
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-count-stepper-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* ── 素材选择模式切换 Tab ── */
|
||||
.xx-material-mode-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-material-mode-tab {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-secondary, #64748b);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-material-mode-tab:first-child {
|
||||
border-right: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.xx-material-mode-tab:hover {
|
||||
background: var(--primary-50, #eef2ff);
|
||||
color: var(--primary-600, #4f46e5);
|
||||
}
|
||||
|
||||
.xx-material-mode-tab.active {
|
||||
background: var(--primary-500, #6366f1);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 自动匹配卡片 ── */
|
||||
.xx-auto-match-card {
|
||||
margin-top: 14px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-auto-match-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-auto-match-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xx-auto-match-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.xx-auto-match-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.xx-auto-match-features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-auto-match-feature {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--primary-600, #4f46e5);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid var(--border-light, #f1f5f9);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
Regular → Executable
+40
-39
@@ -5,13 +5,14 @@
|
||||
* / PricingSection / CTASection
|
||||
* 全部使用 CSS 变量,零 antd 直接导入
|
||||
*/
|
||||
@import "../../styles/global.css";
|
||||
|
||||
/* ── 页面容器 ────────────────────────────────────────────── */
|
||||
|
||||
.hp-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-base, #fafafa);
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow-x: hidden;
|
||||
@@ -25,10 +26,10 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-glass, rgba(255, 255, 255, 0.72));
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--line, rgba(0, 0, 0, 0.06));
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.hp-nav-inner {
|
||||
@@ -58,7 +59,7 @@
|
||||
.hp-nav-brand-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hp-nav-actions {
|
||||
@@ -97,7 +98,7 @@
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: 6px 16px;
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
color: var(--primary, var(--primary-color));
|
||||
font-size: 14px;
|
||||
@@ -108,7 +109,7 @@
|
||||
font-size: 44px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
@@ -116,7 +117,7 @@
|
||||
.hp-hero-desc {
|
||||
font-size: 17px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
max-width: 520px;
|
||||
}
|
||||
@@ -136,7 +137,7 @@
|
||||
|
||||
.hp-hero-video {
|
||||
position: relative;
|
||||
border-radius: var(--radius-lg, 16px);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
@@ -202,18 +203,18 @@
|
||||
|
||||
.hp-hero-badge {
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--line, rgba(0, 0, 0, 0.06));
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hp-hero-pill {
|
||||
padding: 5px 14px;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--secondary-color) 10%, transparent);
|
||||
color: var(--secondary-color, #059669);
|
||||
font-size: 13px;
|
||||
@@ -233,13 +234,13 @@
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0 0 12px;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hp-section-desc {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 48px;
|
||||
}
|
||||
|
||||
@@ -257,9 +258,9 @@
|
||||
|
||||
.hp-feature-card {
|
||||
padding: 32px 24px;
|
||||
border-radius: var(--radius-lg, 16px);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--line, rgba(0, 0, 0, 0.06));
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
@@ -275,7 +276,7 @@
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: var(--radius-md, 12px);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -287,13 +288,13 @@
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hp-feature-desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -319,9 +320,9 @@
|
||||
.hp-step-card {
|
||||
position: relative;
|
||||
padding: 32px 24px;
|
||||
border-radius: var(--radius-lg, 16px);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--line, rgba(0, 0, 0, 0.06));
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -351,13 +352,13 @@
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hp-step-desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -367,7 +368,7 @@
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 20px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.4;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -388,9 +389,9 @@
|
||||
.hp-pricing-card {
|
||||
position: relative;
|
||||
padding: 36px 28px;
|
||||
border-radius: var(--radius-lg, 16px);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--line, rgba(0, 0, 0, 0.06));
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -415,7 +416,7 @@
|
||||
top: -12px;
|
||||
right: 20px;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--primary, var(--primary-color));
|
||||
color: var(--text-inverse);
|
||||
font-size: 12px;
|
||||
@@ -426,25 +427,25 @@
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hp-pricing-price {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hp-pricing-period {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hp-pricing-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -462,7 +463,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hp-pricing-check {
|
||||
@@ -482,13 +483,13 @@
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
padding: 60px 40px;
|
||||
border-radius: var(--radius-lg, 16px);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--primary-color) 6%, transparent) 0%,
|
||||
color-mix(in srgb, var(--color-primary-400, #8b5cf6) 6%, transparent) 100%
|
||||
);
|
||||
border: 1px solid var(--line, rgba(0, 0, 0, 0.06));
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -499,13 +500,13 @@
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hp-cta-desc {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -514,13 +515,13 @@
|
||||
.hp-footer {
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--line, rgba(0, 0, 0, 0.06));
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.hp-footer p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── 响应式 768px ────────────────────────────────────────── */
|
||||
|
||||
Regular → Executable
+4
-3
@@ -1,9 +1,10 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
* 我的模板页面样式
|
||||
* 我的模板页面样式 - V21 CSS变量体系
|
||||
* ═══════════════════════════════════════════════════ */
|
||||
@import "../../styles/global.css";
|
||||
|
||||
.mt-page {
|
||||
padding: 24px;
|
||||
padding: var(--space-xl);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -28,7 +29,7 @@
|
||||
/* 卡片样式 */
|
||||
.mt-card {
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
transform 0.2s;
|
||||
|
||||
@@ -178,7 +178,10 @@ const MyTemplates: React.FC = () => {
|
||||
description="还没有模板,点击右上角「新建模板」开始创建"
|
||||
style={{ padding: 80 }}
|
||||
>
|
||||
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate("/app/editing-planner")}
|
||||
>
|
||||
新建模板
|
||||
</Button>
|
||||
</Empty>
|
||||
@@ -223,6 +226,7 @@ const MyTemplates: React.FC = () => {
|
||||
>
|
||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
|
||||
</Tag>
|
||||
<Tag color="green">用户自制</Tag>
|
||||
</div>
|
||||
|
||||
<div className="mt-card-meta">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Modal, Input, Tooltip } from "@/components/ui";
|
||||
import type { ButtonProps } from "antd";
|
||||
import PageHead from "@/components/layout/PageHead";
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress";
|
||||
import {
|
||||
@@ -206,9 +207,11 @@ const MyVoices: React.FC = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
// Mock: 使用 sample_url 或占位 URL
|
||||
const url = voice.sample_url || `/mock/audio/clone-${voice.id}.mp3`;
|
||||
const audio = new Audio(url);
|
||||
if (!voice.sample_url) {
|
||||
showToast("暂无试听音频", "error");
|
||||
return;
|
||||
}
|
||||
const audio = new Audio(voice.sample_url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"));
|
||||
audio.onended = () => setPlayingId(null);
|
||||
@@ -378,7 +381,7 @@ const MyVoices: React.FC = () => {
|
||||
onOk={handleDeleteConfirm}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true } as any}
|
||||
okButtonProps={{ danger: true } as ButtonProps}
|
||||
>
|
||||
<p>确定要删除这个克隆音色吗?删除后无法恢复。</p>
|
||||
</Modal>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
我的音色页面 — V21 Design System(任务 3.12)
|
||||
我的音色页面 — V21 Design System
|
||||
命名前缀:xx-mv-
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
@import "../../styles/global.css";
|
||||
|
||||
/* ── 页面容器 ──────────────────────────────────────────── */
|
||||
|
||||
.xx-mv-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md, 20px);
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
/* ── 轮询提示 ──────────────────────────────────────────── */
|
||||
@@ -19,9 +21,9 @@
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--accent-color, #f59e0b);
|
||||
background: color-mix(in srgb, var(--accent-color, #f59e0b) 8%, transparent);
|
||||
border-radius: var(--radius-md, 10px);
|
||||
color: var(--accent-color);
|
||||
background: color-mix(in srgb, var(--accent-color) 8%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
animation: xx-mv-polling-fade 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -29,7 +31,7 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color, #f59e0b);
|
||||
background: var(--accent-color);
|
||||
animation: xx-mv-blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -48,12 +50,12 @@
|
||||
.xx-mv-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 16px);
|
||||
padding: var(--space-sm, 12px) var(--space-md, 16px);
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm, 12px) var(--space-md);
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
border-radius: var(--radius-md, 10px);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-mv-stat {
|
||||
@@ -75,11 +77,11 @@
|
||||
}
|
||||
|
||||
.xx-mv-stat--ready {
|
||||
color: var(--secondary-color, #10b981);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.xx-mv-stat--processing {
|
||||
color: var(--accent-color, #f59e0b);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* ── 卡片网格 ──────────────────────────────────────────── */
|
||||
@@ -87,7 +89,7 @@
|
||||
.xx-mv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: var(--space-md, 16px);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* ── 卡片 ──────────────────────────────────────────────── */
|
||||
@@ -95,7 +97,7 @@
|
||||
.xx-mv-card {
|
||||
position: relative;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg, 14px);
|
||||
padding: var(--space-md, 20px);
|
||||
transition:
|
||||
@@ -110,19 +112,11 @@
|
||||
}
|
||||
|
||||
.xx-mv-card--processing {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-color, #f59e0b) 30%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--accent-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.xx-mv-card--failed {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--error-color, #ef4444) 25%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--error-color) 25%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -146,7 +140,7 @@
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--secondary-color, #10b981),
|
||||
var(--secondary-color),
|
||||
var(--color-secondary-700, #059669)
|
||||
);
|
||||
}
|
||||
@@ -154,7 +148,7 @@
|
||||
.xx-mv-card-avatar--processing {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--accent-color, #f59e0b),
|
||||
var(--accent-color),
|
||||
var(--accent-dark, #d97706)
|
||||
);
|
||||
animation: xx-mv-pulse 2s ease-in-out infinite;
|
||||
@@ -201,7 +195,7 @@
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-mv-status-dot {
|
||||
@@ -212,16 +206,16 @@
|
||||
}
|
||||
|
||||
.xx-mv-status-dot--ready {
|
||||
color: var(--secondary-color, #10b981);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.xx-mv-status-dot--processing {
|
||||
color: var(--accent-color, #f59e0b);
|
||||
color: var(--accent-color);
|
||||
animation: xx-mv-blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.xx-mv-status-dot--failed {
|
||||
color: var(--error-color, #ef4444);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
@keyframes xx-mv-blink {
|
||||
@@ -242,7 +236,7 @@
|
||||
gap: var(--space-sm, 12px);
|
||||
margin-bottom: var(--space-sm, 14px);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-mv-card-meta-item {
|
||||
@@ -277,11 +271,11 @@
|
||||
width: 100%;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--accent-color, #f59e0b) 0%,
|
||||
var(--accent-color, #f59e0b) 25%,
|
||||
var(--secondary-color, #10b981) 25%,
|
||||
var(--secondary-color, #10b981) 50%,
|
||||
var(--accent-color, #f59e0b) 50%
|
||||
var(--accent-color) 0%,
|
||||
var(--accent-color) 25%,
|
||||
var(--secondary-color) 25%,
|
||||
var(--secondary-color) 50%,
|
||||
var(--accent-color) 50%
|
||||
);
|
||||
background-size: 60px 100%;
|
||||
animation: xx-mv-progress-flow 1.2s linear infinite;
|
||||
@@ -315,7 +309,7 @@
|
||||
right: 0;
|
||||
top: -18px;
|
||||
font-size: 11px;
|
||||
color: var(--accent-color, #f59e0b);
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -326,7 +320,7 @@
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 10px);
|
||||
padding-top: var(--space-sm, 12px);
|
||||
border-top: 1px solid var(--border-color, #e2e8f0);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-mv-card-actions .xx-btn {
|
||||
@@ -346,7 +340,7 @@
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #64748b);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -366,7 +360,7 @@
|
||||
|
||||
.xx-mv-icon-btn--danger:hover {
|
||||
background: var(--error-soft, #fef2f2);
|
||||
color: var(--error-color, #ef4444);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
/* ── 空状态 ────────────────────────────────────────────── */
|
||||
@@ -376,18 +370,18 @@
|
||||
text-align: center;
|
||||
padding: 60px var(--space-md, 24px);
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px dashed var(--border-color, #e2e8f0);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-lg, 14px);
|
||||
}
|
||||
|
||||
.xx-mv-empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: var(--space-md, 16px);
|
||||
margin-bottom: var(--space-md);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-mv-empty-title {
|
||||
margin: 0 0 var(--space-sm, 8px);
|
||||
margin: 0 0 var(--space-sm);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
@@ -396,7 +390,7 @@
|
||||
.xx-mv-empty-desc {
|
||||
margin: 0 0 var(--space-md, 24px);
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Toast ─────────────────────────────────────────────── */
|
||||
@@ -413,11 +407,11 @@
|
||||
|
||||
.xx-mv-toast {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-md, 10px);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 4px 12px var(--shadow-sm, rgba(0, 0, 0, 0.08));
|
||||
animation: xx-mv-toast-in 0.25s ease-out;
|
||||
display: flex;
|
||||
@@ -426,13 +420,13 @@
|
||||
}
|
||||
|
||||
.xx-mv-toast--success {
|
||||
border-color: var(--secondary-color, #10b981);
|
||||
color: var(--secondary-color, #10b981);
|
||||
border-color: var(--secondary-color);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.xx-mv-toast--error {
|
||||
border-color: var(--error-color, #ef4444);
|
||||
color: var(--error-color, #ef4444);
|
||||
border-color: var(--error-color);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
@keyframes xx-mv-toast-in {
|
||||
@@ -461,11 +455,11 @@
|
||||
|
||||
.xx-mv-stats {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm, 8px);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-mv-empty {
|
||||
padding: 40px var(--space-md, 16px);
|
||||
padding: 40px var(--space-md);
|
||||
}
|
||||
|
||||
.xx-mv-empty-icon {
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* 成品库 — 产品详情页
|
||||
* 路由:/app/products/:id
|
||||
* 展示视频播放器 + 完整元数据 + 下载/分享/删除操作
|
||||
*/
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleFilled,
|
||||
PauseCircleFilled,
|
||||
SoundOutlined,
|
||||
MutedOutlined,
|
||||
ExpandOutlined,
|
||||
LoadingOutlined,
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
getProduct,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
type ProductItem,
|
||||
} from "../../api/products";
|
||||
import { Button } from "../../components/ui";
|
||||
import "./products.css";
|
||||
|
||||
/* ============================================================
|
||||
* 工具函数
|
||||
* ============================================================ */
|
||||
|
||||
/** 格式化时长(秒 → "MM:SS") */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (!seconds || seconds <= 0) return "00:00";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/** 格式化文件大小(MB) */
|
||||
const formatFileSize = (mb: number): string => {
|
||||
if (!mb || mb <= 0) return "-";
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
/** 格式化日期 */
|
||||
const formatDate = (dateStr: string): string => {
|
||||
if (!dateStr) return "-";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
/** 状态标签 */
|
||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
completed: { label: "已完成", color: "#10b981" },
|
||||
processing: { label: "处理中", color: "#6366f1" },
|
||||
pending: { label: "待处理", color: "#f59e0b" },
|
||||
failed: { label: "失败", color: "#ef4444" },
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const ProductDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* ── 获取产品详情 ── */
|
||||
const {
|
||||
data: product,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<ProductItem, Error>({
|
||||
queryKey: ["product", id],
|
||||
queryFn: () => getProduct(id!),
|
||||
enabled: !!id,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
/* ── 删除 mutation ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteProduct(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
navigate("/app/products");
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 视频播放器状态 ── */
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── 自动隐藏控制条 ── */
|
||||
const resetHideTimer = useCallback(() => {
|
||||
setShowControls(true);
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
if (isPlaying) {
|
||||
hideTimerRef.current = setTimeout(() => setShowControls(false), 3000);
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
/* ── 播放控制 ── */
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) {
|
||||
v.play().catch(() => {});
|
||||
} else {
|
||||
v.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const v = videoRef.current;
|
||||
const bar = progressRef.current;
|
||||
if (!v || !bar || !duration) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
v.currentTime = ratio * duration;
|
||||
},
|
||||
[duration],
|
||||
);
|
||||
|
||||
const handleVolumeChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = videoRef.current;
|
||||
const val = parseFloat(e.target.value);
|
||||
if (v) v.volume = val;
|
||||
setVolume(val);
|
||||
setIsMuted(val === 0);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (isMuted) {
|
||||
v.muted = false;
|
||||
v.volume = volume || 1;
|
||||
setIsMuted(false);
|
||||
} else {
|
||||
v.muted = true;
|
||||
setIsMuted(true);
|
||||
}
|
||||
}, [isMuted, volume]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (!document.fullscreenElement) {
|
||||
el.requestFullscreen?.().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen?.().catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ── 视频事件监听 ── */
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onTimeUpdate = () => setCurrentTime(v.currentTime);
|
||||
const onLoadedMetadata = () => setDuration(v.duration);
|
||||
const onProgress = () => {
|
||||
if (v.buffered.length > 0) {
|
||||
setBuffered(v.buffered.end(v.buffered.length - 1));
|
||||
}
|
||||
};
|
||||
const onEnded = () => setIsPlaying(false);
|
||||
const onFSChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
|
||||
v.addEventListener("play", onPlay);
|
||||
v.addEventListener("pause", onPause);
|
||||
v.addEventListener("timeupdate", onTimeUpdate);
|
||||
v.addEventListener("loadedmetadata", onLoadedMetadata);
|
||||
v.addEventListener("progress", onProgress);
|
||||
v.addEventListener("ended", onEnded);
|
||||
document.addEventListener("fullscreenchange", onFSChange);
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
v.removeEventListener("timeupdate", onTimeUpdate);
|
||||
v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||
v.removeEventListener("progress", onProgress);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
document.removeEventListener("fullscreenchange", onFSChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* 播放时自动隐藏/显示控制条 */
|
||||
useEffect(() => {
|
||||
resetHideTimer();
|
||||
return () => {
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
};
|
||||
}, [isPlaying, resetHideTimer]);
|
||||
|
||||
/* ── 下载 ── */
|
||||
const handleDownload = async () => {
|
||||
if (!product || product.status !== "completed") return;
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(product.id);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
} catch {
|
||||
// message.error handled by caller
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 分享 ── */
|
||||
const handleShare = () => {
|
||||
if (!product) return;
|
||||
const link = `${window.location.origin}/app/products/${product.id}`;
|
||||
navigator.clipboard?.writeText(link).then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 删除 ── */
|
||||
const handleDelete = () => {
|
||||
if (!id) return;
|
||||
deleteMutation.mutate();
|
||||
};
|
||||
|
||||
/* ── 加载状态 ── */
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-page xx-product-detail-page">
|
||||
<div className="xx-detail-loading">
|
||||
<LoadingOutlined
|
||||
style={{ fontSize: 32, color: "var(--primary-color)" }}
|
||||
spin
|
||||
/>
|
||||
<p style={{ color: "var(--text-secondary)", marginTop: 16 }}>
|
||||
加载中…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 错误状态 ── */
|
||||
if (isError || !product) {
|
||||
return (
|
||||
<div className="xx-page xx-product-detail-page">
|
||||
<div className="xx-detail-error">
|
||||
<WarningOutlined
|
||||
style={{ fontSize: 48, color: "var(--error-color)" }}
|
||||
/>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error?.message || "无法获取产品信息"}</p>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
<ArrowLeftOutlined /> 返回成品库
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusInfo = STATUS_MAP[product.status] || {
|
||||
label: product.status,
|
||||
color: "#94a3b8",
|
||||
};
|
||||
const progress = Math.round((currentTime / (duration || 1)) * 100);
|
||||
const bufferedPct = Math.round((buffered / (duration || 1)) * 100);
|
||||
|
||||
return (
|
||||
<div className="xx-page xx-product-detail-page">
|
||||
{/* ── 顶部导航 ── */}
|
||||
<div className="xx-detail-header">
|
||||
<button
|
||||
className="xx-detail-back-btn"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
<ArrowLeftOutlined /> 返回成品库
|
||||
</button>
|
||||
<div className="xx-detail-actions">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownload}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={handleShare}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDelete}
|
||||
loading={deleteMutation.isPending}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 主体内容 ── */}
|
||||
<div className="xx-detail-body">
|
||||
{/* 视频播放器 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`xx-detail-player ${isFullscreen ? "is-fullscreen" : ""}`}
|
||||
onMouseMove={resetHideTimer}
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{product.video_url ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={product.video_url}
|
||||
poster={product.thumbnail_url || undefined}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-detail-player-empty">
|
||||
{product.thumbnail_url ? (
|
||||
<img src={product.thumbnail_url} alt={product.title} />
|
||||
) : (
|
||||
<div className="xx-detail-player-placeholder">
|
||||
<PlayCircleFilled
|
||||
style={{ fontSize: 64, color: "var(--text-tertiary)" }}
|
||||
/>
|
||||
<p>视频暂不可用</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制条 */}
|
||||
<div
|
||||
className={`xx-detail-player-controls ${showControls || !isPlaying ? "visible" : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
className="xx-dp-progress"
|
||||
ref={progressRef}
|
||||
onClick={handleSeek}
|
||||
>
|
||||
<div className="xx-dp-progress-track">
|
||||
<div
|
||||
className="xx-dp-progress-buffered"
|
||||
style={{ width: `${bufferedPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="xx-dp-progress-played"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-dp-bar">
|
||||
{/* 左:播放/暂停 */}
|
||||
<button className="xx-dp-btn" onClick={togglePlay}>
|
||||
{isPlaying ? <PauseCircleFilled /> : <PlayCircleFilled />}
|
||||
</button>
|
||||
|
||||
{/* 时间 */}
|
||||
<span className="xx-dp-time">
|
||||
{formatDuration(currentTime)} / {formatDuration(duration)}
|
||||
</span>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* 音量 */}
|
||||
<div className="xx-dp-volume">
|
||||
<button className="xx-dp-btn" onClick={toggleMute}>
|
||||
{isMuted ? <MutedOutlined /> : <SoundOutlined />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="xx-dp-volume-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 全屏 */}
|
||||
<button className="xx-dp-btn" onClick={toggleFullscreen}>
|
||||
<ExpandOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品信息 */}
|
||||
<div className="xx-detail-info">
|
||||
<h1 className="xx-detail-title">{product.title}</h1>
|
||||
|
||||
<div className="xx-detail-meta-grid">
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">状态</span>
|
||||
<span
|
||||
className="xx-detail-meta-value"
|
||||
style={{ color: statusInfo.color }}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">时长</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{formatDuration(product.duration_seconds ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">分辨率</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{product.resolution || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">文件大小</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{formatFileSize(product.file_size ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">查重率</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{(product.duplicate_rate ?? 0) > 0
|
||||
? `${(product.duplicate_rate ?? 0).toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">创建时间</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{formatDate(product.created_at ?? "")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetail;
|
||||
@@ -11,6 +11,7 @@ import React, {
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { message, Popconfirm } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
CloseOutlined,
|
||||
CheckOutlined,
|
||||
CloudUploadOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Input, Select } from "@/components/ui";
|
||||
import {
|
||||
@@ -311,7 +313,8 @@ const VideoPlayer: React.FC<{
|
||||
onClose: () => void;
|
||||
onDownload: (product: ProductItem) => void;
|
||||
onShare: (product: ProductItem) => void;
|
||||
}> = ({ product, onClose, onDownload, onShare }) => {
|
||||
onViewDetail: (product: ProductItem) => void;
|
||||
}> = ({ product, onClose, onDownload, onShare, onViewDetail }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -480,6 +483,14 @@ const VideoPlayer: React.FC<{
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onViewDetail(product)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onClose}>
|
||||
关闭
|
||||
@@ -495,6 +506,7 @@ const VideoPlayer: React.FC<{
|
||||
* ============================================================ */
|
||||
const ProductLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
@@ -645,6 +657,12 @@ const ProductLibrary: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/* 查看详情 — 跳转到产品详情页 */
|
||||
const handleViewDetail = (product: ProductItem) => {
|
||||
setPlayingProduct(null); // 关闭播放器
|
||||
navigate(`/app/products/${product.id}`);
|
||||
};
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id);
|
||||
@@ -719,11 +737,39 @@ const ProductLibrary: React.FC = () => {
|
||||
|
||||
// ── Error 状态 ──
|
||||
if (isError) {
|
||||
console.error("[ProductLibrary] 加载失败:", error);
|
||||
const errorMsg = error?.message || "加载失败";
|
||||
// 404 视为空数据(API 尚未就绪或无数据)
|
||||
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found");
|
||||
if (is404) {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-header">
|
||||
<h2>
|
||||
<VideoCameraOutlined /> 成片库
|
||||
</h2>
|
||||
</div>
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">🎬</div>
|
||||
<p>暂无成片数据</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
完成视频生成后,成片将自动保存到这里
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">❌</div>
|
||||
<p>{error?.message || "加载失败"}</p>
|
||||
<p>{errorMsg || "加载失败,请稍后重试"}</p>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
@@ -912,6 +958,7 @@ const ProductLibrary: React.FC = () => {
|
||||
onClose={() => setPlayingProduct(null)}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
.xx-products-checkbox.checked {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -193,7 +193,7 @@
|
||||
backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-product-thumb-bg {
|
||||
@@ -270,7 +270,7 @@
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
@@ -309,8 +309,8 @@
|
||||
}
|
||||
|
||||
.xx-product-status.completed {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
background: var(--success-soft);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.xx-product-status.processing {
|
||||
@@ -319,20 +319,20 @@
|
||||
}
|
||||
|
||||
.xx-product-status.review {
|
||||
background: #fefce8;
|
||||
color: #ca8a04;
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.xx-product-status.failed {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
background: var(--error-soft);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
/* 深色模式状态标签 */
|
||||
.dark .xx-product-status.completed,
|
||||
[data-theme="dark"] .xx-product-status.completed {
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: #34d399;
|
||||
color: var(--success-hover);
|
||||
}
|
||||
|
||||
.dark .xx-product-status.processing,
|
||||
@@ -344,13 +344,13 @@
|
||||
.dark .xx-product-status.review,
|
||||
[data-theme="dark"] .xx-product-status.review {
|
||||
background: rgba(202, 138, 4, 0.15);
|
||||
color: #facc15;
|
||||
color: var(--warning-hover);
|
||||
}
|
||||
|
||||
.dark .xx-product-status.failed,
|
||||
[data-theme="dark"] .xx-product-status.failed {
|
||||
background: rgba(220, 38, 38, 0.15);
|
||||
color: #f87171;
|
||||
color: var(--error-hover);
|
||||
}
|
||||
|
||||
/* 日期 */
|
||||
@@ -366,15 +366,15 @@
|
||||
}
|
||||
|
||||
.xx-product-dup-rate.good {
|
||||
color: #059669;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.xx-product-dup-rate.warn {
|
||||
color: #ca8a04;
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.xx-product-dup-rate.bad {
|
||||
color: #dc2626;
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
/* 操作按钮区 */
|
||||
@@ -409,7 +409,7 @@
|
||||
|
||||
.xx-product-action-btn.primary {
|
||||
background: var(--gradient-primary);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -419,13 +419,13 @@
|
||||
}
|
||||
|
||||
.xx-product-action-btn.success {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
background: var(--success-color);
|
||||
color: var(--text-inverse);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.xx-product-action-btn.success:hover {
|
||||
background: #047857;
|
||||
background: var(--secondary-dark);
|
||||
}
|
||||
|
||||
.xx-product-action-btn:disabled {
|
||||
@@ -480,7 +480,7 @@
|
||||
|
||||
.xx-player-video-wrap {
|
||||
position: relative;
|
||||
background: #000;
|
||||
background: var(--color-gray-950);
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 60vh;
|
||||
display: grid;
|
||||
@@ -504,7 +504,7 @@
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
font-size: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -602,7 +602,7 @@
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
font-size: var(--font-size-md);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -631,6 +631,327 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
产品详情页
|
||||
============================================================ */
|
||||
.xx-product-detail-page {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.xx-detail-loading,
|
||||
.xx-detail-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-detail-error h3 {
|
||||
font-size: 20px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: var(--space-md) 0 0;
|
||||
}
|
||||
|
||||
.xx-detail-error p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin: 0 0 var(--space-lg);
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.xx-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.xx-detail-back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.xx-detail-back-btn:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-detail-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* 主体 */
|
||||
.xx-detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
gap: var(--space-lg);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.xx-detail-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 播放器 */
|
||||
.xx-detail-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.xx-detail-player video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.xx-detail-player.is-fullscreen {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.xx-detail-player-empty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-detail-player-empty img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.xx-detail-player-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-detail-player-placeholder p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 播放器控制条 */
|
||||
.xx-detail-player-controls {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
padding: var(--space-lg) var(--space-md) var(--space-md);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-base);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xx-detail-player-controls.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.xx-dp-progress {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.xx-dp-progress-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
transition: height var(--transition-fast);
|
||||
}
|
||||
|
||||
.xx-dp-progress:hover .xx-dp-progress-track {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.xx-dp-progress-buffered {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.xx-dp-progress-played {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--primary-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 控制栏 */
|
||||
.xx-dp-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-dp-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-xs);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.xx-dp-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.xx-dp-time {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-dp-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-dp-volume-slider {
|
||||
width: 72px;
|
||||
height: 4px;
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-dp-volume-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-dp-volume-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 产品信息面板 */
|
||||
.xx-detail-info {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.xx-detail-title {
|
||||
font-size: 22px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-lg);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.xx-detail-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-detail-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-detail-meta-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-detail-meta-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 详情页响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.xx-product-detail-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-detail-header {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.xx-detail-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.xx-detail-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
|
||||
Regular → Executable
+70
-69
@@ -1,96 +1,97 @@
|
||||
/**
|
||||
* V21 个人设置页面样式
|
||||
* V21 个人设置页面样式 - CSS变量体系
|
||||
*/
|
||||
@import "../../styles/global.css";
|
||||
|
||||
/* 页面容器 */
|
||||
.xx-settings-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px;
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
}
|
||||
|
||||
/* 页面头部 */
|
||||
.xx-settings-head {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 32px 36px;
|
||||
margin-bottom: 28px;
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--bg-glass-border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: var(--space-xl);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.xx-settings-head h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 900;
|
||||
color: var(--slate, #0f172a);
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-sm);
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.xx-settings-head p {
|
||||
font-size: 16px;
|
||||
color: var(--muted, #64748b);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 内容卡片 */
|
||||
.xx-settings-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 32px 36px;
|
||||
margin-bottom: 28px;
|
||||
transition: all 0.25s;
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--bg-glass-border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: var(--space-xl);
|
||||
margin-bottom: var(--space-xl);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.xx-settings-card:hover {
|
||||
box-shadow: 0 26px 64px rgba(15, 23, 42, 0.14);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 卡片标题 */
|
||||
.xx-settings-card h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
color: var(--slate, #0f172a);
|
||||
margin: 0 0 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line, #e2e8f0);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-lg);
|
||||
padding-bottom: var(--space-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* 结果页面容器 */
|
||||
.xx-result-page {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
padding: 60px 40px;
|
||||
margin-bottom: 28px;
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--bg-glass-border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: var(--space-3xl) var(--space-2xl);
|
||||
margin-bottom: var(--space-xl);
|
||||
text-align: center;
|
||||
transition: all 0.25s;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.xx-result-page:hover {
|
||||
box-shadow: 0 26px 64px rgba(15, 23, 42, 0.14);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 按钮容器 */
|
||||
.xx-btn-group {
|
||||
margin-top: 24px;
|
||||
margin-top: var(--space-lg);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: var(--space-md);
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 表单区块标题 */
|
||||
.xx-section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--slate, #0f172a);
|
||||
margin: 24px 0 16px;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: var(--space-lg) 0 var(--space-md);
|
||||
}
|
||||
|
||||
.xx-section-title:first-child {
|
||||
@@ -99,60 +100,60 @@
|
||||
|
||||
/* Ant Design Form 适配 */
|
||||
.ant-form-item {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.ant-form-item-label > label {
|
||||
font-weight: 600;
|
||||
color: var(--slate, #0f172a);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-affix-wrapper {
|
||||
border-radius: 12px !important;
|
||||
border-color: var(--line, #e2e8f0) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.ant-input:hover,
|
||||
.ant-input-affix-wrapper:hover {
|
||||
border-color: var(--indigo, #4f46e5) !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.ant-input:focus,
|
||||
.ant-input-affix-wrapper:focus,
|
||||
.ant-input-affix-wrapper-focused {
|
||||
border-color: var(--indigo, #4f46e5) !important;
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1) !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
box-shadow: 0 0 0 2px var(--primary-soft) !important;
|
||||
}
|
||||
|
||||
/* Switch 样式 */
|
||||
.ant-switch {
|
||||
background: var(--line, #e2e8f0);
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.ant-switch-checked {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
|
||||
background: var(--gradient-primary) !important;
|
||||
}
|
||||
|
||||
/* Alert 样式 */
|
||||
.ant-alert {
|
||||
border-radius: 14px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.ant-alert-info {
|
||||
background: var(--indigo-soft, #eef2ff);
|
||||
border: 1px solid rgba(79, 70, 229, 0.2);
|
||||
background: var(--primary-soft);
|
||||
border: 1px solid var(--info-border);
|
||||
}
|
||||
|
||||
.ant-alert-message {
|
||||
font-weight: 600;
|
||||
color: var(--slate, #0f172a);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ant-alert-description {
|
||||
color: var(--muted, #64748b);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Result 组件样式 */
|
||||
@@ -161,19 +162,19 @@
|
||||
}
|
||||
|
||||
.ant-result-title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--slate, #0f172a);
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ant-result-subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--muted, #64748b);
|
||||
margin-top: 8px;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.ant-divider {
|
||||
border-color: var(--line, #e2e8f0);
|
||||
margin: 24px 0;
|
||||
border-color: var(--border-color);
|
||||
margin: var(--space-lg) 0;
|
||||
}
|
||||
|
||||
Regular → Executable
+33
-33
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* 账单管理页面样式
|
||||
* P1-3: 全部使用 CSS 变量 + 新增 ToggleSwitch/Spinner 样式
|
||||
* 账单管理页面样式 - V21 CSS变量体系
|
||||
*/
|
||||
@import "../../styles/global.css";
|
||||
|
||||
.xx-billing-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
}
|
||||
|
||||
/* 订阅概览 */
|
||||
@@ -14,22 +14,22 @@
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
padding: var(--space-xl);
|
||||
margin-bottom: var(--space-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.xx-billing-overview h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 20px;
|
||||
margin: 0 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.xx-overview-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -41,19 +41,19 @@
|
||||
.xx-overview-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.xx-label {
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.xx-value {
|
||||
font-size: 16px;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
/* 自动续费 */
|
||||
@@ -61,22 +61,22 @@
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 32px;
|
||||
padding: var(--space-xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.xx-billing-auto-renew h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 20px;
|
||||
margin: 0 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.xx-auto-renew-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.xx-auto-renew-info {
|
||||
@@ -84,17 +84,17 @@
|
||||
}
|
||||
|
||||
.xx-auto-renew-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px;
|
||||
margin: 0 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.xx-auto-renew-desc {
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
/* ── ToggleSwitch 组件 ───────────────────────────────── */
|
||||
@@ -109,7 +109,7 @@
|
||||
border-radius: 12px;
|
||||
background: var(--color-gray-300);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
transition: background var(--transition-fast) ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
border-radius: 50%;
|
||||
background: var(--bg-primary);
|
||||
box-shadow: var(--shadow-xs);
|
||||
transition: transform 0.2s ease;
|
||||
transition: transform var(--transition-fast) ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -169,8 +169,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 40px 0;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-2xl) 0;
|
||||
}
|
||||
|
||||
.xx-spinner-dot {
|
||||
@@ -208,11 +208,11 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.billing-table {
|
||||
.xx-billing-table {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.billing-header {
|
||||
.xx-billing-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
@@ -220,11 +220,11 @@
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.billing-table {
|
||||
font-size: 12px;
|
||||
.xx-billing-table {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.billing-row {
|
||||
.xx-billing-row {
|
||||
padding: var(--space-sm) var(--space-xs);
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+46
-56
@@ -1,26 +1,28 @@
|
||||
/* V21 定价页面 - P1-2/1-5: CSS 变量替换 */
|
||||
/* V21 定价页面 - CSS变量体系 */
|
||||
@import "../../styles/global.css";
|
||||
|
||||
.xx-plans-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 60px 24px;
|
||||
padding: var(--space-3xl) var(--space-lg);
|
||||
}
|
||||
|
||||
/* 页面头部 */
|
||||
.xx-page-head {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
margin-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.xx-page-head h2 {
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
font-size: var(--font-size-5xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 16px;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 var(--space-md);
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.xx-page-head p {
|
||||
font-size: 18px;
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
@@ -32,8 +34,8 @@
|
||||
.xx-plans-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
margin-top: 30px;
|
||||
gap: var(--space-lg);
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@@ -55,11 +57,11 @@
|
||||
.xx-plan-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 28px;
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 32px 28px;
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
transition: var(--transition-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -89,16 +91,12 @@
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-500),
|
||||
var(--primary-color)
|
||||
);
|
||||
background: var(--gradient-primary);
|
||||
color: var(--text-inverse);
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -109,10 +107,10 @@
|
||||
|
||||
/* 套餐名称 */
|
||||
.xx-plan-card h3 {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 20px;
|
||||
margin: 0 0 var(--space-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -123,44 +121,44 @@
|
||||
}
|
||||
|
||||
.xx-plan-price .currency {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-secondary);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.xx-plan-price .amount {
|
||||
font-size: 48px;
|
||||
font-weight: 900;
|
||||
font-size: var(--font-size-5xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--primary-color);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-plan-price .period {
|
||||
font-size: 16px;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* 企业版价格 */
|
||||
.xx-plan-price.custom .amount {
|
||||
font-size: 32px;
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
|
||||
/* 描述 */
|
||||
.xx-plan-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-base);
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 24px;
|
||||
margin-bottom: var(--space-lg);
|
||||
padding-bottom: var(--space-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* 功能列表 */
|
||||
.xx-features {
|
||||
margin-top: 8px;
|
||||
padding-top: 24px;
|
||||
margin-top: var(--space-sm);
|
||||
padding-top: var(--space-lg);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -169,36 +167,32 @@
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
font-size: var(--font-size-base);
|
||||
margin-bottom: var(--space-md);
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.xx-feature span:first-child {
|
||||
color: var(--secondary-color);
|
||||
font-weight: 900;
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 订阅按钮 */
|
||||
.xx-subscribe-btn {
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
padding: 14px 24px !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 850 !important;
|
||||
margin-top: var(--space-lg);
|
||||
padding: var(--space-md) var(--space-lg) !important;
|
||||
font-size: var(--font-size-base) !important;
|
||||
font-weight: var(--font-weight-extrabold) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
transition: all 0.2s !important;
|
||||
transition: var(--transition-fast) !important;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.xx-subscribe-btn.primary {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-500),
|
||||
var(--primary-color)
|
||||
) !important;
|
||||
background: var(--gradient-primary) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border: none !important;
|
||||
box-shadow: var(--shadow-primary) !important;
|
||||
@@ -234,16 +228,12 @@
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.plans-grid {
|
||||
.xx-plans-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
.xx-plan-card {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+32
-30
@@ -1,16 +1,18 @@
|
||||
/* 升级/降级页面 - P1-3: 全部使用 CSS 变量 */
|
||||
/* 升级/降级页面 - V21 CSS变量体系 */
|
||||
@import "../../styles/global.css";
|
||||
|
||||
.xx-upgrade-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
}
|
||||
|
||||
/* 套餐卡片 */
|
||||
.xx-upgrade-plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -22,11 +24,11 @@
|
||||
.xx-upgrade-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 28px 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
transition: var(--transition-base);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -50,24 +52,24 @@
|
||||
.xx-current-badge {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 16px;
|
||||
right: var(--space-md);
|
||||
background: var(--secondary-color);
|
||||
color: var(--text-inverse);
|
||||
padding: 3px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.xx-upgrade-card h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 16px;
|
||||
margin: 0 0 var(--space-md);
|
||||
}
|
||||
|
||||
.xx-price {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ── BillingCycleSwitch 组件 ──────────────────────── */
|
||||
@@ -80,13 +82,13 @@
|
||||
|
||||
.xx-billing-cycle-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
border: none;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -101,10 +103,10 @@
|
||||
|
||||
.xx-save {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
font-size: 11px;
|
||||
margin-left: var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--secondary-color);
|
||||
font-weight: 600;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.xx-billing-cycle-btn.active .xx-save {
|
||||
@@ -118,19 +120,19 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-auto-renew-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.xx-cancel-btn {
|
||||
margin-top: 8px;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ── Spinner 组件 ───────────────────────────────── */
|
||||
@@ -138,8 +140,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 40px 0;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-2xl) 0;
|
||||
}
|
||||
|
||||
.xx-spinner-dot {
|
||||
@@ -177,15 +179,15 @@
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.upgrade-container {
|
||||
.xx-upgrade-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.upgrade-actions {
|
||||
.xx-upgrade-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.upgrade-actions button {
|
||||
.xx-upgrade-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user