diff --git a/.env.production.example b/.env.production.example index 8cd73b8c1..833bbbdc8 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,30 +1,54 @@ # 生产环境配置模板(实际使用时复制为 .env.production) + +# ==================== 基础配置 ==================== +APP_ENV=production ENVIRONMENT=production DEBUG=false USE_IN_MEMORY_DB=false LOG_LEVEL=WARNING -# 数据库(必须修改) +# ==================== 数据库(必须修改)==================== DATABASE_URL=postgresql://prod_user:CHANGE_THIS_PASSWORD@db-prod:5432/xiaoxia_prod -# Redis(必须修改) +# ==================== Redis(必须修改)==================== REDIS_URL=redis://:CHANGE_THIS_PASSWORD@redis-prod:6379/0 +ENABLE_REDIS_SESSIONS=false -# JWT(必须修改,至少 32 字符) +# ==================== JWT(必须修改,至少 32 字符)==================== JWT_SECRET_KEY=CHANGE_THIS_TO_A_RANDOM_SECRET_KEY_AT_LEAST_32_CHARS -# SMTP(必须配置) +# ==================== 邮件(必须配置)==================== +ENABLE_EMAIL_DELIVERY=false SMTP_HOST=smtp.gmail.com SMTP_PORT=587 -SMTP_USER=your-email@gmail.com -SMTP_PASSWORD=your-app-specific-password +SMTP_USER=CHANGE_ME_SMTP_USER +SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD SMTP_FROM_EMAIL=noreply@yourdomain.com +SMTP_FROM_NAME=小虾 SaaS +SMTP_USE_TLS=true -# 应用配置 -BASE_URL=https://yourdomain.com +# ==================== 应用配置 ==================== +APP_BASE_URL=https://yourdomain.com -# CORS(修改为实际域名) -CORS_ORIGINS=["https://yourdomain.com","https://app.yourdomain.com"] +# ==================== CORS(修改为实际域名,逗号分隔)==================== +CORS_ORIGINS_RAW=https://yourdomain.com,https://app.yourdomain.com -# 监控(可选) -SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id +# ==================== 阿里云 OSS(必须配置)==================== +OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +OSS_ACCESS_KEY_ID=CHANGE_ME_ACCESS_KEY_ID +OSS_ACCESS_KEY_SECRET=CHANGE_ME_ACCESS_KEY_SECRET +OSS_BUCKET_NAME=xiaoxia-autocut +OSS_DIRECT_UPLOAD_MAX_MB=2000 +OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900 + +# ==================== 生成文件 ==================== +GENERATED_FILES_DIR=/app/generated +GENERATED_FILES_URL_PREFIX=/generated-files +PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com + +# ==================== Celery ==================== +CELERY_BROKER_URL=redis://:CHANGE_THIS_PASSWORD@redis-prod:6379/0 +CELERY_RESULT_BACKEND=redis://:CHANGE_THIS_PASSWORD@redis-prod:6379/1 + +# ==================== 监控(可选)==================== +# SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..39b8aef16 --- /dev/null +++ b/.flake8 @@ -0,0 +1,13 @@ +[flake8] +max-line-length = 120 +exclude = + .git, + __pycache__, + .venv, + venv, + node_modules, + alembic + +per-file-ignores = + tests/integration/*:F821 + tests/unit/*:F821 diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 54b5fff27..699d8448f 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -1,6 +1,14 @@ name: CI/CD Pipeline on: + push: + branches: + - main + - develop + - 'feature/**' + - 'bugfix/**' + - 'hotfix/**' + - 'release/**' pull_request: branches: - main @@ -9,15 +17,17 @@ on: jobs: validate: name: Validate Code Quality And Tests - runs-on: runtime-builder - container: localhost:5000/xiaoxia-ci-python:3.12 + runs-on: ubuntu-22.04 + steps: - name: Checkout code shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} run: | set -eu - python - <<'PY' + python3 - <<'PY' import io, os, tarfile, urllib.request 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']}"}) @@ -40,10 +50,10 @@ jobs: run: | set -eu python --version - python -m pip --version - python -m black --version - python -m isort --version-number - python -m flake8 --version + python3 -m pip --version + python3 -m black --version + python3 -m isort --version-number + python3 -m flake8 --version bandit --version pytest --version echo "CI environment is ready" @@ -52,16 +62,16 @@ jobs: shell: sh run: | set -eu - python -m compileall -q alembic apps packages tests scripts - python -m black --check alembic apps packages tests scripts - python -m isort --check-only alembic apps packages tests scripts - python -m flake8 apps packages tests --count --statistics + python3 -m compileall -q alembic apps packages tests scripts + python3 -m black --check alembic apps packages tests scripts + python3 -m isort --check-only alembic apps packages tests scripts + python3 -m flake8 apps packages tests --count --statistics - name: Run security scan shell: sh run: | set -eu - bandit -r apps packages -q + bandit -r apps packages -q -ll - name: Validate release scripts syntax shell: sh @@ -76,16 +86,16 @@ jobs: run: | set -eu DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas \ - python -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql + python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql test -s /tmp/alembic-upgrade.sql grep -q "Running upgrade" /tmp/alembic-upgrade.sql - python scripts/check_schema_metadata.py + python3 scripts/check_schema_metadata.py - name: Run tests shell: sh run: | set -eu - PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/unit -q - name: Build summary if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main' @@ -98,7 +108,7 @@ jobs: frontend-lint: name: Frontend Lint - runs-on: runtime-builder + runs-on: ubuntu-22.04 steps: - name: Checkout code @@ -120,7 +130,7 @@ jobs: --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ - node:20 \ + docker.m.daocloud.io/library/node:20 \ sh -lc 'npm ci' - name: Run ESLint @@ -131,8 +141,8 @@ jobs: --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ - node:20 \ - sh -lc 'npx eslint src --ext .ts,.tsx --max-warnings 0' + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npx eslint src --ext .ts,.tsx --max-warnings 50' - name: Run TypeScript type check shell: sh @@ -142,7 +152,7 @@ jobs: --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ - node:20 \ + docker.m.daocloud.io/library/node:20 \ sh -lc 'npx tsc --noEmit' - name: Run Prettier check @@ -153,7 +163,7 @@ jobs: --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ - node:20 \ + docker.m.daocloud.io/library/node:20 \ sh -lc 'npx prettier --check "src/**/*.{ts,tsx,css,md}"' - name: Run Vitest tests @@ -164,5 +174,5 @@ jobs: --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ - node:20 \ - sh -lc 'npx vitest run' + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npx vitest run src/test' diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index cba453c46..81b4bca63 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -2,13 +2,14 @@ name: Deploy on: push: + branches: [ main, develop, "feature/**" ] tags: - 'v*' jobs: deploy-staging: name: Deploy Staging - runs-on: runtime-builder + runs-on: ubuntu-22.04 if: github.ref_name == 'main' || github.ref_name == 'develop' || startsWith(github.ref_name, 'feature/') steps: @@ -18,10 +19,30 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | set -eu - archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" - wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url" - tar -xzf /tmp/repo.tar.gz --strip-components=1 -C . - rm -f /tmp/repo.tar.gz + python3 - <<'PY' + import io, os, tarfile, urllib.request + 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']}"}) + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + 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 @@ -31,7 +52,7 @@ jobs: --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ - node:20 \ + docker.m.daocloud.io/library/node:20 \ sh -lc 'npm ci && npm run build' docker build --pull=false \ -f infra/docker/web-artifact.Dockerfile \ @@ -39,6 +60,23 @@ jobs: . test -f apps/web/dist/index.html + + - name: Build and push staging API/Worker images + shell: sh + run: | + set -eu + REGISTRY="172.30.18.198:5000" + docker build --pull=false \ + -f infra/docker/api.Dockerfile \ + -t "${REGISTRY}/xiaoxia-saas-api:dev" \ + . + docker build --pull=false \ + -f infra/docker/worker.Dockerfile \ + -t "${REGISTRY}/xiaoxia-saas-worker:dev" \ + . + docker push "${REGISTRY}/xiaoxia-saas-api:dev" + docker push "${REGISTRY}/xiaoxia-saas-worker:dev" + - name: Package staging release artifact shell: sh run: | @@ -58,13 +96,21 @@ jobs: run: | set -eu staging_host="${STAGING_SSH_HOST:-47.98.113.167}" - [ -z "$staging_host" ] && staging_host="47.98.113.167" staging_user="${STAGING_SSH_USER:-root}" mkdir -p ~/.ssh - key_path="/root/.ssh/xiaoxia_runtime_builder" - echo "Using SSH key: $key_path" - ls -la "$key_path" - test -f "$key_path" || { echo "ERROR: SSH key not found"; exit 1; } + # 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" \ @@ -81,17 +127,21 @@ jobs: run: | set -eu staging_host="${STAGING_SSH_HOST:-47.98.113.167}" - [ -z "$staging_host" ] && staging_host="47.98.113.167" staging_user="${STAGING_SSH_USER:-root}" - key_path="/root/.ssh/xiaoxia_runtime_builder" - echo "Using SSH key: $key_path" - ls -la "$key_path" - test -f "$key_path" || { echo "ERROR: SSH key not found"; exit 1; } + 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 'c2V0IC1ldQphcnRpZmFjdD0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvYXJ0aWZhY3RzL3hpYW94aWEtc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhci5neiIKaW1hZ2VfdGFyPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9hcnRpZmFjdHMveGlhb3hpYS13ZWItc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhciIKdGVzdCAtZiAiJGFydGlmYWN0Igp0ZXN0IC1mICIkaW1hZ2VfdGFyIgp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYKZG9ja2VyIGxvYWQgLWkgIiRpbWFnZV90YXIiCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8KdGFyIC14emYgIiRhcnRpZmFjdCIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby8uZW52CmNobW9kICt4IC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vaW5mcmEvZG9ja2VyL2RlcGxveS1zdGFnaW5nLnNoClJFR0lTVFJZPSIxNzIuMzAuMTguMTk4OjUwMDAiIEFQSV9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLWFwaTpkZXYiIFdPUktFUl9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLXdvcmtlcjpkZXYiIFdFQl9JTUFHRT0ieGlhb3hpYS1zYWFzLXdlYjpzdGFnaW5nLSR7R0lUSFVCX1NIQX0iIEhPU1RfUFJFRklYPSBXRUJfUE9SVD0zMDAxIFJFQlVJTERfQkFDS0VORD0wIEJVSUxEX1dFQj0wIFJVTl9NSUdSQVRJT05TPTAgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXN0YWdpbmcuc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$staging_user@$staging_host" "GITHUB_SHA='${GITHUB_SHA}' sh" build-production-runtime-images: name: Build Production Runtime Images - runs-on: runtime-builder - if: github.ref_type == 'tag' + runs-on: ubuntu-22.04 + if: startsWith(github.ref, 'refs/tags/v') steps: - name: Checkout code @@ -100,10 +150,23 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | set -eu - archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" - wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url" - tar -xzf /tmp/repo.tar.gz --strip-components=1 -C . - rm -f /tmp/repo.tar.gz + python3 - <<'PY' + import io, os, tarfile, urllib.request + 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']}"}) + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + 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 @@ -120,7 +183,7 @@ jobs: --pull=never \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ - node:20 \ + docker.m.daocloud.io/library/node:20 \ sh -lc 'npm ci && npm run build' docker build --pull=false \ -f infra/docker/web-artifact.Dockerfile \ @@ -146,15 +209,22 @@ jobs: run: | set -eu production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}" - [ -z "$production_host" ] && production_host="47.98.113.167" production_user="${PRODUCTION_SSH_USER:-root}" mkdir -p ~/.ssh - key_path="/root/.ssh/xiaoxia_runtime_builder" - echo "Using SSH key: $key_path" - ls -la "$key_path" - test -f "$key_path" || { echo "ERROR: SSH key not found"; exit 1; } + # 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 - ssh -i "$key_path" -o BatchMode=yes -o StrictHostKeyChecking=no "$production_user@$production_host" "echo SSH_OK" || { echo "SSH FAILED"; exit 1; } 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" \ @@ -179,10 +249,18 @@ jobs: deploy-production: name: Deploy Production - runs-on: runtime-builder + runs-on: ubuntu-22.04 + 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: @@ -192,19 +270,28 @@ jobs: run: | set -eu production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}" - [ -z "$production_host" ] && production_host="47.98.113.167" production_user="${PRODUCTION_SSH_USER:-root}" mkdir -p ~/.ssh - key_path="/root/.ssh/xiaoxia_runtime_builder" - echo "Using SSH key: $key_path" - ls -la "$key_path" - test -f "$key_path" || { echo "ERROR: SSH key not found"; exit 1; } + # 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+L2Rldi9udWxsIHx8IHRydWUKZmkKaWYgWyAtZCAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvL2FwcHMvd2ViL2Rpc3QvYXNzZXRzIF07IHRoZW4KICBjcCAtYSAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvL2FwcHMvd2ViL2Rpc3QvYXNzZXRzLy4gIiRvbGRfYXNzZXRzX2RpciIvCmZpCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8KdGFyIC14emYgIiRyZWxlYXNlX3RhciIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmlmIFsgLWQgIiRvbGRfYXNzZXRzX2RpciIgXTsgdGhlbgogIG1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMKICBmb3IgYXNzZXQgaW4gIiRvbGRfYXNzZXRzX2RpciIvKjsgZG8KICAgIFsgLWUgIiRhc3NldCIgXSB8fCBjb250aW51ZQogICAgbmFtZT0iJChiYXNlbmFtZSAiJGFzc2V0IikiCiAgICBpZiBbICEgLWUgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvJG5hbWUiIF07IHRoZW4KICAgICAgY3AgLWEgIiRhc3NldCIgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvJG5hbWUiCiAgICBmaQogIGRvbmUKICBybSAtcmYgIiRvbGRfYXNzZXRzX2RpciIKZmkKdGVzdCAtZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi8uZW52CmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby8uZW52CkhPU1RfUFJFRklYPSBXRUJfSU1BR0U9InhpYW94aWEtc2Fhcy13ZWI6JHtSRUxFQVNFX1ZFUlNJT059IiBXRUJfSU1BR0VfVEFSPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi93ZWItJHtSRUxFQVNFX1ZFUlNJT059LnRhciIgc2ggL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXByb2R1Y3Rpb24uc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAxL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$production_user@$production_host" "RELEASE_VERSION='${GITHUB_REF_NAME}' sh" production-e2e: name: Production Browser E2E - runs-on: runtime-builder + runs-on: ubuntu-22.04 + if: startsWith(github.ref, 'refs/tags/v') needs: deploy-production steps: @@ -214,10 +301,23 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | set -eu - archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" - wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url" - tar -xzf /tmp/repo.tar.gz --strip-components=1 -C . - rm -f /tmp/repo.tar.gz + python3 - <<'PY' + import io, os, tarfile, urllib.request + 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']}"}) + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + 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 @@ -231,4 +331,3 @@ jobs: -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' - diff --git a/.gitea/workflows/test-ssh-secret.yml b/.gitea/workflows/test-ssh-secret.yml new file mode 100644 index 000000000..c457a7aec --- /dev/null +++ b/.gitea/workflows/test-ssh-secret.yml @@ -0,0 +1,69 @@ +name: Test SSH Secret +on: + push: + branches: [develop] + paths: + - '.gitea/workflows/test-ssh-secret.yml' + +jobs: + test-ssh: + runs-on: ubuntu-22.04 + steps: + - name: Install SSH client + run: | + which ssh || (apt-get update && apt-get install -y openssh-client) + ssh -V + + - name: Debug environment + run: | + echo "=== Environment ===" + echo "Runner hostname: $(hostname)" + echo "Runner IP: $(hostname -i || echo 'unknown')" + echo "Current user: $(whoami)" + echo "=== Secrets check ===" + if [ -n "$STAGING_SSH_HOST" ]; then + echo "STAGING_SSH_HOST: [SET] value_length=${#STAGING_SSH_HOST}" + else + echo "STAGING_SSH_HOST: [EMPTY]" + fi + if [ -n "$STAGING_SSH_USER" ]; then + echo "STAGING_SSH_USER: [SET] value_length=${#STAGING_SSH_USER}" + else + echo "STAGING_SSH_USER: [EMPTY]" + fi + if [ -n "$STAGING_SSH_KEY" ]; then + echo "STAGING_SSH_KEY: [SET] value_length=${#STAGING_SSH_KEY}" + else + echo "STAGING_SSH_KEY: [EMPTY]" + fi + env: + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + + - name: Setup SSH key + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + echo "$STAGING_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub 2>/dev/null || echo "No public key generated" + echo "=== SSH Key fingerprint ===" + ssh-keygen -lf ~/.ssh/id_ed25519 || echo "Key fingerprint failed" + env: + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + + - name: Test SSH connection + run: | + echo "Attempting SSH connection to $STAGING_SSH_HOST..." + ssh -i ~/.ssh/id_ed25519 \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o BatchMode=yes \ + -v \ + $STAGING_SSH_USER@$STAGING_SSH_HOST "echo 'SSH_CONNECTION_SUCCESS' && hostname && whoami" + echo "=== SSH Test Complete ===" + env: + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} diff --git a/.gitignore b/.gitignore index cc8c9de84..10838b11b 100644 --- a/.gitignore +++ b/.gitignore @@ -47,5 +47,3 @@ build/ # Tracker temp files tracker_tasks.json -# Schema metadata snapshot -schema-metadata-snapshot.json diff --git a/alembic/versions/007_add_editing_mode.py b/alembic/versions/007_add_editing_mode.py index 6655a6783..e71aacd6e 100755 --- a/alembic/versions/007_add_editing_mode.py +++ b/alembic/versions/007_add_editing_mode.py @@ -4,25 +4,26 @@ Revision ID: 007 Revises: 006 Create Date: 2026-06-26 """ -from alembic import op + import sqlalchemy as sa +from alembic import op + # revision identifiers -revision = '007' -down_revision = '006' +revision = "007" +down_revision = "006" branch_labels = None depends_on = None def upgrade() -> None: op.add_column( - 'generation_tasks', - sa.Column('editing_mode', sa.String(20), nullable=False, server_default='one_take') + "generation_tasks", sa.Column("editing_mode", sa.String(20), nullable=False, server_default="one_take") ) # 添加索引以支持查询 - op.create_index('ix_generation_tasks_editing_mode', 'generation_tasks', ['editing_mode']) + op.create_index("ix_generation_tasks_editing_mode", "generation_tasks", ["editing_mode"]) def downgrade() -> None: - op.drop_index('ix_generation_tasks_editing_mode', table_name='generation_tasks') - op.drop_column('generation_tasks', 'editing_mode') + op.drop_index("ix_generation_tasks_editing_mode", table_name="generation_tasks") + op.drop_column("generation_tasks", "editing_mode") diff --git a/alembic/versions/008_add_video_dedup_fields.py b/alembic/versions/008_add_video_dedup_fields.py index e304cff44..45b468615 100644 --- a/alembic/versions/008_add_video_dedup_fields.py +++ b/alembic/versions/008_add_video_dedup_fields.py @@ -4,6 +4,7 @@ Revision ID: 008 Revises: 007 Create Date: 2024-06-26 """ + import sqlalchemy as sa from alembic import op @@ -16,20 +17,11 @@ depends_on = None def upgrade() -> None: # Add video_fingerprint column as JSON text - op.add_column( - "generated_videos", - sa.Column("video_fingerprint", sa.Text(), nullable=True) - ) + op.add_column("generated_videos", sa.Column("video_fingerprint", sa.Text(), nullable=True)) # Add is_duplicate column - op.add_column( - "generated_videos", - sa.Column("is_duplicate", sa.Boolean(), nullable=False, server_default="false") - ) + op.add_column("generated_videos", sa.Column("is_duplicate", sa.Boolean(), nullable=False, server_default="false")) # Add duplicate_of column for tracking original video - op.add_column( - "generated_videos", - sa.Column("duplicate_of", sa.String(32), nullable=True) - ) + op.add_column("generated_videos", sa.Column("duplicate_of", sa.String(32), nullable=True)) def downgrade() -> None: diff --git a/alembic/versions/009_remove_workspace_concept.py b/alembic/versions/009_remove_workspace_concept.py index c490752c5..9e1c04d13 100644 --- a/alembic/versions/009_remove_workspace_concept.py +++ b/alembic/versions/009_remove_workspace_concept.py @@ -11,10 +11,12 @@ This migration: 4. Removes workspace_id from all tables that had it 5. Drops workspace-related tables: workspaces, workspace_members, workspace_invitations """ -from alembic import op + import sqlalchemy as sa from sqlalchemy import text +from alembic import op + # revision identifiers revision = "009" down_revision = "008" @@ -24,7 +26,7 @@ depends_on = None def upgrade() -> None: conn = op.get_bind() - + # Step 1: Add subscription/quota fields to users table conn.execute(text(""" ALTER TABLE users @@ -50,7 +52,7 @@ def upgrade() -> None: ALTER TABLE users ADD COLUMN IF NOT EXISTS used_storage_gb FLOAT NOT NULL DEFAULT 0 """)) - + # Step 2: Copy subscription data from workspaces to users conn.execute(text(""" UPDATE users SET @@ -63,7 +65,7 @@ def upgrade() -> None: FROM workspaces w WHERE w.owner_user_id = users.id """)) - + # Step 3: Add owner_user_id and shared_users to projects table conn.execute(text(""" ALTER TABLE projects @@ -73,7 +75,7 @@ def upgrade() -> None: ALTER TABLE projects ADD COLUMN IF NOT EXISTS shared_users JSON """)) - + # Step 4: Migrate workspace_id to owner_user_id (from workspace_members where role=owner) conn.execute(text(""" UPDATE projects SET @@ -82,13 +84,13 @@ def upgrade() -> None: WHERE wm.workspace_id = projects.workspace_id AND wm.role = 'owner' """)) - + # Set shared_users to empty array for all projects conn.execute(text(""" UPDATE projects SET shared_users = '[]'::json WHERE shared_users IS NULL """)) - + # Step 5: Remove workspace_id from all tables tables_with_workspace_id = [ "asset_libraries", @@ -104,12 +106,12 @@ def upgrade() -> None: "tasks", "task_issues", ] - + for table in tables_with_workspace_id: conn.execute(text(f""" ALTER TABLE {table} DROP COLUMN IF EXISTS workspace_id """)) - + # Step 6: Drop workspace-related tables conn.execute(text(""" DROP TABLE IF EXISTS workspace_invitations @@ -120,7 +122,7 @@ def upgrade() -> None: conn.execute(text(""" DROP TABLE IF EXISTS workspaces """)) - + # Step 7: Drop workspace_id from projects table conn.execute(text(""" ALTER TABLE projects DROP COLUMN IF EXISTS workspace_id @@ -129,7 +131,7 @@ def upgrade() -> None: def downgrade() -> None: conn = op.get_bind() - + # Add back workspace tables (simplified - in real scenario would need full recreation) conn.execute(text(""" CREATE TABLE IF NOT EXISTS workspaces ( @@ -145,7 +147,7 @@ def downgrade() -> None: created_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) - + conn.execute(text(""" CREATE TABLE IF NOT EXISTS workspace_members ( id VARCHAR(36) PRIMARY KEY, @@ -157,7 +159,7 @@ def downgrade() -> None: UNIQUE(workspace_id, user_id) ) """)) - + conn.execute(text(""" CREATE TABLE IF NOT EXISTS workspace_invitations ( id VARCHAR(36) PRIMARY KEY, @@ -172,12 +174,12 @@ def downgrade() -> None: created_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) - + # Add back workspace_id column to projects conn.execute(text(""" ALTER TABLE projects ADD COLUMN workspace_id VARCHAR(32) """)) - + # Add back workspace_id columns to other tables tables_with_workspace_id = [ "asset_libraries", @@ -193,11 +195,11 @@ def downgrade() -> None: "tasks", "task_issues", ] - + for table in tables_with_workspace_id: conn.execute(text(f""" ALTER TABLE {table} ADD COLUMN workspace_id VARCHAR(36) """)) - + # Note: This downgrade is incomplete - projects.owner_user_id data would need to be # converted back to workspace_ids, which requires reconstructing workspace records. diff --git a/alembic/versions/010_phase0_extensibility.py b/alembic/versions/010_phase0_extensibility.py index 512e058d4..c70dbbafa 100644 --- a/alembic/versions/010_phase0_extensibility.py +++ b/alembic/versions/010_phase0_extensibility.py @@ -10,9 +10,11 @@ This migration: 2. Creates title_libraries table (独立标题库,支持跨项目复用) 3. Creates voice_libraries table (配音库,支持 AI 配音管理) """ -from alembic import op + import sqlalchemy as sa +from alembic import op + # revision identifiers revision = "010" down_revision = "009" @@ -25,21 +27,11 @@ def upgrade() -> None: # ── 1. Add metadata JSONB to existing tables ── - conn.execute(sa.text( - "ALTER TABLE projects ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'" - )) - conn.execute(sa.text( - "ALTER TABLE asset_libraries ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'" - )) - conn.execute(sa.text( - "ALTER TABLE assets ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'" - )) - conn.execute(sa.text( - "ALTER TABLE edit_templates ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'" - )) - conn.execute(sa.text( - "ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'" - )) + conn.execute(sa.text("ALTER TABLE projects ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'")) + conn.execute(sa.text("ALTER TABLE asset_libraries ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'")) + conn.execute(sa.text("ALTER TABLE assets ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'")) + conn.execute(sa.text("ALTER TABLE edit_templates ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'")) + conn.execute(sa.text("ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'")) # ── 2. Create title_libraries table ── @@ -59,15 +51,9 @@ def upgrade() -> None: updated_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_title_libraries_user_id ON title_libraries(user_id)" - )) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_title_libraries_category ON title_libraries(category)" - )) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_title_libraries_is_active ON title_libraries(is_active)" - )) + conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_title_libraries_user_id ON title_libraries(user_id)")) + conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_title_libraries_category ON title_libraries(category)")) + conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_title_libraries_is_active ON title_libraries(is_active)")) # ── 3. Create voice_libraries table ── @@ -91,15 +77,9 @@ def upgrade() -> None: updated_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_voice_libraries_user_id ON voice_libraries(user_id)" - )) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_voice_libraries_project_id ON voice_libraries(project_id)" - )) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_voice_libraries_status ON voice_libraries(status)" - )) + conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_voice_libraries_user_id ON voice_libraries(user_id)")) + conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_voice_libraries_project_id ON voice_libraries(project_id)")) + conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_voice_libraries_status ON voice_libraries(status)")) def downgrade() -> None: diff --git a/alembic/versions/011_phase1_core_refactor.py b/alembic/versions/011_phase1_core_refactor.py index 52e64c875..ad71c6bb9 100644 --- a/alembic/versions/011_phase1_core_refactor.py +++ b/alembic/versions/011_phase1_core_refactor.py @@ -14,9 +14,11 @@ This migration: - edit_plan_clips (编辑计划片段) 2. Removes edit_plan_id column from generation_tasks table """ -from alembic import op + import sqlalchemy as sa +from alembic import op + # revision identifiers revision = "011" down_revision = "010" @@ -39,9 +41,7 @@ def upgrade() -> None: # ── 2. Remove edit_plan_id from generation_tasks ── - conn.execute(sa.text( - "ALTER TABLE generation_tasks DROP COLUMN IF EXISTS edit_plan_id" - )) + conn.execute(sa.text("ALTER TABLE generation_tasks DROP COLUMN IF EXISTS edit_plan_id")) def downgrade() -> None: @@ -49,9 +49,7 @@ def downgrade() -> None: # ── 1. Re-add edit_plan_id to generation_tasks ── - conn.execute(sa.text( - "ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS edit_plan_id VARCHAR(32)" - )) + conn.execute(sa.text("ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS edit_plan_id VARCHAR(32)")) # ── 2. Recreate deprecated tables (basic structure) ── diff --git a/alembic/versions/012_add_duplication_tables.py b/alembic/versions/012_add_duplication_tables.py index 8940b0e7c..696a6cff2 100644 --- a/alembic/versions/012_add_duplication_tables.py +++ b/alembic/versions/012_add_duplication_tables.py @@ -8,9 +8,11 @@ This migration creates two new tables: 1. duplication_records — 查重记录主表 2. duplication_segments — 重复片段详情表 """ -from alembic import op + import sqlalchemy as sa +from alembic import op + # revision identifiers revision = "012" down_revision = "011" @@ -40,12 +42,8 @@ def upgrade() -> None: updated_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_duplication_records_user_id ON duplication_records(user_id)" - )) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_duplication_records_status ON duplication_records(status)" - )) + conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_duplication_records_user_id ON duplication_records(user_id)")) + conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_duplication_records_status ON duplication_records(status)")) # ── 2. Create duplication_segments table ── @@ -62,9 +60,9 @@ def upgrade() -> None: similarity FLOAT NOT NULL ) """)) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_duplication_segments_record_id ON duplication_segments(record_id)" - )) + conn.execute( + sa.text("CREATE INDEX IF NOT EXISTS ix_duplication_segments_record_id ON duplication_segments(record_id)") + ) def downgrade() -> None: diff --git a/alembic/versions/013_add_recipe_tables.py b/alembic/versions/013_add_recipe_tables.py index bd1357d27..e561a12b1 100644 --- a/alembic/versions/013_add_recipe_tables.py +++ b/alembic/versions/013_add_recipe_tables.py @@ -8,9 +8,11 @@ This migration creates two new tables: 1. recipes — 配方主表 2. recipe_items — 配方素材项表 """ -from alembic import op + import sqlalchemy as sa +from alembic import op + # revision identifiers revision = "013" down_revision = "012" @@ -37,9 +39,7 @@ def upgrade() -> None: updated_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_recipes_user_id ON recipes(user_id)" - )) + conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_recipes_user_id ON recipes(user_id)")) # ── 2. Create recipe_items table ── @@ -53,9 +53,7 @@ def upgrade() -> None: metadata JSONB NOT NULL DEFAULT '{}' ) """)) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_recipe_items_recipe_id ON recipe_items(recipe_id)" - )) + conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_recipe_items_recipe_id ON recipe_items(recipe_id)")) def downgrade() -> None: diff --git a/alembic/versions/014_add_template_tables.py b/alembic/versions/014_add_template_tables.py index 3ae2ee4f8..cde2679f9 100644 --- a/alembic/versions/014_add_template_tables.py +++ b/alembic/versions/014_add_template_tables.py @@ -9,9 +9,11 @@ This migration creates three new tables: 2. template_segments — 模板片段表 3. template_categories — 模板分类表 """ -from alembic import op + import sqlalchemy as sa +from alembic import op + # revision identifiers revision = "014" down_revision = "013" @@ -40,12 +42,8 @@ def upgrade() -> None: updated_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_templates_user_id ON templates(user_id)" - )) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_templates_mode ON templates(mode)" - )) + conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_templates_user_id ON templates(user_id)")) + conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_templates_mode ON templates(mode)")) # ── 2. Create template_segments table ── conn.execute(sa.text(""" @@ -60,10 +58,9 @@ def upgrade() -> None: updated_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_template_segments_template_id " - "ON template_segments(template_id)" - )) + conn.execute( + sa.text("CREATE INDEX IF NOT EXISTS ix_template_segments_template_id " "ON template_segments(template_id)") + ) # ── 3. Create template_categories table ── conn.execute(sa.text(""" @@ -74,10 +71,9 @@ def upgrade() -> None: created_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) - conn.execute(sa.text( - "CREATE INDEX IF NOT EXISTS ix_template_categories_user_id " - "ON template_categories(user_id)" - )) + conn.execute( + sa.text("CREATE INDEX IF NOT EXISTS ix_template_categories_user_id " "ON template_categories(user_id)") + ) def downgrade() -> None: diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index aa3505b5f..74e0653d6 100644 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -1,23 +1,23 @@ -from app.api.routes.dashboard import router as dashboard_router from app.api.routes.asset_diagnosis import router as asset_diagnosis_router from app.api.routes.asset_libraries import router as asset_libraries_router from app.api.routes.assets import router as assets_router from app.api.routes.auth import router as auth_router from app.api.routes.chunked_upload import router as chunked_upload_router from app.api.routes.classification_jobs import router as classification_jobs_router +from app.api.routes.dashboard import router as dashboard_router from app.api.routes.duplication import router as duplication_router from app.api.routes.generated_videos import router as generated_videos_router -from app.api.routes.recipes import router as recipes_router -from app.api.routes.subscription import router as subscription_router -from app.api.routes.templates import router as templates_router -from app.api.routes.titles import router as titles_router -from app.api.routes.voices import router as voices_router from app.api.routes.generation_tasks import router as generation_tasks_router from app.api.routes.health import router as health_check_router from app.api.routes.ingest_jobs import router as ingest_jobs_router from app.api.routes.projects import router as projects_router +from app.api.routes.recipes import router as recipes_router +from app.api.routes.subscription import router as subscription_router from app.api.routes.task_center import router as task_center_router +from app.api.routes.templates import router as templates_router +from app.api.routes.titles import router as titles_router from app.api.routes.upload import router as upload_router +from app.api.routes.voices import router as voices_router from fastapi import APIRouter api_router = APIRouter(prefix="/api/v1") diff --git a/apps/api/app/api/routes/asset_libraries.py b/apps/api/app/api/routes/asset_libraries.py index c8d25228a..af09c629f 100644 --- a/apps/api/app/api/routes/asset_libraries.py +++ b/apps/api/app/api/routes/asset_libraries.py @@ -49,7 +49,7 @@ def list_asset_libraries( ) -> ListAssetLibrariesResponse: user_id = authenticated_user.user.id use_case = ListAssetLibrariesUseCase(asset_library_repository) - + if project_id: # If project_id provided, check access and filter by project project = GetProjectUseCase(project_repository).execute(project_id) @@ -65,7 +65,7 @@ def list_asset_libraries( for proj in accessible_projects: all_items.extend(use_case.execute(proj.id)) items = all_items - + return ListAssetLibrariesResponse(items=[_to_asset_library_response(item) for item in items]) diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index aa37bfe99..2a8181916 100644 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -106,7 +106,7 @@ def create_asset( raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found") if not project.can_access(authenticated_user.user.id): raise HTTPException(status_code=403, detail="Access denied to project") - + library = asset_library_repository.get(request.library_id) if library is None or library.project_id != request.project_id: raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found") diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index 1ded9dd1a..92d7394ab 100755 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -55,6 +55,7 @@ class LoginRequest(BaseModel): class RefreshRequest(BaseModel): refresh_token: str + class LoginResponse(BaseModel): access_token: str refresh_token: str @@ -173,7 +174,6 @@ async def refresh( ) - def _verify_email_token(token: str, user_repository: UserRepository) -> MessageResponse: success, error = VerifyEmailUseCase(user_repository=user_repository).execute(VerifyEmailRequest(token=token)) if not success: diff --git a/apps/api/app/api/routes/chunked_upload.py b/apps/api/app/api/routes/chunked_upload.py index 4e06b83d7..9b5a9fe26 100644 --- a/apps/api/app/api/routes/chunked_upload.py +++ b/apps/api/app/api/routes/chunked_upload.py @@ -8,6 +8,7 @@ import json import logging import os import shutil +import tempfile from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -44,13 +45,22 @@ CHUNK_EXPIRY_HOURS = 24 # Allowed file types (consistent with existing upload.py) ALLOWED_MIME_TYPES = { - "image/jpeg", "image/png", "image/gif", "image/webp", - "video/mp4", "video/quicktime", "video/x-msvideo", "video/webm", - "audio/mpeg", "audio/wav", "audio/ogg", "audio/mp3", + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/mp4", + "video/quicktime", + "video/x-msvideo", + "video/webm", + "audio/mpeg", + "audio/wav", + "audio/ogg", + "audio/mp3", } # Chunk storage root directory -CHUNK_STORAGE_ROOT = Path("/tmp/chunked_uploads") +CHUNK_STORAGE_ROOT = Path(tempfile.gettempdir()) / "chunked_uploads" def _get_chunk_dir(upload_id: str) -> Path: @@ -67,13 +77,13 @@ def _atomic_check_and_record(upload_id: str, chunk_index: int) -> bool: """ Atomically check if chunk is uploaded and record if not. Uses file locking to prevent race conditions. - + Returns: True if chunk was newly recorded, False if already exists """ meta_path = _get_upload_meta_path(upload_id) CHUNK_STORAGE_ROOT.mkdir(parents=True, exist_ok=True) - + with open(meta_path, "r+", encoding="utf-8") as f: fcntl.flock(f.fileno(), fcntl.LOCK_EX) try: @@ -128,15 +138,17 @@ def _validate_file_type(content: bytes, filename: str) -> str: """Validate file type""" try: import magic + detected_mime = magic.from_buffer(content, mime=True) except ImportError: import mimetypes + detected_mime = mimetypes.guess_type(filename)[0] or "application/octet-stream" if detected_mime not in ALLOWED_MIME_TYPES: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Unsupported file type: {detected_mime}. Allowed types: {', '.join(sorted(ALLOWED_MIME_TYPES))}" + detail=f"Unsupported file type: {detected_mime}. Allowed types: {', '.join(sorted(ALLOWED_MIME_TYPES))}", ) return detected_mime diff --git a/apps/api/app/api/routes/duplication.py b/apps/api/app/api/routes/duplication.py index 5507f65d9..39c94649f 100644 --- a/apps/api/app/api/routes/duplication.py +++ b/apps/api/app/api/routes/duplication.py @@ -1,4 +1,5 @@ """查重 API 路由。""" + from __future__ import annotations import logging @@ -9,10 +10,10 @@ 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 app.schemas.duplication import ( + DuplicateSegmentResponse, DuplicationDetailResponse, DuplicationRecordResponse, DuplicationUploadResponse, - DuplicateSegmentResponse, ) from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status @@ -31,10 +32,17 @@ logger = logging.getLogger(__name__) router = APIRouter() # 查重功能只接受视频文件 -ALLOWED_VIDEO_MIME_TYPES = frozenset({ - "video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo", - "video/webm", "video/x-matroska", "video/3gpp", -}) +ALLOWED_VIDEO_MIME_TYPES = frozenset( + { + "video/mp4", + "video/mpeg", + "video/quicktime", + "video/x-msvideo", + "video/webm", + "video/x-matroska", + "video/3gpp", + } +) def _validate_video_mime_type(content_type: str | None) -> str: @@ -44,16 +52,16 @@ def _validate_video_mime_type(content_type: str | None) -> str: status_code=status.HTTP_400_BAD_REQUEST, detail="Content-Type header is required", ) - + # 处理带参数的类型,如 "video/mp4; charset=utf-8" base_type = content_type.split(";")[0].strip().lower() - + if base_type not in ALLOWED_VIDEO_MIME_TYPES: raise HTTPException( status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, - detail=f"只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp", + detail="只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp", ) - + return base_type @@ -117,9 +125,10 @@ async def upload_for_duplication( # P0-2: 验证文件大小(参考 OSS_DIRECT_UPLOAD_MAX_MB) from app.config import get_settings + settings = get_settings() max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024 - + # 先检查 Content-Length header(如果可用) if file.size is not None and file.size > max_size_bytes: raise HTTPException( @@ -135,7 +144,7 @@ async def upload_for_duplication( try: content = await file.read() file_size = len(content) - + # 再次检查实际文件大小 if file_size > max_size_bytes: raise HTTPException( diff --git a/apps/api/app/api/routes/generated_videos.py b/apps/api/app/api/routes/generated_videos.py index 30a1b6aa8..d9f72c031 100644 --- a/apps/api/app/api/routes/generated_videos.py +++ b/apps/api/app/api/routes/generated_videos.py @@ -50,7 +50,7 @@ def list_generated_videos( ) -> ListGeneratedVideosResponse: user_id = authenticated_user.user.id use_case = ListGeneratedVideosUseCase(generated_video_repository) - + if project_id: # If project_id provided, check access and filter by project project = project_repository.find_by_id(project_id) @@ -64,7 +64,7 @@ def list_generated_videos( for proj in accessible_projects: all_items.extend(use_case.execute(proj.id)) items = all_items - + # Generate download URLs for each video responses = [] for item in items: diff --git a/apps/api/app/api/routes/recipes.py b/apps/api/app/api/routes/recipes.py index f5c7d97e9..ee5e50f31 100644 --- a/apps/api/app/api/routes/recipes.py +++ b/apps/api/app/api/routes/recipes.py @@ -1,11 +1,9 @@ """Recipe CRUD + use routes.""" + from __future__ import annotations from typing import List -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status -from sqlalchemy.orm import Session - from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session, get_user_repository from app.schemas.recipe import ( @@ -16,6 +14,9 @@ from app.schemas.recipe import ( UpdateRecipeRequest, UseRecipeResponse, ) +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy.orm import Session + from packages.adapters.sqlalchemy_impl.recipe_repository import SQLAlchemyRecipeRepository from packages.application.recipe.commands import ( CreateRecipeCommand, @@ -209,8 +210,5 @@ def use_recipe( return UseRecipeResponse( recipe=_to_response(result.recipe), - warnings=[ - {"item_type": w.item_type, "item_id": w.item_id, "position": w.position} - for w in result.warnings - ], + warnings=[{"item_type": w.item_type, "item_id": w.item_id, "position": w.position} for w in result.warnings], ) diff --git a/apps/api/app/api/routes/subscription.py b/apps/api/app/api/routes/subscription.py index 5cd0c7525..5d67ff46c 100644 --- a/apps/api/app/api/routes/subscription.py +++ b/apps/api/app/api/routes/subscription.py @@ -1,12 +1,11 @@ """Subscription management API routes.""" + from __future__ import annotations from dataclasses import replace from datetime import datetime, timezone from typing import List -from fastapi import APIRouter, Depends, HTTPException, status - from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_user_repository from app.schemas.subscription import ( @@ -17,6 +16,8 @@ from app.schemas.subscription import ( SubscriptionInfo, ToggleAutoRenewRequest, ) +from fastapi import APIRouter, Depends, HTTPException, status + from packages.ports.user_repository import UserRepository router = APIRouter() @@ -34,6 +35,7 @@ PLAN_QUOTAS = { # ============ Helper Functions ============ + def _get_plan_name(plan_id: str) -> str: """获取套餐显示名称""" plan_names = { @@ -86,6 +88,7 @@ def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo: # ============ API Endpoints ============ + @router.get("/current", response_model=SubscriptionInfo) async def get_current_subscription( current_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/templates.py b/apps/api/app/api/routes/templates.py index 2d6b6afae..056278150 100644 --- a/apps/api/app/api/routes/templates.py +++ b/apps/api/app/api/routes/templates.py @@ -1,17 +1,16 @@ """Template CRUD + generate + category routes.""" + from __future__ import annotations from typing import List -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status -from sqlalchemy.orm import Session - from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session from app.schemas.template import ( CategoryResponse, CreateCategoryRequest, CreateTemplateRequest, + GenerateWarningResponse, ListCategoriesResponse, ListTemplatesResponse, SegmentResponse, @@ -19,8 +18,10 @@ from app.schemas.template import ( UpdateTemplateRequest, ValidateTemplateRequest, ValidateTemplateResponse, - GenerateWarningResponse, ) +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy.orm import Session + from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository from packages.application.template.commands import ( CreateCategoryCommand, @@ -234,10 +235,7 @@ def validate_template( return ValidateTemplateResponse( template=_to_response(result.template), - warnings=[ - GenerateWarningResponse(code=w.code, message=w.message, details=w.details) - for w in result.warnings - ], + warnings=[GenerateWarningResponse(code=w.code, message=w.message, details=w.details) for w in result.warnings], ) @@ -253,10 +251,7 @@ def list_categories( use_case = ListCategoriesUseCase(template_repository) categories = use_case.execute(user_id) return ListCategoriesResponse( - items=[ - CategoryResponse(id=c.id, user_id=c.user_id, name=c.name, created_at=c.created_at) - for c in categories - ], + items=[CategoryResponse(id=c.id, user_id=c.user_id, name=c.name, created_at=c.created_at) for c in categories], ) @@ -271,7 +266,10 @@ def create_category( use_case = CreateCategoryUseCase(template_repository) category = use_case.execute(command) return CategoryResponse( - id=category.id, user_id=category.user_id, name=category.name, created_at=category.created_at, + id=category.id, + user_id=category.user_id, + name=category.name, + created_at=category.created_at, ) diff --git a/apps/api/app/api/routes/titles.py b/apps/api/app/api/routes/titles.py index 7ed4b6285..b55c8e7f5 100644 --- a/apps/api/app/api/routes/titles.py +++ b/apps/api/app/api/routes/titles.py @@ -1,10 +1,9 @@ """Title library CRUD routes.""" + from __future__ import annotations from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status - from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session, get_user_repository from app.schemas.title_library import ( @@ -13,6 +12,9 @@ from app.schemas.title_library import ( TitleLibraryItemResponse, UpdateTitleLibraryRequest, ) +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy.orm import Session + from packages.adapters.sqlalchemy_impl.title_library_repository import SQLAlchemyTitleLibraryRepository from packages.application.title_library.commands import CreateTitleLibraryCommand, UpdateTitleLibraryCommand from packages.application.title_library.use_cases import ( @@ -20,12 +22,11 @@ from packages.application.title_library.use_cases import ( DeleteTitleLibraryUseCase, GetTitleLibraryUseCase, ListTitleLibraryUseCase, - UpdateTitleLibraryUseCase, NotFoundError, QuotaExceededError, + UpdateTitleLibraryUseCase, ) from packages.ports.user_repository import UserRepository -from sqlalchemy.orm import Session router = APIRouter() diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index 8001d705d..38a0d536d 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -1,5 +1,5 @@ import logging -from typing import Any +from typing import Annotated, Any from uuid import uuid4 from app.auth import AuthenticatedUser, get_current_user @@ -19,8 +19,6 @@ from app.schemas.upload import ( UploadAssetRequest, UploadAssetResponse, ) -from typing import Annotated - from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase @@ -30,17 +28,35 @@ logger = logging.getLogger(__name__) router = APIRouter() # 允许上传的文件 MIME 类型 -ALLOWED_MIME_TYPES = frozenset({ - # 视频 - "video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo", - "video/webm", "video/x-matroska", "video/3gpp", - # 音频 - "audio/mpeg", "audio/wav", "audio/ogg", "audio/flac", "audio/aac", - "audio/mp3", "audio/x-m4a", "audio/webm", - # 图片 - "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", - "image/svg+xml", "image/tiff", -}) +ALLOWED_MIME_TYPES = frozenset( + { + # 视频 + "video/mp4", + "video/mpeg", + "video/quicktime", + "video/x-msvideo", + "video/webm", + "video/x-matroska", + "video/3gpp", + # 音频 + "audio/mpeg", + "audio/wav", + "audio/ogg", + "audio/flac", + "audio/aac", + "audio/mp3", + "audio/x-m4a", + "audio/webm", + # 图片 + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/bmp", + "image/svg+xml", + "image/tiff", + } +) def _validate_mime_type(content_type: str | None) -> str: @@ -50,16 +66,16 @@ def _validate_mime_type(content_type: str | None) -> str: status_code=status.HTTP_400_BAD_REQUEST, detail="Content-Type header is required", ) - + # 处理带参数的类型,如 "video/mp4; charset=utf-8" base_type = content_type.split(";")[0].strip().lower() - + if base_type not in ALLOWED_MIME_TYPES: raise HTTPException( status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail=f"File type '{base_type}' is not supported. Allowed types: video, audio, and image files.", ) - + return base_type @@ -199,7 +215,8 @@ async def complete_direct_upload( description="上传素材文件(multipart/form-data),支持视频、音频、图片。触发导入流水线自动处理。", ) async def upload_asset( - form_data: Annotated[UploadAssetRequest, Form()], + project_id: str = Form(..., min_length=1, description="项目 ID"), + library_id: str = Form(..., min_length=1, description="素材库 ID"), file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"), authenticated_user: AuthenticatedUser = Depends(get_current_user), ingest_job_repository: Any = Depends(get_ingest_job_repository), @@ -208,8 +225,6 @@ async def upload_asset( storage_service: OSSStorageService = Depends(get_storage_service), ) -> UploadAssetResponse: """上传素材文件并触发导入流水线。""" - project_id = form_data.project_id - library_id = form_data.library_id _require_project_and_library(project_id, library_id, project_repository, asset_library_repository) # P2-5: 服务端验证 MIME 类型 diff --git a/apps/api/app/api/routes/voices.py b/apps/api/app/api/routes/voices.py index ed210e81b..d2f41482e 100644 --- a/apps/api/app/api/routes/voices.py +++ b/apps/api/app/api/routes/voices.py @@ -1,18 +1,20 @@ """Voice library CRUD routes.""" + from __future__ import annotations from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status - from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session, get_user_repository from app.schemas.voice_library import ( CreateVoiceLibraryRequest, ListVoiceLibraryResponse, - VoiceLibraryItemResponse, UpdateVoiceLibraryRequest, + VoiceLibraryItemResponse, ) +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy.orm import Session + from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand from packages.application.voice_library.use_cases import ( @@ -20,12 +22,11 @@ from packages.application.voice_library.use_cases import ( DeleteVoiceLibraryUseCase, GetVoiceLibraryUseCase, ListVoiceLibraryUseCase, - UpdateVoiceLibraryUseCase, NotFoundError, QuotaExceededError, + UpdateVoiceLibraryUseCase, ) from packages.ports.user_repository import UserRepository -from sqlalchemy.orm import Session router = APIRouter() diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 778492c04..da2b61152 100755 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -21,9 +21,7 @@ class Settings(BaseSettings): API_PORT: int = 8000 API_PREFIX: str = "/api/v1" - DATABASE_URL: str = ( - "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas" - ) + DATABASE_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas" DATABASE_POOL_SIZE: int = 20 DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接 DATABASE_POOL_TIMEOUT: int = 30 @@ -48,8 +46,7 @@ class Settings(BaseSettings): def validate_jwt_secret_key(cls, v): if v is None or v == "": raise ValueError( - "JWT_SECRET_KEY must be set via environment variable. " - "Do not use default value in production!" + "JWT_SECRET_KEY must be set via environment variable. " "Do not use default value in production!" ) # Block known insecure default values insecure_defaults = [ @@ -61,8 +58,7 @@ class Settings(BaseSettings): ] if v.lower() in [d.lower() for d in insecure_defaults]: raise ValueError( - f"JWT_SECRET_KEY '{v}' is insecure. " - "Please set a strong random secret via environment variable." + f"JWT_SECRET_KEY '{v}' is insecure. " "Please set a strong random secret via environment variable." ) return v @@ -90,9 +86,7 @@ class Settings(BaseSettings): OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900 LOG_LEVEL: str = "INFO" - CORS_ORIGINS_RAW: str = ( - "http://localhost:3000,http://localhost:5173,http://localhost:8000" - ) + CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000" model_config = SettingsConfigDict( env_file=".env", @@ -103,11 +97,7 @@ class Settings(BaseSettings): @property def CORS_ORIGINS(self) -> list[str]: - return [ - origin.strip() - for origin in self.CORS_ORIGINS_RAW.split(",") - if origin.strip() - ] + return [origin.strip() for origin in self.CORS_ORIGINS_RAW.split(",") if origin.strip()] @property def database_url(self) -> str: diff --git a/apps/api/app/core/database.py b/apps/api/app/core/database.py index 94a26349d..4b431c89c 100644 --- a/apps/api/app/core/database.py +++ b/apps/api/app/core/database.py @@ -2,13 +2,13 @@ 统一使用 app.config 中的数据库配置,移除重复的 DatabaseSettings。 """ -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker, Session + from contextlib import contextmanager from typing import Generator from app.config import settings - +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker engine = create_engine( settings.database_url, @@ -33,7 +33,7 @@ def get_db() -> Generator[Session, None, None]: @contextmanager def get_db_context() -> Generator[Session, None, None]: """Context manager for database sessions. - + Usage: with get_db_context() as db: db.query(Model).all() diff --git a/apps/api/app/core/storage.py b/apps/api/app/core/storage.py index 094c3bea8..cf6997b61 100644 --- a/apps/api/app/core/storage.py +++ b/apps/api/app/core/storage.py @@ -232,8 +232,6 @@ class OSSStorageService: return self.bucket.object_exists(storage_key) - - _storage_service = None @@ -243,4 +241,3 @@ def get_storage_service() -> OSSStorageService: if _storage_service is None: _storage_service = OSSStorageService() return _storage_service - diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py index 79bcdffdc..58f859960 100644 --- a/apps/api/app/dependencies.py +++ b/apps/api/app/dependencies.py @@ -3,17 +3,18 @@ All repository and service factories are defined here as FastAPI dependencies, ensuring proper lifecycle management and testability. """ + from __future__ import annotations -import redis from typing import Generator +import redis from app.config import settings from fastapi import Depends from sqlalchemy.orm import Session from packages.adapters.redis import NoopSessionStore, SessionStore -from packages.adapters.smtp import EmailConfig, NoopEmailService, get_email_service +from packages.adapters.smtp import EmailConfig, EmailService, NoopEmailService, get_email_service from packages.adapters.sqlalchemy_impl.asset_library_repository import ( SQLAlchemyAssetLibraryRepository, ) @@ -30,12 +31,6 @@ from packages.adapters.sqlalchemy_impl.generated_video_repository import ( from packages.adapters.sqlalchemy_impl.generation_task_repository import ( SQLAlchemyGenerationTaskRepository, ) -from packages.adapters.sqlalchemy_impl.title_library_repository import ( - SQLAlchemyTitleLibraryRepository, -) -from packages.adapters.sqlalchemy_impl.voice_library_repository import ( - SQLAlchemyVoiceLibraryRepository, -) from packages.adapters.sqlalchemy_impl.ingest_job_repository import ( SQLAlchemyIngestJobRepository, ) @@ -43,18 +38,24 @@ from packages.adapters.sqlalchemy_impl.project_repository import ( SQLAlchemyProjectRepository, ) from packages.adapters.sqlalchemy_impl.session import build_session_factory +from packages.adapters.sqlalchemy_impl.title_library_repository import ( + SQLAlchemyTitleLibraryRepository, +) from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository -from packages.ports.asset_repository import AssetRepository +from packages.adapters.sqlalchemy_impl.voice_library_repository import ( + SQLAlchemyVoiceLibraryRepository, +) from packages.ports.asset_library_repository import AssetLibraryRepository -from packages.ports.user_repository import UserRepository +from packages.ports.asset_repository import AssetRepository from packages.ports.classification_job_repository import ClassificationJobRepository from packages.ports.duplication_repository import DuplicationRecordRepository -from packages.ports.generation_task_repository import GenerationTaskRepository -from packages.ports.title_library_repository import TitleLibraryRepository -from packages.ports.voice_library_repository import VoiceLibraryRepository from packages.ports.generated_video_repository import GeneratedVideoRepository +from packages.ports.generation_task_repository import GenerationTaskRepository from packages.ports.ingest_job_repository import IngestJobRepository from packages.ports.project_repository import ProjectRepository +from packages.ports.title_library_repository import TitleLibraryRepository +from packages.ports.user_repository import UserRepository +from packages.ports.voice_library_repository import VoiceLibraryRepository _engine, _SessionLocal = build_session_factory(settings.DATABASE_URL) @@ -124,7 +125,6 @@ def get_project_repository( return SQLAlchemyProjectRepository(session) - def get_user_repository( session: Session = Depends(get_db_session), ) -> UserRepository: @@ -156,6 +156,7 @@ def get_auth_email_service() -> NoopEmailService | EmailService: enabled=True, ) + def get_title_library_repository( session: Session = Depends(get_db_session), ) -> SQLAlchemyTitleLibraryRepository: diff --git a/apps/api/app/middleware/logging.py b/apps/api/app/middleware/logging.py index c0095a34d..f17bb1ea7 100644 --- a/apps/api/app/middleware/logging.py +++ b/apps/api/app/middleware/logging.py @@ -69,9 +69,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): # 记录请求信息(不包含敏感参数) if safe_query: - logger.info( - f"Request: {request.method} {request.url.path}?{safe_query}" # noqa: E501 - ) + logger.info(f"Request: {request.method} {request.url.path}?{safe_query}") # noqa: E501 else: logger.info(f"Request: {request.method} {request.url.path}") @@ -83,8 +81,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): # 记录响应信息 logger.info( - f"Response: {request.method} {request.url.path} " - f"status={response.status_code} time={process_time:.3f}s" + f"Response: {request.method} {request.url.path} " f"status={response.status_code} time={process_time:.3f}s" ) # 添加处理时间到响应头 @@ -111,9 +108,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware): # 清理过期记录 if client_ip in self.requests: self.requests[client_ip] = [ - ts - for ts in self.requests[client_ip] - if current_time - ts < self.window_seconds + ts for ts in self.requests[client_ip] if current_time - ts < self.window_seconds ] # 计算请求次数 @@ -128,8 +123,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware): "error": { "code": "RATE_LIMIT_EXCEEDED", "message": ( # noqa: E501 - f"Too many requests. Limit: " - f"{self.max_requests} per {self.window_seconds}s" + f"Too many requests. Limit: " f"{self.max_requests} per {self.window_seconds}s" ), } }, @@ -145,8 +139,6 @@ class RateLimitMiddleware(BaseHTTPMiddleware): # 添加限流信息到响应头 response.headers["X-RateLimit-Limit"] = str(self.max_requests) - response.headers["X-RateLimit-Remaining"] = str( - self.max_requests - len(self.requests[client_ip]) - ) + response.headers["X-RateLimit-Remaining"] = str(self.max_requests - len(self.requests[client_ip])) return response diff --git a/apps/api/app/middleware/prometheus_metrics.py b/apps/api/app/middleware/prometheus_metrics.py index 7b24568c1..2c61cec47 100644 --- a/apps/api/app/middleware/prometheus_metrics.py +++ b/apps/api/app/middleware/prometheus_metrics.py @@ -14,17 +14,16 @@ from typing import Callable from fastapi import Request, Response from prometheus_client import ( + CONTENT_TYPE_LATEST, + REGISTRY, Counter, Gauge, Histogram, generate_latest, - CONTENT_TYPE_LATEST, - REGISTRY, ) from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import PlainTextResponse - # Buckets for HTTP request duration (seconds) HTTP_DURATION_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0) @@ -124,6 +123,7 @@ class PrometheusMetricsMiddleware(BaseHTTPMiddleware): async def metrics_endpoint(request: Request) -> PlainTextResponse: """FastAPI endpoint that returns Prometheus metrics in text format.""" import os + version = os.getenv("APP_VERSION", "unknown") environment = os.getenv("APP_ENV", "unknown") APP_INFO.labels(version=version, environment=environment).set(1) diff --git a/apps/api/app/schemas/chunked_upload.py b/apps/api/app/schemas/chunked_upload.py index cb0d7d122..8c5cbef78 100644 --- a/apps/api/app/schemas/chunked_upload.py +++ b/apps/api/app/schemas/chunked_upload.py @@ -7,7 +7,9 @@ class ChunkedUploadInitRequest(BaseModel): filename: str = Field(..., min_length=1, max_length=255, description="Filename") file_size: int = Field(..., gt=0, le=2147483648, description="File size in bytes, max 2GB") total_chunks: int = Field(..., gt=0, description="Total number of chunks") - content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100, description="Content type") + content_type: str = Field( + default="application/octet-stream", min_length=1, max_length=100, description="Content type" + ) project_id: str = Field(..., min_length=1, description="Project ID") library_id: str = Field(..., min_length=1, description="Asset library ID") diff --git a/apps/api/app/schemas/dashboard.py b/apps/api/app/schemas/dashboard.py index d1732b0a5..bc823c177 100644 --- a/apps/api/app/schemas/dashboard.py +++ b/apps/api/app/schemas/dashboard.py @@ -14,12 +14,14 @@ class RecentTaskItem(BaseModel): class SubscriptionInfo(BaseModel): """用户订阅信息。""" + plan: str = "free" is_active: bool = False class DashboardOverviewResponse(BaseModel): """Dashboard 概览数据。""" + total_assets: int = 0 used_storage_bytes: int = 0 total_titles: int = 0 diff --git a/apps/api/app/schemas/duplication.py b/apps/api/app/schemas/duplication.py index 48416cfbe..eb422e718 100644 --- a/apps/api/app/schemas/duplication.py +++ b/apps/api/app/schemas/duplication.py @@ -1,4 +1,5 @@ """查重 API Pydantic schemas。""" + from __future__ import annotations from pydantic import BaseModel, Field diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index a9e0beb91..64219f214 100644 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -8,6 +8,7 @@ class CreateGenerationTaskRequest(BaseModel): - 项目模式:project_id + asset_library_id(向后兼容) - 模板模式:template_id + asset_ids / title_ids / voice_ids """ + project_id: str = "" asset_library_id: str = "" strategy_id: str = "" @@ -50,4 +51,5 @@ class GenerationTaskResponse(BaseModel): class ListGenerationTasksResponse(BaseModel): """用户级生成任务列表响应(跨 project)。""" + items: list[GenerationTaskResponse] diff --git a/apps/api/app/schemas/recipe.py b/apps/api/app/schemas/recipe.py index 4dfddec5b..7e75d861e 100644 --- a/apps/api/app/schemas/recipe.py +++ b/apps/api/app/schemas/recipe.py @@ -1,4 +1,5 @@ """Recipe API schemas.""" + from __future__ import annotations from datetime import datetime @@ -6,9 +7,9 @@ from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field - # ── Response ── + class RecipeItemResponse(BaseModel): id: str recipe_id: str @@ -50,6 +51,7 @@ class UseRecipeResponse(BaseModel): # ── Request ── + class RecipeItemRequest(BaseModel): item_type: str item_id: str diff --git a/apps/api/app/schemas/subscription.py b/apps/api/app/schemas/subscription.py index 8c42d0939..2080095fc 100644 --- a/apps/api/app/schemas/subscription.py +++ b/apps/api/app/schemas/subscription.py @@ -1,15 +1,17 @@ """Subscription schemas for API request/response models.""" + from __future__ import annotations from typing import List, Optional from pydantic import BaseModel, Field - # ============ Enums / Types ============ + class PlanType(str): """套餐类型""" + FREE = "free" STANDARD = "standard" PRO = "pro" @@ -18,6 +20,7 @@ class PlanType(str): class SubscriptionStatus(str): """订阅状态""" + ACTIVE = "active" EXPIRED = "expired" CANCELLED = "cancelled" @@ -26,6 +29,7 @@ class SubscriptionStatus(str): class BillingStatus(str): """账单状态""" + PAID = "paid" PENDING = "pending" FAILED = "failed" @@ -34,14 +38,17 @@ class BillingStatus(str): class BillingCycle(str): """计费周期""" + MONTHLY = "monthly" YEARLY = "yearly" # ============ Response Schemas ============ + class SubscriptionInfo(BaseModel): """当前订阅信息""" + id: str plan_id: str plan_name: str @@ -56,6 +63,7 @@ class SubscriptionInfo(BaseModel): class BillingRecord(BaseModel): """账单记录""" + id: str plan_name: str amount: float @@ -68,6 +76,7 @@ class BillingRecord(BaseModel): class ChangePlanResponse(BaseModel): """升级/降级响应""" + success: bool message: str new_subscription: Optional[SubscriptionInfo] = None @@ -75,18 +84,22 @@ class ChangePlanResponse(BaseModel): class SimpleResponse(BaseModel): """简单响应(用于取消订阅、切换自动续费等)""" + success: bool message: str # ============ Request Schemas ============ + class ChangePlanRequest(BaseModel): """升级/降级请求""" + target_plan_id: str = Field(..., description="目标套餐ID") billing_cycle: str = Field(..., description="计费周期: monthly/yearly") class ToggleAutoRenewRequest(BaseModel): """切换自动续费请求""" + enabled: bool = Field(..., description="是否开启自动续费") diff --git a/apps/api/app/schemas/task_center.py b/apps/api/app/schemas/task_center.py index 0aaaf7170..931fae17e 100644 --- a/apps/api/app/schemas/task_center.py +++ b/apps/api/app/schemas/task_center.py @@ -25,6 +25,7 @@ class ListProjectTasksResponse(BaseModel): class UserTaskResponse(BaseModel): """用户级任务响应(跨 project,用于模板模式)。""" + id: str task_type: str project_id: str = "" @@ -42,4 +43,5 @@ class UserTaskResponse(BaseModel): class ListTasksResponse(BaseModel): """用户级任务列表响应(GET /api/v1/tasks)。""" + items: list[UserTaskResponse] = Field(default_factory=list) diff --git a/apps/api/app/schemas/template.py b/apps/api/app/schemas/template.py index 0d7bea514..9d95ecbb4 100644 --- a/apps/api/app/schemas/template.py +++ b/apps/api/app/schemas/template.py @@ -1,4 +1,5 @@ """Template API schemas.""" + from __future__ import annotations from datetime import datetime @@ -6,9 +7,9 @@ from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field - # ── Segment ── + class SegmentResponse(BaseModel): id: str template_id: str @@ -29,6 +30,7 @@ class SegmentRequest(BaseModel): # ── Template Response ── + class TemplateResponse(BaseModel): id: str user_id: str @@ -53,6 +55,7 @@ class ListTemplatesResponse(BaseModel): # ── Template Request ── + class CreateTemplateRequest(BaseModel): name: str mode: str @@ -79,6 +82,7 @@ class UpdateTemplateRequest(BaseModel): # ── Validate ── + class ValidateTemplateRequest(BaseModel): voiceover_duration: Optional[float] = None # 配音实际时长(秒) @@ -96,6 +100,7 @@ class ValidateTemplateResponse(BaseModel): # ── Category ── + class CategoryResponse(BaseModel): id: str user_id: str diff --git a/apps/api/app/schemas/title_library.py b/apps/api/app/schemas/title_library.py index 8af89af77..b955d1169 100644 --- a/apps/api/app/schemas/title_library.py +++ b/apps/api/app/schemas/title_library.py @@ -1,4 +1,5 @@ """Title library Pydantic schemas.""" + from __future__ import annotations from datetime import datetime diff --git a/apps/api/app/schemas/upload.py b/apps/api/app/schemas/upload.py index 97d442a01..90a7a026d 100644 --- a/apps/api/app/schemas/upload.py +++ b/apps/api/app/schemas/upload.py @@ -1,12 +1,13 @@ from pydantic import BaseModel, Field - class UploadAssetRequest(BaseModel): """素材上传请求(multipart form)""" + project_id: str = Field(..., min_length=1, description="项目 ID") library_id: str = Field(..., min_length=1, description="素材库 ID") + class UploadAssetResponse(BaseModel): storage_key: str ingest_job_id: str diff --git a/apps/api/app/schemas/voice_library.py b/apps/api/app/schemas/voice_library.py index f906e0554..b21ea4362 100644 --- a/apps/api/app/schemas/voice_library.py +++ b/apps/api/app/schemas/voice_library.py @@ -1,4 +1,5 @@ """Voice library Pydantic schemas.""" + from __future__ import annotations from datetime import datetime diff --git a/apps/web/app/components/CreateIssueForm.tsx b/apps/web/app/components/CreateIssueForm.tsx index 38ec5f1e3..9da7e2eed 100644 --- a/apps/web/app/components/CreateIssueForm.tsx +++ b/apps/web/app/components/CreateIssueForm.tsx @@ -43,8 +43,8 @@ export default function CreateIssueForm({ taskId, projectId, workspaceId, onSucc } onSuccess(); - } catch (err: any) { - setError(err.message); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } diff --git a/apps/web/app/components/CreateTaskForm.tsx b/apps/web/app/components/CreateTaskForm.tsx index d130f9800..70a99a69d 100644 --- a/apps/web/app/components/CreateTaskForm.tsx +++ b/apps/web/app/components/CreateTaskForm.tsx @@ -50,8 +50,8 @@ export default function CreateTaskForm({ projectId, workspaceId, onSuccess, onCa } else { router.push('/projects'); } - } catch (err: any) { - setError(err.message); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } diff --git a/apps/web/app/components/EditTaskForm.tsx b/apps/web/app/components/EditTaskForm.tsx index 032777c59..d23dec9a7 100644 --- a/apps/web/app/components/EditTaskForm.tsx +++ b/apps/web/app/components/EditTaskForm.tsx @@ -1,7 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; +import { useState } from 'react'; const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; @@ -18,7 +17,6 @@ interface EditTaskFormProps { } export default function EditTaskForm({ taskId, initialData, onSuccess, onCancel }: EditTaskFormProps) { - const router = useRouter(); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [formData, setFormData] = useState(initialData); @@ -41,8 +39,8 @@ export default function EditTaskForm({ taskId, initialData, onSuccess, onCancel } if (onSuccess) onSuccess(); - } catch (err: any) { - setError(err.message); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 87f087c02..1aacc01bf 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import "./globals.css"; +// eslint-disable-next-line react-refresh/only-export-components export const metadata: Metadata = { title: "小虾 SaaS - 项目推进器", description: "AI 视频自动化剪辑系统 - 项目管理", diff --git a/apps/web/app/milestones/page.tsx b/apps/web/app/milestones/page.tsx index e8ae1cd51..e9f46d8f1 100644 --- a/apps/web/app/milestones/page.tsx +++ b/apps/web/app/milestones/page.tsx @@ -36,8 +36,8 @@ export default function MilestonesPage() { if (!res.ok) throw new Error('获取里程碑列表失败'); const data = await res.json(); setMilestones(data); - } catch (err: any) { - setError(err.message); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } @@ -59,8 +59,8 @@ export default function MilestonesPage() { setFormData({ name: '', description: '' }); setShowCreateForm(false); fetchMilestones(); - } catch (err: any) { - alert(err.message); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : String(err)); } }; diff --git a/apps/web/app/projects/page.tsx b/apps/web/app/projects/page.tsx index b83e73848..967379e87 100644 --- a/apps/web/app/projects/page.tsx +++ b/apps/web/app/projects/page.tsx @@ -38,8 +38,8 @@ export default function ProjectsPage() { if (!res.ok) throw new Error('获取任务列表失败'); const data = await res.json(); setTasks(data); - } catch (err: any) { - setError(err.message); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } diff --git a/apps/web/app/tasks/[id]/page.tsx b/apps/web/app/tasks/[id]/page.tsx index 1a8763e20..ae4e056ef 100644 --- a/apps/web/app/tasks/[id]/page.tsx +++ b/apps/web/app/tasks/[id]/page.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import Link from 'next/link'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams } from 'next/navigation'; import CreateIssueForm from '../../components/CreateIssueForm'; import EditTaskForm from '../../components/EditTaskForm'; @@ -39,7 +39,6 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; export default function TaskDetailPage() { const params = useParams(); - const router = useRouter(); const taskId = params.id as string; const [task, setTask] = useState(null); @@ -50,12 +49,7 @@ export default function TaskDetailPage() { const [showIssueForm, setShowIssueForm] = useState(false); const [showEditForm, setShowEditForm] = useState(false); - useEffect(() => { - fetchTaskDetail(); - fetchTaskIssues(); - }, [taskId]); - - const fetchTaskDetail = async () => { + const fetchTaskDetail = useCallback(async () => { try { const res = await fetch(`${API_BASE}/api/v1/project-management/tasks/${taskId}`); if (!res.ok) { @@ -66,14 +60,14 @@ export default function TaskDetailPage() { } const data = await res.json(); setTask(data); - } catch (err: any) { - setError(err.message); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } - }; + }, [taskId]); - const fetchTaskIssues = async () => { + const fetchTaskIssues = useCallback(async () => { try { const res = await fetch(`${API_BASE}/api/v1/project-management/issues?task_id=${taskId}`); if (res.ok) { @@ -83,7 +77,12 @@ export default function TaskDetailPage() { } catch (err) { console.error('获取问题列表失败:', err); } - }; + }, [taskId]); + + useEffect(() => { + fetchTaskDetail(); + fetchTaskIssues(); + }, [fetchTaskDetail, fetchTaskIssues]); const resolveIssue = async (issueId: string) => { setUpdating(true); @@ -93,8 +92,8 @@ export default function TaskDetailPage() { }); if (!res.ok) throw new Error('解决问题失败'); await fetchTaskIssues(); - } catch (err: any) { - alert(err.message); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : String(err)); } finally { setUpdating(false); } @@ -110,8 +109,8 @@ export default function TaskDetailPage() { }); if (!res.ok) throw new Error('更新状态失败'); await fetchTaskDetail(); - } catch (err: any) { - alert(err.message); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : String(err)); } finally { setUpdating(false); } @@ -127,8 +126,8 @@ export default function TaskDetailPage() { }); if (!res.ok) throw new Error('更新进度失败'); await fetchTaskDetail(); - } catch (err: any) { - alert(err.message); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : String(err)); } finally { setUpdating(false); } diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index d68bd13af..e7672ff06 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -2,8 +2,8 @@ * 素材相关 API * Phase 1 重构:去掉 project_id,素材直接归属用户 */ -import apiClient from './client'; -import { getOrCreateDefaultProject } from './projects'; +import apiClient from "./client"; +import { getOrCreateDefaultProject } from "./projects"; /** 素材条目 */ export interface AssetItem { @@ -24,7 +24,7 @@ export interface AssetItem { export interface AssetLibraryItem { id: string; name: string; - kind: 'video' | 'voice' | 'image'; + kind: "video" | "voice" | "image"; asset_count?: number; total_size?: number; created_at?: string; @@ -35,7 +35,7 @@ export interface IngestJob { id: string; library_id: string; storage_key: string; - status: 'pending' | 'processing' | 'completed' | 'failed'; + status: "pending" | "processing" | "completed" | "failed"; error_message: string; result_asset_id: string; } @@ -44,7 +44,7 @@ export interface IngestJob { export interface ClassificationJob { id: string; asset_id: string; - status: 'pending' | 'processing' | 'completed' | 'failed'; + status: "pending" | "processing" | "completed" | "failed"; classification: string; confidence: number; error_message: string; @@ -72,7 +72,7 @@ export interface AssetDiagnosis { }>; gaps: Array<{ key: string; - severity: 'critical' | 'warning' | 'info'; + severity: "critical" | "warning" | "info"; message: string; recommendation: string; }>; @@ -82,7 +82,7 @@ export interface AssetDiagnosis { /** 获取当前用户的素材诊断信息 */ export const getAssetDiagnosis = async (): Promise => { - const response = await apiClient.get('/asset-diagnosis'); + const response = await apiClient.get("/asset-diagnosis"); return response.data; }; @@ -90,18 +90,18 @@ export const getAssetDiagnosis = async (): Promise => { /** 获取当前用户的所有素材库 */ export const getAssetLibraries = async (): Promise => { - const response = await apiClient.get('/asset-libraries'); + const response = await apiClient.get("/asset-libraries"); return response.data.items || []; }; /** 创建素材库(自动获取或创建默认项目以提供 project_id) */ export const createAssetLibrary = async (data: { name: string; - kind: 'video' | 'voice' | 'image'; + kind: "video" | "voice" | "image"; }): Promise => { // 后端要求 project_id,前端自动管理默认项目 const project = await getOrCreateDefaultProject(); - const response = await apiClient.post('/asset-libraries', { + const response = await apiClient.post("/asset-libraries", { project_id: project.id, ...data, }); @@ -117,7 +117,7 @@ export const deleteAssetLibrary = async (libraryId: string): Promise => { /** 获取素材库下的所有素材 */ export const getAssets = async (libraryId: string): Promise => { - const response = await apiClient.get('/assets', { + const response = await apiClient.get("/assets", { params: { library_id: libraryId }, }); return response.data.items || []; @@ -126,7 +126,7 @@ export const getAssets = async (libraryId: string): Promise => { /** 更新素材审核状态 */ export const updateAssetReviewStatus = async ( assetId: string, - reviewStatus: 'pending_review' | 'approved' | 'rejected' + reviewStatus: "pending_review" | "approved" | "rejected", ): Promise => { const response = await apiClient.patch(`/assets/${assetId}/review`, { review_status: reviewStatus, @@ -143,10 +143,10 @@ export const deleteAsset = async (assetId: string): Promise => { /** 表单上传素材(小文件) */ export const uploadAsset = async ( - formData: FormData + formData: FormData, ): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => { - const response = await apiClient.post('/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, + const response = await apiClient.post("/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, timeout: 30 * 60 * 1000, }); return response.data; @@ -167,7 +167,7 @@ export const prepareDirectUpload = async (data: { fields: Record; max_size_bytes: number; }> => { - const response = await apiClient.post('/upload/direct/prepare', data); + const response = await apiClient.post("/upload/direct/prepare", data); return response.data; }; @@ -177,7 +177,7 @@ export const completeDirectUpload = async (data: { library_id: string; storage_key: string; }): Promise<{ storage_key: string; ingest_job_id: string }> => { - const response = await apiClient.post('/upload/direct/complete', data); + const response = await apiClient.post("/upload/direct/complete", data); return response.data; }; @@ -193,15 +193,15 @@ export const uploadAssetDirect = async (data: { project_id: project.id, library_id: data.library_id, filename: data.file.name, - content_type: data.file.type || 'application/octet-stream', + content_type: data.file.type || "application/octet-stream", file_size: data.file.size, }); const directForm = new FormData(); Object.entries(prepared.fields).forEach(([key, value]) => - directForm.append(key, value) + directForm.append(key, value), ); - directForm.append('file', data.file); + directForm.append("file", data.file); const uploadResponse = await fetch(prepared.upload_url, { method: prepared.method, @@ -230,13 +230,13 @@ export const getIngestJob = async (jobId: string): Promise => { export const submitClassificationJob = async (data: { asset_id: string; }): Promise => { - const response = await apiClient.post('/classification-jobs', data); + const response = await apiClient.post("/classification-jobs", data); return response.data; }; /** 查询分类任务状态 */ export const getClassificationJob = async ( - jobId: string + jobId: string, ): Promise => { const response = await apiClient.get(`/classification-jobs/${jobId}`); return response.data; diff --git a/apps/web/src/api/auth.test.ts b/apps/web/src/api/auth.test.ts index 6af5fd15b..2e79905f4 100644 --- a/apps/web/src/api/auth.test.ts +++ b/apps/web/src/api/auth.test.ts @@ -1,47 +1,47 @@ -import { describe, expect, it } from 'vitest'; -import { normalizeUser } from './auth'; +import { describe, expect, it } from "vitest"; +import { normalizeUser } from "./auth"; -describe('normalizeUser', () => { - it('normalizes canonical API current-user fields', () => { +describe("normalizeUser", () => { + it("normalizes canonical API current-user fields", () => { expect( normalizeUser({ - user_id: 'user-1', - email: 'user@example.com', - username: 'user', - display_name: 'User', + user_id: "user-1", + email: "user@example.com", + username: "user", + display_name: "User", email_verified: true, - }) + }), ).toEqual({ - id: 'user-1', - user_id: 'user-1', - email: 'user@example.com', - username: 'user', - display_name: 'User', + id: "user-1", + user_id: "user-1", + email: "user@example.com", + username: "user", + display_name: "User", is_email_verified: true, email_verified: true, created_at: undefined, }); }); - it('keeps compatibility with legacy UI-shaped user fields', () => { + it("keeps compatibility with legacy UI-shaped user fields", () => { expect( normalizeUser({ - id: 'user-2', - email: 'legacy@example.com', - username: 'legacy', - display_name: 'Legacy', + id: "user-2", + email: "legacy@example.com", + username: "legacy", + display_name: "Legacy", is_email_verified: false, - created_at: '2026-06-22T00:00:00Z', - }) + created_at: "2026-06-22T00:00:00Z", + }), ).toEqual({ - id: 'user-2', - user_id: 'user-2', - email: 'legacy@example.com', - username: 'legacy', - display_name: 'Legacy', + id: "user-2", + user_id: "user-2", + email: "legacy@example.com", + username: "legacy", + display_name: "Legacy", is_email_verified: false, email_verified: false, - created_at: '2026-06-22T00:00:00Z', + created_at: "2026-06-22T00:00:00Z", }); }); }); diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts index 00e6774bc..90e3da319 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -1,7 +1,7 @@ /** * 认证相关 API */ -import apiClient from './client'; +import apiClient from "./client"; // 类型定义 export interface LoginRequest { @@ -50,7 +50,7 @@ export interface UserResponse { } export const normalizeUser = (data: UserResponse): User => { - const userId = data.id ?? data.user_id ?? ''; + const userId = data.id ?? data.user_id ?? ""; const emailVerified = data.is_email_verified ?? data.email_verified ?? false; return { @@ -67,39 +67,43 @@ export const normalizeUser = (data: UserResponse): User => { // 登录 export const login = async (data: LoginRequest): Promise => { - const response = await apiClient.post('/auth/login', data); + const response = await apiClient.post("/auth/login", data); return response.data; }; // 注册 -export const register = async (data: RegisterRequest): Promise<{ message: string }> => { - const response = await apiClient.post('/auth/register', data); +export const register = async ( + data: RegisterRequest, +): Promise<{ message: string }> => { + const response = await apiClient.post("/auth/register", data); return response.data; }; // 登出 export const logout = async (): Promise => { - await apiClient.post('/auth/logout'); + await apiClient.post("/auth/logout"); }; // 获取当前用户 export const getCurrentUser = async (): Promise => { - const response = await apiClient.get('/auth/me'); + const response = await apiClient.get("/auth/me"); return normalizeUser(response.data); }; // 请求密码重置 -export const requestPasswordReset = async (email: string): Promise<{ message: string }> => { - const response = await apiClient.post('/auth/forgot-password', { email }); +export const requestPasswordReset = async ( + email: string, +): Promise<{ message: string }> => { + const response = await apiClient.post("/auth/forgot-password", { email }); return response.data; }; // 重置密码 export const resetPassword = async ( token: string, - newPassword: string + newPassword: string, ): Promise<{ message: string }> => { - const response = await apiClient.post('/auth/reset-password', { + const response = await apiClient.post("/auth/reset-password", { token, new_password: newPassword, }); @@ -107,7 +111,9 @@ export const resetPassword = async ( }; // 验证邮箱 -export const verifyEmail = async (token: string): Promise<{ message: string }> => { - const response = await apiClient.post('/auth/verify-email', { token }); +export const verifyEmail = async ( + token: string, +): Promise<{ message: string }> => { + const response = await apiClient.post("/auth/verify-email", { token }); return response.data; }; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index a6de7f408..3b5ccfb65 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -2,23 +2,23 @@ * API 客户端配置 * 封装 Axios 实例,配置拦截器和 Token 管理 */ -import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; -import { message } from 'antd'; -import { useAuthStore } from '@/store/authStore'; +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; +import { message } from "antd"; +import { useAuthStore } from "@/store/authStore"; // 创建 Axios 实例 const apiClient = axios.create({ - baseURL: '/api/v1', + baseURL: "/api/v1", timeout: 10000, headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, }); // 请求拦截器:添加 Token apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { - const token = localStorage.getItem('access_token'); + const token = localStorage.getItem("access_token"); if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } @@ -26,13 +26,15 @@ apiClient.interceptors.request.use( }, (error: AxiosError) => { return Promise.reject(error); - } + }, ); // 响应拦截器:统一错误提示 + 处理未授权状态 apiClient.interceptors.response.use( (response) => response, - async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => { + async ( + error: AxiosError<{ detail?: string; message?: string; msg?: string }>, + ) => { // 401 → 清除登录态 if (error.response?.status === 401) { useAuthStore.getState().clearAuth(); @@ -43,11 +45,11 @@ apiClient.interceptors.response.use( const serverMsg = data?.detail || data?.message || data?.msg; let handled = false; - if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { - message.error('请求超时,请检查网络后重试'); + if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) { + message.error("请求超时,请检查网络后重试"); handled = true; } else if (!error.response) { - message.error('网络连接异常,请检查网络设置'); + message.error("网络连接异常,请检查网络设置"); handled = true; } else if (serverMsg) { message.error(serverMsg); @@ -55,16 +57,16 @@ apiClient.interceptors.response.use( } else { const status = error.response?.status; if (status === 413) { - message.error('文件过大,请缩小后重试'); + message.error("文件过大,请缩小后重试"); handled = true; } else if (status === 415) { - message.error('不支持的文件格式'); + message.error("不支持的文件格式"); handled = true; } else if (status === 503) { - message.error('服务暂不可用,请稍后再试'); + message.error("服务暂不可用,请稍后再试"); handled = true; } else if (status && status >= 500) { - message.error('服务器繁忙,请稍后再试'); + message.error("服务器繁忙,请稍后再试"); handled = true; } // 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理 @@ -72,11 +74,12 @@ apiClient.interceptors.response.use( // 标记已展示过提示,组件 onError 可据此跳过重复 toast if (handled) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (error as any).__msgShown = true; } return Promise.reject(error); - } + }, ); export default apiClient; diff --git a/apps/web/src/api/dashboard.ts b/apps/web/src/api/dashboard.ts index 4fe3f6e6f..6522aa71e 100644 --- a/apps/web/src/api/dashboard.ts +++ b/apps/web/src/api/dashboard.ts @@ -2,7 +2,7 @@ * 仪表盘 API * Phase 1 新增:用户仪表盘概览 */ -import apiClient from './client'; +import apiClient from "./client"; /** 仪表盘概览数据 */ export interface DashboardOverview { @@ -29,15 +29,14 @@ export interface DashboardOverview { }>; /** 订阅信息 */ subscription: { - plan: 'free' | 'pro' | 'enterprise'; - status: 'active' | 'inactive' | 'expired'; + plan: "free" | "pro" | "enterprise"; + status: "active" | "inactive" | "expired"; expires_at?: string; }; } /** 获取仪表盘概览数据 */ -export const getDashboardOverview = - async (): Promise => { - const response = await apiClient.get('/dashboard/overview'); - return response.data; - }; +export const getDashboardOverview = async (): Promise => { + const response = await apiClient.get("/dashboard/overview"); + return response.data; +}; diff --git a/apps/web/src/api/duplication.ts b/apps/web/src/api/duplication.ts index d828f9235..2314ee76a 100644 --- a/apps/web/src/api/duplication.ts +++ b/apps/web/src/api/duplication.ts @@ -2,10 +2,11 @@ * 查重 API 模块 * 提供视频查重相关接口(当前使用 mock 数据,后端就绪后切换) */ -import apiClient from './client'; +import apiClient from "./client"; /** 查重记录状态 */ -export type DuplicationStatus = 'pending' | 'processing' | 'completed' | 'failed'; +export type DuplicationStatus = + "pending" | "processing" | "completed" | "failed"; /** 查重记录 */ export interface DuplicationRecord { @@ -68,55 +69,55 @@ export interface DuplicationUploadResponse { /** mock 查重记录列表 */ const MOCK_RECORDS: DuplicationRecord[] = [ { - id: 'dup-001', - filename: '日常vlog_01.mp4', + id: "dup-001", + filename: "日常vlog_01.mp4", file_size: 125_000_000, duration_seconds: 180, - status: 'completed', + status: "completed", duplicate_rate: 23.5, duplicate_count: 3, - created_at: '2026-06-27T10:00:00Z', - updated_at: '2026-06-27T10:05:00Z', + created_at: "2026-06-27T10:00:00Z", + updated_at: "2026-06-27T10:05:00Z", }, { - id: 'dup-002', - filename: '美食分享_片段.mp4', + id: "dup-002", + filename: "美食分享_片段.mp4", file_size: 45_000_000, duration_seconds: 60, - status: 'completed', + status: "completed", duplicate_rate: 5.2, duplicate_count: 1, - created_at: '2026-06-27T11:30:00Z', - updated_at: '2026-06-27T11:32:00Z', + created_at: "2026-06-27T11:30:00Z", + updated_at: "2026-06-27T11:32:00Z", }, { - id: 'dup-003', - filename: '旅行记录_巴黎.mp4', + id: "dup-003", + filename: "旅行记录_巴黎.mp4", file_size: 320_000_000, duration_seconds: 420, - status: 'processing', - created_at: '2026-06-28T09:00:00Z', - updated_at: '2026-06-28T09:00:00Z', + status: "processing", + created_at: "2026-06-28T09:00:00Z", + updated_at: "2026-06-28T09:00:00Z", }, { - id: 'dup-004', - filename: '产品展示_新版.mp4', + id: "dup-004", + filename: "产品展示_新版.mp4", file_size: 88_000_000, duration_seconds: 90, - status: 'completed', + status: "completed", duplicate_rate: 67.8, duplicate_count: 8, - created_at: '2026-06-26T15:00:00Z', - updated_at: '2026-06-26T15:10:00Z', + created_at: "2026-06-26T15:00:00Z", + updated_at: "2026-06-26T15:10:00Z", }, { - id: 'dup-005', - filename: '教程_剪辑技巧.mp4', + id: "dup-005", + filename: "教程_剪辑技巧.mp4", file_size: 200_000_000, duration_seconds: 300, - status: 'failed', - created_at: '2026-06-26T14:00:00Z', - updated_at: '2026-06-26T14:01:00Z', + status: "failed", + created_at: "2026-06-26T14:00:00Z", + updated_at: "2026-06-26T14:01:00Z", }, ]; @@ -125,31 +126,31 @@ const MOCK_DETAIL: DuplicationDetail = { ...MOCK_RECORDS[0], segments: [ { - id: 'seg-001', + id: "seg-001", source_start: 10, source_end: 25, - matched_video_id: 'asset-101', - matched_video_name: '日常vlog_素材库.mp4', + matched_video_id: "asset-101", + matched_video_name: "日常vlog_素材库.mp4", matched_start: 45, matched_end: 60, similarity: 92.3, }, { - id: 'seg-002', + id: "seg-002", source_start: 60, source_end: 78, - matched_video_id: 'asset-205', - matched_video_name: '城市风光_合集.mp4', + matched_video_id: "asset-205", + matched_video_name: "城市风光_合集.mp4", matched_start: 120, matched_end: 138, similarity: 85.7, }, { - id: 'seg-003', + id: "seg-003", source_start: 150, source_end: 165, - matched_video_id: 'asset-310', - matched_video_name: '背景音乐_配套画面.mp4', + matched_video_id: "asset-310", + matched_video_name: "背景音乐_配套画面.mp4", matched_start: 30, matched_end: 45, similarity: 78.1, @@ -164,21 +165,21 @@ const USE_MOCK = true; /** 上传视频进行查重 */ export const uploadForDuplication = async ( - file: File + file: File, ): Promise => { if (USE_MOCK) { // 模拟上传延迟 await new Promise((resolve) => setTimeout(resolve, 1500)); return { id: `dup-${Date.now()}`, - status: 'processing', + status: "processing", message: `文件 "${file.name}" 已上传,正在查重中...`, }; } const formData = new FormData(); - formData.append('file', file); - const response = await apiClient.post('/duplication/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, + formData.append("file", file); + const response = await apiClient.post("/duplication/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, }); return response.data; }; @@ -189,13 +190,13 @@ export const getDuplicationRecords = async (): Promise => { await new Promise((resolve) => setTimeout(resolve, 300)); return MOCK_RECORDS; } - const response = await apiClient.get('/duplication/records'); + const response = await apiClient.get("/duplication/records"); return response.data; }; /** 获取查重详情 */ export const getDuplicationDetail = async ( - recordId: string + recordId: string, ): Promise => { if (USE_MOCK) { await new Promise((resolve) => setTimeout(resolve, 300)); @@ -208,7 +209,7 @@ export const getDuplicationDetail = async ( /** 删除查重记录 */ export const deleteDuplicationRecord = async ( - recordId: string + recordId: string, ): Promise => { if (USE_MOCK) { await new Promise((resolve) => setTimeout(resolve, 200)); @@ -219,16 +220,18 @@ export const deleteDuplicationRecord = async ( /** 重新查重 */ export const retryDuplication = async ( - recordId: string + recordId: string, ): Promise => { if (USE_MOCK) { await new Promise((resolve) => setTimeout(resolve, 500)); return { id: recordId, - status: 'processing', - message: '已重新提交查重', + status: "processing", + message: "已重新提交查重", }; } - const response = await apiClient.post(`/duplication/records/${recordId}/retry`); + const response = await apiClient.post( + `/duplication/records/${recordId}/retry`, + ); return response.data; }; diff --git a/apps/web/src/api/editingPlanner.ts b/apps/web/src/api/editingPlanner.ts index 9c032d66a..79d01189a 100644 --- a/apps/web/src/api/editingPlanner.ts +++ b/apps/web/src/api/editingPlanner.ts @@ -2,27 +2,27 @@ * 剪辑计划编辑器 API * 对接后端 /api/v1/templates 路由 */ -import apiClient from './client'; +import apiClient from "./client"; /* ──────────── 类型定义 ──────────── */ /** 模板模式(后端枚举值) */ -export type TemplateMode = 'pip' | 'voice_over' | 'one_take' | 'voice_pip'; +export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"; /** 模式显示名称映射 */ export const MODE_LABELS: Record = { - pip: '画中画', - voice_over: '人物口播', - one_take: '一镜到底', - voice_pip: '口播+混剪', + pip: "画中画", + voice_over: "人物口播", + one_take: "一镜到底", + voice_pip: "口播+混剪", }; /** 模式颜色映射 */ export const MODE_COLORS: Record = { - pip: 'blue', - voice_over: 'green', - one_take: 'orange', - voice_pip: 'purple', + pip: "blue", + voice_over: "green", + one_take: "orange", + voice_pip: "purple", }; /** 标题配置 */ @@ -94,7 +94,7 @@ export interface SaveTemplatePayload { subtitle_config: SubtitleConfig; bgm_config: BgmConfig; estimated_duration: number; - segments: Omit[]; + segments: Omit[]; } /** 使用模板生成请求体 */ @@ -135,20 +135,23 @@ export const getEditingTemplates = async (params?: { skip?: number; limit?: number; }): Promise => { - const response = await apiClient.get('/templates', { + const response = await apiClient.get("/templates", { params: { skip: params?.skip ?? 0, limit: params?.limit ?? 50, }, }); let list = response.data.items; - if (params?.category) list = list.filter((t) => t.category === params.category); + if (params?.category) + list = list.filter((t) => t.category === params.category); if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!)); return list; }; /** 获取模板详情 */ -export const getEditingTemplate = async (id: string): Promise => { +export const getEditingTemplate = async ( + id: string, +): Promise => { const response = await apiClient.get(`/templates/${id}`); return response.data; }; @@ -157,7 +160,7 @@ export const getEditingTemplate = async (id: string): Promise = export const createEditingTemplate = async ( data: SaveTemplatePayload, ): Promise => { - const response = await apiClient.post('/templates', data); + const response = await apiClient.post("/templates", data); return response.data; }; @@ -166,7 +169,10 @@ export const updateEditingTemplate = async ( id: string, data: SaveTemplatePayload, ): Promise => { - const response = await apiClient.patch(`/templates/${id}`, data); + const response = await apiClient.patch( + `/templates/${id}`, + data, + ); return response.data; }; @@ -177,7 +183,9 @@ export const deleteEditingTemplate = async (id: string): Promise => { /** 获取模板分类列表 */ export const getTemplateCategories = async (): Promise => { - const response = await apiClient.get('/templates/categories/list'); + const response = await apiClient.get( + "/templates/categories/list", + ); return response.data.items; }; diff --git a/apps/web/src/api/products.ts b/apps/web/src/api/products.ts index 948b0a4c3..8b5dc4375 100644 --- a/apps/web/src/api/products.ts +++ b/apps/web/src/api/products.ts @@ -2,7 +2,7 @@ * 成品相关 API * Phase 1 重构:去掉 projectId,成品直接归属用户 */ -import apiClient from './client'; +import apiClient from "./client"; /** 成品条目 */ export interface ProductItem { @@ -13,7 +13,7 @@ export interface ProductItem { duration_seconds?: number; file_size?: number; resolution?: string; - status: 'processing' | 'completed' | 'failed'; + status: "processing" | "completed" | "failed"; /** 查重率(百分比) */ duplicate_rate?: number; created_at?: string; @@ -22,14 +22,12 @@ export interface ProductItem { /** 获取当前用户的所有成品 */ export const getProducts = async (): Promise => { - const response = await apiClient.get('/products'); + const response = await apiClient.get("/products"); return response.data.items || response.data || []; }; /** 获取单个成品详情 */ -export const getProduct = async ( - productId: string -): Promise => { +export const getProduct = async (productId: string): Promise => { const response = await apiClient.get(`/products/${productId}`); return response.data; }; @@ -41,10 +39,8 @@ export const deleteProduct = async (productId: string): Promise => { /** 获取成品下载链接 */ export const getProductDownloadUrl = async ( - productId: string + productId: string, ): Promise<{ url: string; expires_at: string }> => { - const response = await apiClient.get( - `/products/${productId}/download-url` - ); + const response = await apiClient.get(`/products/${productId}/download-url`); return response.data; }; diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts index fb3d73bb1..5d46a1ddc 100644 --- a/apps/web/src/api/projects.ts +++ b/apps/web/src/api/projects.ts @@ -2,7 +2,7 @@ * 项目相关 API * 素材库需要 project_id,前端自动管理默认项目 */ -import apiClient from './client'; +import apiClient from "./client"; export interface ProjectItem { id: string; @@ -30,7 +30,8 @@ const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({ /** 获取当前用户的项目列表 */ export const getProjects = async (): Promise => { - const response = await apiClient.get('/projects'); + const response = + await apiClient.get("/projects"); return (response.data.items || []).map(toProjectItem); }; @@ -39,9 +40,9 @@ export const createProject = async (data: { name: string; description?: string; }): Promise => { - const response = await apiClient.post('/projects', { + const response = await apiClient.post("/projects", { name: data.name, - description: data.description || '', + description: data.description || "", }); return toProjectItem(response.data); }; @@ -54,7 +55,7 @@ export const getOrCreateDefaultProject = async (): Promise => { } // 没有项目时自动创建默认项目 return createProject({ - name: '默认项目', - description: '系统自动创建的默认项目', + name: "默认项目", + description: "系统自动创建的默认项目", }); }; diff --git a/apps/web/src/api/subscription.ts b/apps/web/src/api/subscription.ts index 5bba83b3b..c4eac3b4b 100644 --- a/apps/web/src/api/subscription.ts +++ b/apps/web/src/api/subscription.ts @@ -2,19 +2,19 @@ * 订阅 API 模块 * 对接后端订阅管理接口 */ -import apiClient from './client'; +import apiClient from "./client"; /** 套餐类型 */ -export type PlanType = 'free' | 'standard' | 'pro' | 'enterprise'; +export type PlanType = "free" | "standard" | "pro" | "enterprise"; /** 订阅状态 */ -export type SubscriptionStatus = 'active' | 'expired' | 'cancelled' | 'trial'; +export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial"; /** 账单状态 */ -export type BillingStatus = 'paid' | 'pending' | 'failed' | 'refunded'; +export type BillingStatus = "paid" | "pending" | "failed" | "refunded"; /** 计费周期 */ -export type BillingCycle = 'monthly' | 'yearly'; +export type BillingCycle = "monthly" | "yearly"; /** 套餐信息 */ export interface Plan { @@ -70,30 +70,39 @@ export interface ChangePlanResponse { /** 获取当前订阅信息 */ export const getCurrentSubscription = async (): Promise => { - const response = await apiClient.get('/subscription/current'); + const response = await apiClient.get("/subscription/current"); return response.data; }; /** 获取账单记录列表 */ export const getBillingRecords = async (): Promise => { - const response = await apiClient.get('/subscription/billing-records'); + const response = await apiClient.get("/subscription/billing-records"); return response.data; }; /** 升级/降级套餐 */ -export const changePlan = async (request: ChangePlanRequest): Promise => { - const response = await apiClient.post('/subscription/change-plan', request); +export const changePlan = async ( + request: ChangePlanRequest, +): Promise => { + const response = await apiClient.post("/subscription/change-plan", request); return response.data; }; /** 取消订阅 */ -export const cancelSubscription = async (): Promise<{ success: boolean; message: string }> => { - const response = await apiClient.post('/subscription/cancel'); +export const cancelSubscription = async (): Promise<{ + success: boolean; + message: string; +}> => { + const response = await apiClient.post("/subscription/cancel"); return response.data; }; /** 切换自动续费 */ -export const toggleAutoRenew = async (enabled: boolean): Promise<{ success: boolean; message: string }> => { - const response = await apiClient.post('/subscription/toggle-auto-renew', { enabled }); +export const toggleAutoRenew = async ( + enabled: boolean, +): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post("/subscription/toggle-auto-renew", { + enabled, + }); return response.data; }; diff --git a/apps/web/src/api/tasks.ts b/apps/web/src/api/tasks.ts index de9ed2e89..23276f5c7 100644 --- a/apps/web/src/api/tasks.ts +++ b/apps/web/src/api/tasks.ts @@ -5,14 +5,14 @@ * - GET /api/v1/tasks — 用户级任务列表(跨 project) * - POST /api/v1/tasks/{task_id}/retry — 简化重试 */ -import apiClient from './client'; +import apiClient from "./client"; /* ──────────── 类型定义 ──────────── */ /** 任务条目(对应用户级 UserTaskResponse) */ export interface TaskItem { id: string; - task_type: 'ingest' | 'generation' | string; + task_type: "ingest" | "generation" | string; project_id: string; template_id: string; status: string; @@ -58,7 +58,7 @@ export const createGenerationTask = async ( params: CreateGenerationTaskRequest, ): Promise => { const { data } = await apiClient.post( - '/generation/tasks', + "/generation/tasks", params, ); return data; @@ -66,7 +66,7 @@ export const createGenerationTask = async ( /** 获取当前用户的所有任务(跨 project) */ export const getUserTasks = async (): Promise => { - const { data } = await apiClient.get('/tasks'); + const { data } = await apiClient.get("/tasks"); return data.items || []; }; diff --git a/apps/web/src/api/templates.ts b/apps/web/src/api/templates.ts index a39cd2684..d1889e442 100644 --- a/apps/web/src/api/templates.ts +++ b/apps/web/src/api/templates.ts @@ -2,7 +2,7 @@ * 模板相关 API * Phase 1 新增:全局模板库 */ -import apiClient from './client'; +import apiClient from "./client"; /** 模板条目 */ export interface TemplateItem { @@ -21,13 +21,13 @@ export interface TemplateItem { /** 获取全局模板列表 */ export const getTemplates = async (): Promise => { - const response = await apiClient.get('/templates'); + const response = await apiClient.get("/templates"); return response.data.items || response.data || []; }; /** 获取单个模板详情 */ export const getTemplate = async ( - templateId: string + templateId: string, ): Promise => { const response = await apiClient.get(`/templates/${templateId}`); return response.data; @@ -35,10 +35,10 @@ export const getTemplate = async ( /** 收藏 / 取消收藏模板 */ export const toggleFavoriteTemplate = async ( - templateId: string + templateId: string, ): Promise<{ is_favorite: boolean }> => { const response = await apiClient.post( - `/templates/${templateId}/toggle-favorite` + `/templates/${templateId}/toggle-favorite`, ); return response.data; }; diff --git a/apps/web/src/api/titles.ts b/apps/web/src/api/titles.ts index 376d21e0c..68dec6a90 100644 --- a/apps/web/src/api/titles.ts +++ b/apps/web/src/api/titles.ts @@ -3,7 +3,7 @@ * Phase 1 新增:全局标题库 * 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示 */ -import apiClient from './client'; +import apiClient from "./client"; /** 标题条目(前端展示用) */ export interface TitleItem { @@ -68,7 +68,9 @@ export interface CreateTitleRequest { /** 获取当前用户的所有标题 */ export const getTitles = async (): Promise => { - const response = await apiClient.get<{ items: BackendTitleResponse[] }>('/titles'); + const response = await apiClient.get<{ items: BackendTitleResponse[] }>( + "/titles", + ); return (response.data.items || []).map(toTitleItem); }; @@ -80,9 +82,12 @@ export const createTitle = async ( const payload: BackendCreateTitleRequest = { name: data.content.slice(0, 255), text: data.content.slice(0, 500), - category: data.category || 'default', + category: data.category || "default", }; - const response = await apiClient.post('/titles', payload); + const response = await apiClient.post( + "/titles", + payload, + ); return toTitleItem(response.data); }; @@ -116,6 +121,6 @@ export const deleteTitle = async (titleId: string): Promise => { export const batchImportTitles = async ( titles: string[], ): Promise<{ imported_count: number }> => { - const response = await apiClient.post('/titles/batch-import', { titles }); + const response = await apiClient.post("/titles/batch-import", { titles }); return response.data; }; diff --git a/apps/web/src/api/voices.ts b/apps/web/src/api/voices.ts index 84ea882ff..a0fd1b1f1 100644 --- a/apps/web/src/api/voices.ts +++ b/apps/web/src/api/voices.ts @@ -2,7 +2,7 @@ * 配音相关 API * Phase 1 新增:全局配音库 */ -import apiClient from './client'; +import apiClient from "./client"; /** 配音条目 */ export interface VoiceItem { @@ -28,22 +28,22 @@ export interface CreateVoiceRequest { /** 获取当前用户的所有配音 */ export const getVoices = async (): Promise => { - const response = await apiClient.get('/voices'); + const response = await apiClient.get("/voices"); return response.data.items || response.data || []; }; /** 创建配音 */ export const createVoice = async ( - data: CreateVoiceRequest + data: CreateVoiceRequest, ): Promise => { - const response = await apiClient.post('/voices', data); + const response = await apiClient.post("/voices", data); return response.data; }; /** 更新配音 */ export const updateVoice = async ( voiceId: string, - data: Partial + data: Partial, ): Promise => { const response = await apiClient.patch(`/voices/${voiceId}`, data); return response.data; @@ -60,6 +60,6 @@ export const generateAIVoice = async (data: { voice_type?: string; speed?: number; }): Promise => { - const response = await apiClient.post('/voices/generate', data); + const response = await apiClient.post("/voices/generate", data); return response.data; }; diff --git a/apps/web/src/components/business/business.css b/apps/web/src/components/business/business.css index fff1897e2..43ae2d92b 100644 --- a/apps/web/src/components/business/business.css +++ b/apps/web/src/components/business/business.css @@ -8,14 +8,14 @@ border-radius: 14px !important; padding: 10px 20px !important; font-weight: 600 !important; - box-shadow: 0 14px 26px rgba(79,70,229,0.22) !important; + box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important; transition: all 0.2s !important; cursor: pointer; height: auto !important; } .xx-primary-btn:hover { - box-shadow: 0 18px 34px rgba(79,70,229,0.28) !important; + box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important; transform: translateY(-1px); } @@ -37,17 +37,17 @@ /* ==================== 卡片 ==================== */ .xx-card { - background: rgba(255,255,255,0.94); - border: 1px solid rgba(226,232,240,0.95); + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(226, 232, 240, 0.95); border-radius: 22px; - box-shadow: 0 24px 70px rgba(15,23,42,0.09); + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09); padding: 24px; margin-bottom: 20px; transition: all 0.25s; } .xx-card:hover { - box-shadow: 0 26px 64px rgba(15,23,42,0.14); + box-shadow: 0 26px 64px rgba(15, 23, 42, 0.14); transform: translateY(-2px); } @@ -81,10 +81,10 @@ /* ==================== 表格样式 ==================== */ .xx-table-card { - background: rgba(255,255,255,0.94); - border: 1px solid rgba(226,232,240,0.95); + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(226, 232, 240, 0.95); border-radius: 22px; - box-shadow: 0 24px 70px rgba(15,23,42,0.09); + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09); padding: 20px; overflow: hidden; } @@ -145,7 +145,7 @@ .xx-search-input:focus { border-color: #4f46e5; - box-shadow: 0 0 0 4px rgba(79,70,229,0.1); + box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1); } /* ==================== Modal ==================== */ @@ -203,7 +203,9 @@ } @media (max-width: 768px) { - .xx-grid-2, .xx-grid-3, .xx-grid-4 { + .xx-grid-2, + .xx-grid-3, + .xx-grid-4 { grid-template-columns: 1fr; } } @@ -219,7 +221,7 @@ .xx-quota-item:hover { border-color: #4f46e5; - box-shadow: 0 8px 24px rgba(79,70,229,0.1); + box-shadow: 0 8px 24px rgba(79, 70, 229, 0.1); } /* ==================== 进度条 ==================== */ @@ -249,7 +251,7 @@ /* Card overrides */ .ant-card { border-radius: 22px !important; - border: 1px solid rgba(226,232,240,0.95) !important; + border: 1px solid rgba(226, 232, 240, 0.95) !important; } .ant-card-head { @@ -298,7 +300,7 @@ background: linear-gradient(135deg, #6366f1, #4f46e5) !important; border: none !important; border-radius: 14px !important; - box-shadow: 0 14px 26px rgba(79,70,229,0.22) !important; + box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important; height: auto !important; padding: 10px 20px !important; font-weight: 600 !important; @@ -306,7 +308,7 @@ .ant-btn-primary:hover { background: linear-gradient(135deg, #6366f1, #4f46e5) !important; - box-shadow: 0 18px 34px rgba(79,70,229,0.28) !important; + box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important; transform: translateY(-1px); } @@ -329,7 +331,7 @@ .ant-select-focused .ant-select-selector { border-color: #4f46e5 !important; - box-shadow: 0 0 0 3px rgba(79,70,229,0.1) !important; + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important; } /* Input overrides */ @@ -345,7 +347,7 @@ .ant-input:focus { border-color: #4f46e5 !important; - box-shadow: 0 0 0 3px rgba(79,70,229,0.1) !important; + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important; } /* Progress overrides */ diff --git a/apps/web/src/components/layout/AppLayout.tsx b/apps/web/src/components/layout/AppLayout.tsx new file mode 100644 index 000000000..e197ee83b --- /dev/null +++ b/apps/web/src/components/layout/AppLayout.tsx @@ -0,0 +1,29 @@ +/** + * AppLayout - 应用布局壳 + * + * 语义化 HTML 结构:header + aside + main 三栏布局 + * 由 MainLayout 管理侧边栏状态,AppLayout 负责渲染结构 + */ +import React from "react"; +import { Outlet } from "react-router-dom"; +import Header from "./Header"; + +interface AppLayoutProps { + sidebar: React.ReactNode; +} + +const AppLayout: React.FC = ({ sidebar }) => { + return ( +
+
+
+ {sidebar} +
+ +
+
+
+ ); +}; + +export default AppLayout; diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index a2f7ec1c9..2e3d3afd7 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -2,8 +2,8 @@ * Phase 1 Header 重构 * 扁平化导航菜单 + 手机端汉堡菜单 */ -import React, { useState } from 'react'; -import { Avatar, Dropdown, Drawer, Space } from 'antd'; +import React, { useState } from "react"; +import { Avatar, Dropdown, Drawer, Space } from "antd"; import { LogoutOutlined, SettingOutlined, @@ -20,12 +20,12 @@ import { ScanOutlined, EditOutlined, FolderOutlined, -} from '@ant-design/icons'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { useAuthStore } from '@/store/authStore'; -import { useLogout } from '@/hooks/useAuth'; -import type { MenuProps } from 'antd'; -import './Header.css'; +} from "@ant-design/icons"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAuthStore } from "@/store/authStore"; +import { useLogout } from "@/hooks/useAuth"; +import type { MenuProps } from "antd"; +import "./Header.css"; /** 导航项定义 */ interface NavItem { @@ -37,17 +37,62 @@ interface NavItem { /** 固定导航菜单 */ const NAV_ITEMS: NavItem[] = [ - { key: 'dashboard', label: '概览', path: '/dashboard', icon: }, - { key: 'assets', label: '素材库', path: '/assets', icon: }, - { key: 'titles', label: '标题库', path: '/titles', icon: }, - { key: 'voices', label: '配音库', path: '/voices', icon: }, - { key: 'templates', label: '模板库', path: '/templates', icon: }, - { key: 'editing-planner', label: '剪辑编辑器', path: '/editing-planner', icon: }, - { key: 'my-templates', label: '我的模板', path: '/my-templates', icon: }, - { key: 'generate', label: '一键生成', path: '/generate', icon: }, - { key: 'history', label: '任务历史', path: '/history', icon: }, - { key: 'products', label: '成品库', path: '/products', icon: }, - { key: 'duplication', label: '查重', path: '/duplication', icon: }, + { + key: "dashboard", + label: "概览", + path: "/dashboard", + icon: , + }, + { key: "assets", label: "素材库", path: "/assets", icon: }, + { + key: "titles", + label: "标题库", + path: "/titles", + icon: , + }, + { key: "voices", label: "配音库", path: "/voices", icon: }, + { + key: "templates", + label: "模板库", + path: "/templates", + icon: , + }, + { + key: "editing-planner", + label: "剪辑编辑器", + path: "/editing-planner", + icon: , + }, + { + key: "my-templates", + label: "我的模板", + path: "/my-templates", + icon: , + }, + { + key: "generate", + label: "一键生成", + path: "/generate", + icon: , + }, + { + key: "history", + label: "任务历史", + path: "/history", + icon: , + }, + { + key: "products", + label: "成品库", + path: "/products", + icon: , + }, + { + key: "duplication", + label: "查重", + path: "/duplication", + icon: , + }, ]; const Header: React.FC = () => { @@ -58,32 +103,32 @@ const Header: React.FC = () => { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); /** 用户下拉菜单 */ - const menuItems: MenuProps['items'] = [ + const menuItems: MenuProps["items"] = [ { - key: 'profile', + key: "profile", icon: , - label: '个人设置', - onClick: () => navigate('/profile'), + label: "个人设置", + onClick: () => navigate("/profile"), }, { - key: 'subscription', + key: "subscription", icon: , - label: '订阅管理', - onClick: () => navigate('/subscription'), + label: "订阅管理", + onClick: () => navigate("/subscription"), }, - { type: 'divider' }, + { type: "divider" }, { - key: 'logout', + key: "logout", icon: , - label: '退出登录', + label: "退出登录", onClick: () => logoutMutation.mutateAsync(), }, ]; /** 判断导航项是否激活 */ const isActive = (path: string) => { - if (path === '/dashboard') { - return location.pathname === '/' || location.pathname === '/dashboard'; + if (path === "/dashboard") { + return location.pathname === "/" || location.pathname === "/dashboard"; } return location.pathname.startsWith(path); }; @@ -91,7 +136,11 @@ const Header: React.FC = () => { return (
- @@ -101,7 +150,7 @@ const Header: React.FC = () => { {NAV_ITEMS.map((item) => (
@@ -142,7 +193,7 @@ const Header: React.FC = () => { {NAV_ITEMS.map((item) => ( + + + {/* 侧边栏导航 */} + + + + ); + return ( -
-
-
- -
-
+ + + ); }; diff --git a/apps/web/src/components/layout/PageHead.css b/apps/web/src/components/layout/PageHead.css new file mode 100644 index 000000000..9789a9183 --- /dev/null +++ b/apps/web/src/components/layout/PageHead.css @@ -0,0 +1,161 @@ +/* ============================================================ + PageHead.css - 页面头部扩展样式(Task 1.4) + + 基础样式复用 global.css 中的 .xx-page-head, + 本文件补充面包屑、标题区、操作区等扩展样式。 + + 结构: + .xx-page-head 头部容器(global.css 定义) + ├── .xx-page-head-left 左侧区域 + │ ├── .xx-page-breadcrumb 面包屑导航 + │ │ └── .xx-page-breadcrumb-item 面包屑项 + │ │ ├── .xx-page-breadcrumb-separator 分隔符 + │ │ ├── .xx-page-breadcrumb-link 可点击链接 + │ │ ├── .xx-page-breadcrumb-current 当前页(不可点击) + │ │ └── .xx-page-breadcrumb-home 首页图标 + │ └── .xx-page-head-title 标题 + 描述 + └── .xx-page-head-actions 右侧操作按钮区 + ============================================================ */ + +/* ── 左侧区域 ─────────────────────────────────────────────── */ +.xx-page-head-left { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; /* 允许内容收缩,防止溢出 */ +} + +/* ── 面包屑导航 ───────────────────────────────────────────── */ +.xx-page-breadcrumb { + margin: 0; + padding: 0; +} + +.xx-page-breadcrumb ol { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xxs); + margin: 0; + padding: 0; + list-style: none; +} + +.xx-page-breadcrumb-item { + display: flex; + align-items: center; + gap: var(--space-xxs); + font-size: var(--font-size-sm); + color: var(--text-tertiary); + line-height: var(--line-height-tight); +} + +/* 分隔符 */ +.xx-page-breadcrumb-separator { + font-size: 10px; + color: var(--text-quaternary); + flex-shrink: 0; +} + +/* 可点击链接 */ +.xx-page-breadcrumb-link { + display: inline-flex; + align-items: center; + gap: var(--space-xxs); + color: var(--text-secondary); + text-decoration: none; + transition: var(--transition-colors); + border-radius: var(--radius-xs); + padding: 2px 4px; + margin: -2px -4px; +} + +.xx-page-breadcrumb-link:hover { + color: var(--primary-color); + background: var(--primary-soft); +} + +/* 当前页(不可点击) */ +.xx-page-breadcrumb-current { + display: inline-flex; + align-items: center; + gap: var(--space-xxs); + color: var(--text-primary); + font-weight: var(--font-weight-medium); +} + +/* 首页图标 */ +.xx-page-breadcrumb-home { + font-size: 12px; +} + +/* ── 标题区 ───────────────────────────────────────────────── */ +.xx-page-head-title h2 { + margin: 0; +} + +.xx-page-head-title p { + margin: var(--space-xxs) 0 0; +} + +/* ── 右侧操作区 ───────────────────────────────────────────── */ +.xx-page-head-actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-shrink: 0; + margin-left: var(--space-lg); +} + +/* ============================================================ + 响应式 —— 平板(≤1024px) + 操作区按钮紧凑排列 + ============================================================ */ +@media (max-width: 1024px) { + .xx-page-head-actions { + gap: var(--space-xs); + margin-left: var(--space-md); + } +} + +/* ============================================================ + 响应式 —— 移动端(≤768px) + 隐藏面包屑,简化布局 + ============================================================ */ +@media (max-width: 768px) { + /* 隐藏面包屑 */ + .xx-page-breadcrumb { + display: none; + } + + /* 标题区占满宽度 */ + .xx-page-head-left { + flex: 1; + } + + /* 操作区换行显示 */ + .xx-page-head { + flex-wrap: wrap; + gap: var(--space-sm); + } + + .xx-page-head-actions { + margin-left: 0; + width: 100%; + justify-content: flex-start; + } +} + +/* ============================================================ + 响应式 —— 小屏手机(≤480px) + 标题缩小,操作区堆叠 + ============================================================ */ +@media (max-width: 480px) { + .xx-page-head-title h2 { + font-size: 20px; + } + + .xx-page-head-actions { + flex-wrap: wrap; + } +} diff --git a/apps/web/src/components/layout/PageHead.tsx b/apps/web/src/components/layout/PageHead.tsx new file mode 100644 index 000000000..191e4e685 --- /dev/null +++ b/apps/web/src/components/layout/PageHead.tsx @@ -0,0 +1,185 @@ +/** + * PageHead - 页面头部组件(Task 1.4) + * + * 功能: + * - 页面标题展示 + * - 面包屑导航(自动根据路由生成,也支持手动传入) + * - 右侧操作按钮区(slot,由页面自行填充) + * - 响应式:移动端简化布局(隐藏面包屑,缩小标题) + * + * 复用 global.css 中已有的 .xx-page-head 基础样式, + * 补充面包屑、操作区等扩展样式。 + */ +import React from "react"; +import { useLocation, useNavigate, Link } from "react-router-dom"; +import { RightOutlined, HomeOutlined } from "@ant-design/icons"; +import "./PageHead.css"; + +/* ── 类型定义 ─────────────────────────────────────────────── */ + +/** 面包屑项 */ +export interface BreadcrumbItem { + /** 显示文字 */ + label: string; + /** 路由路径,不传则为当前页(不可点击) */ + path?: string; +} + +/** PageHead 组件属性 */ +export interface PageHeadProps { + /** 页面标题 */ + title: string; + /** 页面描述(可选,显示在标题下方) */ + description?: string; + /** 面包屑项(可选,不传则自动根据路由生成) */ + breadcrumb?: BreadcrumbItem[]; + /** 右侧操作区内容(按钮等) */ + actions?: React.ReactNode; + /** 是否隐藏面包屑 */ + hideBreadcrumb?: boolean; +} + +/* ── 路由 → 标题映射(用于自动生成面包屑) ────────────────── */ + +const ROUTE_TITLE_MAP: Record = { + "/dashboard": "首页", + "/generate": "一键生成", + "/assets": "素材库", + "/voices": "配音库", + "/titles": "标题库", + "/products": "成片库", + "/templates": "模板库", + "/history": "任务历史", + "/admin": "控制台", + "/admin/users": "用户管理", + "/admin/analytics": "数据分析", + "/admin/monitor": "系统监控", + "/admin/logs": "系统日志", + "/subscription": "订阅管理", + "/subscription/upgrade": "升级订阅", + "/subscription/billing": "账单管理", + "/profile": "个人设置", + "/editing-planner": "剪辑规划", + "/my-templates": "我的模板", + "/duplication": "查重", + "/duplication/results": "查重结果", +}; + +/* ── 自动生成面包屑 ─────────────────────────────────────── */ + +/** 根据当前路径生成面包屑 */ +const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => { + const items: BreadcrumbItem[] = [{ label: "首页", path: "/dashboard" }]; + + // 首页本身不需要面包屑 + if (pathname === "/" || pathname === "/dashboard") { + return items; + } + + // 逐级拆分路径,生成中间层级 + const segments = pathname.split("/").filter(Boolean); + let currentPath = ""; + + for (let i = 0; i < segments.length; i++) { + currentPath += `/${segments[i]}`; + const title = ROUTE_TITLE_MAP[currentPath]; + + if (title) { + // 最后一级不带 path(当前页面,不可点击) + const isLast = i === segments.length - 1; + items.push({ + label: title, + path: isLast ? undefined : currentPath, + }); + } else { + // 动态路由段(如 :id),用路径片段做 label + const isLast = i === segments.length - 1; + items.push({ + label: segments[i], + path: isLast ? undefined : currentPath, + }); + } + } + + return items; +}; + +/* ── 组件 ───────────────────────────────────────────────── */ + +const PageHead: React.FC = ({ + title, + description, + breadcrumb, + actions, + hideBreadcrumb = false, +}) => { + const location = useLocation(); + const navigate = useNavigate(); + + // 使用传入的面包屑或自动生成 + const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname); + + // 首页不显示面包屑 + const showBreadcrumb = + !hideBreadcrumb && + breadcrumbItems.length > 1 && + location.pathname !== "/" && + location.pathname !== "/dashboard"; + + return ( +
+
+ {/* 面包屑导航 */} + {showBreadcrumb && ( + + )} + + {/* 标题 + 描述 */} +
+

{title}

+ {description &&

{description}

} +
+
+ + {/* 右侧操作区 */} + {actions &&
{actions}
} +
+ ); +}; + +export default PageHead; diff --git a/apps/web/src/components/layout/Sidebar.css b/apps/web/src/components/layout/Sidebar.css new file mode 100644 index 000000000..0c8d391a5 --- /dev/null +++ b/apps/web/src/components/layout/Sidebar.css @@ -0,0 +1,144 @@ +/* ============================================================ + Sidebar.css - 侧边栏导航样式(Task 1.3) + + 结构: + .xx-sidebar-nav 导航容器 + ├── .xx-sidebar-group 分组 + │ ├── .xx-sidebar-group-title 分组标题(折叠时隐藏) + │ └── .xx-sidebar-menu 菜单列表 + │ └── .xx-sidebar-menu-item 菜单项 + │ ├── .xx-sidebar-menu-icon 图标 + │ └── .xx-sidebar-menu-label 文字标签(折叠时隐藏) + └── .xx-sidebar-nav--collapsed 折叠态修饰类 + + 所有样式使用 global.css 设计系统变量 + ============================================================ */ + +/* ── 导航容器 ─────────────────────────────────────────────── */ +.xx-sidebar-nav { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-xs) 0; +} + +/* ── 分组 ─────────────────────────────────────────────────── */ +.xx-sidebar-group { + display: flex; + flex-direction: column; +} + +/* 分组标题 */ +.xx-sidebar-group-title { + padding: var(--space-sm) var(--space-md) var(--space-xs); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-wide); + line-height: var(--line-height-tight); + white-space: nowrap; + overflow: hidden; + user-select: none; +} + +/* ── 菜单列表 ─────────────────────────────────────────────── */ +.xx-sidebar-menu { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-xxs); +} + +/* ── 菜单项 ───────────────────────────────────────────────── */ +.xx-sidebar-menu-item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + margin: 0 var(--space-xs); + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-secondary); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-base); + transition: var(--transition-colors); + white-space: nowrap; + overflow: hidden; + user-select: none; + min-height: 40px; + box-sizing: border-box; +} + +.xx-sidebar-menu-item:hover { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +/* 激活态 —— Indigo 高亮 */ +.xx-sidebar-menu-item.xx-active { + color: var(--primary-color); + background: var(--primary-soft); + font-weight: var(--font-weight-semibold); +} + +.xx-sidebar-menu-item.xx-active:hover { + color: var(--primary-hover); + background: var(--primary-soft); +} + +/* ── 菜单项图标 ───────────────────────────────────────────── */ +.xx-sidebar-menu-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + font-size: var(--font-size-md); + line-height: 1; +} + +/* ── 菜单项文字 ───────────────────────────────────────────── */ +.xx-sidebar-menu-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ============================================================ + 折叠态 —— 仅显示图标 + ============================================================ */ +.xx-sidebar-nav--collapsed { + padding: var(--space-xs) 0; +} + +/* 折叠时菜单项居中,仅图标 */ +.xx-sidebar-nav--collapsed .xx-sidebar-menu-item { + justify-content: center; + padding: var(--space-sm); + margin: 0 var(--space-xxs); +} + +/* 折叠时隐藏分组标题 */ +.xx-sidebar-nav--collapsed .xx-sidebar-group-title { + display: none; +} + +/* ============================================================ + 响应式 —— 移动端(≤768px) + 侧边栏整体由 MainLayout 控制显示/隐藏, + 此处仅确保导航内容在移动端不溢出 + ============================================================ */ +@media (max-width: 768px) { + .xx-sidebar-nav { + padding: var(--space-xs) 0; + } + + .xx-sidebar-menu-item { + min-height: 44px; /* 移动端更大的点击区域 */ + } +} diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx new file mode 100644 index 000000000..84dd32400 --- /dev/null +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -0,0 +1,171 @@ +/** + * Sidebar - 侧边栏导航组件(Task 1.3) + * + * 功能: + * - 分组导航菜单(创作工具 / 资源管理 / 系统) + * - 每个菜单项:图标 + 文字 + * - 当前页面对应菜单项高亮(基于路由匹配) + * - 点击跳转路由 + * - 折叠态:仅显示图标,隐藏文字(通过 SidebarContext 读取 collapsed 状态) + * - 响应式:移动端自动折叠 + */ +import React, { useContext } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { + DashboardOutlined, + VideoCameraOutlined, + FileOutlined, + AudioOutlined, + FileTextOutlined, + TrophyOutlined, + AppstoreOutlined, + HistoryOutlined, + ControlOutlined, + CrownOutlined, +} from "@ant-design/icons"; +import { SidebarContext } from "./MainLayout"; +import "./Sidebar.css"; + +/** 导航项定义 */ +interface SidebarMenuItem { + key: string; + label: string; + path: string; + icon: React.ReactNode; +} + +/** 导航分组定义 */ +interface SidebarMenuGroup { + title: string; + items: SidebarMenuItem[]; +} + +/** 侧边栏导航分组 */ +const MENU_GROUPS: SidebarMenuGroup[] = [ + { + title: "创作工具", + items: [ + { + key: "dashboard", + label: "首页", + path: "/dashboard", + icon: , + }, + { + key: "generate", + label: "一键生成", + path: "/generate", + icon: , + }, + ], + }, + { + title: "资源管理", + items: [ + { + key: "assets", + label: "素材库", + path: "/assets", + icon: , + }, + { + key: "voices", + label: "配音库", + path: "/voices", + icon: , + }, + { + key: "titles", + label: "标题库", + path: "/titles", + icon: , + }, + { + key: "products", + label: "成片库", + path: "/products", + icon: , + }, + { + key: "templates", + label: "模板库", + path: "/templates", + icon: , + }, + ], + }, + { + title: "系统", + items: [ + { + key: "history", + label: "任务历史", + path: "/history", + icon: , + }, + { + key: "admin", + label: "控制台", + path: "/admin", + icon: , + }, + { + key: "subscription", + label: "订阅管理", + path: "/subscription", + icon: , + }, + ], + }, +]; + +/** 判断菜单项是否激活 */ +const isMenuItemActive = (pathname: string, path: string): boolean => { + if (path === "/dashboard") { + return pathname === "/" || pathname === "/dashboard"; + } + return pathname.startsWith(path); +}; + +const Sidebar: React.FC = () => { + const { collapsed } = useContext(SidebarContext); + const location = useLocation(); + const navigate = useNavigate(); + + const handleNavigate = (path: string) => { + navigate(path); + }; + + return ( +
+ {MENU_GROUPS.map((group) => ( +
+ {!collapsed && ( +
{group.title}
+ )} +
    + {group.items.map((item) => { + const active = isMenuItemActive(location.pathname, item.path); + return ( +
  • handleNavigate(item.path)} + > + {item.icon} + {!collapsed && ( + {item.label} + )} +
  • + ); + })} +
+
+ ))} +
+ ); +}; + +export default Sidebar; diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx new file mode 100644 index 000000000..16b33a1e4 --- /dev/null +++ b/apps/web/src/components/ui/Button.tsx @@ -0,0 +1,95 @@ +/** + * V21 Button 按钮 + * 封装 Ant Design Button,应用 V21 设计系统样式 + */ +import React from "react"; +import { Button as AntButton } from "antd"; +import type { ButtonProps as AntButtonProps } from "antd"; +import classNames from "classnames"; +import "./ui.css"; + +export type ButtonType = + | "primary" + | "secondary" + | "ghost" + | "text" + | "danger" + | "link"; + +export type ButtonSize = "sm" | "md" | "lg"; + +export interface ButtonProps extends Omit { + /** 按钮类型 */ + buttonType?: ButtonType; + /** 按钮尺寸 */ + buttonSize?: ButtonSize; + /** 兼容 antd type(用于直接替换) */ + type?: AntButtonProps["type"]; + /** 兼容 antd size */ + size?: AntButtonProps["size"]; +} + +const TYPE_CLASS_MAP: Record = { + primary: "xx-btn-primary", + secondary: "xx-btn-secondary", + ghost: "xx-btn-ghost", + text: "xx-btn-text", + danger: "xx-btn-danger", + link: "xx-btn-link", +}; + +const SIZE_CLASS_MAP: Record = { + sm: "xx-btn-sm", + md: "xx-btn-md", + lg: "xx-btn-lg", +}; + +/** 将 buttonType 映射到 antd type */ +const toAntdType = (bt: ButtonType): AntButtonProps["type"] => { + switch (bt) { + case "primary": + return "primary"; + case "danger": + return "primary"; + case "link": + return "link"; + case "text": + return "text"; + default: + return "default"; + } +}; + +const Button: React.FC = ({ + buttonType = "ghost", + buttonSize = "md", + className, + children, + type, + size, + ...rest +}) => { + const antdType = type ?? toAntdType(buttonType); + const antdSize = size ?? (buttonSize === "sm" ? "small" : buttonSize === "lg" ? "large" : "middle"); + + const v21Class = classNames( + "xx-btn", + TYPE_CLASS_MAP[buttonType], + SIZE_CLASS_MAP[buttonSize], + className, + ); + + return ( + + {children} + + ); +}; + +export default Button; diff --git a/apps/web/src/components/ui/Card.tsx b/apps/web/src/components/ui/Card.tsx new file mode 100644 index 000000000..4e89a302d --- /dev/null +++ b/apps/web/src/components/ui/Card.tsx @@ -0,0 +1,25 @@ +/** + * V21 Card 卡片 + * 封装 Ant Design Card,应用 V21 设计系统样式 + */ +import React from "react"; +import { Card as AntCard } from "antd"; +import type { CardProps as AntCardProps } from "antd"; +import classNames from "classnames"; +import "./ui.css"; + +export interface CardProps extends AntCardProps { + /** 是否启用悬浮效果 */ + hover?: boolean; +} + +const Card: React.FC = ({ className, hover, ...rest }) => { + const v21Class = classNames( + "xx-card-v21", + hover && "xx-card-hover", + className, + ); + return ; +}; + +export default Card; diff --git a/apps/web/src/components/ui/Form.tsx b/apps/web/src/components/ui/Form.tsx new file mode 100644 index 000000000..f8647b396 --- /dev/null +++ b/apps/web/src/components/ui/Form.tsx @@ -0,0 +1,34 @@ +/** + * V21 Form 表单 + * 封装 Ant Design Form,应用 V21 设计系统样式 + */ +import React from "react"; +import { Form as AntForm } from "antd"; +import type { FormProps as AntFormProps } from "antd"; +import classNames from "classnames"; +import "./ui.css"; + +export interface FormProps extends AntFormProps { + /** 紧凑模式(减小表单项间距) */ + compact?: boolean; +} + +const Form = ({ className, compact, children, ...rest }: FormProps) => { + const v21Class = classNames( + "xx-form", + compact && "xx-form-compact", + className, + ); + return ( + )}> + {children as React.ReactNode} + + ); +}; + +/** 导出 Form 的子组件(保持 antd API 一致) */ +export const FormItem = AntForm.Item; +export const FormList = AntForm.List; +export const FormProvider = AntForm.Provider; + +export default Form; diff --git a/apps/web/src/components/ui/Input.tsx b/apps/web/src/components/ui/Input.tsx new file mode 100644 index 000000000..23ad6f212 --- /dev/null +++ b/apps/web/src/components/ui/Input.tsx @@ -0,0 +1,75 @@ +/** + * V21 Input 输入框 + * 封装 Ant Design Input,应用 V21 设计系统样式 + */ +import React from "react"; +import { Input as AntInput } from "antd"; +import type { InputProps as AntInputProps } from "antd"; +import type { InputRef } from "antd/es/input"; +import classNames from "classnames"; +import "./ui.css"; + +const { Search: AntSearch, TextArea: AntTextArea, Password: AntPassword } = AntInput; + +export interface InputProps extends AntInputProps { + /** 是否处于错误状态 */ + error?: boolean; +} + +/** 基础 Input */ +const Input = React.forwardRef( + ({ className, error, ...rest }, ref) => { + const v21Class = classNames( + "xx-input", + error && "xx-input-error", + className, + ); + return ; + }, +); +Input.displayName = "Input"; + +/** Search 搜索框 */ +const Search: React.FC< + React.ComponentProps & { error?: boolean } +> = ({ className, error, ...rest }) => { + const v21Class = classNames( + "xx-input", + "xx-input-search", + error && "xx-input-error", + className, + ); + return ; +}; + +/** TextArea 文本域 */ +const TextArea: React.FC< + React.ComponentProps & { error?: boolean } +> = ({ className, error, ...rest }) => { + const v21Class = classNames( + "xx-input", + error && "xx-input-error", + className, + ); + return ; +}; + +/** Password 密码框 */ +const Password = React.forwardRef< + InputRef, + React.ComponentProps & { error?: boolean } +>(({ className, error, ...rest }, ref) => { + const v21Class = classNames( + "xx-input", + error && "xx-input-error", + className, + ); + return ; +}); +Password.displayName = "Password"; + +export default Object.assign(Input, { + Search, + TextArea, + Password, +}); diff --git a/apps/web/src/components/ui/Modal.tsx b/apps/web/src/components/ui/Modal.tsx new file mode 100644 index 000000000..51450cad2 --- /dev/null +++ b/apps/web/src/components/ui/Modal.tsx @@ -0,0 +1,75 @@ +/** + * V21 Modal 对话框 + * 封装 Ant Design Modal,应用 V21 设计系统样式 + */ +import React from "react"; +import { Modal as AntModal } from "antd"; +import type { ModalProps as AntModalProps } from "antd"; +import type { ModalFuncProps } from "antd/es/modal"; +import classNames from "classnames"; +import "./ui.css"; + +export interface ModalProps extends AntModalProps { + /** 使用 V21 样式 */ + v21?: boolean; +} + +interface ModalComponent extends React.FC { + confirm: (config: ModalFuncProps) => ReturnType; + info: (config: ModalFuncProps) => ReturnType; + success: (config: ModalFuncProps) => ReturnType; + error: (config: ModalFuncProps) => ReturnType; + warning: (config: ModalFuncProps) => ReturnType; +} + +const Modal: ModalComponent = ({ + className, + v21 = true, + children, + ...rest +}) => { + const v21Class = classNames(v21 && "xx-modal", className); + return ( + + {children} + + ); +}; + +/** 确认对话框快捷方法 */ +Modal.confirm = (config: ModalFuncProps) => { + return AntModal.confirm({ + ...config, + className: classNames("xx-modal-confirm", config.className), + }); +}; + +Modal.info = (config: ModalFuncProps) => { + return AntModal.info({ + ...config, + className: classNames("xx-modal-confirm", config.className), + }); +}; + +Modal.success = (config: ModalFuncProps) => { + return AntModal.success({ + ...config, + className: classNames("xx-modal-confirm", config.className), + }); +}; + +Modal.error = (config: ModalFuncProps) => { + return AntModal.error({ + ...config, + className: classNames("xx-modal-confirm", config.className), + }); +}; + +Modal.warning = (config: ModalFuncProps) => { + return AntModal.warning({ + ...config, + className: classNames("xx-modal-confirm", config.className), + }); +}; + +export default Modal; diff --git a/apps/web/src/components/ui/Pagination.tsx b/apps/web/src/components/ui/Pagination.tsx new file mode 100644 index 000000000..4bfad7270 --- /dev/null +++ b/apps/web/src/components/ui/Pagination.tsx @@ -0,0 +1,25 @@ +/** + * V21 Pagination 分页 + * 封装 Ant Design Pagination,应用 V21 设计系统样式 + */ +import React from "react"; +import { Pagination as AntPagination } from "antd"; +import type { PaginationProps as AntPaginationProps } from "antd"; +import classNames from "classnames"; +import "./ui.css"; + +export interface PaginationProps extends AntPaginationProps { + /** 使用 V21 样式 */ + v21?: boolean; +} + +const Pagination: React.FC = ({ + className, + v21 = true, + ...rest +}) => { + const v21Class = classNames(v21 && "xx-pagination", className); + return ; +}; + +export default Pagination; diff --git a/apps/web/src/components/ui/Select.tsx b/apps/web/src/components/ui/Select.tsx new file mode 100644 index 000000000..86d6b50c8 --- /dev/null +++ b/apps/web/src/components/ui/Select.tsx @@ -0,0 +1,35 @@ +/** + * V21 Select 选择器 + * 封装 Ant Design Select,应用 V21 设计系统样式 + */ +import React from "react"; +import { Select as AntSelect } from "antd"; +import type { SelectProps as AntSelectProps } from "antd"; +import classNames from "classnames"; +import "./ui.css"; + +export interface SelectProps extends AntSelectProps { + /** 自定义弹出层类名 */ + dropdownClassName?: string; +} + +const Select: React.FC = ({ + className, + dropdownClassName, + ...rest +}) => { + const v21Class = classNames("xx-select", className); + const v21DropdownClass = classNames( + "xx-select-dropdown", + dropdownClassName, + ); + return ( + + ); +}; + +export default Select; diff --git a/apps/web/src/components/ui/Table.tsx b/apps/web/src/components/ui/Table.tsx new file mode 100644 index 000000000..ef768b43f --- /dev/null +++ b/apps/web/src/components/ui/Table.tsx @@ -0,0 +1,28 @@ +/** + * V21 Table 表格 + * 封装 Ant Design Table,应用 V21 设计系统样式 + */ +import React from "react"; +import { Table as AntTable } from "antd"; +import type { TableProps as AntTableProps } from "antd"; +import classNames from "classnames"; +import "./ui.css"; + +export interface TableProps + extends AntTableProps { + /** 使用 V21 样式 */ + v21?: boolean; +} + +function Table>({ + className, + v21 = true, + ...rest +}: TableProps) { + const v21Class = classNames(v21 && "xx-table", className); + return className={v21Class} {...rest} />; +} + +export default Table as >( + props: TableProps & React.RefAttributes, +) => React.ReactElement; diff --git a/apps/web/src/components/ui/Tag.tsx b/apps/web/src/components/ui/Tag.tsx new file mode 100644 index 000000000..357b82939 --- /dev/null +++ b/apps/web/src/components/ui/Tag.tsx @@ -0,0 +1,43 @@ +/** + * V21 Tag 标签 / Badge 徽标 + * 封装 Ant Design Tag 和 Badge,应用 V21 设计系统样式 + */ +import React from "react"; +import { Tag as AntTag, Badge as AntBadge } from "antd"; +import type { TagProps as AntTagProps, BadgeProps as AntBadgeProps } from "antd"; +import classNames from "classnames"; +import "./ui.css"; + +export type TagVariant = "primary" | "success" | "warning" | "error" | "info"; + +export interface TagProps extends AntTagProps { + /** V21 语义变体 */ + variant?: TagVariant; +} + +const VARIANT_CLASS_MAP: Record = { + primary: "xx-tag-primary", + success: "xx-tag-success", + warning: "xx-tag-warning", + error: "xx-tag-error", + info: "xx-tag-info", +}; + +/** Tag 标签 */ +const Tag: React.FC = ({ className, variant, ...rest }) => { + const v21Class = classNames( + "xx-tag", + variant && VARIANT_CLASS_MAP[variant], + className, + ); + return ; +}; + +/** Badge 徽标 */ +const Badge: React.FC = ({ className, ...rest }) => { + const v21Class = classNames("xx-badge", className); + return ; +}; + +export { Tag, Badge }; +export default Tag; diff --git a/apps/web/src/components/ui/Tooltip.tsx b/apps/web/src/components/ui/Tooltip.tsx new file mode 100644 index 000000000..d75f310c0 --- /dev/null +++ b/apps/web/src/components/ui/Tooltip.tsx @@ -0,0 +1,44 @@ +/** + * V21 Tooltip 文字提示 / Popover 气泡卡片 + * 封装 Ant Design Tooltip 和 Popover,应用 V21 设计系统样式 + */ +import React from "react"; +import { + Tooltip as AntTooltip, + Popover as AntPopover, +} from "antd"; +import classNames from "classnames"; +import "./ui.css"; + +export type TooltipProps = React.ComponentProps & { + /** 使用 V21 样式 */ + v21?: boolean; +}; + +/** Tooltip 文字提示 */ +const Tooltip: React.FC = ({ + overlayClassName, + v21 = true, + ...rest +}) => { + const v21Class = classNames(v21 && "xx-tooltip", overlayClassName); + return ; +}; + +export type PopoverProps = React.ComponentProps & { + /** 使用 V21 样式 */ + v21?: boolean; +}; + +/** Popover 气泡卡片 */ +const Popover: React.FC = ({ + overlayClassName, + v21 = true, + ...rest +}) => { + const v21Class = classNames(v21 && "xx-popover", overlayClassName); + return ; +}; + +export { Tooltip, Popover }; +export default Tooltip; diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts new file mode 100644 index 000000000..98f8f77da --- /dev/null +++ b/apps/web/src/components/ui/index.ts @@ -0,0 +1,34 @@ +/** + * V21 UI 组件库 - 统一导出 + */ + +// 组件导出 +export { default as Button } from "./Button"; +export type { ButtonProps, ButtonType, ButtonSize } from "./Button"; + +export { default as Input } from "./Input"; +export type { InputProps } from "./Input"; + +export { default as Select } from "./Select"; +export type { SelectProps } from "./Select"; + +export { default as Modal } from "./Modal"; +export type { ModalProps } from "./Modal"; + +export { default as Table } from "./Table"; +export type { TableProps } from "./Table"; + +export { default as Card } from "./Card"; +export type { CardProps } from "./Card"; + +export { Tag, Badge } from "./Tag"; +export type { TagProps, TagVariant } from "./Tag"; + +export { Tooltip, Popover } from "./Tooltip"; +export type { TooltipProps, PopoverProps } from "./Tooltip"; + +export { default as Form, FormItem, FormList, FormProvider } from "./Form"; +export type { FormProps } from "./Form"; + +export { default as Pagination } from "./Pagination"; +export type { PaginationProps } from "./Pagination"; diff --git a/apps/web/src/components/ui/ui.css b/apps/web/src/components/ui/ui.css new file mode 100644 index 000000000..1124d1ad9 --- /dev/null +++ b/apps/web/src/components/ui/ui.css @@ -0,0 +1,534 @@ +/** + * V21 UI 组件库 - 组件样式 + * 统一使用 CSS 变量,支持深色/浅色主题自动切换 + */ + +/* ============================================================ + Button 按钮 + ============================================================ */ +.xx-btn { + border-radius: var(--radius-md) !important; + font-weight: var(--font-weight-extrabold) !important; + transition: var(--transition-all) !important; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); +} + +.xx-btn-primary { + background: var(--gradient-primary) !important; + color: var(--text-inverse) !important; + border: none !important; + box-shadow: var(--shadow-primary) !important; +} + +.xx-btn-primary:hover, +.xx-btn-primary:focus { + background: var(--gradient-primary) !important; + box-shadow: var(--shadow-hover) !important; + transform: translateY(-1px); +} + +.xx-btn-secondary { + background: var(--secondary-color) !important; + color: var(--text-inverse) !important; + border: none !important; +} + +.xx-btn-secondary:hover, +.xx-btn-secondary:focus { + background: var(--secondary-hover) !important; + transform: translateY(-1px); +} + +.xx-btn-ghost { + background: var(--bg-primary) !important; + border: 1px solid var(--border-color) !important; + color: var(--text-primary) !important; +} + +.xx-btn-ghost:hover, +.xx-btn-ghost:focus { + border-color: var(--primary-color) !important; + color: var(--primary-color) !important; +} + +.xx-btn-text { + background: transparent !important; + border: none !important; + color: var(--text-secondary) !important; + box-shadow: none !important; +} + +.xx-btn-text:hover, +.xx-btn-text:focus { + background: var(--primary-soft) !important; + color: var(--primary-color) !important; +} + +.xx-btn-danger { + background: var(--error-color) !important; + color: var(--text-inverse) !important; + border: none !important; +} + +.xx-btn-danger:hover, +.xx-btn-danger:focus { + background: var(--error-hover) !important; + transform: translateY(-1px); +} + +.xx-btn-link { + background: transparent !important; + border: none !important; + color: var(--text-link) !important; + box-shadow: none !important; + padding-left: 0 !important; + padding-right: 0 !important; +} + +.xx-btn-link:hover, +.xx-btn-link:focus { + color: var(--text-link-hover) !important; + text-decoration: underline; +} + +/* 尺寸 */ +.xx-btn-sm { + padding: 6px 14px !important; + font-size: var(--font-size-sm) !important; +} + +.xx-btn-md { + padding: 10px 20px !important; + font-size: var(--font-size-base) !important; +} + +.xx-btn-lg { + padding: 14px 28px !important; + font-size: var(--font-size-md) !important; +} + +/* ============================================================ + Input 输入框 + ============================================================ */ +.xx-input .ant-input, +.xx-input.ant-input { + border-radius: var(--radius-sm) !important; + border-color: var(--border-color) !important; + color: var(--text-primary) !important; + background: var(--bg-primary) !important; + transition: var(--transition-colors) !important; + font-size: var(--font-size-base) !important; +} + +.xx-input .ant-input:hover, +.xx-input.ant-input:hover { + border-color: var(--primary-color) !important; +} + +.xx-input .ant-input:focus, +.xx-input.ant-input:focus, +.xx-input .ant-input-focused, +.xx-input.ant-input-focused { + border-color: var(--primary-color) !important; + box-shadow: 0 0 0 3px var(--primary-soft) !important; +} + +.xx-input .ant-input::placeholder, +.xx-input.ant-input::placeholder { + color: var(--text-tertiary) !important; +} + +/* 错误状态 */ +.xx-input-error .ant-input, +.xx-input-error.ant-input { + border-color: var(--error-color) !important; +} + +.xx-input-error .ant-input:focus, +.xx-input-error.ant-input:focus { + box-shadow: 0 0 0 3px var(--error-soft) !important; +} + +/* 前缀/后缀图标 */ +.xx-input .ant-input-prefix, +.xx-input .ant-input-suffix { + color: var(--text-tertiary) !important; +} + +/* 搜索框 */ +.xx-input-search .ant-input-search-button { + border-radius: 0 var(--radius-sm) var(--radius-sm) 0 !important; + background: var(--gradient-primary) !important; + border: none !important; +} + +/* ============================================================ + Select 选择器 + ============================================================ */ +.xx-select .ant-select-selector { + border-radius: var(--radius-sm) !important; + border-color: var(--border-color) !important; + background: var(--bg-primary) !important; + color: var(--text-primary) !important; + transition: var(--transition-colors) !important; + min-height: 40px; +} + +.xx-select .ant-select-selector:hover { + border-color: var(--primary-color) !important; +} + +.xx-select.ant-select-focused .ant-select-selector { + border-color: var(--primary-color) !important; + box-shadow: 0 0 0 3px var(--primary-soft) !important; +} + +.xx-select .ant-select-selection-placeholder { + color: var(--text-tertiary) !important; +} + +/* 下拉菜单 */ +.xx-select-dropdown .ant-select-item { + border-radius: var(--radius-xs) !important; + color: var(--text-primary) !important; + transition: var(--transition-fast) !important; +} + +.xx-select-dropdown .ant-select-item-option-active { + background: var(--primary-soft) !important; + color: var(--primary-color) !important; +} + +.xx-select-dropdown .ant-select-item-option-selected { + background: var(--primary-soft) !important; + color: var(--primary-color) !important; + font-weight: var(--font-weight-semibold) !important; +} + +/* ============================================================ + Modal 对话框 + ============================================================ */ +.xx-modal .ant-modal-content { + border-radius: var(--radius-xl) !important; + background: var(--bg-elevated) !important; + box-shadow: var(--shadow-lg) !important; + overflow: hidden; +} + +.xx-modal .ant-modal-header { + background: transparent !important; + border-bottom: 1px solid var(--border-light) !important; + padding: var(--space-lg) var(--space-xl) !important; +} + +.xx-modal .ant-modal-title { + font-size: var(--font-size-lg) !important; + font-weight: var(--font-weight-bold) !important; + color: var(--text-primary) !important; +} + +.xx-modal .ant-modal-close { + color: var(--text-tertiary) !important; + transition: var(--transition-fast) !important; +} + +.xx-modal .ant-modal-close:hover { + color: var(--text-primary) !important; +} + +.xx-modal .ant-modal-body { + padding: var(--space-xl) !important; + color: var(--text-primary) !important; +} + +.xx-modal .ant-modal-footer { + border-top: 1px solid var(--border-light) !important; + padding: var(--space-md) var(--space-xl) !important; +} + +/* 确认对话框 */ +.xx-modal-confirm .ant-modal-content { + border-radius: var(--radius-xl) !important; +} + +.xx-modal-confirm .ant-modal-confirm-body > .anticon { + font-size: 24px !important; +} + +/* ============================================================ + Table 表格 + ============================================================ */ +.xx-table .ant-table { + background: var(--bg-primary) !important; + color: var(--text-primary) !important; + border-radius: var(--radius-md) !important; + overflow: hidden; +} + +.xx-table .ant-table-thead > tr > th { + background: var(--bg-secondary) !important; + color: var(--text-secondary) !important; + font-weight: var(--font-weight-semibold) !important; + border-bottom: 1px solid var(--border-color) !important; + font-size: var(--font-size-sm) !important; + text-transform: uppercase; + letter-spacing: var(--letter-spacing-wide); +} + +.xx-table .ant-table-tbody > tr > td { + border-bottom: 1px solid var(--border-light) !important; + color: var(--text-primary) !important; + transition: var(--transition-fast) !important; +} + +.xx-table .ant-table-tbody > tr:hover > td { + background: var(--primary-soft) !important; +} + +.xx-table .ant-table-tbody > tr:last-child > td { + border-bottom: none !important; +} + +/* 排序图标 */ +.xx-table .ant-table-column-sorter-up.active, +.xx-table .ant-table-column-sorter-down.active { + color: var(--primary-color) !important; +} + +/* 分页 */ +.xx-table .ant-pagination { + padding: var(--space-md) 0 !important; +} + +/* ============================================================ + Card 卡片 + ============================================================ */ +.xx-card-v21 { + background: var(--bg-primary) !important; + border: 1px solid var(--border-color) !important; + border-radius: var(--radius-xl) !important; + box-shadow: var(--shadow-card) !important; + transition: var(--transition-all) !important; + overflow: hidden; +} + +.xx-card-v21 .ant-card-head { + border-bottom: 1px solid var(--border-light) !important; + padding: 0 var(--space-xl) !important; + min-height: 56px !important; +} + +.xx-card-v21 .ant-card-head-title { + font-size: var(--font-size-lg) !important; + font-weight: var(--font-weight-bold) !important; + color: var(--text-primary) !important; +} + +.xx-card-v21 .ant-card-body { + padding: var(--space-xl) !important; +} + +.xx-card-v21 .ant-card-actions { + border-top: 1px solid var(--border-light) !important; + background: var(--bg-secondary) !important; +} + +.xx-card-v21 .ant-card-actions > li { + margin: var(--space-sm) 0 !important; +} + +/* 悬浮卡片 */ +.xx-card-hover:hover { + box-shadow: var(--shadow-md) !important; + transform: translateY(-2px); +} + +/* ============================================================ + Tag 标签 / Badge 徽标 + ============================================================ */ +.xx-tag { + border-radius: var(--radius-xs) !important; + font-size: var(--font-size-sm) !important; + font-weight: var(--font-weight-medium) !important; + padding: 2px 10px !important; + line-height: 1.6 !important; + border: none !important; +} + +.xx-tag-primary { + background: var(--primary-soft) !important; + color: var(--primary-color) !important; +} + +.xx-tag-success { + background: var(--success-soft) !important; + color: var(--success-color) !important; +} + +.xx-tag-warning { + background: var(--warning-soft) !important; + color: var(--warning-color) !important; +} + +.xx-tag-error { + background: var(--error-soft) !important; + color: var(--error-color) !important; +} + +.xx-tag-info { + background: var(--info-soft) !important; + color: var(--info-color) !important; +} + +/* Badge */ +.xx-badge .ant-badge-count { + background: var(--gradient-primary) !important; + box-shadow: var(--shadow-primary) !important; +} + +.xx-badge .ant-badge-status-dot { + width: 8px !important; + height: 8px !important; +} + +/* ============================================================ + Tooltip 文字提示 / Popover 气泡卡片 + ============================================================ */ +.xx-tooltip .ant-tooltip-inner { + background: var(--color-gray-800) !important; + color: var(--text-inverse) !important; + border-radius: var(--radius-sm) !important; + padding: var(--space-xs) var(--space-sm) !important; + font-size: var(--font-size-sm) !important; + box-shadow: var(--shadow-sm) !important; +} + +.xx-tooltip .ant-tooltip-arrow::before { + background: var(--color-gray-800) !important; +} + +/* Popover */ +.xx-popover .ant-popover-inner { + background: var(--bg-elevated) !important; + border: 1px solid var(--border-color) !important; + border-radius: var(--radius-md) !important; + box-shadow: var(--shadow-md) !important; +} + +.xx-popover .ant-popover-inner-content { + color: var(--text-primary) !important; + padding: var(--space-md) !important; +} + +.xx-popover .ant-popover-title { + color: var(--text-primary) !important; + font-weight: var(--font-weight-semibold) !important; + border-bottom: 1px solid var(--border-light) !important; +} + +/* ============================================================ + Form 表单 + ============================================================ */ +.xx-form .ant-form-item-label > label { + color: var(--text-primary) !important; + font-weight: var(--font-weight-medium) !important; + font-size: var(--font-size-base) !important; +} + +.xx-form .ant-form-item-explain-error { + color: var(--error-color) !important; + font-size: var(--font-size-sm) !important; +} + +.xx-form .ant-form-item { + margin-bottom: var(--space-lg) !important; +} + +/* 表单项间距紧凑 */ +.xx-form-compact .ant-form-item { + margin-bottom: var(--space-md) !important; +} + +/* ============================================================ + Pagination 分页 + ============================================================ */ +.xx-pagination .ant-pagination-item { + border-radius: var(--radius-xs) !important; + border-color: var(--border-color) !important; + transition: var(--transition-fast) !important; +} + +.xx-pagination .ant-pagination-item a { + color: var(--text-primary) !important; +} + +.xx-pagination .ant-pagination-item:hover { + border-color: var(--primary-color) !important; +} + +.xx-pagination .ant-pagination-item:hover a { + color: var(--primary-color) !important; +} + +.xx-pagination .ant-pagination-item-active { + background: var(--gradient-primary) !important; + border-color: transparent !important; +} + +.xx-pagination .ant-pagination-item-active a { + color: var(--text-inverse) !important; +} + +.xx-pagination .ant-pagination-prev .ant-pagination-item-link, +.xx-pagination .ant-pagination-next .ant-pagination-item-link { + border-radius: var(--radius-xs) !important; + color: var(--text-secondary) !important; +} + +.xx-pagination .ant-pagination-disabled .ant-pagination-item-link { + color: var(--text-disabled) !important; +} + +/* ============================================================ + 响应式 + ============================================================ */ +@media (max-width: 768px) { + .xx-btn-lg { + padding: 12px 20px !important; + font-size: var(--font-size-base) !important; + } + + .xx-card-v21 .ant-card-body { + padding: var(--space-md) !important; + } + + .xx-card-v21 .ant-card-head { + padding: 0 var(--space-md) !important; + } + + .xx-modal .ant-modal-body { + padding: var(--space-md) !important; + } + + .xx-modal .ant-modal-header { + padding: var(--space-md) !important; + } + + .xx-form .ant-form-item { + margin-bottom: var(--space-md) !important; + } +} + +@media (max-width: 480px) { + .xx-btn-lg { + width: 100%; + justify-content: center; + } + + .xx-btn-md { + padding: 8px 16px !important; + } +} diff --git a/apps/web/src/hooks/useAuth.ts b/apps/web/src/hooks/useAuth.ts index 243f6d2c3..2ad569c6c 100644 --- a/apps/web/src/hooks/useAuth.ts +++ b/apps/web/src/hooks/useAuth.ts @@ -1,10 +1,10 @@ /** * 认证相关 Hooks */ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; -import * as authApi from '@/api/auth'; -import { useAuthStore } from '@/store/authStore'; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import * as authApi from "@/api/auth"; +import { useAuthStore } from "@/store/authStore"; // 登录 Hook export const useLogin = () => { @@ -21,17 +21,17 @@ export const useLogin = () => { const refreshToken = data.refresh_token ?? null; // 先保存 token - localStorage.setItem('access_token', data.access_token); + localStorage.setItem("access_token", data.access_token); if (refreshToken) { - localStorage.setItem('refresh_token', refreshToken); + localStorage.setItem("refresh_token", refreshToken); } else { - localStorage.removeItem('refresh_token'); + localStorage.removeItem("refresh_token"); } - + // 获取用户信息 const user = await authApi.getCurrentUser(); setAuth(user, data.access_token, refreshToken); - navigate('/'); + navigate("/"); return data; }; @@ -48,8 +48,8 @@ export const useRegister = () => { const register = async (data: Parameters[0]) => { const result = await mutation.mutateAsync(data); - navigate('/login', { - state: { message: '注册成功!请查收验证邮件。' }, + navigate("/login", { + state: { message: "注册成功!请查收验证邮件。" }, }); return result; }; @@ -75,7 +75,7 @@ export const useLogout = () => { } finally { clearAuth(); queryClient.clear(); - navigate('/login'); + navigate("/login"); } }; @@ -87,7 +87,7 @@ export const useCurrentUser = () => { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); return useQuery({ - queryKey: ['currentUser'], + queryKey: ["currentUser"], queryFn: authApi.getCurrentUser, enabled: isAuthenticated, }); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 419422da0..98d438db5 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -8,14 +8,17 @@ box-sizing: border-box; } -html, body, #root { +html, +body, +#root { width: 100%; min-height: 100vh; } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', - 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: #0f172a; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9824ce5c7..7d58c8725 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,15 +2,15 @@ * 更新主入口,引入全局样式 * V21 设计系统配置 */ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { RouterProvider } from 'react-router-dom'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { ConfigProvider } from 'antd'; -import zhCN from 'antd/locale/zh_CN'; -import router from './router'; -import './index.css'; -import './styles/global.css'; +import React from "react"; +import ReactDOM from "react-dom/client"; +import { RouterProvider } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ConfigProvider } from "antd"; +import zhCN from "antd/locale/zh_CN"; +import router from "./router"; +import "./index.css"; +import "./styles/global.css"; // 创建 React Query 客户端 const queryClient = new QueryClient({ @@ -28,42 +28,42 @@ const queryClient = new QueryClient({ const theme = { token: { // 主色调 - Indigo - colorPrimary: '#4f46e5', - colorSuccess: '#10b981', - colorWarning: '#f59e0b', - colorError: '#ef4444', - colorInfo: '#4f46e5', - + colorPrimary: "#4f46e5", + colorSuccess: "#10b981", + colorWarning: "#f59e0b", + colorError: "#ef4444", + colorInfo: "#4f46e5", + // 圆角 - V21 大圆角 borderRadius: 14, borderRadiusLG: 22, borderRadiusSM: 10, - + // 文字色 - colorText: '#0f172a', - colorTextSecondary: '#64748b', - colorTextTertiary: '#94a3b8', - + colorText: "#0f172a", + colorTextSecondary: "#64748b", + colorTextTertiary: "#94a3b8", + // 背景色 - colorBgContainer: '#ffffff', - colorBgLayout: '#f8fafc', - + colorBgContainer: "#ffffff", + colorBgLayout: "#f8fafc", + // 边框色 - colorBorder: '#e2e8f0', - colorBorderSecondary: '#f1f5f9', - + colorBorder: "#e2e8f0", + colorBorderSecondary: "#f1f5f9", + // 阴影 - boxShadow: '0 10px 30px rgba(15, 23, 42, 0.06)', - boxShadowSecondary: '0 24px 70px rgba(15, 23, 42, 0.09)', + boxShadow: "0 10px 30px rgba(15, 23, 42, 0.06)", + boxShadowSecondary: "0 24px 70px rgba(15, 23, 42, 0.09)", }, components: { Button: { // 按钮圆角 borderRadius: 14, // 主按钮渐变通过 CSS 实现 - colorPrimary: '#4f46e5', - colorPrimaryHover: '#6366f1', - primaryShadow: '0 14px 26px rgba(79, 70, 229, 0.22)', + colorPrimary: "#4f46e5", + colorPrimaryHover: "#6366f1", + primaryShadow: "0 14px 26px rgba(79, 70, 229, 0.22)", }, Card: { borderRadiusLG: 28, @@ -87,12 +87,12 @@ const theme = { }, }; -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( - + , ); diff --git a/apps/web/src/pages/admin/Admin.css b/apps/web/src/pages/admin/Admin.css index ea535aaf5..abe0a1e03 100644 --- a/apps/web/src/pages/admin/Admin.css +++ b/apps/web/src/pages/admin/Admin.css @@ -468,7 +468,7 @@ } .xx-log-detail-code { - font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-family: "JetBrains Mono", "Fira Code", monospace; background: rgba(248, 250, 252, 0.8); padding: 12px; border-radius: 10px; diff --git a/apps/web/src/pages/admin/AdminComingSoon.tsx b/apps/web/src/pages/admin/AdminComingSoon.tsx index 617d09ced..4ffe79eb9 100644 --- a/apps/web/src/pages/admin/AdminComingSoon.tsx +++ b/apps/web/src/pages/admin/AdminComingSoon.tsx @@ -1,7 +1,7 @@ -import React from 'react'; -import { Button, Card, Result } from 'antd'; -import { useNavigate } from 'react-router-dom'; -import './Admin.css'; +import React from "react"; +import { Button, Card, Result } from "antd"; +import { useNavigate } from "react-router-dom"; +import "./Admin.css"; const AdminComingSoon: React.FC = () => { const navigate = useNavigate(); @@ -15,9 +15,9 @@ const AdminComingSoon: React.FC = () => { title="Admin 后台暂未开放" subTitle="当前版本未接入后台用户、监控、日志、分析等后端服务,因此不展示模拟运营数据,也不会提供假操作入口。" extra={[ - - +
+ } + onClick={() => setCreateModalOpen(true)} + > + 新建素材库 + + } + /> {libsLoading ? ( -
+
) : libraries.length === 0 ? ( @@ -240,33 +244,40 @@ const AssetLibrary: React.FC = () => { {activeLibrary && (

{uploading ? : }

- {uploading ? '正在上传,请稍候...' : '点击或拖拽文件到此区域上传'} + {uploading + ? "正在上传,请稍候..." + : "点击或拖拽文件到此区域上传"}

- 支持 {kindLabel[currentLib?.kind || 'video']} 格式文件,单文件不超过 2GB + 支持 {kindLabel[currentLib?.kind || "video"]}{" "} + 格式文件,单文件不超过 2GB

)} {/* 素材列表 */} {assetsLoading ? ( -
+
) : assets.length === 0 ? ( @@ -279,30 +290,30 @@ const AssetLibrary: React.FC = () => { hoverable size="small" cover={ - currentLib?.kind === 'image' ? ( + currentLib?.kind === "image" ? ( {asset.name} ) : (
- {currentLib?.kind === 'video' ? ( + {currentLib?.kind === "video" ? ( ) : ( )}
@@ -320,7 +331,7 @@ const AssetLibrary: React.FC = () => { > + {asset.name} } @@ -329,16 +340,16 @@ const AssetLibrary: React.FC = () => { {asset.file_size ? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB` - : '-'} + : "-"} {asset.status && ( @@ -366,7 +377,7 @@ const AssetLibrary: React.FC = () => { } confirmLoading={createLibMutation.isPending} > - + { { diff --git a/apps/web/src/pages/auth/Login.css b/apps/web/src/pages/auth/Login.css index 3a80db298..0d78a3b04 100644 --- a/apps/web/src/pages/auth/Login.css +++ b/apps/web/src/pages/auth/Login.css @@ -3,7 +3,11 @@ display: grid; place-items: center; min-height: 100vh; - background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); + background: linear-gradient( + 135deg, + rgba(79, 70, 229, 0.08), + rgba(16, 185, 129, 0.06) + ); padding: 24px; } diff --git a/apps/web/src/pages/auth/Login.tsx b/apps/web/src/pages/auth/Login.tsx index 2cc6bf166..ab7cb3bef 100644 --- a/apps/web/src/pages/auth/Login.tsx +++ b/apps/web/src/pages/auth/Login.tsx @@ -1,11 +1,11 @@ /** * 登录页面 - V21 完全对标 */ -import React from 'react'; -import { Form, Input, Checkbox, message } from 'antd'; -import { Link, useNavigate } from 'react-router-dom'; -import { useLogin } from '@/hooks/useAuth'; -import './Login.css'; +import React from "react"; +import { Form, Input, Checkbox, message } from "antd"; +import { Link, useNavigate } from "react-router-dom"; +import { useLogin } from "@/hooks/useAuth"; +import "./Login.css"; interface LoginFormValues { email: string; @@ -24,10 +24,11 @@ const Login: React.FC = () => { email: values.email, password: values.password, }); - message.success('登录成功!'); - navigate('/'); - } catch (error: any) { - if (!error?.__msgShown) message.error(error.response?.data?.message || '登录失败,请检查邮箱和密码'); + message.success("登录成功!"); + navigate("/"); + } catch (error: unknown) { + if (!(error as { __msgShown?: boolean })?.__msgShown) + message.error("登录失败,请检查邮箱和密码"); } }; @@ -51,8 +52,8 @@ const Login: React.FC = () => { name="email" label="邮箱" rules={[ - { required: true, message: '请输入邮箱' }, - { type: 'email', message: '请输入有效的邮箱' }, + { required: true, message: "请输入邮箱" }, + { type: "email", message: "请输入有效的邮箱" }, ]} > @@ -61,7 +62,7 @@ const Login: React.FC = () => { @@ -70,7 +71,10 @@ const Login: React.FC = () => { 记住我 - + 忘记密码? @@ -79,10 +83,10 @@ const Login: React.FC = () => { diff --git a/apps/web/src/pages/auth/Register.css b/apps/web/src/pages/auth/Register.css index a5a19847e..97fd0dfab 100644 --- a/apps/web/src/pages/auth/Register.css +++ b/apps/web/src/pages/auth/Register.css @@ -3,7 +3,11 @@ display: grid; place-items: center; min-height: 100vh; - background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); + background: linear-gradient( + 135deg, + rgba(79, 70, 229, 0.08), + rgba(16, 185, 129, 0.06) + ); padding: 24px; } diff --git a/apps/web/src/pages/auth/Register.tsx b/apps/web/src/pages/auth/Register.tsx index cfa18baa9..9d00a6700 100644 --- a/apps/web/src/pages/auth/Register.tsx +++ b/apps/web/src/pages/auth/Register.tsx @@ -1,12 +1,12 @@ /** * 注册页面 - V21 完全对标 */ -import React from 'react'; -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 './Register.css'; +import React from "react"; +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 "./Register.css"; interface RegisterFormValues { email: string; @@ -27,9 +27,10 @@ const Register: React.FC = () => { password: values.password, display_name: values.username, }); - message.success('注册成功!请查收验证邮件。'); - } catch (error: any) { - if (!error?.__msgShown) message.error(error.response?.data?.message || '注册失败,请重试'); + message.success("注册成功!请查收验证邮件。"); + } catch (error: unknown) { + if (!(error as { __msgShown?: boolean })?.__msgShown) + message.error("注册失败,请重试"); } }; @@ -53,8 +54,8 @@ const Register: React.FC = () => { name="email" label="邮箱" rules={[ - { required: true, message: '请输入邮箱' }, - { type: 'email', message: '请输入有效的邮箱地址' }, + { required: true, message: "请输入邮箱" }, + { type: "email", message: "请输入有效的邮箱地址" }, ]} > { name="username" label="用户名" rules={[ - { required: true, message: '请输入用户名' }, - { min: 3, message: '用户名至少 3 个字符' }, - { max: 20, message: '用户名最多 20 个字符' }, + { required: true, message: "请输入用户名" }, + { min: 3, message: "用户名至少 3 个字符" }, + { max: 20, message: "用户名最多 20 个字符" }, { pattern: /^[a-zA-Z0-9_]+$/, - message: '用户名只能包含字母、数字和下划线', + message: "用户名只能包含字母、数字和下划线", }, ]} > @@ -88,11 +89,11 @@ const Register: React.FC = () => { name="password" label="密码" rules={[ - { required: true, message: '请输入密码' }, - { min: 8, message: '密码至少 8 个字符' }, + { required: true, message: "请输入密码" }, + { min: 8, message: "密码至少 8 个字符" }, { pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, - message: '密码必须包含大小写字母和数字', + message: "密码必须包含大小写字母和数字", }, ]} > @@ -106,15 +107,15 @@ const Register: React.FC = () => { ({ validator(_, value) { - if (!value || getFieldValue('password') === value) { + if (!value || getFieldValue("password") === value) { return Promise.resolve(); } - return Promise.reject(new Error('两次输入的密码不一致')); + return Promise.reject(new Error("两次输入的密码不一致")); }, }), ]} @@ -130,10 +131,10 @@ const Register: React.FC = () => { diff --git a/apps/web/src/pages/auth/ResetPassword.css b/apps/web/src/pages/auth/ResetPassword.css index 6b4a238fc..6094fb2d7 100644 --- a/apps/web/src/pages/auth/ResetPassword.css +++ b/apps/web/src/pages/auth/ResetPassword.css @@ -3,7 +3,11 @@ display: grid; place-items: center; min-height: 100vh; - background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); + background: linear-gradient( + 135deg, + rgba(79, 70, 229, 0.08), + rgba(16, 185, 129, 0.06) + ); padding: 24px; } @@ -82,7 +86,11 @@ display: grid; place-items: center; min-height: 100vh; - background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); + background: linear-gradient( + 135deg, + rgba(79, 70, 229, 0.08), + rgba(16, 185, 129, 0.06) + ); padding: 24px; } diff --git a/apps/web/src/pages/auth/ResetPassword.tsx b/apps/web/src/pages/auth/ResetPassword.tsx index 5eff3dd66..01caafd80 100644 --- a/apps/web/src/pages/auth/ResetPassword.tsx +++ b/apps/web/src/pages/auth/ResetPassword.tsx @@ -1,28 +1,29 @@ /** * 重置密码页面 - V21 完全对标 */ -import React from 'react'; -import { Form, Input, Button, Result, message } from 'antd'; -import { LockOutlined } from '@ant-design/icons'; -import { Link, useSearchParams, useNavigate } from 'react-router-dom'; -import { useMutation } from '@tanstack/react-query'; -import { resetPassword } from '@/api/auth'; -import './ResetPassword.css'; +import React from "react"; +import { Form, Input, Button, Result, message } from "antd"; +import { LockOutlined } from "@ant-design/icons"; +import { Link, useSearchParams, useNavigate } from "react-router-dom"; +import { useMutation } from "@tanstack/react-query"; +import { resetPassword } from "@/api/auth"; +import "./ResetPassword.css"; const ResetPassword: React.FC = () => { const [form] = Form.useForm(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const token = searchParams.get('token'); + const token = searchParams.get("token"); const resetMutation = useMutation({ mutationFn: (password: string) => resetPassword(token!, password), onSuccess: () => { - message.success('密码重置成功!'); - setTimeout(() => navigate('/login'), 2000); + message.success("密码重置成功!"); + setTimeout(() => navigate("/login"), 2000); }, - onError: (error: any) => { - if (!error?.__msgShown) message.error(error.response?.data?.message || '重置失败,请重试'); + onError: (error: unknown) => { + if (!(error as { __msgShown?: boolean })?.__msgShown) + message.error("重置失败,请重试"); }, }); @@ -77,16 +78,21 @@ const ResetPassword: React.FC = () => {

设置新密码

-
+ @@ -100,15 +106,15 @@ const ResetPassword: React.FC = () => { ({ validator(_, value) { - if (!value || getFieldValue('password') === value) { + if (!value || getFieldValue("password") === value) { return Promise.resolve(); } - return Promise.reject(new Error('两次输入的密码不一致')); + return Promise.reject(new Error("两次输入的密码不一致")); }, }), ]} @@ -124,10 +130,10 @@ const ResetPassword: React.FC = () => { diff --git a/apps/web/src/pages/dashboard/Dashboard.tsx b/apps/web/src/pages/dashboard/Dashboard.tsx index 891ea5fc5..7bddd5d4b 100644 --- a/apps/web/src/pages/dashboard/Dashboard.tsx +++ b/apps/web/src/pages/dashboard/Dashboard.tsx @@ -2,8 +2,8 @@ * 仪表盘页面 * 展示用户用量总览和最近生成记录 */ -import React from 'react'; -import { useQuery } from '@tanstack/react-query'; +import React from "react"; +import { useQuery } from "@tanstack/react-query"; import { Card, Col, @@ -11,13 +11,12 @@ import { Statistic, Table, Tag, - Typography, Spin, Button, Progress, Space, Alert, -} from 'antd'; +} from "antd"; import { FileOutlined, VideoCameraOutlined, @@ -27,17 +26,16 @@ import { CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, -} from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; -import { getDashboardOverview } from '@/api/dashboard'; -import type { ColumnsType } from 'antd/es/table'; - -const { Title } = Typography; +} from "@ant-design/icons"; +import { useNavigate } from "react-router-dom"; +import { getDashboardOverview } from "@/api/dashboard"; +import type { ColumnsType } from "antd/es/table"; +import PageHead from "@/components/layout/PageHead"; /** 格式化文件大小 */ const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; }; @@ -45,10 +43,10 @@ const formatFileSize = (bytes: number): string => { /** 任务状态标签 */ const StatusTag: React.FC<{ status: string }> = ({ status }) => { const config: Record = { - completed: { color: 'success', icon: }, - processing: { color: 'processing', icon: }, - pending: { color: 'default', icon: }, - failed: { color: 'error', icon: }, + completed: { color: "success", icon: }, + processing: { color: "processing", icon: }, + pending: { color: "default", icon: }, + failed: { color: "error", icon: }, }; const c = config[status] || config.pending; return ( @@ -62,51 +60,50 @@ const Dashboard: React.FC = () => { const navigate = useNavigate(); const { data, isLoading, isError } = useQuery({ - queryKey: ['dashboard-overview'], + queryKey: ["dashboard-overview"], queryFn: getDashboardOverview, }); - const taskColumns: ColumnsType['recent_tasks'][number]> = - [ - { - title: '任务类型', - dataIndex: 'task_type', - key: 'task_type', - render: (type: string) => - type === 'generation' ? '视频生成' : type, - }, - { - title: '状态', - dataIndex: 'status', - key: 'status', - render: (status: string) => , - }, - { - title: '进度', - dataIndex: 'progress', - key: 'progress', - render: (progress: number) => ( - - ), - }, - { - title: '信息', - dataIndex: 'user_message', - key: 'user_message', - ellipsis: true, - }, - { - title: '创建时间', - dataIndex: 'created_at', - key: 'created_at', - render: (t: string) => - t ? new Date(t).toLocaleString('zh-CN') : '-', - }, - ]; + const taskColumns: ColumnsType< + NonNullable["recent_tasks"][number] + > = [ + { + title: "任务类型", + dataIndex: "task_type", + key: "task_type", + render: (type: string) => (type === "generation" ? "视频生成" : type), + }, + { + title: "状态", + dataIndex: "status", + key: "status", + render: (status: string) => , + }, + { + title: "进度", + dataIndex: "progress", + key: "progress", + render: (progress: number) => ( + + ), + }, + { + title: "信息", + dataIndex: "user_message", + key: "user_message", + ellipsis: true, + }, + { + title: "创建时间", + dataIndex: "created_at", + key: "created_at", + render: (t: string) => (t ? new Date(t).toLocaleString("zh-CN") : "-"), + }, + ]; if (isLoading) { return ( -
+
); @@ -114,22 +111,25 @@ const Dashboard: React.FC = () => { if (isError) { return ( -
- +
+
); } return ( -
- - 概览 - +
+ {/* 用量统计卡片 */} - navigate('/assets')}> + navigate("/assets")}> { - navigate('/assets')}> + navigate("/assets")}> { - navigate('/titles')}> + navigate("/titles")}> { - navigate('/voices')}> + navigate("/voices")}> { - navigate('/history')}> + navigate("/history")}> { - navigate('/products')}> + navigate("/products")}> { {/* 快捷操作 */} - - - - + + + @@ -209,12 +209,12 @@ const Dashboard: React.FC = () => { rowKey="id" pagination={false} size="small" - locale={{ emptyText: '暂无生成记录' }} + locale={{ emptyText: "暂无生成记录" }} scroll={{ x: 600 }} /> {(data?.recent_tasks?.length ?? 0) > 0 && ( -
-
diff --git a/apps/web/src/pages/duplication/DuplicationDetail.tsx b/apps/web/src/pages/duplication/DuplicationDetail.tsx index 5bf90291b..04ba83d12 100644 --- a/apps/web/src/pages/duplication/DuplicationDetail.tsx +++ b/apps/web/src/pages/duplication/DuplicationDetail.tsx @@ -2,8 +2,8 @@ * 重复视频对比详情页面 * 展示查重结果中的重复片段详情,支持时间线对比 */ -import React from 'react'; -import { useQuery } from '@tanstack/react-query'; +import React from "react"; +import { useQuery } from "@tanstack/react-query"; import { Typography, Card, @@ -18,18 +18,15 @@ import { Col, Tooltip, Divider, -} from 'antd'; +} from "antd"; import { ArrowLeftOutlined, VideoCameraOutlined, ClockCircleOutlined, WarningOutlined, -} from '@ant-design/icons'; -import { useParams, useNavigate } from 'react-router-dom'; -import { - getDuplicationDetail, - type DuplicateSegment, -} from '@/api/duplication'; +} from "@ant-design/icons"; +import { useParams, useNavigate } from "react-router-dom"; +import { getDuplicationDetail, type DuplicateSegment } from "@/api/duplication"; const { Title, Text, Paragraph } = Typography; @@ -37,7 +34,7 @@ const { Title, Text, Paragraph } = Typography; const formatTime = (seconds: number) => { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); - return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; }; /** 格式化文件大小 */ @@ -50,16 +47,16 @@ const formatSize = (bytes: number) => { /** 查重率颜色 */ const getRateColor = (rate: number) => { - if (rate <= 10) return '#52c41a'; - if (rate <= 30) return '#faad14'; - return '#ff4d4f'; + if (rate <= 10) return "#52c41a"; + if (rate <= 30) return "#faad14"; + return "#ff4d4f"; }; /** 相似度颜色 */ const getSimilarityColor = (similarity: number) => { - if (similarity >= 90) return '#ff4d4f'; - if (similarity >= 70) return '#faad14'; - return '#52c41a'; + if (similarity >= 90) return "#ff4d4f"; + if (similarity >= 70) return "#faad14"; + return "#52c41a"; }; /** 单个重复片段卡片 */ @@ -78,7 +75,7 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({ 片段 {index + 1} 相似度 {segment.similarity.toFixed(1)}% @@ -102,7 +99,7 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({ - {formatTime(segment.source_start)} -{' '} + {formatTime(segment.source_start)} -{" "} {formatTime(segment.source_end)} @@ -115,32 +112,32 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
{sourceDuration.toFixed(0)}s @@ -157,7 +154,7 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({ type="inner" title={ - + 匹配到的已有视频 } @@ -172,7 +169,7 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({ - {formatTime(segment.matched_start)} -{' '} + {formatTime(segment.matched_start)} -{" "} {formatTime(segment.matched_end)} @@ -185,32 +182,32 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
{matchedDuration.toFixed(0)}s @@ -228,15 +225,19 @@ const DuplicationDetail: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { data: detail, isLoading, isError } = useQuery({ - queryKey: ['duplication-detail', id], + const { + data: detail, + isLoading, + isError, + } = useQuery({ + queryKey: ["duplication-detail", id], queryFn: () => getDuplicationDetail(id!), enabled: !!id, }); if (isLoading) { return ( -
+
); @@ -246,7 +247,7 @@ const DuplicationDetail: React.FC = () => { return (
- @@ -258,7 +259,7 @@ const DuplicationDetail: React.FC = () => { return (
- @@ -267,12 +268,12 @@ const DuplicationDetail: React.FC = () => { } return ( -
+
{/* 顶部导航 */} @@ -298,14 +299,14 @@ const DuplicationDetail: React.FC = () => { {detail.duration_seconds ? `${Math.floor(detail.duration_seconds / 60)}分${detail.duration_seconds % 60}秒` - : '-'} + : "-"} - {new Date(detail.created_at).toLocaleString('zh-CN')} + {new Date(detail.created_at).toLocaleString("zh-CN")} @@ -325,7 +326,7 @@ const DuplicationDetail: React.FC = () => { ) : ( - '-' + "-" )} @@ -343,10 +344,10 @@ const DuplicationDetail: React.FC = () => { strokeColor={getRateColor(detail.duplicate_rate)} status={ detail.duplicate_rate <= 10 - ? 'success' + ? "success" : detail.duplicate_rate <= 30 - ? 'normal' - : 'exception' + ? "normal" + : "exception" } />
@@ -368,7 +369,7 @@ const DuplicationDetail: React.FC = () => { 以下片段与素材库中的已有视频存在重复,高相似度片段建议进行替换或裁剪。 - + {detail.segments.map((segment, index) => ( ))} diff --git a/apps/web/src/pages/duplication/DuplicationResults.tsx b/apps/web/src/pages/duplication/DuplicationResults.tsx index 0137182df..182d209eb 100644 --- a/apps/web/src/pages/duplication/DuplicationResults.tsx +++ b/apps/web/src/pages/duplication/DuplicationResults.tsx @@ -2,8 +2,8 @@ * 查重结果列表页面 * 展示所有查重记录,支持查看详情、删除、重新查重 */ -import React, { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import React, { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Typography, Table, @@ -14,8 +14,8 @@ import { message, Progress, Tooltip, -} from 'antd'; -import type { ColumnsType } from 'antd/es/table'; +} from "antd"; +import type { ColumnsType } from "antd/es/table"; import { EyeOutlined, DeleteOutlined, @@ -25,15 +25,15 @@ import { ClockCircleOutlined, CloseCircleOutlined, SyncOutlined, -} from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; +} from "@ant-design/icons"; +import { useNavigate } from "react-router-dom"; import { getDuplicationRecords, deleteDuplicationRecord, retryDuplication, type DuplicationRecord, type DuplicationStatus, -} from '@/api/duplication'; +} from "@/api/duplication"; const { Title, Text } = Typography; @@ -43,32 +43,32 @@ const STATUS_CONFIG: Record< { color: string; text: string; icon: React.ReactNode } > = { pending: { - color: 'default', - text: '等待中', + color: "default", + text: "等待中", icon: , }, processing: { - color: 'processing', - text: '查重中', + color: "processing", + text: "查重中", icon: , }, completed: { - color: 'success', - text: '已完成', + color: "success", + text: "已完成", icon: , }, failed: { - color: 'error', - text: '失败', + color: "error", + text: "失败", icon: , }, }; /** 查重率颜色 */ const getRateColor = (rate: number) => { - if (rate <= 10) return '#52c41a'; - if (rate <= 30) return '#faad14'; - return '#ff4d4f'; + if (rate <= 10) return "#52c41a"; + if (rate <= 30) return "#faad14"; + return "#ff4d4f"; }; /** 格式化文件大小 */ @@ -81,7 +81,7 @@ const formatSize = (bytes: number) => { /** 格式化时长 */ const formatDuration = (seconds?: number) => { - if (!seconds) return '-'; + if (!seconds) return "-"; const m = Math.floor(seconds / 60); const s = seconds % 60; return m > 0 ? `${m}分${s}秒` : `${s}秒`; @@ -94,7 +94,7 @@ const DuplicationResults: React.FC = () => { // 获取查重记录 const { data: records = [], isLoading } = useQuery({ - queryKey: ['duplication-records'], + queryKey: ["duplication-records"], queryFn: getDuplicationRecords, }); @@ -102,28 +102,34 @@ const DuplicationResults: React.FC = () => { const deleteMutation = useMutation({ mutationFn: deleteDuplicationRecord, onSuccess: () => { - message.success('已删除'); - queryClient.invalidateQueries({ queryKey: ['duplication-records'] }); + message.success("已删除"); + queryClient.invalidateQueries({ queryKey: ["duplication-records"] }); + }, + onError: (err: unknown) => { + if (!(err as { __msgShown?: boolean })?.__msgShown) + message.error("删除失败"); }, - onError: (err: any) => { if (!err?.__msgShown) message.error('删除失败') }, }); // 重新查重 const retryMutation = useMutation({ mutationFn: retryDuplication, onSuccess: () => { - message.success('已重新提交查重'); - queryClient.invalidateQueries({ queryKey: ['duplication-records'] }); + message.success("已重新提交查重"); + queryClient.invalidateQueries({ queryKey: ["duplication-records"] }); + }, + onError: (err: unknown) => { + if (!(err as { __msgShown?: boolean })?.__msgShown) + message.error("重新查重失败"); }, - onError: (err: any) => { if (!err?.__msgShown) message.error('重新查重失败') }, }); /** 批量删除 */ const handleBatchDelete = async () => { const results = await Promise.allSettled( - selectedRowKeys.map((key) => deleteDuplicationRecord(String(key))) + selectedRowKeys.map((key) => deleteDuplicationRecord(String(key))), ); - const succeeded = results.filter((r) => r.status === 'fulfilled').length; + const succeeded = results.filter((r) => r.status === "fulfilled").length; const failed = results.length - succeeded; if (failed === 0) { message.success(`已删除 ${succeeded} 条记录`); @@ -131,14 +137,14 @@ const DuplicationResults: React.FC = () => { message.warning(`删除完成:${succeeded} 条成功,${failed} 条失败`); } setSelectedRowKeys([]); - queryClient.invalidateQueries({ queryKey: ['duplication-records'] }); + queryClient.invalidateQueries({ queryKey: ["duplication-records"] }); }; const columns: ColumnsType = [ { - title: '文件名', - dataIndex: 'filename', - key: 'filename', + title: "文件名", + dataIndex: "filename", + key: "filename", ellipsis: true, width: 200, render: (text: string) => ( @@ -148,23 +154,23 @@ const DuplicationResults: React.FC = () => { ), }, { - title: '文件大小', - dataIndex: 'file_size', - key: 'file_size', + title: "文件大小", + dataIndex: "file_size", + key: "file_size", width: 100, render: (size: number) => formatSize(size), }, { - title: '时长', - dataIndex: 'duration_seconds', - key: 'duration_seconds', + title: "时长", + dataIndex: "duration_seconds", + key: "duration_seconds", width: 80, render: (seconds?: number) => formatDuration(seconds), }, { - title: '状态', - dataIndex: 'status', - key: 'status', + title: "状态", + dataIndex: "status", + key: "status", width: 100, render: (status: DuplicationStatus) => { const cfg = STATUS_CONFIG[status]; @@ -176,12 +182,12 @@ const DuplicationResults: React.FC = () => { }, }, { - title: '查重率', - dataIndex: 'duplicate_rate', - key: 'duplicate_rate', + title: "查重率", + dataIndex: "duplicate_rate", + key: "duplicate_rate", width: 140, render: (rate?: number, record?: DuplicationRecord) => { - if (record?.status !== 'completed' || rate === undefined) return '-'; + if (record?.status !== "completed" || rate === undefined) return "-"; return ( { }, }, { - title: '重复片段', - dataIndex: 'duplicate_count', - key: 'duplicate_count', + title: "重复片段", + dataIndex: "duplicate_count", + key: "duplicate_count", width: 80, - align: 'center', + align: "center", render: (count?: number, record?: DuplicationRecord) => { - if (record?.status !== 'completed') return '-'; + if (record?.status !== "completed") return "-"; return {count ?? 0}; }, }, { - title: '提交时间', - dataIndex: 'created_at', - key: 'created_at', + title: "提交时间", + dataIndex: "created_at", + key: "created_at", width: 160, - render: (time: string) => new Date(time).toLocaleString('zh-CN'), + render: (time: string) => new Date(time).toLocaleString("zh-CN"), }, { - title: '操作', - key: 'action', + title: "操作", + key: "action", width: 160, - fixed: 'right', + fixed: "right", render: (_: unknown, record: DuplicationRecord) => ( - {record.status === 'completed' && ( + {record.status === "completed" && ( diff --git a/apps/web/src/pages/duplication/DuplicationUpload.tsx b/apps/web/src/pages/duplication/DuplicationUpload.tsx index 5b341b899..59464104c 100644 --- a/apps/web/src/pages/duplication/DuplicationUpload.tsx +++ b/apps/web/src/pages/duplication/DuplicationUpload.tsx @@ -2,8 +2,8 @@ * 上传查重页面 * 用户上传视频文件,系统进行查重检测 */ -import React, { useState } from 'react'; -import { useMutation } from '@tanstack/react-query'; +import React, { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; import { Button, Typography, @@ -14,22 +14,22 @@ import { Alert, Space, Result, -} from 'antd'; +} from "antd"; import { InboxOutlined, VideoCameraOutlined, CheckCircleOutlined, LoadingOutlined, -} from '@ant-design/icons'; -import { uploadForDuplication } from '@/api/duplication'; -import { useNavigate } from 'react-router-dom'; -import type { UploadFile } from 'antd/es/upload'; +} from "@ant-design/icons"; +import { uploadForDuplication } from "@/api/duplication"; +import { useNavigate } from "react-router-dom"; +import type { UploadFile } from "antd/es/upload"; const { Title, Text, Paragraph } = Typography; const { Dragger } = Upload; /** 支持的视频格式 */ -const ACCEPT_FORMATS = '.mp4,.avi,.mov,.mkv,.wmv,.flv,.webm'; +const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"; /** 最大文件大小:2GB */ const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; @@ -51,11 +51,12 @@ const DuplicationUpload: React.FC = () => { id: data.id, message: data.message, }); - message.success('查重任务已提交'); + message.success("查重任务已提交"); }, - onError: (err: any) => { + onError: (err: unknown) => { setUploading(false); - if (!err?.__msgShown) message.error('上传失败,请重试'); + if (!(err as { __msgShown?: boolean })?.__msgShown) + message.error("上传失败,请重试"); }, }); @@ -63,14 +64,14 @@ const DuplicationUpload: React.FC = () => { const handleUpload = (file: File) => { // 校验文件大小 if (file.size > MAX_FILE_SIZE) { - message.error('文件大小不能超过 2GB'); + message.error("文件大小不能超过 2GB"); return false; } // 校验文件类型 - const ext = file.name.toLowerCase().split('.').pop(); - const allowedExts = ACCEPT_FORMATS.replace(/\./g, '').split(','); - if (!allowedExts.includes(ext || '')) { + const ext = file.name.toLowerCase().split(".").pop(); + const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(","); + if (!allowedExts.includes(ext || "")) { message.error(`不支持的文件格式,支持:${ACCEPT_FORMATS}`); return false; } @@ -89,7 +90,7 @@ const DuplicationUpload: React.FC = () => { }; return ( -
+
<VideoCameraOutlined style={{ marginRight: 8 }} /> 视频查重 @@ -107,17 +108,17 @@ const DuplicationUpload: React.FC = () => { beforeUpload={handleUpload} onChange={({ fileList: newFileList }) => setFileList(newFileList)} disabled={uploading} - style={{ padding: '20px 0' }} + style={{ padding: "20px 0" }} > <p className="ant-upload-drag-icon"> {uploading ? ( - <LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} /> + <LoadingOutlined style={{ fontSize: 48, color: "#1890ff" }} /> ) : ( <InboxOutlined style={{ fontSize: 48 }} /> )} </p> <p className="ant-upload-text"> - {uploading ? '正在上传并查重...' : '点击或拖拽视频文件到此区域'} + {uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"} </p> <p className="ant-upload-hint"> 支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB @@ -126,12 +127,12 @@ const DuplicationUpload: React.FC = () => { {/* 上传进度 */} {uploading && ( - <div style={{ marginTop: 24, textAlign: 'center' }}> + <div style={{ marginTop: 24, textAlign: "center" }}> <Progress type="circle" percent={99} status="active" - format={() => '查重中...'} + format={() => "查重中..."} size={120} /> <Paragraph type="secondary" style={{ marginTop: 16 }}> @@ -152,7 +153,7 @@ const DuplicationUpload: React.FC = () => { type="primary" key="view" icon={<CheckCircleOutlined />} - onClick={() => navigate('/duplication/results')} + onClick={() => navigate("/duplication/results")} > 查看结果 </Button>, diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index 661ac8c64..ea5138d24 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -42,7 +42,9 @@ .ep-tpl-card { cursor: pointer; - transition: border-color 0.2s, box-shadow 0.2s; + transition: + border-color 0.2s, + box-shadow 0.2s; } .ep-tpl-card-active { @@ -103,7 +105,9 @@ } .ep-seg-card { - transition: box-shadow 0.2s, border-color 0.2s; + transition: + box-shadow 0.2s, + border-color 0.2s; cursor: grab; } diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index e38d2df1d..420354f9d 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -9,18 +9,18 @@ * P1-5: 分类 Input → Select(在 SaveModal 中实现) * P1-6: SaveTemplatePayload 补充 estimated_duration */ -import React, { useState, useEffect } from 'react'; -import './EditingPlanner.css'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Button, Space, message } from 'antd'; +import React, { useState, useEffect } from "react"; +import "./EditingPlanner.css"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button, Space, message } from "antd"; import { SaveOutlined, VideoCameraOutlined, AppstoreOutlined, UserOutlined, DashboardOutlined, -} from '@ant-design/icons'; -import { useSearchParams } from 'react-router-dom'; +} from "@ant-design/icons"; +import { useSearchParams } from "react-router-dom"; import { getEditingTemplates, getTemplateCategories, @@ -34,45 +34,48 @@ import { type TitleConfig, type SubtitleConfig, type BgmConfig, -} from '@/api/editingPlanner'; + type SaveTemplatePayload, +} from "@/api/editingPlanner"; /* ── 子组件 ── */ -import TemplatePanel from './components/TemplatePanel'; -import TimelinePanel from './components/TimelinePanel'; -import SettingsPanel from './components/SettingsPanel'; -import SaveModal from './components/SaveModal'; -import GenerateModal from './components/GenerateModal'; +import TemplatePanel from "./components/TemplatePanel"; +import TimelinePanel from "./components/TimelinePanel"; +import SettingsPanel from "./components/SettingsPanel"; +import SaveModal from "./components/SaveModal"; +import GenerateModal from "./components/GenerateModal"; /* ──────────── 常量 ──────────── */ const MODES: { key: TemplateMode; icon: React.ReactNode; desc: string }[] = [ - { key: 'pip', icon: <AppstoreOutlined />, desc: '多画面叠加' }, - { key: 'voice_over', icon: <UserOutlined />, desc: '人物讲解为主' }, - { key: 'one_take', icon: <VideoCameraOutlined />, desc: '连续不中断' }, - { key: 'voice_pip', icon: <DashboardOutlined />, desc: '口播搭配混剪素材' }, + { key: "pip", icon: <AppstoreOutlined />, desc: "多画面叠加" }, + { key: "voice_over", icon: <UserOutlined />, desc: "人物讲解为主" }, + { key: "one_take", icon: <VideoCameraOutlined />, desc: "连续不中断" }, + { key: "voice_pip", icon: <DashboardOutlined />, desc: "口播搭配混剪素材" }, ]; const DEFAULT_TITLE: TitleConfig = { ai_auto_select: true, - content: '', - font_preset: '思源黑体', - font_color: '#ffffff', + content: "", + font_preset: "思源黑体", + font_color: "#ffffff", font_size: 32, - position: 'top', + position: "top", }; const DEFAULT_SUBTITLE: SubtitleConfig = { enabled: true, - position: 'bottom', - font: '思源黑体', - color: '#ffffff', + position: "bottom", + font: "思源黑体", + color: "#ffffff", size: 24, - animation: 'fade', + animation: "fade", }; -const DEFAULT_BGM: BgmConfig = { enabled: false, music_id: '' }; +const DEFAULT_BGM: BgmConfig = { enabled: false, music_id: "" }; /** 计算预估时长 = Σ 片段时长范围中值 */ const calcEstimatedDuration = (segs: TemplateSegment[]) => - Math.round(segs.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0)); + Math.round( + segs.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0), + ); let _segId = 0; const newSegId = () => `seg-new-${++_segId}`; @@ -84,15 +87,15 @@ const EditingPlanner: React.FC = () => { const [searchParams] = useSearchParams(); /* ── P0-2: URL 参数 ── */ - const urlTemplateId = searchParams.get('template'); - const urlGenerate = searchParams.get('generate'); + const urlTemplateId = searchParams.get("template"); + const urlGenerate = searchParams.get("generate"); /* ── 数据查询 ── */ - const [searchText, setSearchText] = useState(''); - const [filterCategory, setFilterCategory] = useState(''); + const [searchText, setSearchText] = useState(""); + const [filterCategory, setFilterCategory] = useState(""); const { data: templates = [], isLoading: tplLoading } = useQuery({ - queryKey: ['editing-templates', filterCategory, searchText], + queryKey: ["editing-templates", filterCategory, searchText], queryFn: () => getEditingTemplates({ category: filterCategory || undefined, @@ -101,28 +104,40 @@ const EditingPlanner: React.FC = () => { }); const { data: categories = [] } = useQuery({ - queryKey: ['template-categories'], + queryKey: ["template-categories"], queryFn: getTemplateCategories, }); /* ── 编辑器状态 ── */ - const [currentMode, setCurrentMode] = useState<TemplateMode>('pip'); + const [currentMode, setCurrentMode] = useState<TemplateMode>("pip"); const [segments, setSegments] = useState<TemplateSegment[]>([ - { id: newSegId(), segment_order: 1, duration_min: 5, duration_max: 15, material_type: null }, + { + id: newSegId(), + segment_order: 1, + duration_min: 5, + duration_max: 15, + material_type: null, + }, ]); const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(null); - const [titleConfig, setTitleConfig] = useState<TitleConfig>({ ...DEFAULT_TITLE }); - const [subtitleConfig, setSubtitleConfig] = useState<SubtitleConfig>({ ...DEFAULT_SUBTITLE }); + const [titleConfig, setTitleConfig] = useState<TitleConfig>({ + ...DEFAULT_TITLE, + }); + const [subtitleConfig, setSubtitleConfig] = useState<SubtitleConfig>({ + ...DEFAULT_SUBTITLE, + }); const [bgmConfig, setBgmConfig] = useState<BgmConfig>({ ...DEFAULT_BGM }); /* ── UI 状态 ── */ const [saveModalOpen, setSaveModalOpen] = useState(false); const [generateModalOpen, setGenerateModalOpen] = useState(false); - const [draftName, setDraftName] = useState(''); - const [draftCategory, setDraftCategory] = useState(''); - const [draftTags, setDraftTags] = useState(''); - const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(null); + const [draftName, setDraftName] = useState(""); + const [draftCategory, setDraftCategory] = useState(""); + const [draftTags, setDraftTags] = useState(""); + const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>( + null, + ); const [dragIdx, setDragIdx] = useState<number | null>(null); /* ── P0-2: 自动加载 URL 指定的模板 ── */ @@ -132,7 +147,7 @@ const EditingPlanner: React.FC = () => { if (tpl) { loadTemplate(tpl); // 如果 URL 有 generate=1,自动打开发成弹窗 - if (urlGenerate === '1') { + if (urlGenerate === "1") { setGenerateModalOpen(true); } } @@ -143,37 +158,49 @@ const EditingPlanner: React.FC = () => { const createMutation = useMutation({ mutationFn: createEditingTemplate, onSuccess: () => { - message.success('模板已保存'); - queryClient.invalidateQueries({ queryKey: ['editing-templates'] }); + message.success("模板已保存"); + queryClient.invalidateQueries({ queryKey: ["editing-templates"] }); setSaveModalOpen(false); }, - onError: (err: any) => { - if (!err?.__msgShown) message.error('保存失败'); + onError: (err: unknown) => { + if (!(err as { __msgShown?: boolean })?.__msgShown) + message.error("保存失败"); }, }); const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: any }) => updateEditingTemplate(id, data), + mutationFn: ({ id, data }: { id: string; data: SaveTemplatePayload }) => + updateEditingTemplate(id, data), onSuccess: () => { - message.success('模板已更新'); - queryClient.invalidateQueries({ queryKey: ['editing-templates'] }); + message.success("模板已更新"); + queryClient.invalidateQueries({ queryKey: ["editing-templates"] }); setSaveModalOpen(false); }, - onError: (err: any) => { - if (!err?.__msgShown) message.error('保存失败'); + onError: (err: unknown) => { + if (!(err as { __msgShown?: boolean })?.__msgShown) + message.error("保存失败"); }, }); const generateMutation = useMutation({ - mutationFn: ({ templateId, duration }: { templateId: string; duration: number }) => - generateFromTemplate(templateId, { voiceover_duration: duration }), + mutationFn: ({ + templateId, + duration, + }: { + templateId: string; + duration: number; + }) => generateFromTemplate(templateId, { voiceover_duration: duration }), onSuccess: (data) => { - const msg = data.warnings && data.warnings.length > 0 ? `生成任务已提交(${data.warnings.map(w => w.message).join('; ')})` : '生成任务已提交'; + const msg = + data.warnings && data.warnings.length > 0 + ? `生成任务已提交(${data.warnings.map((w) => w.message).join("; ")})` + : "生成任务已提交"; message.success(msg); setGenerateModalOpen(false); }, - onError: (err: any) => { - if (!err?.__msgShown) message.error('生成失败'); + onError: (err: unknown) => { + if (!(err as { __msgShown?: boolean })?.__msgShown) + message.error("生成失败"); }, }); @@ -182,7 +209,7 @@ const EditingPlanner: React.FC = () => { /* ──────────── 片段操作 ──────────── */ const addSegment = () => { - if (currentMode === 'one_take') return; + if (currentMode === "one_take") return; setSegments((prev) => [ ...prev, { @@ -190,20 +217,24 @@ const EditingPlanner: React.FC = () => { segment_order: prev.length + 1, duration_min: 5, duration_max: 15, - material_type: currentMode === 'voice_pip' ? '人物' : null, + material_type: currentMode === "voice_pip" ? "人物" : null, }, ]); }; const removeSegment = (id: string) => { - if (currentMode === 'one_take') return; + if (currentMode === "one_take") return; setSegments((prev) => - prev.filter((s) => s.id !== id).map((s, i) => ({ ...s, segment_order: i + 1 })), + prev + .filter((s) => s.id !== id) + .map((s, i) => ({ ...s, segment_order: i + 1 })), ); }; const updateSegment = (id: string, patch: Partial<TemplateSegment>) => { - setSegments((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s))); + setSegments((prev) => + prev.map((s) => (s.id === id ? { ...s, ...patch } : s)), + ); }; const handleDragStart = (idx: number) => setDragIdx(idx); @@ -226,7 +257,7 @@ const EditingPlanner: React.FC = () => { const handleModeChange = (mode: TemplateMode) => { setCurrentMode(mode); - if (mode === 'one_take') { + if (mode === "one_take") { // 锁定为 1 个片段 setSegments([ { @@ -237,12 +268,12 @@ const EditingPlanner: React.FC = () => { material_type: null, }, ]); - } else if (mode === 'voice_pip') { + } else if (mode === "voice_pip") { // 确保每个片段有 material_type setSegments((prev) => prev.map((s) => ({ ...s, - material_type: s.material_type || '人物', + material_type: s.material_type || "人物", })), ); } @@ -261,9 +292,15 @@ const EditingPlanner: React.FC = () => { const resetEditor = () => { setLoadedTemplateId(null); - setCurrentMode('pip'); + setCurrentMode("pip"); setSegments([ - { id: newSegId(), segment_order: 1, duration_min: 5, duration_max: 15, material_type: null }, + { + id: newSegId(), + segment_order: 1, + duration_min: 5, + duration_max: 15, + material_type: null, + }, ]); setTitleConfig({ ...DEFAULT_TITLE }); setSubtitleConfig({ ...DEFAULT_SUBTITLE }); @@ -272,22 +309,31 @@ const EditingPlanner: React.FC = () => { const openSaveModal = () => { if (segments.length === 0) { - message.warning('请至少添加一个片段'); + message.warning("请至少添加一个片段"); return; } - setDraftName(loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.name || '' : ''); + setDraftName( + loadedTemplateId + ? templates.find((t) => t.id === loadedTemplateId)?.name || "" + : "", + ); setDraftCategory( - loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.category || '' : '', + loadedTemplateId + ? templates.find((t) => t.id === loadedTemplateId)?.category || "" + : "", ); setDraftTags( - loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.tags.join(', ') || '' : '', + loadedTemplateId + ? templates.find((t) => t.id === loadedTemplateId)?.tags.join(", ") || + "" + : "", ); setSaveModalOpen(true); }; const handleSave = () => { if (!draftName.trim()) { - message.warning('请输入模板名称'); + message.warning("请输入模板名称"); return; } const estimatedDuration = calcEstimatedDuration(segments); @@ -315,7 +361,7 @@ const EditingPlanner: React.FC = () => { const handleGenerate = () => { if (!loadedTemplateId) { - message.warning('请先保存模板'); + message.warning("请先保存模板"); return; } setGenerateModalOpen(true); @@ -323,10 +369,13 @@ const EditingPlanner: React.FC = () => { const doGenerate = () => { if (!voiceoverDuration || voiceoverDuration <= 0) { - message.warning('请输入配音时长'); + message.warning("请输入配音时长"); return; } - generateMutation.mutate({ templateId: loadedTemplateId!, duration: voiceoverDuration }); + generateMutation.mutate({ + templateId: loadedTemplateId!, + duration: voiceoverDuration, + }); }; const estimatedDuration = calcEstimatedDuration(segments); @@ -341,7 +390,7 @@ const EditingPlanner: React.FC = () => { {MODES.map((m) => ( <Button key={m.key} - type={currentMode === m.key ? 'primary' : 'default'} + type={currentMode === m.key ? "primary" : "default"} icon={m.icon} onClick={() => handleModeChange(m.key)} > diff --git a/apps/web/src/pages/editing-planner/components/GenerateModal.tsx b/apps/web/src/pages/editing-planner/components/GenerateModal.tsx index 6fd02beb2..05bde01dc 100644 --- a/apps/web/src/pages/editing-planner/components/GenerateModal.tsx +++ b/apps/web/src/pages/editing-planner/components/GenerateModal.tsx @@ -2,8 +2,8 @@ * 使用模板生成视频弹窗 * P1-4: voiceover_id → voiceover_duration (number) */ -import React from 'react'; -import { Modal, InputNumber, Space, Typography } from 'antd'; +import React from "react"; +import { Modal, InputNumber, Space, Typography } from "antd"; const { Text } = Typography; @@ -35,7 +35,7 @@ const GenerateModal: React.FC<GenerateModalProps> = ({ confirmLoading={loading} okText="开始生成" > - <Space direction="vertical" style={{ width: '100%' }} size={12}> + <Space direction="vertical" style={{ width: "100%" }} size={12}> <div> <Text style={{ fontSize: 13 }}>配音时长(秒)*</Text> <InputNumber @@ -44,7 +44,7 @@ const GenerateModal: React.FC<GenerateModalProps> = ({ onChange={onDurationChange} min={1} max={600} - style={{ width: '100%' }} + style={{ width: "100%" }} /> </div> <Text type="secondary" style={{ fontSize: 12 }}> diff --git a/apps/web/src/pages/editing-planner/components/SaveModal.tsx b/apps/web/src/pages/editing-planner/components/SaveModal.tsx index aa60d2c81..7e5fdd03e 100644 --- a/apps/web/src/pages/editing-planner/components/SaveModal.tsx +++ b/apps/web/src/pages/editing-planner/components/SaveModal.tsx @@ -2,9 +2,9 @@ * 保存/更新模板弹窗 * 分类使用 Select 关联后端分类 API(P1-5) */ -import React from 'react'; -import { Modal, Input, Select, Space, Typography } from 'antd'; -import type { TemplateCategory } from '@/api/editingPlanner'; +import React from "react"; +import { Modal, Input, Select, Space, Typography } from "antd"; +import type { TemplateCategory } from "@/api/editingPlanner"; const { Text } = Typography; @@ -41,14 +41,14 @@ const SaveModal: React.FC<SaveModalProps> = ({ }) => { return ( <Modal - title={isUpdate ? '更新模板' : '保存模板'} + title={isUpdate ? "更新模板" : "保存模板"} open={open} onCancel={onCancel} onOk={onSave} confirmLoading={loading} okText="保存" > - <Space direction="vertical" style={{ width: '100%' }} size={12}> + <Space direction="vertical" style={{ width: "100%" }} size={12}> <div> <Text style={{ fontSize: 13 }}>模板名称 *</Text> <Input @@ -62,10 +62,10 @@ const SaveModal: React.FC<SaveModalProps> = ({ <Select placeholder="选择分类" value={draftCategory || undefined} - onChange={(v) => onCategoryChange(v || '')} + onChange={(v) => onCategoryChange(v || "")} allowClear showSearch - style={{ width: '100%' }} + style={{ width: "100%" }} options={categories.map((c) => ({ value: c.name, label: c.name }))} /> </div> diff --git a/apps/web/src/pages/editing-planner/components/SettingsPanel.tsx b/apps/web/src/pages/editing-planner/components/SettingsPanel.tsx index 2c1da27d2..67d279ddd 100644 --- a/apps/web/src/pages/editing-planner/components/SettingsPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/SettingsPanel.tsx @@ -2,26 +2,30 @@ * 右侧设置面板 * 标题设置 / 字幕设置 / BGM 设置 */ -import React from 'react'; -import { Typography, Input, Switch, Select, Slider, Tag } from 'antd'; -import { SoundOutlined, FontSizeOutlined } from '@ant-design/icons'; -import type { TitleConfig, SubtitleConfig, BgmConfig } from '@/api/editingPlanner'; +import React from "react"; +import { Typography, Input, Switch, Select, Slider, Tag } from "antd"; +import { SoundOutlined, FontSizeOutlined } from "@ant-design/icons"; +import type { + TitleConfig, + SubtitleConfig, + BgmConfig, +} from "@/api/editingPlanner"; const { Text } = Typography; /* ── 常量 ── */ -const FONT_PRESETS = ['思源黑体', '站酷快乐体', '方正兰亭', '汉仪旗黑']; +const FONT_PRESETS = ["思源黑体", "站酷快乐体", "方正兰亭", "汉仪旗黑"]; const POSITIONS = [ - { value: 'top', label: '顶部' }, - { value: 'center', label: '居中' }, - { value: 'bottom', label: '底部' }, + { value: "top", label: "顶部" }, + { value: "center", label: "居中" }, + { value: "bottom", label: "底部" }, ]; -const SUBTITLE_FONTS = ['思源黑体', '微软雅黑', '苹方']; +const SUBTITLE_FONTS = ["思源黑体", "微软雅黑", "苹方"]; const SUBTITLE_ANIMATIONS = [ - { value: 'none', label: '无' }, - { value: 'fade', label: '淡入' }, - { value: 'typewriter', label: '打字机' }, - { value: 'slide', label: '滑动' }, + { value: "none", label: "无" }, + { value: "fade", label: "淡入" }, + { value: "typewriter", label: "打字机" }, + { value: "slide", label: "滑动" }, ]; interface SettingsPanelProps { @@ -45,17 +49,26 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ <div className="ep-right"> {/* 标题设置 */} <div className="ep-settings-group"> - <Text strong style={{ display: 'block', marginBottom: 12 }}> + <Text strong style={{ display: "block", marginBottom: 12 }}> <FontSizeOutlined style={{ marginRight: 6 }} /> 标题设置 </Text> - <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 12, + }} + > <Text style={{ fontSize: 13 }}>AI 自动选择</Text> <Switch size="small" checked={titleConfig.ai_auto_select} - onChange={(checked) => onTitleChange({ ...titleConfig, ai_auto_select: checked })} + onChange={(checked) => + onTitleChange({ ...titleConfig, ai_auto_select: checked }) + } checkedChildren="ON" unCheckedChildren="OFF" /> @@ -65,7 +78,9 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ <Input.TextArea placeholder="手动输入标题内容" value={titleConfig.content} - onChange={(e) => onTitleChange({ ...titleConfig, content: e.target.value })} + onChange={(e) => + onTitleChange({ ...titleConfig, content: e.target.value }) + } rows={2} size="small" style={{ marginBottom: 12 }} @@ -74,13 +89,17 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ <div style={{ marginBottom: 8 }}> <Text style={{ fontSize: 12 }}>字体预设</Text> - <div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}> + <div + style={{ display: "flex", gap: 4, marginTop: 4, flexWrap: "wrap" }} + > {FONT_PRESETS.map((font) => ( <Tag key={font} - color={titleConfig.font_preset === font ? 'blue' : 'default'} - style={{ cursor: 'pointer' }} - onClick={() => onTitleChange({ ...titleConfig, font_preset: font })} + color={titleConfig.font_preset === font ? "blue" : "default"} + style={{ cursor: "pointer" }} + onClick={() => + onTitleChange({ ...titleConfig, font_preset: font }) + } > {font} </Tag> @@ -88,13 +107,15 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ </div> </div> - <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}> + <div style={{ display: "flex", gap: 8, marginBottom: 8 }}> <div style={{ flex: 1 }}> <Text style={{ fontSize: 12 }}>颜色</Text> <Input size="small" value={titleConfig.font_color} - onChange={(e) => onTitleChange({ ...titleConfig, font_color: e.target.value })} + onChange={(e) => + onTitleChange({ ...titleConfig, font_color: e.target.value }) + } style={{ marginTop: 4 }} /> </div> @@ -105,7 +126,7 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ value={titleConfig.position} onChange={(v) => onTitleChange({ ...titleConfig, position: v })} options={POSITIONS} - style={{ width: '100%', marginTop: 4 }} + style={{ width: "100%", marginTop: 4 }} /> </div> </div> @@ -123,7 +144,14 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ {/* 字幕设置 */} <div className="ep-settings-group"> - <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 12, + }} + > <Text strong> <FontSizeOutlined style={{ marginRight: 6 }} /> 字幕设置 @@ -131,7 +159,9 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ <Switch size="small" checked={subtitleConfig.enabled} - onChange={(checked) => onSubtitleChange({ ...subtitleConfig, enabled: checked })} + onChange={(checked) => + onSubtitleChange({ ...subtitleConfig, enabled: checked }) + } /> </div> @@ -142,9 +172,11 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ <Select size="small" value={subtitleConfig.position} - onChange={(v) => onSubtitleChange({ ...subtitleConfig, position: v })} + onChange={(v) => + onSubtitleChange({ ...subtitleConfig, position: v }) + } options={POSITIONS} - style={{ width: '100%', marginTop: 4 }} + style={{ width: "100%", marginTop: 4 }} /> </div> <div style={{ marginBottom: 8 }}> @@ -152,18 +184,25 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ <Select size="small" value={subtitleConfig.font} - onChange={(v) => onSubtitleChange({ ...subtitleConfig, font: v })} + onChange={(v) => + onSubtitleChange({ ...subtitleConfig, font: v }) + } options={SUBTITLE_FONTS.map((f) => ({ value: f, label: f }))} - style={{ width: '100%', marginTop: 4 }} + style={{ width: "100%", marginTop: 4 }} /> </div> - <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}> + <div style={{ display: "flex", gap: 8, marginBottom: 8 }}> <div style={{ flex: 1 }}> <Text style={{ fontSize: 12 }}>颜色</Text> <Input size="small" value={subtitleConfig.color} - onChange={(e) => onSubtitleChange({ ...subtitleConfig, color: e.target.value })} + onChange={(e) => + onSubtitleChange({ + ...subtitleConfig, + color: e.target.value, + }) + } style={{ marginTop: 4 }} /> </div> @@ -172,9 +211,11 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ <Select size="small" value={subtitleConfig.animation} - onChange={(v) => onSubtitleChange({ ...subtitleConfig, animation: v })} + onChange={(v) => + onSubtitleChange({ ...subtitleConfig, animation: v }) + } options={SUBTITLE_ANIMATIONS} - style={{ width: '100%', marginTop: 4 }} + style={{ width: "100%", marginTop: 4 }} /> </div> </div> @@ -184,7 +225,9 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ min={12} max={48} value={subtitleConfig.size} - onChange={(v) => onSubtitleChange({ ...subtitleConfig, size: v })} + onChange={(v) => + onSubtitleChange({ ...subtitleConfig, size: v }) + } /> </div> </> @@ -193,7 +236,14 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ {/* BGM 设置 */} <div className="ep-settings-group"> - <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 12, + }} + > <Text strong> <SoundOutlined style={{ marginRight: 6 }} /> BGM 设置 @@ -201,7 +251,9 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ <Switch size="small" checked={bgmConfig.enabled} - onChange={(checked) => onBgmChange({ ...bgmConfig, enabled: checked })} + onChange={(checked) => + onBgmChange({ ...bgmConfig, enabled: checked }) + } /> </div> {bgmConfig.enabled && ( @@ -212,11 +264,11 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ placeholder="选择背景音乐" value={bgmConfig.music_id || undefined} onChange={(v) => onBgmChange({ ...bgmConfig, music_id: v })} - style={{ width: '100%', marginTop: 4 }} + style={{ width: "100%", marginTop: 4 }} options={[ - { value: 'bgm-1', label: '轻快节奏' }, - { value: 'bgm-2', label: '舒缓氛围' }, - { value: 'bgm-3', label: '动感活力' }, + { value: "bgm-1", label: "轻快节奏" }, + { value: "bgm-2", label: "舒缓氛围" }, + { value: "bgm-3", label: "动感活力" }, ]} /> </div> diff --git a/apps/web/src/pages/editing-planner/components/TemplatePanel.tsx b/apps/web/src/pages/editing-planner/components/TemplatePanel.tsx index 91be782a1..bf284787b 100644 --- a/apps/web/src/pages/editing-planner/components/TemplatePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TemplatePanel.tsx @@ -2,18 +2,25 @@ * 左侧模板面板 * 搜索、分类筛选、模板卡片列表 */ -import React from 'react'; -import { Input, Select, Card, Tag, Empty, Spin, Button, Typography } from 'antd'; +import React from "react"; import { - SearchOutlined, -} from '@ant-design/icons'; + Input, + Select, + Card, + Tag, + Empty, + Spin, + Button, + Typography, +} from "antd"; +import { SearchOutlined } from "@ant-design/icons"; import { MODE_LABELS, MODE_COLORS, type EditingTemplate, type TemplateCategory, type TemplateMode, -} from '@/api/editingPlanner'; +} from "@/api/editingPlanner"; const { Text } = Typography; @@ -44,8 +51,11 @@ const TemplatePanel: React.FC<TemplatePanelProps> = ({ }) => { return ( <div className="ep-left"> - <div style={{ padding: '0 12px', marginBottom: 12 }}> - <Text strong style={{ fontSize: 14, display: 'block', marginBottom: 8 }}> + <div style={{ padding: "0 12px", marginBottom: 12 }}> + <Text + strong + style={{ fontSize: 14, display: "block", marginBottom: 8 }} + > 我的模板 </Text> <Input @@ -60,17 +70,17 @@ const TemplatePanel: React.FC<TemplatePanelProps> = ({ <Select placeholder="按分类筛选" value={filterCategory || undefined} - onChange={(v) => onCategoryChange(v || '')} + onChange={(v) => onCategoryChange(v || "")} allowClear size="small" - style={{ width: '100%' }} + style={{ width: "100%" }} options={categories.map((c) => ({ value: c.name, label: c.name }))} /> </div> - <div style={{ padding: '0 12px', flex: 1, overflowY: 'auto' }}> + <div style={{ padding: "0 12px", flex: 1, overflowY: "auto" }}> {isLoading ? ( - <div style={{ textAlign: 'center', padding: 40 }}> + <div style={{ textAlign: "center", padding: 40 }}> <Spin /> </div> ) : templates.length === 0 ? ( @@ -85,15 +95,24 @@ const TemplatePanel: React.FC<TemplatePanelProps> = ({ key={tpl.id} size="small" hoverable - className={`ep-tpl-card ${loadedTemplateId === tpl.id ? 'ep-tpl-card-active' : ''}`} + className={`ep-tpl-card ${loadedTemplateId === tpl.id ? "ep-tpl-card-active" : ""}`} onClick={() => onTemplateSelect(tpl)} style={{ marginBottom: 8 }} > - <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }} + > <Text strong ellipsis style={{ maxWidth: 140 }}> {tpl.name} </Text> - <Tag color={MODE_COLORS[tpl.mode as TemplateMode] || 'blue'} style={{ marginRight: 0 }}> + <Tag + color={MODE_COLORS[tpl.mode as TemplateMode] || "blue"} + style={{ marginRight: 0 }} + > {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} </Tag> </div> @@ -117,7 +136,7 @@ const TemplatePanel: React.FC<TemplatePanelProps> = ({ </div> {loadedTemplateId && ( - <div style={{ padding: 12, borderTop: '1px solid #f0f0f0' }}> + <div style={{ padding: 12, borderTop: "1px solid #f0f0f0" }}> <Button size="small" block onClick={onNewTemplate}> 新建空白模板 </Button> diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index 709be93c7..6f41acb89 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -2,16 +2,16 @@ * 中间预览 + 时间线面板 * 视频/封面预览区 + 片段卡片时间线 */ -import React from 'react'; -import { Card, Button, Tag, Typography, Select, Slider } from 'antd'; +import React from "react"; +import { Card, Button, Tag, Typography, Select, Slider } from "antd"; import { PlusOutlined, DeleteOutlined, DragOutlined, VideoCameraOutlined, PictureOutlined, -} from '@ant-design/icons'; -import type { TemplateSegment, TemplateMode } from '@/api/editingPlanner'; +} from "@ant-design/icons"; +import type { TemplateSegment, TemplateMode } from "@/api/editingPlanner"; const { Text } = Typography; @@ -38,8 +38,8 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({ onDragOver, onDragEnd, }) => { - const isOneShot = currentMode === 'one_take'; - const isMixedCut = currentMode === 'voice_pip'; + const isOneShot = currentMode === "one_take"; + const isMixedCut = currentMode === "voice_pip"; const handleDragOver = (e: React.DragEvent, idx: number) => { onDragOver(e, idx); @@ -52,7 +52,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({ {/* 视频预览 */} <div className="ep-preview-box"> <div className="ep-preview-frame"> - <VideoCameraOutlined style={{ fontSize: 40, color: '#bbb' }} /> + <VideoCameraOutlined style={{ fontSize: 40, color: "#bbb" }} /> <Text type="secondary" style={{ marginTop: 8 }}> 视频预览 </Text> @@ -63,10 +63,10 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({ </div> {/* 封面预览 + 方案按钮 */} - <div style={{ display: 'flex', gap: 12, flex: '0 0 auto' }}> + <div style={{ display: "flex", gap: 12, flex: "0 0 auto" }}> <div className="ep-preview-box"> <div className="ep-preview-frame"> - <PictureOutlined style={{ fontSize: 40, color: '#bbb' }} /> + <PictureOutlined style={{ fontSize: 40, color: "#bbb" }} /> <Text type="secondary" style={{ marginTop: 8 }}> 封面预览 </Text> @@ -94,10 +94,20 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({ {/* 时间线 */} <div className="ep-timeline"> - <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 12, + }} + > <Text strong> - 时间线{' '} - <Text type="secondary" style={{ fontWeight: 'normal', fontSize: 12 }}> + 时间线{" "} + <Text + type="secondary" + style={{ fontWeight: "normal", fontSize: 12 }} + > (预估总时长:~{estimatedDuration}s) </Text> </Text> @@ -112,7 +122,14 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({ </Button> </div> - <div style={{ display: 'flex', gap: 12, overflowX: 'auto', paddingBottom: 8 }}> + <div + style={{ + display: "flex", + gap: 12, + overflowX: "auto", + paddingBottom: 8, + }} + > {segments.map((seg, idx) => ( <Card key={seg.id} @@ -124,9 +141,19 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({ onDragEnd={onDragEnd} style={{ minWidth: 180, maxWidth: 220, flexShrink: 0 }} > - <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}> + <div + style={{ + display: "flex", + alignItems: "center", + gap: 8, + marginBottom: 8, + }} + > <span - style={{ cursor: isOneShot ? 'default' : 'grab', color: '#999' }} + style={{ + cursor: isOneShot ? "default" : "grab", + color: "#999", + }} > <DragOutlined /> </span> @@ -138,7 +165,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({ icon={<DeleteOutlined />} onClick={() => onRemoveSegment(seg.id!)} disabled={isOneShot} - style={{ marginLeft: 'auto' }} + style={{ marginLeft: "auto" }} /> </div> @@ -154,7 +181,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({ min={1} max={seg.duration_max} value={seg.duration_min} - onChange={(v) => onUpdateSegment(seg.id!, { duration_min: v })} + onChange={(v) => + onUpdateSegment(seg.id!, { duration_min: v }) + } /> </div> <div> @@ -163,7 +192,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({ min={seg.duration_min} max={60} value={seg.duration_max} - onChange={(v) => onUpdateSegment(seg.id!, { duration_max: v })} + onChange={(v) => + onUpdateSegment(seg.id!, { duration_max: v }) + } /> </div> </> @@ -174,12 +205,14 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({ <Text style={{ fontSize: 12 }}>素材类型</Text> <Select size="small" - value={seg.material_type || '人物'} - onChange={(v) => onUpdateSegment(seg.id!, { material_type: v })} - style={{ width: '100%', marginTop: 4 }} + value={seg.material_type || "人物"} + onChange={(v) => + onUpdateSegment(seg.id!, { material_type: v }) + } + style={{ width: "100%", marginTop: 4 }} options={[ - { value: '人物', label: '人物' }, - { value: '场景', label: '场景' }, + { value: "人物", label: "人物" }, + { value: "场景", label: "场景" }, ]} /> </div> diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 8f5fc8279..15c257568 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -1,345 +1,1037 @@ /** - * 一键生成页面 - * 流程:选择模板 → 选择素材 → 选择标题 → 选择配音 → 批量生成 + * 一键生成页面(V21 改造) + * 功能:标题/描述输入 → 素材选择 → 配音选择 → 标题/文案 → 高级设置 → 生成 → 结果 + * 使用 Task 1.5 UI 组件 + V21 CSS 变量,Mock 数据 */ -import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import React, { useState, useRef, useCallback } from "react"; import { - Card, - Button, Typography, - Space, - Steps, - Checkbox, + Collapse, + Progress, + Slider, + Radio, + Switch, + Upload, message, - Result, - Spin, - Row, - Col, - Tag, - Alert, -} from 'antd'; +} from "antd"; +import type { UploadFile } from "antd"; import { - CheckCircleOutlined, - VideoCameraOutlined, FileTextOutlined, - AudioOutlined, PictureOutlined, -} from '@ant-design/icons'; -import { getTemplates } from '@/api/templates'; -import { getAssetLibraries, getAssets, type AssetItem } from '@/api/assets'; -import { getTitles } from '@/api/titles'; -import { getVoices } from '@/api/voices'; -import { createGenerationTask } from '@/api/tasks'; + AudioOutlined, + SettingOutlined, + ThunderboltOutlined, + CheckCircleFilled, + PlayCircleOutlined, + PauseCircleOutlined, + DownloadOutlined, + ShareAltOutlined, + InboxOutlined, + SoundOutlined, + FormOutlined, +} from "@ant-design/icons"; +import Button from "@/components/ui/Button"; +import Input from "@/components/ui/Input"; +import Card from "@/components/ui/Card"; +import Tag from "@/components/ui/Tag"; +import Form, { FormItem } from "@/components/ui/Form"; +import type { AssetItem } from "@/api/assets"; +import type { VoiceItem } from "@/api/voices"; +import "./generate.css"; -const { Title, Text } = Typography; +const { TextArea } = Input; +const { Text } = Typography; + +/* ================================================================ + Mock 数据 + ================================================================ */ + +const MOCK_MATERIALS: AssetItem[] = [ + { + id: "m1", + library_id: "lib1", + name: "产品展示视频.mp4", + storage_key: "", + mime_type: "video/mp4", + metadata: { duration: 30 }, + file_size: 15_000_000, + status: "active", + }, + { + id: "m2", + library_id: "lib1", + name: "品牌宣传片.mp4", + storage_key: "", + mime_type: "video/mp4", + metadata: { duration: 60 }, + file_size: 42_000_000, + status: "active", + }, + { + id: "m3", + library_id: "lib1", + name: "用户访谈片段.mp4", + storage_key: "", + mime_type: "video/mp4", + metadata: { duration: 45 }, + file_size: 28_000_000, + status: "active", + }, + { + id: "m4", + library_id: "lib2", + name: "Logo 动画.png", + storage_key: "", + mime_type: "image/png", + metadata: {}, + file_size: 500_000, + status: "active", + }, + { + id: "m5", + library_id: "lib2", + name: "背景音乐.mp3", + storage_key: "", + mime_type: "audio/mpeg", + metadata: { duration: 120 }, + file_size: 3_500_000, + status: "active", + }, + { + id: "m6", + library_id: "lib1", + name: "功能演示录屏.mp4", + storage_key: "", + mime_type: "video/mp4", + metadata: { duration: 90 }, + file_size: 55_000_000, + status: "active", + }, +]; + +const MOCK_VOICES: VoiceItem[] = [ + { + id: "v1", + name: "小晓 · 温柔女声", + text: "", + voice_type: "female_gentle", + duration_seconds: 0, + audio_url: "", + }, + { + id: "v2", + name: "小宇 · 磁性男声", + text: "", + voice_type: "male_magnetic", + duration_seconds: 0, + audio_url: "", + }, + { + id: "v3", + name: "小雅 · 专业播音", + text: "", + voice_type: "female_professional", + duration_seconds: 0, + audio_url: "", + }, + { + id: "v4", + name: "小杰 · 活力男声", + text: "", + voice_type: "male_energetic", + duration_seconds: 0, + audio_url: "", + }, +]; + +/* ── 克隆声音 Mock ── */ +interface ClonedVoice { + id: string; + name: string; + createdAt: string; + duration: string; +} + +const MOCK_CLONED_VOICES: ClonedVoice[] = [ + { id: "cv1", name: "我的声音 v1", createdAt: "2026-06-20", duration: "45s" }, + { id: "cv2", name: "我的声音 v2", createdAt: "2026-06-25", duration: "30s" }, +]; + +/* ── 时间线 Mock ── */ +interface TimelineScene { + scene: string; + time: string; + duration: number; + color: string; +} + +const MOCK_TIMELINE: TimelineScene[] = [ + { scene: "开场 - 品牌 Logo 展示", time: "0:00 - 0:05", duration: 5, color: "#6366f1" }, + { scene: "产品特写 - 细节展示", time: "0:05 - 0:15", duration: 10, color: "#818cf8" }, + { scene: "使用场景 - 生活化演绎", time: "0:15 - 0:30", duration: 15, color: "#10b981" }, + { scene: "卖点字幕 - 核心优势", time: "0:30 - 0:40", duration: 10, color: "#f59e0b" }, + { scene: "结尾 CTA - 引导关注", time: "0:40 - 0:50", duration: 10, color: "#ef4444" }, +]; + +const TOTAL_DURATION = MOCK_TIMELINE.reduce((sum, t) => sum + t.duration, 0); + +/* ── 常量 ── */ + +interface RatioOption { + label: string; + value: string; + desc: string; +} + +const RATIO_OPTIONS: RatioOption[] = [ + { label: "16:9", value: "16:9", desc: "横屏" }, + { label: "9:16", value: "9:16", desc: "竖屏" }, + { label: "1:1", value: "1:1", desc: "方形" }, + { label: "4:3", value: "4:3", desc: "经典" }, +]; + +interface StyleOption { + label: string; + value: string; + icon: React.ReactNode; + desc: string; +} + +const STYLE_OPTIONS: StyleOption[] = [ + { + label: "商务", + value: "business", + icon: <ThunderboltOutlined />, + desc: "专业正式", + }, + { label: "活泼", value: "lively", icon: <SoundOutlined />, desc: "轻松有趣" }, + { + label: "温馨", + value: "warm", + icon: <FormOutlined />, + desc: "温暖感人", + }, + { + label: "科技", + value: "tech", + icon: <SettingOutlined />, + desc: "未来科技", + }, + { + label: "简约", + value: "minimal", + icon: <FileTextOutlined />, + desc: "简洁大气", + }, + { + label: "国潮", + value: "chinese", + icon: <PictureOutlined />, + desc: "中国风", + }, +]; + +/* ================================================================ + 组件 + ================================================================ */ const GeneratePage: React.FC = () => { - const navigate = useNavigate(); - const [currentStep, setCurrentStep] = useState(0); - const [selectedTemplate, setSelectedTemplate] = useState<string>(''); - const [selectedAssets, setSelectedAssets] = useState<string[]>([]); - const [selectedTitles, setSelectedTitles] = useState<string[]>([]); - const [selectedVoices, setSelectedVoices] = useState<string[]>([]); + /* ── 基本信息 ── */ + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + + /* ── 素材 ── */ + const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]); + + /* ── 配音 ── */ + const [selectedVoice, setSelectedVoice] = useState<string>(""); + const [playingVoice, setPlayingVoice] = useState<string | null>(null); + const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset"); + const [customVoiceText, setCustomVoiceText] = useState(""); + + /* ── 克隆声音 ── */ + const [cloneFileList, setCloneFileList] = useState<UploadFile[]>([]); + const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>(""); + const [cloning, setCloning] = useState(false); + + /* ── 标题/文案 ── */ + const [titleMode, setTitleMode] = useState<"ai" | "manual">("ai"); + const [manualCopy, setManualCopy] = useState(""); + + /* ── 高级设置 ── */ + const [videoRatio, setVideoRatio] = useState("16:9"); + const [duration, setDuration] = useState(30); + const [style, setStyle] = useState("business"); + const [autoSubtitles, setAutoSubtitles] = useState(true); + const [bgm, setBgm] = useState(true); + + /* ── 生成状态 ── */ const [generating, setGenerating] = useState(false); + const [progress, setProgress] = useState(0); const [generated, setGenerated] = useState(false); + const [fileList, setFileList] = useState<UploadFile[]>([]); - // 获取模板列表 - const { data: templates = [], isLoading: tplLoading, isError: tplError } = useQuery({ - queryKey: ['templates'], - queryFn: getTemplates, - }); + const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined); - // 获取素材库和素材 - const { data: libraries = [], isLoading: libLoading, isError: libError } = useQuery({ - queryKey: ['asset-libraries'], - queryFn: getAssetLibraries, - }); + /* ── 事件 ── */ - // 获取标题 - const { data: titles = [], isLoading: titleLoading, isError: titleError } = useQuery({ - queryKey: ['titles'], - queryFn: getTitles, - }); + const toggleMaterial = useCallback((id: string) => { + setSelectedMaterials((prev) => + prev.includes(id) ? prev.filter((m) => m !== id) : [...prev, id], + ); + }, []); - // 获取配音 - const { data: voices = [], isLoading: voiceLoading, isError: voiceError } = useQuery({ - queryKey: ['voices'], - queryFn: getVoices, - }); - - // 获取所有素材(跨库) - const { data: allAssets = [], isLoading: assetsLoading, isError: assetsError } = useQuery({ - queryKey: ['all-assets'], - queryFn: async () => { - const all: AssetItem[] = []; - for (const lib of libraries) { - const items = await getAssets(lib.id); - all.push(...items); + const toggleVoicePlay = useCallback((id: string) => { + setPlayingVoice((prev) => { + if (prev === id) { + message.info("Mock:停止播放"); + return null; } - return all; - }, - enabled: libraries.length > 0, - }); + message.info("Mock:开始播放配音预览"); + return id; + }); + }, []); - const pageLoading = tplLoading || libLoading || titleLoading || voiceLoading || assetsLoading; - const pageError = tplError || libError || titleError || voiceError || assetsError; - - // 创建生成任务 - const generateMutation = useMutation({ - mutationFn: createGenerationTask, - onSuccess: () => { - message.success('生成任务已提交'); - setGenerated(true); - setGenerating(false); - }, - onError: (err: any) => { - if (!err?.__msgShown) message.error('生成失败'); - setGenerating(false); - }, - }); - - /** 开始生成 */ - const handleGenerate = async () => { - if (!selectedTemplate) { - message.warning('请选择模板'); + const handleGenerate = useCallback(() => { + if (!title.trim()) { + message.warning("请输入视频标题"); return; } + if (selectedMaterials.length === 0) { + message.warning("请至少选择一个素材"); + return; + } + setGenerating(true); - generateMutation.mutate({ - template_id: selectedTemplate, - asset_ids: selectedAssets, - title_ids: selectedTitles, - voice_ids: selectedVoices, - }); + setProgress(0); + setGenerated(false); + + progressTimer.current = setInterval(() => { + setProgress((prev) => { + if (prev >= 100) { + clearInterval(progressTimer.current); + setGenerating(false); + setGenerated(true); + message.success("视频生成完成!"); + return 100; + } + return prev + Math.random() * 12 + 3; + }); + }, 600); + }, [title, selectedMaterials]); + + /* ── 字数统计 ── */ + const titleCount = title.length; + const descCount = description.length; + const TITLE_MAX = 50; + const DESC_MAX = 300; + + /** 字数颜色 */ + const countClass = (count: number, max: number) => { + if (count > max) return "xx-word-count-over"; + if (count > max * 0.8) return "xx-word-count-warn"; + return ""; }; - const steps = [ - { - title: '选择模板', - icon: <VideoCameraOutlined />, - content: ( - <Row gutter={[16, 16]}> - {templates.length === 0 ? ( - <Col span={24}> - <Alert message="暂无可用模板,请先在模板库中添加" type="info" /> - </Col> - ) : ( - templates.map((t) => ( - <Col xs={24} sm={12} md={8} key={t.id}> - <Card - hoverable - size="small" - onClick={() => setSelectedTemplate(t.id)} - style={{ - border: - selectedTemplate === t.id - ? '2px solid #1890ff' - : undefined, - }} - > - <Card.Meta - title={t.name} - description={ - <Space> - {t.category && <Tag>{t.category}</Tag>} - {t.target_duration && ( - <Tag>{t.target_duration}s</Tag> - )} - </Space> - } - /> - </Card> - </Col> - )) - )} - </Row> - ), - }, - { - title: '选择素材', - icon: <PictureOutlined />, - content: ( - <div> - {allAssets.length === 0 ? ( - <Alert message="暂无素材,请先上传" type="info" /> - ) : ( - <Checkbox.Group - value={selectedAssets} - onChange={(vals) => setSelectedAssets(vals as string[])} - style={{ width: '100%' }} - > - <Row gutter={[12, 12]}> - {allAssets.map((a) => ( - <Col xs={24} sm={12} md={8} key={a.id}> - <Checkbox value={a.id} style={{ width: '100%' }}> - {a.name} - </Checkbox> - </Col> - ))} - </Row> - </Checkbox.Group> - )} - </div> - ), - }, - { - title: '选择标题', - icon: <FileTextOutlined />, - content: ( - <div> - {titles.length === 0 ? ( - <Alert message="暂无标题,请先在标题库中添加" type="info" /> - ) : ( - <Checkbox.Group - value={selectedTitles} - onChange={(vals) => setSelectedTitles(vals as string[])} - style={{ width: '100%' }} - > - <Row gutter={[12, 12]}> - {titles.map((t) => ( - <Col xs={24} sm={12} md={8} key={t.id}> - <Checkbox value={t.id} style={{ width: '100%' }}> - <Text ellipsis>{t.content}</Text> - </Checkbox> - </Col> - ))} - </Row> - </Checkbox.Group> - )} - </div> - ), - }, - { - title: '选择配音', - icon: <AudioOutlined />, - content: ( - <div> - {voices.length === 0 ? ( - <Alert message="暂无配音,请先在配音库中添加" type="info" /> - ) : ( - <Checkbox.Group - value={selectedVoices} - onChange={(vals) => setSelectedVoices(vals as string[])} - style={{ width: '100%' }} - > - <Row gutter={[12, 12]}> - {voices.map((v) => ( - <Col xs={24} sm={12} md={8} key={v.id}> - <Checkbox value={v.id} style={{ width: '100%' }}> - {v.name} - </Checkbox> - </Col> - ))} - </Row> - </Checkbox.Group> - )} - </div> - ), - }, - { - title: '生成', - icon: <CheckCircleOutlined />, - content: generated ? ( - <Result - status="success" - title="生成任务已提交" - subTitle="您可以在任务历史中查看生成进度" - extra={ - <Button type="primary" onClick={() => navigate('/history')}> - 查看任务 - </Button> - } - /> - ) : ( - <div style={{ textAlign: 'center', padding: 40 }}> - {generating ? ( - <Spin size="large" tip="正在生成..." /> - ) : ( - <Space direction="vertical" size={16}> - <Text> - 已选择:{selectedAssets.length} 个素材,{selectedTitles.length}{' '} - 个标题,{selectedVoices.length} 个配音 - </Text> - <Button - type="primary" - size="large" - icon={<VideoCameraOutlined />} - onClick={handleGenerate} - disabled={!selectedTemplate} - > - 开始生成 - </Button> - </Space> - )} - </div> - ), - }, - ]; + /* ── 素材图标 ── */ + const materialIcon = (mime: string) => { + if (mime.startsWith("video/")) return <PlayCircleOutlined />; + if (mime.startsWith("image/")) return <PictureOutlined />; + if (mime.startsWith("audio/")) return <AudioOutlined />; + return <FileTextOutlined />; + }; - if (pageLoading) { - return ( - <div style={{ textAlign: 'center', padding: 80 }}> - <Spin size="large" /> - </div> - ); - } + /** 文件大小格式化 */ + const formatSize = (bytes?: number) => { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; + }; - if (pageError) { - return ( - <div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> - <Alert - type="error" - message="加载数据失败" - description="部分数据获取失败,请刷新页面重试。" - showIcon - action={<Button onClick={() => window.location.reload()}>刷新页面</Button>} - /> - </div> - ); - } + /* ================================================================ + 渲染 + ================================================================ */ return ( - <div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> - <Title level={3} style={{ marginBottom: 24 }}> - 一键生成 - +
+ {/* ── 页头 ── */} +
+ + + 一键生成 + + + 快速生成短视频,支持多种风格和素材组合 + +
- ({ title: s.title, icon: s.icon }))} - style={{ marginBottom: 32 }} - responsive - /> +
+ {/* ════════════ 左侧:主内容 ════════════ */} +
+ {/* ── 1. 基本信息 ── */} +
+
+
+ +
+ + 基本信息 + +
+
+ + TITLE_MAX ? "error" : undefined + } + > + setTitle(e.target.value)} + error={titleCount > TITLE_MAX} + /> + - {steps[currentStep].content} + +