Merge develop into main - v0.1.119: V21 UI全局改造 + CI门禁 + E2E修复 + 暗色模式修复

This commit is contained in:
DevOps Bot
2026-07-06 12:39:55 +08:00
67 changed files with 2686 additions and 2190 deletions
+567 -6
View File
@@ -9,11 +9,16 @@ on:
- 'bugfix/**'
- 'hotfix/**'
- 'release/**'
tags:
- 'v*'
pull_request:
branches:
- main
- develop
permissions:
contents: read
jobs:
validate:
name: Validate Code Quality And Tests
@@ -34,7 +39,6 @@ jobs:
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:
@@ -136,9 +140,7 @@ jobs:
shell: sh
run: |
set -eu
# 清理可能残留的旧容器
docker rm -f ci-pg-validate 2>/dev/null || true
# 启动 PG 容器
docker run -d --name ci-pg-validate \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
@@ -149,7 +151,6 @@ jobs:
--health-timeout 5s \
--health-retries 12 \
postgres:16
# 等待健康检查通过
for i in $(seq 1 30); do
if docker inspect --format='{{.State.Health.Status}}' ci-pg-validate 2>/dev/null | grep -q healthy; then
echo "PostgreSQL is ready"
@@ -200,7 +201,6 @@ jobs:
run: |
set -eu
archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
# Retry up to 5 times with backoff for transient 5xx errors
for i in 1 2 3 4 5; do
if wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url" 2>&1; then
break
@@ -255,7 +255,7 @@ jobs:
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npx prettier --check "src/**/*.{ts,tsx,css,md}"'
sh -lc 'npx prettier --check "src/**/*.{ts,tsx,md}"'
- name: Run Vitest tests
shell: sh
@@ -266,3 +266,564 @@ jobs:
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npx vitest run src/test'
deploy-staging:
name: Deploy Staging
runs-on: saas
needs: [validate, frontend-lint]
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
needs: [validate, frontend-lint]
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'
-569
View File
@@ -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
View File
@@ -47,3 +47,4 @@ build/
# Tracker temp files
tracker_tasks.json
frontend-v21-ui-prototype-final.html
+22 -15
View File
@@ -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:
+4 -7
View File
@@ -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"}
+3 -2
View File
@@ -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 -1
View File
@@ -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();
});
});
+80 -80
View File
@@ -19,7 +19,6 @@ const routeBrowserApiToTestApi = async (
});
};
/** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry(
request: any,
email: string,
@@ -46,22 +45,13 @@ type AssetListResponse = {
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 +59,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 +89,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,11 +102,11 @@ 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 = "";
@@ -166,74 +156,87 @@ test.describe("Core generation flow", () => {
// Navigate to generate page
await page.goto("/app/generate");
await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({
timeout: 20_000,
});
// 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({ timeout: 20_000 });
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();
// Fill in title
const titleText = `E2E 生成测试 ${suffix}`;
await page.getByPlaceholder("请输入视频标题").fill(titleText);
// 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();
// Select the uploaded material
await expect(page.locator(".xx-material-card").first()).toBeVisible({
timeout: 15_000,
});
const materialCard = page
.locator(".xx-material-card")
.filter({ hasText: sourceFileName });
await materialCard.click({ force: true });
await expect(materialCard).toHaveClass(/xx-material-card-selected/, {
timeout: 10_000,
});
// 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();
// Click generate
const generateButton = page.getByRole("button", {
name: "开始生成视频",
});
await expect(generateButton).toBeEnabled();
// Step 5: confirm and generate
await expect(
page.getByRole("heading", { name: /确认生成/ }),
).toBeVisible();
// Wait for the generation task to be created
const createTaskResponsePromise = page.waitForResponse(
// 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
const hasProgress = 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 +254,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 -1
View File
@@ -100,7 +100,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();
}
+30
View File
@@ -150,6 +150,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 }) => {
+3 -1
View File
@@ -83,7 +83,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 });
+5 -1
View File
@@ -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);
};
+7 -2
View File
@@ -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);
+18 -4
View File
@@ -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);
}
/* ── 菜单项文字 ───────────────────────────────────────────── */
+3 -1
View File
@@ -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);
};
@@ -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;
+33 -13
View File
@@ -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>
+54 -19
View File
@@ -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 {
+12 -2
View File
@@ -43,14 +43,24 @@ 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: "我的音色",
+2 -2
View File
@@ -21,8 +21,8 @@ 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);
}
/* 滚动条 - V21 样式 */
+2 -2
View File
@@ -36,8 +36,8 @@ const theme = {
// 圆角 - V21 大圆角
borderRadius: 14,
borderRadiusLG: 22,
borderRadiusSM: 10,
borderRadiusLG: 24,
borderRadiusSM: 8,
// 文字色
colorText: "#0f172a",
+58 -56
View File
@@ -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);
}
}
+18 -18
View File
@@ -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;
}
+26 -6
View File
@@ -194,9 +194,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>
);
@@ -668,8 +682,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 +715,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>
+16 -11
View File
@@ -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);
}
/* ============================================================
+171
View File
@@ -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 -13
View File
@@ -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%;
+44 -78
View File
@@ -1,91 +1,57 @@
/* V21 登录页 */
.xx-auth-page {
display: grid;
place-items: center;
min-height: 100vh;
background: linear-gradient(
135deg,
rgba(79, 70, 229, 0.08),
rgba(16, 185, 129, 0.06)
);
padding: 24px;
}
/**
* 登录页样式 — V21 Design System
* 共享样式在 Auth.shared.css,此处仅保留登录页特有样式
*/
@import "./Auth.shared.css";
.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;
/* ── 品牌展示 ─────────────────────────────────────────────── */
.xx-auth-brand {
display: flex;
gap: 10px;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 900;
margin-bottom: 12px;
}
.xx-auth-header p {
color: #64748b;
margin: 0;
font-size: 14px;
.xx-auth-logo {
font-size: 28px;
line-height: 1;
}
.xx-auth-footer {
text-align: center;
font-size: 14px;
color: #64748b;
margin-top: 20px;
.xx-auth-brand-name {
color: var(--text-primary);
}
.xx-auth-footer a {
color: #4f46e5;
/* ── 登录按钮 ───────────────────────────────────────────── */
.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, var(--color-primary-500), var(--color-primary-600));
border: none;
box-shadow: 0 4px 14px
color-mix(in srgb, var(--primary-color) 30%, transparent);
transition: all 0.2s;
}
.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-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);
}
+42 -20
View File
@@ -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>
);
+34 -80
View File
@@ -1,91 +1,45 @@
/* V21 注册页 - 与 Login 保持一致 */
.xx-auth-page {
display: grid;
place-items: center;
min-height: 100vh;
background: linear-gradient(
135deg,
rgba(79, 70, 229, 0.08),
rgba(16, 185, 129, 0.06)
);
padding: 24px;
}
/**
* 注册页样式 — V21 Design System
* 共享样式在 Auth.shared.css,此处仅保留注册页特有样式
*/
@import "./Auth.shared.css";
.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;
/* ── 品牌展示 ─────────────────────────────────────────────── */
.xx-auth-brand {
display: flex;
gap: 10px;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 900;
margin-bottom: 12px;
}
.xx-auth-header p {
color: #64748b;
margin: 0;
font-size: 14px;
.xx-auth-logo {
font-size: 28px;
line-height: 1;
}
.xx-auth-footer {
text-align: center;
font-size: 14px;
color: #64748b;
margin-top: 20px;
.xx-auth-brand-name {
color: var(--text-primary);
}
.xx-auth-footer a {
color: #4f46e5;
text-decoration: none;
font-weight: 850;
/* ── 注册按钮 ───────────────────────────────────────────── */
.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, var(--color-primary-500), var(--color-primary-600));
border: none;
box-shadow: 0 4px 14px
color-mix(in srgb, var(--primary-color) 30%, transparent);
transition: all 0.2s;
}
.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);
}
+12 -8
View File
@@ -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 -13
View File
@@ -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 />,
+11 -38
View File
@@ -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);
}
/* ============================================================
迁移自内联样式的工具类
============================================================ */
+2 -2
View File
@@ -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
+12 -8
View File
@@ -25,7 +25,11 @@ import {
generateFromTemplate,
MODE_LABELS,
} from "@/api/editingPlanner";
import type { EditPlanClip, EditPlanGeneration, MediaAsset } from "@/api/editPlans";
import type {
EditPlanClip,
EditPlanGeneration,
MediaAsset,
} from "@/api/editPlans";
import {
getMediaAssets,
getEditPlanGenerations,
@@ -387,7 +391,9 @@ const EditingPlanner: React.FC = () => {
asset_ids: assetIds,
cover_type: coverType,
});
setCurrentCoverScheme(coverType === "ai_frame" ? "ai_frame" : "ai_reselect");
setCurrentCoverScheme(
coverType === "ai_frame" ? "ai_frame" : "ai_reselect",
);
msg.success("AI 封面生成成功");
} catch {
msg.error("AI 封面生成失败");
@@ -654,8 +660,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 +670,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>
@@ -749,10 +756,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>
@@ -287,7 +287,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 +325,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}>
@@ -375,7 +375,9 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
<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>
<span className="ep-clip-detail-linked">
{selectedClip.assetName}
</span>
</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>
);
+5
View File
@@ -31,6 +31,11 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
}) => {
return (
<div className="ep-left-panel">
{/* 标题栏 */}
<div className="ep-left-header">
<span className="ep-left-title">📋 已保存模板</span>
</div>
{/* 搜索 */}
<div className="ep-search-wrap ep-media-panel-inner">
<span className="ep-search-icon">🔍</span>
@@ -73,9 +73,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
style={{ width: isPlaying ? "45%" : "0%" }}
/>
</div>
<div className="ep-phone-clip-label">
{displayClip.name}
</div>
<div className="ep-phone-clip-label">{displayClip.name}</div>
</>
) : (
<span className="ep-phone-empty-hint">暂无片段</span>
+17 -7
View File
@@ -121,13 +121,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,7 +148,10 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
}}
title="删除最后一个片段"
>
✕ 末尾
↩
</button>
<button className="ep-timeline-action-btn" title="重做">
↪
</button>
</div>
</div>
@@ -181,9 +193,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onDragOver={handleEmptyDragOver}
>
{clips.length === 0 ? (
<div className="ep-track-empty">
🎬 拖入素材或从模板加载片段
</div>
<div className="ep-track-empty">🎬 拖入素材或从模板加载片段</div>
) : (
clips.map((clip, idx) => (
<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) => {
+115 -39
View File
@@ -6,10 +6,7 @@
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import {
Typography,
message,
} from "antd";
import { Typography, message } from "antd";
import {
AudioOutlined,
ThunderboltOutlined,
@@ -110,7 +107,6 @@ const MOCK_TIMELINE: TimelineScene[] = [
{ scene: "用户反馈 · 结尾", time: "20-30s", duration: 10 },
];
/* ── 步骤定义 ── */
const STEPS = [
{ key: 1, label: "选择模板" },
@@ -149,7 +145,9 @@ const GeneratePage: React.FC = () => {
/* ── 配音 ── */
const [selectedVoice, setSelectedVoice] = useState<string>("");
const [playingVoice, setPlayingVoice] = useState<string | null>(null);
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset");
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">(
"preset",
);
const [customVoiceText, setCustomVoiceText] = useState("");
/* ── 克隆声音 ── */
@@ -215,7 +213,9 @@ const GeneratePage: React.FC = () => {
if (plan.name) setTitle(plan.name);
const cfg = plan.config as Record<string, unknown>;
if (cfg && Array.isArray(cfg.asset_ids)) {
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"));
setSelectedMaterials(
cfg.asset_ids.filter((v): v is string => typeof v === "string"),
);
}
} catch (err) {
console.warn("加载剪辑计划配置失败:", err);
@@ -250,7 +250,9 @@ const GeneratePage: React.FC = () => {
}
}, [libraries, selectedLibraryId]);
const { data: materials = [], isLoading: materialsLoading } = useQuery<AssetItem[]>({
const { data: materials = [], isLoading: materialsLoading } = useQuery<
AssetItem[]
>({
queryKey: ["generate-assets", selectedLibraryId],
queryFn: () => getAssets(selectedLibraryId),
enabled: !!selectedLibraryId,
@@ -387,7 +389,8 @@ const GeneratePage: React.FC = () => {
} else if (voiceMode === "custom") {
voiceConfig.voice_id = selectedVoice || undefined;
if (customAudioUrl) voiceConfig.custom_audio_url = customAudioUrl;
if (customVoiceText.trim()) voiceConfig.custom_text = customVoiceText.trim();
if (customVoiceText.trim())
voiceConfig.custom_text = customVoiceText.trim();
}
const plan = await createEditPlan({
@@ -527,7 +530,10 @@ const GeneratePage: React.FC = () => {
}}
>
<span className="xx-choice-check">✓</span>
<div className="xx-choice-thumb" style={{ background: tpl.gradient }}>
<div
className="xx-choice-thumb"
style={{ background: tpl.gradient }}
>
{tpl.abbr}
</div>
<h4>{tpl.label}</h4>
@@ -555,7 +561,14 @@ const GeneratePage: React.FC = () => {
))}
</select>
</div>
<div style={{ marginTop: 14, display: "flex", alignItems: "center", gap: 10 }}>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span className="xx-pill xx-pill-ok">
已选 {selectedMaterials.length} 个素材
</span>
@@ -586,7 +599,9 @@ const GeneratePage: React.FC = () => {
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
background: checked
? "var(--primary-soft, #eef2ff)"
: "#f8fafc",
borderRadius: 10,
cursor: "pointer",
border: checked
@@ -607,10 +622,21 @@ const GeneratePage: React.FC = () => {
}}
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
/>
<span style={{ fontSize: 13, color: "var(--text-primary)", flex: 1 }}>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
}}
>
{m.name}
</span>
<span style={{ fontSize: 11, color: "var(--text-tertiary, #94a3b8)" }}>
<span
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
}}
>
{m.mime_type.split("/")[1].toUpperCase()}
</span>
</label>
@@ -628,10 +654,7 @@ const GeneratePage: React.FC = () => {
<h3>📝 选择标题</h3>
<div className="xx-form-field">
<label>选择标题</label>
<select
value={title}
onChange={(e) => setTitle(e.target.value)}
>
<select value={title} onChange={(e) => setTitle(e.target.value)}>
<option value="">请选择标题…</option>
{TITLE_OPTIONS.map((t) => (
<option key={t} value={t}>
@@ -737,7 +760,14 @@ const GeneratePage: React.FC = () => {
</div>
{/* 试听按钮 */}
{presetVoices.length > 0 && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 8 }}>
<div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
marginTop: 8,
}}
>
{presetVoices.slice(0, 4).map((v) => (
<button
key={v.voice_id}
@@ -746,9 +776,13 @@ const GeneratePage: React.FC = () => {
onClick={() => toggleVoicePlay(v.voice_id, v.preview_url)}
>
{playingVoice === v.voice_id ? (
<><PauseCircleOutlined /> 停止</>
<>
<PauseCircleOutlined /> 停止
</>
) : (
<><PlayCircleOutlined /> {v.name}</>
<>
<PlayCircleOutlined /> {v.name}
</>
)}
</button>
))}
@@ -782,16 +816,29 @@ const GeneratePage: React.FC = () => {
disabled={!customVoiceText.trim() || synthesizeMutation.isPending}
onClick={handleSynthesizeVoice}
>
<AudioOutlined /> {synthesizeMutation.isPending ? "合成中…" : "合成语音"}
<AudioOutlined />{" "}
{synthesizeMutation.isPending ? "合成中…" : "合成语音"}
</button>
</div>
{ttsError && (
<Text style={{ color: "var(--error, #ef4444)", marginTop: 8, display: "block" }}>
<Text
style={{
color: "var(--error, #ef4444)",
marginTop: 8,
display: "block",
}}
>
{ttsError}
</Text>
)}
{customAudioUrl && (
<Text style={{ color: "var(--success, #10b981)", marginTop: 8, display: "block" }}>
<Text
style={{
color: "var(--success, #10b981)",
marginTop: 8,
display: "block",
}}
>
✓ 语音合成完成
</Text>
)}
@@ -802,7 +849,9 @@ const GeneratePage: React.FC = () => {
{voiceMode === "clone" && (
<div className="xx-clone-section">
<p className="xx-clone-section-title">克隆我的声音</p>
<Text style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)" }}>
<Text
style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)" }}
>
上传一段您的语音样本,AI 将克隆您的声音用于视频配音
</Text>
@@ -856,14 +905,21 @@ const GeneratePage: React.FC = () => {
{statusCfg.label}
</span>
{isReady && (
<span style={{ color: "var(--text-secondary)", marginLeft: 8 }}>
<span
style={{
color: "var(--text-secondary)",
marginLeft: 8,
}}
>
{formatDuration(cv.duration_seconds)}
</span>
)}
</div>
</div>
{selected && isReady && (
<CheckCircleFilled style={{ color: "var(--primary-color, #4f46e5)" }} />
<CheckCircleFilled
style={{ color: "var(--primary-color, #4f46e5)" }}
/>
)}
</div>
);
@@ -872,12 +928,26 @@ const GeneratePage: React.FC = () => {
)}
{clonedVoices.length === 0 && (
<Text style={{ color: "var(--text-secondary)", display: "block", textAlign: "center", padding: "16px 0" }}>
<Text
style={{
color: "var(--text-secondary)",
display: "block",
textAlign: "center",
padding: "16px 0",
}}
>
暂无克隆音色,点击「克隆新声音」开始
</Text>
)}
<Text style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)", marginTop: 10, display: "block" }}>
<Text
style={{
fontSize: 12,
color: "var(--text-tertiary, #94a3b8)",
marginTop: 10,
display: "block",
}}
>
💡 提示:录音环境越安静,克隆效果越好。
</Text>
</div>
@@ -896,7 +966,9 @@ const GeneratePage: React.FC = () => {
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">素材</span>
<span className="xx-summary-value">{selectedMaterials.length} 个素材</span>
<span className="xx-summary-value">
{selectedMaterials.length} 个素材
</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">标题</span>
@@ -928,12 +1000,18 @@ const GeneratePage: React.FC = () => {
/** 渲染当前步骤 */
const renderCurrentStep = () => {
switch (currentStep) {
case 1: return renderStep1();
case 2: return renderStep2();
case 3: return renderStep3();
case 4: return renderStep4();
case 5: return renderStep5();
default: return null;
case 1:
return renderStep1();
case 2:
return renderStep2();
case 3:
return renderStep3();
case 4:
return renderStep4();
case 5:
return renderStep5();
default:
return null;
}
};
@@ -992,9 +1070,7 @@ const GeneratePage: React.FC = () => {
role="button"
tabIndex={0}
>
<div className="xx-step-num">
{isDone ? "✓" : step.key}
</div>
<div className="xx-step-num">{isDone ? "✓" : step.key}</div>
<span className="xx-step-label">{step.label}</span>
</div>
</React.Fragment>
+100 -100
View File
@@ -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,19 +408,19 @@
}
.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 {
@@ -457,7 +457,7 @@
.xx-clone-progress {
position: relative;
height: 4px;
background: #f1f5f9;
background: var(--bg-tertiary);
border-radius: 3px;
margin-top: 6px;
overflow: hidden;
@@ -465,7 +465,7 @@
.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,9 +474,9 @@
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;
@@ -492,7 +492,7 @@
right: 0;
top: -14px;
font-size: 10px;
color: #f59e0b;
color: var(--warning-color);
font-weight: 600;
}
@@ -503,9 +503,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,7 +513,7 @@
width: 6px;
height: 6px;
border-radius: 50%;
background: #f59e0b;
background: var(--warning-color);
animation: xx-blink 1.5s ease-in-out infinite;
}
@@ -531,8 +531,8 @@
确认生成(步骤5)摘要
============================================================ */
.xx-summary-card {
background: #f8fafc;
border-radius: 14px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
padding: 16px;
}
@@ -544,11 +544,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 +585,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 +601,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 +612,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 +639,11 @@
.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;
@@ -662,7 +662,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 +678,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 +692,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 +721,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 +740,7 @@
}
.xx-timeline-item span {
color: var(--text-tertiary, #94a3b8);
color: var(--text-tertiary);
margin-left: auto;
font-size: 12px;
}
@@ -769,7 +769,7 @@
.xx-progress-bar {
width: 100%;
height: 6px;
background: #f1f5f9;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
margin-bottom: 8px;
@@ -777,7 +777,7 @@
.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;
}
+40 -39
View File
@@ -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 ────────────────────────────────────────── */
+4 -3
View File
@@ -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>
+46 -44
View File
@@ -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:
@@ -112,7 +114,7 @@
.xx-mv-card--processing {
border-color: color-mix(
in srgb,
var(--accent-color, #f59e0b) 30%,
var(--accent-color) 30%,
transparent
);
}
@@ -120,7 +122,7 @@
.xx-mv-card--failed {
border-color: color-mix(
in srgb,
var(--error-color, #ef4444) 25%,
var(--error-color) 25%,
transparent
);
opacity: 0.85;
@@ -146,7 +148,7 @@
flex-shrink: 0;
background: linear-gradient(
135deg,
var(--secondary-color, #10b981),
var(--secondary-color),
var(--color-secondary-700, #059669)
);
}
@@ -154,7 +156,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 +203,7 @@
gap: 5px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #64748b);
color: var(--text-secondary);
}
.xx-mv-status-dot {
@@ -212,16 +214,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 +244,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 +279,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 +317,7 @@
right: 0;
top: -18px;
font-size: 11px;
color: var(--accent-color, #f59e0b);
color: var(--accent-color);
font-weight: 600;
}
@@ -326,7 +328,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 +348,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 +368,7 @@
.xx-mv-icon-btn--danger:hover {
background: var(--error-soft, #fef2f2);
color: var(--error-color, #ef4444);
color: var(--error-color);
}
/* ── 空状态 ────────────────────────────────────────────── */
@@ -376,18 +378,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 +398,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 +415,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 +428,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 +463,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 {
+24 -24
View File
@@ -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;
+70 -69
View File
@@ -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;
}
+33 -33
View File
@@ -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);
}
}
+46 -56
View File
@@ -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;
}
}
+32 -30
View File
@@ -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%;
}
}
@@ -231,9 +231,7 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon ??
"📋"}
</span>
<span className="xx-template-preview-title">
{template.name}
</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
</div>
@@ -522,7 +520,9 @@ const TemplateLibrary: React.FC = () => {
return (
<div className="xx-templates-page">
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon"><LoadingOutlined /></div>
<div className="xx-templates-empty-icon">
<LoadingOutlined />
</div>
<h3>加载模板中...</h3>
</div>
</div>
@@ -534,7 +534,9 @@ const TemplateLibrary: React.FC = () => {
return (
<div className="xx-templates-page">
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon"><ExclamationCircleOutlined /></div>
<div className="xx-templates-empty-icon">
<ExclamationCircleOutlined />
</div>
<h3>加载失败</h3>
<p>{error?.message || "网络异常,请稍后重试"}</p>
</div>
@@ -562,7 +564,9 @@ const TemplateLibrary: React.FC = () => {
{/* ── 工具栏:搜索 + 类型按钮组 ─────────────────────────── */}
<div className="xx-templates-toolbar">
<div className="xx-templates-search">
<span className="xx-templates-search-icon"><SearchOutlined /></span>
<span className="xx-templates-search-icon">
<SearchOutlined />
</span>
<input
className="xx-templates-search-input"
type="text"
@@ -588,7 +592,9 @@ const TemplateLibrary: React.FC = () => {
{/* ── 模板展示区 ────────────────────────────────────────── */}
{filtered.length === 0 ? (
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon"><InboxOutlined /></div>
<div className="xx-templates-empty-icon">
<InboxOutlined />
</div>
<h3>
{searchText || activeType !== "全部"
? "未找到匹配的模板"
+6 -7
View File
@@ -242,7 +242,7 @@
justify-content: center;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
color: #fff;
color: var(--text-inverse);
font-size: 14px;
font-weight: 500;
opacity: 0;
@@ -265,7 +265,7 @@
border: none;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
color: #fff;
color: var(--text-inverse);
font-size: var(--font-size-md);
cursor: pointer;
display: flex;
@@ -356,7 +356,7 @@
border-radius: var(--radius-md);
border: none;
background: var(--gradient-primary);
color: #fff;
color: var(--text-inverse);
font-size: 12px;
font-weight: 500;
cursor: pointer;
@@ -467,7 +467,7 @@
border: none;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
color: #fff;
color: var(--text-inverse);
font-size: var(--font-size-md);
cursor: pointer;
display: flex;
@@ -665,7 +665,7 @@
.xx-template-modal-fav-btn.is-favorite {
border-color: var(--warning-color);
color: var(--warning-color);
background: #fbbf2418;
background: var(--warning-soft);
}
/* 操作按钮 */
@@ -675,7 +675,6 @@
gap: 12px;
}
/* ============================================================
内联样式迁移类
============================================================ */
@@ -686,7 +685,7 @@
.xx-template-preview-title {
font-size: 18px;
font-weight: 600;
color: #fff;
color: var(--text-inverse);
margin-top: var(--space-sm);
}
+2 -2
View File
@@ -259,7 +259,7 @@
.dark .xx-title-type-tag.hot,
[data-theme="dark"] .xx-title-type-tag.hot {
background: rgba(220, 38, 38, 0.15);
color: #f87171;
color: var(--error-color);
}
.dark .xx-title-type-tag.normal,
@@ -271,7 +271,7 @@
.dark .xx-title-type-tag.creative,
[data-theme="dark"] .xx-title-type-tag.creative {
background: rgba(202, 138, 4, 0.15);
color: #facc15;
color: var(--warning-color);
}
/* 使用次数 & 时间 */
+15 -4
View File
@@ -119,11 +119,15 @@ const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
{/* 元信息 */}
<div className="vc-card-meta">
<div className="vc-card-meta-row">
<span className="vc-card-meta-icon"><SoundOutlined /></span>
<span className="vc-card-meta-icon">
<SoundOutlined />
</span>
<span>时长:{formatDuration(voice.duration_seconds)}</span>
</div>
<div className="vc-card-meta-row">
<span className="vc-card-meta-icon"><CalendarOutlined /></span>
<span className="vc-card-meta-icon">
<CalendarOutlined />
</span>
<span>创建于:{createdDate}</span>
</div>
</div>
@@ -291,7 +295,9 @@ const VoiceClone: React.FC = () => {
{/* 空状态 */}
{!isLoading && voices.length === 0 && (
<div className="vc-empty">
<div className="vc-empty-icon"><AudioOutlined style={{ fontSize: 48 }} /></div>
<div className="vc-empty-icon">
<AudioOutlined style={{ fontSize: 48 }} />
</div>
<h3 className="vc-empty-title">还没有克隆音色</h3>
<p className="vc-empty-desc">上传你的声音,AI将克隆你的专属音色</p>
<Button buttonType="primary" onClick={handleCloneNew}>
@@ -305,7 +311,12 @@ const VoiceClone: React.FC = () => {
<div className="vc-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
{t.type === "success" ? <CheckCircleOutlined /> : <CloseCircleOutlined />} {t.message}
{t.type === "success" ? (
<CheckCircleOutlined />
) : (
<CloseCircleOutlined />
)}{" "}
{t.message}
</div>
))}
</div>
+64 -58
View File
@@ -1,3 +1,5 @@
@import "../../styles/global.css";
/* ═══════════════════════════════════════════════════════════
我的音色页面 — V21 Design System
═══════════════════════════════════════════════════════════ */
@@ -7,7 +9,7 @@
.vc-page {
display: flex;
flex-direction: column;
gap: var(--space-lg, 20px);
gap: var(--space-lg);
}
/* ── 卡片网格 ───────────────────────────────────────────── */
@@ -15,27 +17,27 @@
.vc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-md, 16px);
margin-top: var(--space-xs, 4px);
gap: var(--space-md);
margin-top: var(--space-xs);
}
/* ── 卡片 ───────────────────────────────────────────────── */
.vc-card {
position: relative;
background: var(--bg-card, #fff);
border: 1px solid var(--line, #e2e8f0);
border-radius: var(--radius-lg, 14px);
padding: var(--space-lg, 20px);
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: var(--space-lg);
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.vc-card:hover {
border-color: var(--primary-light, #6366f1);
border-color: var(--primary-light);
box-shadow: 0 4px 20px
color-mix(in srgb, var(--primary-color) 8%, transparent);
color-mix(in srgb, var(--primary-color) 8%);
}
/* 右上角操作按钮 */
@@ -59,9 +61,9 @@
display: grid;
place-items: center;
border: none;
border-radius: var(--radius-sm, 8px);
background: var(--bg-secondary, #f8fafc);
color: var(--muted, #64748b);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--muted);
font-size: 13px;
cursor: pointer;
transition:
@@ -70,23 +72,23 @@
}
.vc-card-action-btn:hover {
background: var(--bg-hover, #f1f5f9);
color: var(--text-primary, #1e293b);
background: var(--bg-hover);
color: var(--text-primary);
}
.vc-card-action-btn:active {
transform: scale(0.9);
background: var(--bg-active, #e2e8f0);
background: var(--bg-active);
}
.vc-card-action-btn--danger:hover {
background: var(--error-bg, #fef2f2);
color: var(--error, #ef4444);
background: var(--error-bg);
color: var(--error);
}
.vc-card-action-btn--danger:active {
transform: scale(0.9);
background: var(--error-bg, #fee2e2);
background: var(--error-bg);
}
/* 卡片头部:头像 + 名称 + 状态 */
@@ -101,7 +103,7 @@
width: 52px;
height: 52px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-color), var(--accent-dark));
background: linear-gradient(135deg, var(--accent-color)));
display: grid;
place-items: center;
font-size: 22px;
@@ -137,7 +139,7 @@
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
color: var(--text-primary, #1e293b);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -149,25 +151,25 @@
align-items: center;
gap: 5px;
padding: 2px 10px;
border-radius: 20px;
border-radius: var(--radius-pill);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
.vc-status-pill--ready {
background: var(--success-bg, #f0fdf4);
color: var(--success, #22c55e);
background: var(--success-bg);
color: var(--success);
}
.vc-status-pill--processing {
background: var(--warning-bg, #fffbeb);
color: var(--warning, #f59e0b);
background: var(--warning-bg);
color: var(--warning);
}
.vc-status-pill--failed {
background: var(--error-bg, #fef2f2);
color: var(--error, #ef4444);
background: var(--error-bg);
color: var(--error);
}
.vc-status-dot {
@@ -198,7 +200,7 @@
gap: 4px;
margin-bottom: 14px;
font-size: 13px;
color: var(--muted, #64748b);
color: var(--muted);
}
.vc-card-meta-row {
@@ -218,8 +220,8 @@
align-items: center;
gap: 10px;
padding: 10px;
background: var(--bg-secondary, #f8fafc);
border-radius: 10px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
.vc-card-footer .xx-btn {
@@ -237,9 +239,9 @@
margin-top: 40px;
text-align: center;
padding: 60px 24px;
background: var(--bg-card, #fff);
border: 1px dashed var(--line, #e2e8f0);
border-radius: var(--radius-lg, 14px);
background: var(--bg-card);
border: 1px dashed var(--line);
border-radius: var(--radius-lg);
}
.vc-empty-icon {
@@ -252,13 +254,13 @@
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary, #1e293b);
color: var(--text-primary);
}
.vc-empty-desc {
margin: 0 0 24px;
font-size: 14px;
color: var(--muted, #64748b);
color: var(--muted);
}
/* ── Toast 提示 ─────────────────────────────────────────── */
@@ -275,12 +277,12 @@
.vc-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(--line, #e2e8f0);
box-shadow: 0 4px 12px var(--shadow-sm, rgba(0, 0, 0, 0.08));
background: var(--bg-card);
border: 1px solid var(--line);
box-shadow: 0 4px 12px var(--shadow-sm));
animation: vc-toast-in 0.25s ease-out;
display: flex;
align-items: center;
@@ -288,13 +290,13 @@
}
.vc-toast--success {
border-color: var(--success, #22c55e);
color: var(--success, #22c55e);
border-color: var(--success);
color: var(--success);
}
.vc-toast--error {
border-color: var(--error, #ef4444);
color: var(--error, #ef4444);
border-color: var(--error);
color: var(--error);
}
@keyframes vc-toast-in {
@@ -316,7 +318,7 @@
z-index: 1000;
display: grid;
place-items: center;
background: var(--overlay-bg, rgba(0, 0, 0, 0.4));
background: var(--overlay-bg));
animation: vc-fade-in 0.2s;
}
@@ -330,12 +332,12 @@
}
.vc-edit-dialog {
background: var(--bg-card, #fff);
border-radius: var(--radius-lg, 14px);
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 24px;
width: 400px;
max-width: calc(100vw - 40px);
box-shadow: 0 20px 60px var(--shadow-lg, rgba(0, 0, 0, 0.15));
box-shadow: 0 20px 60px var(--shadow-lg));
animation: vc-scale-in 0.2s ease-out;
}
@@ -354,24 +356,24 @@
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #1e293b);
color: var(--text-primary);
}
.vc-edit-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--line, #e2e8f0);
border-radius: var(--radius-sm, 8px);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-size: 14px;
color: var(--text-primary, #1e293b);
background: var(--bg-primary, #fff);
color: var(--text-primary);
background: var(--bg-primary);
outline: none;
transition: border-color 0.15s;
margin-bottom: 16px;
}
.vc-edit-input:focus {
border-color: var(--primary, #4f46e5);
border-color: var(--primary);
}
.vc-edit-buttons {
@@ -380,14 +382,13 @@
gap: 10px;
}
/* ── 骨架屏加载 ──────────────────────────────────────────── */
.vc-skeleton-avatar {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--bg-secondary, #f1f5f9);
background: var(--bg-secondary);
animation: vc-skeleton-pulse 1.5s ease-in-out infinite;
flex-shrink: 0;
}
@@ -395,7 +396,7 @@
.vc-skeleton-line {
height: 14px;
border-radius: 4px;
background: var(--bg-secondary, #f1f5f9);
background: var(--bg-secondary);
animation: vc-skeleton-pulse 1.5s ease-in-out infinite;
margin-bottom: 8px;
}
@@ -417,8 +418,13 @@
}
@keyframes vc-skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* ── 编辑弹窗按钮状态 ───────────────────────────────────── */
+7 -70
View File
@@ -189,10 +189,10 @@
/* ── 9. 圆角 ─────────────────────────────────────────────── */
--radius-none: 0;
--radius-xs: 6px;
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 22px;
--radius-xs: 8px;
--radius-sm: 14px;
--radius-md: 18px;
--radius-lg: 24px;
--radius-xl: 28px;
--radius-2xl: 36px;
--radius-full: 9999px;
@@ -283,7 +283,7 @@
/* ============================================================
深色主题
通过 .dark 类或 prefers-color-scheme 激活
通过 .dark 类或 data-theme="dark" 属性手动激活(已禁用系统自动跟随)
============================================================ */
.dark,
[data-theme="dark"] {
@@ -368,71 +368,8 @@
--ant-color-primary: #6366f1;
}
/* 跟随系统深色模式(当未显式设置 data-theme 时生效) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]):not(.light) {
/* 主色 */
--color-primary-50: #2e2b5e;
--color-primary-500: #818cf8;
--color-primary-600: #6366f1;
--color-primary-700: #4f46e5;
--primary-color: var(--color-primary-500);
--primary-hover: var(--color-primary-400, #a5b4fc);
--primary-dark: var(--color-primary-600);
--primary-soft: rgba(99, 102, 241, 0.15);
--gradient-primary: linear-gradient(
135deg,
var(--color-primary-500),
var(--color-primary-600)
);
--secondary-color: var(--color-secondary-400, #34d399);
--accent-color: var(--color-accent-400, #fbbf24);
--success-color: var(--color-secondary-400, #34d399);
--warning-color: var(--color-accent-400, #fbbf24);
--error-color: #f87171;
--info-color: var(--color-primary-500);
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-tertiary: #64748b;
--text-disabled: #475569;
--text-inverse: var(--color-gray-900);
--text-link: var(--color-primary-400, #818cf8);
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--bg-elevated: #1e293b;
--bg-overlay: rgba(0, 0, 0, 0.65);
--bg-glass: rgba(15, 23, 42, 0.8);
--border-color: #334155;
--border-light: #1e293b;
--border-dark: #475569;
--divider-color: #1e293b;
--shadow-xs: 0 4px 12px rgba(0, 0, 0, 0.2);
--shadow-sm: 0 10px 30px rgba(0, 0, 0, 0.3);
--shadow-md: 0 20px 58px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 30px 90px rgba(0, 0, 0, 0.5);
--shadow-card: 0 24px 70px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 18px 34px rgba(99, 102, 241, 0.35);
--shadow-primary: 0 14px 26px rgba(99, 102, 241, 0.3);
--slate: #f1f5f9;
--muted: #94a3b8;
--indigo: #6366f1;
--indigo-soft: rgba(99, 102, 241, 0.15);
--line: #334155;
--bg: #1e293b;
--green: #34d399;
--amber: #fbbf24;
--ant-color-primary: #6366f1;
}
}
/* [已禁用] 跟随系统深色模式 — V21 UI 仅适配浅色主题,强制浅色 */
/* 如需手动深色,仍可通过 .dark 类或 data-theme="dark" 激活 */
/* ========== 全局背景装饰层 ========== */
body {
+98
View File
@@ -934,6 +934,30 @@
"type": "JSON",
"unique": false
},
{
"index": true,
"name": "source_edit_plan_id",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(32)",
"unique": false
},
{
"index": true,
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(32)",
"unique": false
},
{
"index": true,
"name": "created_by_user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(32)",
"unique": false
},
{
"index": false,
"name": "created_at",
@@ -952,6 +976,27 @@
}
],
"indexes": [
{
"columns": [
"created_by_user_id"
],
"name": "ix_edit_plans_created_by_user_id",
"unique": false
},
{
"columns": [
"project_id"
],
"name": "ix_edit_plans_project_id",
"unique": false
},
{
"columns": [
"source_edit_plan_id"
],
"name": "ix_edit_plans_source_edit_plan_id",
"unique": false
},
{
"columns": [
"status"
@@ -1416,6 +1461,14 @@
"type": "VARCHAR(32)",
"unique": false
},
{
"index": true,
"name": "source_edit_plan_id",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(32)",
"unique": false
},
{
"index": false,
"name": "metadata",
@@ -1462,6 +1515,13 @@
"name": "ix_generation_tasks_project_id",
"unique": false
},
{
"columns": [
"source_edit_plan_id"
],
"name": "ix_generation_tasks_source_edit_plan_id",
"unique": false
},
{
"columns": [
"status"
@@ -2833,6 +2893,30 @@
"type": "INTEGER",
"unique": false
},
{
"index": false,
"name": "is_admin",
"nullable": false,
"primary_key": false,
"type": "BOOLEAN",
"unique": false
},
{
"index": true,
"name": "wechat_openid",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(128)",
"unique": true
},
{
"index": true,
"name": "wechat_unionid",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(128)",
"unique": true
},
{
"index": false,
"name": "created_at",
@@ -2856,6 +2940,20 @@
],
"name": "ix_users_username",
"unique": true
},
{
"columns": [
"wechat_openid"
],
"name": "ix_users_wechat_openid",
"unique": true
},
{
"columns": [
"wechat_unionid"
],
"name": "ix_users_wechat_unionid",
"unique": true
}
],
"primary_key": [
-1
View File
@@ -13,7 +13,6 @@ from typing import List, Optional
from pydantic import BaseModel, Field
# ── 枚举类型 ──────────────────────────────────────────────────────────────────
+7 -7
View File
@@ -30,15 +30,15 @@ from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
from packages.domain.entities import User
from app.api.routes.duplication import router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import get_db_session, get_duplication_repository
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_duplication_repository
from app.core.storage import get_storage_service
from app.api.routes.duplication import router
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
from packages.domain.entities import User
# ---------------------------------------------------------------------------
# 1. 内存 Repository + 辅助函数
@@ -30,15 +30,15 @@ from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
from packages.domain.entities import User
from app.api.routes.duplication import _validate_video_mime_type, router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_duplication_repository
from packages.domain.duplication import DuplicationRecord
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_duplication_repository
from app.core.storage import get_storage_service, OSSStorageService
from app.api.routes.duplication import router, _validate_video_mime_type
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
from packages.domain.entities import User
# ---------------------------------------------------------------------------
# 1. Fixtures & Mocks
+6 -12
View File
@@ -30,20 +30,16 @@ from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_user_repository
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
from packages.domain.entities import User
from packages.ports.user_repository import UserRepository
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_user_repository
# ── 导入被测路由模块(从 fixtures 加载简化版路由) ─────────────────────────────
_fixture_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "fixtures", "subscription_routes.py"
)
_spec = importlib.util.spec_from_file_location(
"app.api.routes.subscription", _fixture_path
)
_fixture_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures", "subscription_routes.py")
_spec = importlib.util.spec_from_file_location("app.api.routes.subscription", _fixture_path)
subscription = importlib.util.module_from_spec(_spec)
sys.modules["app.api.routes.subscription"] = subscription
_spec.loader.exec_module(subscription)
@@ -376,9 +372,7 @@ class TestCancelSubscription:
)
app = FastAPI()
app.include_router(subscription.router)
app.dependency_overrides[subscription.get_current_user] = lambda: AuthenticatedUser(
user=original_user
)
app.dependency_overrides[subscription.get_current_user] = lambda: AuthenticatedUser(user=original_user)
app.dependency_overrides[subscription.get_user_repository] = lambda: mock_user_repo
tc = TestClient(app)
@@ -315,6 +315,7 @@ class StubEditPlanRepository:
def _make_auth_user():
from app.auth import AuthenticatedUser
from packages.domain.entities import User
user = User(id="user-001", email="test@example.com", display_name="测试用户")
+11 -11
View File
@@ -14,12 +14,12 @@ from unittest.mock import MagicMock
import pytest
from packages.adapters.sqlalchemy_impl.generation_task_repository import SQLAlchemyGenerationTaskRepository
from packages.adapters.sqlalchemy_impl.tts_job_repository import SQLAlchemyTTSJobRepository
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
from packages.adapters.sqlalchemy_impl.generation_task_repository import SQLAlchemyGenerationTaskRepository
from packages.domain.generation_task import GenerationTaskStatus
from packages.domain.tts_job import TTSJobStatus
from packages.domain.voice_clone_profile import VoiceCloneStatus
from packages.domain.generation_task import GenerationTaskStatus
def _make_tts_model(status: str = "pending"):
@@ -105,9 +105,9 @@ class TestTTSJobStatusDeserialization:
model = _make_tts_model(status=status_str)
entity = SQLAlchemyTTSJobRepository._model_to_entity(model)
assert isinstance(entity.status, TTSJobStatus), (
f"status 应该是 TTSJobStatus 枚举,实际是 {type(entity.status).__name__}"
)
assert isinstance(
entity.status, TTSJobStatus
), f"status 应该是 TTSJobStatus 枚举,实际是 {type(entity.status).__name__}"
assert entity.status.value == status_str
def test_status_value_attribute_works(self) -> None:
@@ -136,9 +136,9 @@ class TestVoiceCloneStatusDeserialization:
model = _make_voice_clone_model(status=status_str)
entity = SQLAlchemyVoiceCloneProfileRepository._model_to_entity(model)
assert isinstance(entity.status, VoiceCloneStatus), (
f"status 应该是 VoiceCloneStatus 枚举,实际是 {type(entity.status).__name__}"
)
assert isinstance(
entity.status, VoiceCloneStatus
), f"status 应该是 VoiceCloneStatus 枚举,实际是 {type(entity.status).__name__}"
assert entity.status.value == status_str
def test_status_value_attribute_works(self) -> None:
@@ -162,9 +162,9 @@ class TestGenerationTaskStatusDeserialization:
entity = _to_domain(model)
assert isinstance(entity.status, GenerationTaskStatus), (
f"status 应该是 GenerationTaskStatus 枚举,实际是 {type(entity.status).__name__}"
)
assert isinstance(
entity.status, GenerationTaskStatus
), f"status 应该是 GenerationTaskStatus 枚举,实际是 {type(entity.status).__name__}"
assert entity.status.value == status_str
def test_status_value_attribute_works(self) -> None: