Merge develop → main: 端口绑定安全修复 + CI workflow同步
Auto Merge PRs / auto-merge (push) Failing after 31s
Deploy / Deploy Staging (push) Failing after 205h21m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 205h24m40s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 205h24m48s
Deploy / Production Browser E2E (push) Failing after 1863h44m10s
Deploy / Deploy Production (push) Failing after 1863h50m45s
Deploy / Build Production Runtime Images (push) Failing after 1863h53m54s

This commit is contained in:
CI Test
2026-06-30 23:00:42 +08:00
246 changed files with 17027 additions and 7198 deletions
+36 -12
View File
@@ -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
+13
View File
@@ -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
+33 -23
View File
@@ -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'
+140 -41
View File
@@ -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'
+69
View File
@@ -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 }}
-2
View File
@@ -47,5 +47,3 @@ build/
# Tracker temp files
tracker_tasks.json
# Schema metadata snapshot
schema-metadata-snapshot.json
+9 -8
View File
@@ -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")
+4 -12
View File
@@ -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:
@@ -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.
+14 -34
View File
@@ -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:
+5 -7
View File
@@ -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) ──
+8 -10
View File
@@ -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:
+5 -7
View File
@@ -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:
+11 -15
View File
@@ -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:
+6 -6
View File
@@ -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")
+2 -2
View File
@@ -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])
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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:
+19 -7
View File
@@ -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
+20 -11
View File
@@ -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(
+2 -2
View File
@@ -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:
+5 -7
View File
@@ -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],
)
+5 -2
View File
@@ -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),
+11 -13
View File
@@ -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,
)
+5 -4
View File
@@ -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()
+35 -20
View File
@@ -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 类型
+6 -5
View File
@@ -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()
+5 -15
View File
@@ -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:
+4 -4
View File
@@ -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()
-3
View File
@@ -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
+15 -14
View File
@@ -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:
+5 -13
View File
@@ -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
@@ -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)
+3 -1
View File
@@ -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")
+2
View File
@@ -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
+1
View File
@@ -1,4 +1,5 @@
"""查重 API Pydantic schemas。"""
from __future__ import annotations
from pydantic import BaseModel, Field
+2
View File
@@ -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]
+3 -1
View File
@@ -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
+14 -1
View File
@@ -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="是否开启自动续费")
+2
View File
@@ -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)
+6 -1
View File
@@ -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
+1
View File
@@ -1,4 +1,5 @@
"""Title library Pydantic schemas."""
from __future__ import annotations
from datetime import datetime
+2 -1
View File
@@ -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
+1
View File
@@ -1,4 +1,5 @@
"""Voice library Pydantic schemas."""
from __future__ import annotations
from datetime import datetime
+2 -2
View File
@@ -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);
}
+2 -2
View File
@@ -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);
}
+3 -5
View File
@@ -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);
}
+1
View File
@@ -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 视频自动化剪辑系统 - 项目管理",
+4 -4
View File
@@ -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));
}
};
+2 -2
View File
@@ -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);
}
+19 -20
View File
@@ -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<Task | null>(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);
}
+22 -22
View File
@@ -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<AssetDiagnosis> => {
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<AssetDiagnosis> => {
/** 获取当前用户的所有素材库 */
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
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<AssetLibraryItem> => {
// 后端要求 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<void> => {
/** 获取素材库下的所有素材 */
export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
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<AssetItem[]> => {
/** 更新素材审核状态 */
export const updateAssetReviewStatus = async (
assetId: string,
reviewStatus: 'pending_review' | 'approved' | 'rejected'
reviewStatus: "pending_review" | "approved" | "rejected",
): Promise<AssetItem> => {
const response = await apiClient.patch(`/assets/${assetId}/review`, {
review_status: reviewStatus,
@@ -143,10 +143,10 @@ export const deleteAsset = async (assetId: string): Promise<void> => {
/** 表单上传素材(小文件) */
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<string, string>;
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<IngestJob> => {
export const submitClassificationJob = async (data: {
asset_id: string;
}): Promise<ClassificationJob> => {
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<ClassificationJob> => {
const response = await apiClient.get(`/classification-jobs/${jobId}`);
return response.data;
+27 -27
View File
@@ -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",
});
});
});
+19 -13
View File
@@ -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<LoginResponse> => {
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<void> => {
await apiClient.post('/auth/logout');
await apiClient.post("/auth/logout");
};
// 获取当前用户
export const getCurrentUser = async (): Promise<User> => {
const response = await apiClient.get<UserResponse>('/auth/me');
const response = await apiClient.get<UserResponse>("/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;
};
+19 -16
View File
@@ -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;
+7 -8
View File
@@ -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<DashboardOverview> => {
const response = await apiClient.get('/dashboard/overview');
return response.data;
};
export const getDashboardOverview = async (): Promise<DashboardOverview> => {
const response = await apiClient.get("/dashboard/overview");
return response.data;
};
+51 -48
View File
@@ -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<DuplicationUploadResponse> => {
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<DuplicationRecord[]> => {
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<DuplicationDetail> => {
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<void> => {
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<DuplicationUploadResponse> => {
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;
};
+25 -17
View File
@@ -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<TemplateMode, string> = {
pip: '画中画',
voice_over: '人物口播',
one_take: '一镜到底',
voice_pip: '口播+混剪',
pip: "画中画",
voice_over: "人物口播",
one_take: "一镜到底",
voice_pip: "口播+混剪",
};
/** 模式颜色映射 */
export const MODE_COLORS: Record<TemplateMode, string> = {
pip: 'blue',
voice_over: 'green',
one_take: 'orange',
voice_pip: 'purple',
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<TemplateSegment, 'id'>[];
segments: Omit<TemplateSegment, "id">[];
}
/** 使用模板生成请求体 */
@@ -135,20 +135,23 @@ export const getEditingTemplates = async (params?: {
skip?: number;
limit?: number;
}): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>('/templates', {
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 50,
},
});
let list = response.data.items;
if (params?.category) list = list.filter((t) => t.category === params.category);
if (params?.category)
list = list.filter((t) => t.category === params.category);
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!));
return list;
};
/** 获取模板详情 */
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
export const getEditingTemplate = async (
id: string,
): Promise<EditingTemplate> => {
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`);
return response.data;
};
@@ -157,7 +160,7 @@ export const getEditingTemplate = async (id: string): Promise<EditingTemplate> =
export const createEditingTemplate = async (
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
const response = await apiClient.post<EditingTemplate>('/templates', data);
const response = await apiClient.post<EditingTemplate>("/templates", data);
return response.data;
};
@@ -166,7 +169,10 @@ export const updateEditingTemplate = async (
id: string,
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data);
const response = await apiClient.patch<EditingTemplate>(
`/templates/${id}`,
data,
);
return response.data;
};
@@ -177,7 +183,9 @@ export const deleteEditingTemplate = async (id: string): Promise<void> => {
/** 获取模板分类列表 */
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
const response = await apiClient.get<ListCategoriesResponse>('/templates/categories/list');
const response = await apiClient.get<ListCategoriesResponse>(
"/templates/categories/list",
);
return response.data.items;
};
+6 -10
View File
@@ -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<ProductItem[]> => {
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<ProductItem> => {
export const getProduct = async (productId: string): Promise<ProductItem> => {
const response = await apiClient.get(`/products/${productId}`);
return response.data;
};
@@ -41,10 +39,8 @@ export const deleteProduct = async (productId: string): Promise<void> => {
/** 获取成品下载链接 */
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;
};
+7 -6
View File
@@ -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<ProjectItem[]> => {
const response = await apiClient.get<BackendListProjectsResponse>('/projects');
const response =
await apiClient.get<BackendListProjectsResponse>("/projects");
return (response.data.items || []).map(toProjectItem);
};
@@ -39,9 +40,9 @@ export const createProject = async (data: {
name: string;
description?: string;
}): Promise<ProjectItem> => {
const response = await apiClient.post<BackendProjectResponse>('/projects', {
const response = await apiClient.post<BackendProjectResponse>("/projects", {
name: data.name,
description: data.description || '',
description: data.description || "",
});
return toProjectItem(response.data);
};
@@ -54,7 +55,7 @@ export const getOrCreateDefaultProject = async (): Promise<ProjectItem> => {
}
// 没有项目时自动创建默认项目
return createProject({
name: '默认项目',
description: '系统自动创建的默认项目',
name: "默认项目",
description: "系统自动创建的默认项目",
});
};
+22 -13
View File
@@ -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<SubscriptionInfo> => {
const response = await apiClient.get('/subscription/current');
const response = await apiClient.get("/subscription/current");
return response.data;
};
/** 获取账单记录列表 */
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
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<ChangePlanResponse> => {
const response = await apiClient.post('/subscription/change-plan', request);
export const changePlan = async (
request: ChangePlanRequest,
): Promise<ChangePlanResponse> => {
const response = await apiClient.post("/subscription/change-plan", request);
return response.data;
};
/** 取消订阅 */
export const cancelSubscription = async (): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/subscription/cancel');
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;
};
+4 -4
View File
@@ -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<CreateGenerationTaskResponse> => {
const { data } = await apiClient.post<CreateGenerationTaskResponse>(
'/generation/tasks',
"/generation/tasks",
params,
);
return data;
@@ -66,7 +66,7 @@ export const createGenerationTask = async (
/** 获取当前用户的所有任务(跨 project) */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get('/tasks');
const { data } = await apiClient.get("/tasks");
return data.items || [];
};
+5 -5
View File
@@ -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<TemplateItem[]> => {
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<TemplateItem> => {
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;
};
+10 -5
View File
@@ -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<TitleItem[]> => {
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<BackendTitleResponse>('/titles', payload);
const response = await apiClient.post<BackendTitleResponse>(
"/titles",
payload,
);
return toTitleItem(response.data);
};
@@ -116,6 +121,6 @@ export const deleteTitle = async (titleId: string): Promise<void> => {
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;
};
+6 -6
View File
@@ -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<VoiceItem[]> => {
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<VoiceItem> => {
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<CreateVoiceRequest>
data: Partial<CreateVoiceRequest>,
): Promise<VoiceItem> => {
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<VoiceItem> => {
const response = await apiClient.post('/voices/generate', data);
const response = await apiClient.post("/voices/generate", data);
return response.data;
};
+19 -17
View File
@@ -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 */
@@ -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<AppLayoutProps> = ({ sidebar }) => {
return (
<div className="xx-app-shell">
<Header />
<div className="xx-app-body">
{sidebar}
<main className="xx-app-content">
<Outlet />
</main>
</div>
</div>
);
};
export default AppLayout;
+86 -35
View File
@@ -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: <DashboardOutlined /> },
{ key: 'assets', label: '素材库', path: '/assets', icon: <FileOutlined /> },
{ key: 'titles', label: '标题库', path: '/titles', icon: <FileTextOutlined /> },
{ key: 'voices', label: '配音库', path: '/voices', icon: <AudioOutlined /> },
{ key: 'templates', label: '模板库', path: '/templates', icon: <AppstoreOutlined /> },
{ key: 'editing-planner', label: '剪辑编辑器', path: '/editing-planner', icon: <EditOutlined /> },
{ key: 'my-templates', label: '我的模板', path: '/my-templates', icon: <FolderOutlined /> },
{ key: 'generate', label: '一键生成', path: '/generate', icon: <VideoCameraOutlined /> },
{ key: 'history', label: '任务历史', path: '/history', icon: <HistoryOutlined /> },
{ key: 'products', label: '成品库', path: '/products', icon: <TrophyOutlined /> },
{ key: 'duplication', label: '查重', path: '/duplication', icon: <ScanOutlined /> },
{
key: "dashboard",
label: "概览",
path: "/dashboard",
icon: <DashboardOutlined />,
},
{ key: "assets", label: "素材库", path: "/assets", icon: <FileOutlined /> },
{
key: "titles",
label: "标题库",
path: "/titles",
icon: <FileTextOutlined />,
},
{ key: "voices", label: "配音库", path: "/voices", icon: <AudioOutlined /> },
{
key: "templates",
label: "模板库",
path: "/templates",
icon: <AppstoreOutlined />,
},
{
key: "editing-planner",
label: "剪辑编辑器",
path: "/editing-planner",
icon: <EditOutlined />,
},
{
key: "my-templates",
label: "我的模板",
path: "/my-templates",
icon: <FolderOutlined />,
},
{
key: "generate",
label: "一键生成",
path: "/generate",
icon: <VideoCameraOutlined />,
},
{
key: "history",
label: "任务历史",
path: "/history",
icon: <HistoryOutlined />,
},
{
key: "products",
label: "成品库",
path: "/products",
icon: <TrophyOutlined />,
},
{
key: "duplication",
label: "查重",
path: "/duplication",
icon: <ScanOutlined />,
},
];
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: <UserOutlined />,
label: '个人设置',
onClick: () => navigate('/profile'),
label: "个人设置",
onClick: () => navigate("/profile"),
},
{
key: 'subscription',
key: "subscription",
icon: <SettingOutlined />,
label: '订阅管理',
onClick: () => navigate('/subscription'),
label: "订阅管理",
onClick: () => navigate("/subscription"),
},
{ type: 'divider' },
{ type: "divider" },
{
key: 'logout',
key: "logout",
icon: <LogoutOutlined />,
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 (
<header className="xx-top-nav">
<div className="xx-top-nav-inner">
<button className="xx-brand" type="button" onClick={() => navigate('/dashboard')}>
<button
className="xx-brand"
type="button"
onClick={() => navigate("/dashboard")}
>
<span className="xx-logo">🦐</span>
<span className="xx-brand-text">小虾自动剪辑</span>
</button>
@@ -101,7 +150,7 @@ const Header: React.FC = () => {
{NAV_ITEMS.map((item) => (
<button
key={item.key}
className={isActive(item.path) ? 'active' : ''}
className={isActive(item.path) ? "active" : ""}
type="button"
onClick={() => navigate(item.path)}
>
@@ -123,7 +172,9 @@ const Header: React.FC = () => {
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
<Space className="xx-user-menu">
<Avatar className="xx-avatar" icon={<UserOutlined />} />
<span className="xx-username">{user?.display_name || user?.username || '小虾用户'}</span>
<span className="xx-username">
{user?.display_name || user?.username || "小虾用户"}
</span>
</Space>
</Dropdown>
</div>
@@ -142,7 +193,7 @@ const Header: React.FC = () => {
{NAV_ITEMS.map((item) => (
<button
key={item.key}
className={`xx-mobile-nav-item ${isActive(item.path) ? 'active' : ''}`}
className={`xx-mobile-nav-item ${isActive(item.path) ? "active" : ""}`}
type="button"
onClick={() => {
navigate(item.path);
+180 -19
View File
@@ -1,34 +1,195 @@
/* V21 主布局样式 */
/* ============================================================
MainLayout.css - 主布局样式(Task 1.2)
结构:
.xx-app-shell 全屏 flex 容器
├── header (xx-top-nav) 顶部导航(Header.tsx 管理)
└── .xx-app-body 水平 flex 行
├── .xx-app-sidebar 左侧侧边栏(240px / 64px 折叠)
└── .xx-app-content 主内容区(自适应)
所有尺寸/颜色均使用 global.css 设计系统变量
============================================================ */
/* ── 应用外壳 ─────────────────────────────────────────────── */
.xx-app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg, #f8fafc);
color: var(--slate, #0f172a);
background: var(--bg-secondary);
color: var(--text-primary);
position: relative;
z-index: 1;
}
/* ── 主体区域(侧边栏 + 内容) ────────────────────────────── */
.xx-app-body {
display: flex;
flex: 1;
min-height: 0; /* 允许子元素收缩,防止溢出 */
}
/* ── 侧边栏 ───────────────────────────────────────────────── */
.xx-app-sidebar {
width: 240px;
flex-shrink: 0;
background: var(--bg-primary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: width var(--transition-slow);
overflow: hidden;
z-index: var(--z-fixed);
}
/* 折叠态 —— 64px 图标栏 */
.xx-app-sidebar.xx-collapsed {
width: 64px;
}
/* ── 侧边栏折叠按钮 ───────────────────────────────────────── */
.xx-sidebar-toggle {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border-light);
height: 48px;
flex-shrink: 0;
}
.xx-sidebar-toggle button {
display: flex;
align-items: center;
gap: var(--space-sm);
border: none;
background: none;
cursor: pointer;
color: var(--text-secondary);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-xs);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
transition: var(--transition-colors);
white-space: nowrap;
overflow: hidden;
}
.xx-sidebar-toggle button:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
/* 折叠时隐藏文字标签,仅保留图标居中 */
.xx-app-sidebar.xx-collapsed .xx-sidebar-toggle {
justify-content: center;
padding: var(--space-sm) var(--space-xs);
}
.xx-app-sidebar.xx-collapsed .xx-sidebar-toggle-label {
display: none;
}
/* ── 侧边栏内容区 ─────────────────────────────────────────── */
.xx-sidebar-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-sm);
}
/* ── 主内容区 ─────────────────────────────────────────────── */
.xx-app-content {
flex: 1;
max-width: 1440px;
margin: 0 auto;
width: 100%;
padding: 38px 24px 76px;
min-width: 0; /* 防止内容溢出 */
overflow-y: auto;
padding: var(--space-lg);
}
/* V21 颜色变量 */
:root {
--indigo: #4f46e5;
--slate: #0f172a;
--muted: #64748b;
--line: #e2e8f0;
--bg: #f8fafc;
--green: #10b981;
--amber: #f59e0b;
/* ── 移动端遮罩层(默认隐藏) ─────────────────────────────── */
.xx-sidebar-overlay {
display: none;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
/* ============================================================
响应式 —— 平板(769px ~ 1024px)
侧边栏默认折叠为图标栏
============================================================ */
@media (min-width: 769px) and (max-width: 1024px) {
.xx-app-sidebar {
width: 64px;
}
.xx-app-sidebar .xx-sidebar-toggle {
justify-content: center;
padding: var(--space-sm) var(--space-xs);
}
.xx-app-sidebar .xx-sidebar-toggle-label {
display: none;
}
/* 展开态恢复完整宽度 */
.xx-app-sidebar:not(.xx-collapsed) {
width: 240px;
}
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle {
justify-content: flex-end;
padding: var(--space-sm) var(--space-md);
}
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle-label {
display: inline;
}
}
/* ============================================================
响应式 —— 移动端(≤768px)
侧边栏隐藏,由 Header 汉堡菜单控制显示
============================================================ */
@media (max-width: 768px) {
.xx-app-sidebar {
position: fixed;
top: 56px; /* 移动端 Header 高度 */
left: 0;
bottom: 0;
width: 240px;
transform: translateX(-100%);
transition: transform var(--transition-slow);
box-shadow: none;
z-index: calc(var(--z-fixed) - 1); /* 低于 Header 的汉堡按钮 */
}
/* 展开态:滑入 */
.xx-app-sidebar:not(.xx-collapsed) {
transform: translateX(0);
box-shadow: var(--shadow-lg);
}
/* 展开时显示遮罩 */
.xx-app-sidebar:not(.xx-collapsed) ~ .xx-sidebar-overlay,
.xx-app-sidebar:not(.xx-collapsed) + .xx-app-content::before {
display: none;
}
/* 遮罩层 —— 使用独立选择器 */
.xx-sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: var(--bg-overlay);
z-index: calc(var(--z-fixed) - 2);
cursor: pointer;
}
/* 通过父级控制遮罩显示 */
.xx-app-body:has(.xx-app-sidebar:not(.xx-collapsed)) > .xx-sidebar-overlay {
display: block;
}
.xx-app-content {
padding: var(--space-md);
}
}
+65 -10
View File
@@ -1,16 +1,71 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Header from './Header';
import './MainLayout.css';
/**
* MainLayout - 主布局组件(Task 1.2)
*
* 三栏布局:左侧侧边栏 + 顶部导航栏 + 主内容区
* - 侧边栏:240px 固定宽度,可折叠至 64px 图标栏
* - 顶部导航:复用 Header 组件(68px 固定高度)
* - 主内容区:自适应填充剩余空间
* - 响应式:移动端(<768px)隐藏侧边栏
*
* 侧边栏/导航具体内容 deferred to Tasks 1.3, 1.4
*/
import React, { useState } from "react";
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
import AppLayout from "./AppLayout";
import Sidebar from "./Sidebar";
import "./MainLayout.css";
/** 侧边栏上下文 —— 子组件可读取折叠状态 */
export interface SidebarContextValue {
collapsed: boolean;
}
export const SidebarContext = React.createContext<SidebarContextValue>({
collapsed: false,
});
const MainLayout: React.FC = () => {
// 移动端默认折叠,桌面端默认展开
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 768);
const sidebar = (
<>
{/* 移动端遮罩层 —— 仅在侧边栏展开时由 CSS 显示 */}
<div
className="xx-sidebar-overlay"
onClick={() => setCollapsed(true)}
aria-hidden="true"
/>
<aside
className={`xx-app-sidebar${collapsed ? " xx-collapsed" : ""}`}
aria-label="侧边栏"
>
<div className="xx-sidebar-toggle">
<button
type="button"
onClick={() => setCollapsed((prev) => !prev)}
aria-label={collapsed ? "展开侧边栏" : "收起侧边栏"}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
<span className="xx-sidebar-toggle-label">
{collapsed ? "展开" : "收起"}
</span>
</button>
</div>
{/* 侧边栏导航 */}
<nav className="xx-sidebar-content" aria-label="侧边栏导航">
<Sidebar />
</nav>
</aside>
</>
);
return (
<div className="xx-app-shell">
<Header />
<main className="xx-app-content">
<Outlet />
</main>
</div>
<SidebarContext.Provider value={{ collapsed }}>
<AppLayout sidebar={sidebar} />
</SidebarContext.Provider>
);
};
+161
View File
@@ -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;
}
}
+185
View File
@@ -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<string, string> = {
"/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<PageHeadProps> = ({
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 (
<header className="xx-page-head">
<div className="xx-page-head-left">
{/* 面包屑导航 */}
{showBreadcrumb && (
<nav className="xx-page-breadcrumb" aria-label="面包屑导航">
<ol>
{breadcrumbItems.map((item, index) => {
const isLast = index === breadcrumbItems.length - 1;
return (
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
{index > 0 && (
<RightOutlined className="xx-page-breadcrumb-separator" />
)}
{item.path && !isLast ? (
<Link
to={item.path}
className="xx-page-breadcrumb-link"
onClick={(e) => {
e.preventDefault();
navigate(item.path!);
}}
>
{index === 0 ? (
<HomeOutlined className="xx-page-breadcrumb-home" />
) : null}
<span>{item.label}</span>
</Link>
) : (
<span className="xx-page-breadcrumb-current" aria-current="page">
{index === 0 ? (
<HomeOutlined className="xx-page-breadcrumb-home" />
) : null}
<span>{item.label}</span>
</span>
)}
</li>
);
})}
</ol>
</nav>
)}
{/* 标题 + 描述 */}
<div className="xx-page-head-title">
<h2>{title}</h2>
{description && <p>{description}</p>}
</div>
</div>
{/* 右侧操作区 */}
{actions && <div className="xx-page-head-actions">{actions}</div>}
</header>
);
};
export default PageHead;
+144
View File
@@ -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; /* 移动端更大的点击区域 */
}
}
+171
View File
@@ -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: <DashboardOutlined />,
},
{
key: "generate",
label: "一键生成",
path: "/generate",
icon: <VideoCameraOutlined />,
},
],
},
{
title: "资源管理",
items: [
{
key: "assets",
label: "素材库",
path: "/assets",
icon: <FileOutlined />,
},
{
key: "voices",
label: "配音库",
path: "/voices",
icon: <AudioOutlined />,
},
{
key: "titles",
label: "标题库",
path: "/titles",
icon: <FileTextOutlined />,
},
{
key: "products",
label: "成片库",
path: "/products",
icon: <TrophyOutlined />,
},
{
key: "templates",
label: "模板库",
path: "/templates",
icon: <AppstoreOutlined />,
},
],
},
{
title: "系统",
items: [
{
key: "history",
label: "任务历史",
path: "/history",
icon: <HistoryOutlined />,
},
{
key: "admin",
label: "控制台",
path: "/admin",
icon: <ControlOutlined />,
},
{
key: "subscription",
label: "订阅管理",
path: "/subscription",
icon: <CrownOutlined />,
},
],
},
];
/** 判断菜单项是否激活 */
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 (
<div className={`xx-sidebar-nav${collapsed ? " xx-sidebar-nav--collapsed" : ""}`}>
{MENU_GROUPS.map((group) => (
<div className="xx-sidebar-group" key={group.title}>
{!collapsed && (
<div className="xx-sidebar-group-title">{group.title}</div>
)}
<ul className="xx-sidebar-menu" role="menu">
{group.items.map((item) => {
const active = isMenuItemActive(location.pathname, item.path);
return (
<li
key={item.key}
className={`xx-sidebar-menu-item${active ? " xx-active" : ""}`}
role="menuitem"
title={collapsed ? item.label : undefined}
onClick={() => handleNavigate(item.path)}
>
<span className="xx-sidebar-menu-icon">{item.icon}</span>
{!collapsed && (
<span className="xx-sidebar-menu-label">{item.label}</span>
)}
</li>
);
})}
</ul>
</div>
))}
</div>
);
};
export default Sidebar;
+95
View File
@@ -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<AntButtonProps, "type" | "size"> {
/** 按钮类型 */
buttonType?: ButtonType;
/** 按钮尺寸 */
buttonSize?: ButtonSize;
/** 兼容 antd type(用于直接替换) */
type?: AntButtonProps["type"];
/** 兼容 antd size */
size?: AntButtonProps["size"];
}
const TYPE_CLASS_MAP: Record<ButtonType, string> = {
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<ButtonSize, string> = {
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<ButtonProps> = ({
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 (
<AntButton
type={antdType}
size={antdSize}
className={v21Class}
danger={buttonType === "danger" ? true : rest.danger}
{...rest}
>
{children}
</AntButton>
);
};
export default Button;
+25
View File
@@ -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<CardProps> = ({ className, hover, ...rest }) => {
const v21Class = classNames(
"xx-card-v21",
hover && "xx-card-hover",
className,
);
return <AntCard className={v21Class} {...rest} />;
};
export default Card;
+34
View File
@@ -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 (
<AntForm className={v21Class} {...(rest as Omit<FormProps, "className" | "compact" | "children">)}>
{children as React.ReactNode}
</AntForm>
);
};
/** 导出 Form 的子组件(保持 antd API 一致) */
export const FormItem = AntForm.Item;
export const FormList = AntForm.List;
export const FormProvider = AntForm.Provider;
export default Form;
+75
View File
@@ -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<InputRef, InputProps>(
({ className, error, ...rest }, ref) => {
const v21Class = classNames(
"xx-input",
error && "xx-input-error",
className,
);
return <AntInput ref={ref} className={v21Class} {...rest} />;
},
);
Input.displayName = "Input";
/** Search 搜索框 */
const Search: React.FC<
React.ComponentProps<typeof AntSearch> & { error?: boolean }
> = ({ className, error, ...rest }) => {
const v21Class = classNames(
"xx-input",
"xx-input-search",
error && "xx-input-error",
className,
);
return <AntSearch className={v21Class} {...rest} />;
};
/** TextArea 文本域 */
const TextArea: React.FC<
React.ComponentProps<typeof AntTextArea> & { error?: boolean }
> = ({ className, error, ...rest }) => {
const v21Class = classNames(
"xx-input",
error && "xx-input-error",
className,
);
return <AntTextArea className={v21Class} {...rest} />;
};
/** Password 密码框 */
const Password = React.forwardRef<
InputRef,
React.ComponentProps<typeof AntPassword> & { error?: boolean }
>(({ className, error, ...rest }, ref) => {
const v21Class = classNames(
"xx-input",
error && "xx-input-error",
className,
);
return <AntPassword ref={ref} className={v21Class} {...rest} />;
});
Password.displayName = "Password";
export default Object.assign(Input, {
Search,
TextArea,
Password,
});
+75
View File
@@ -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<ModalProps> {
confirm: (config: ModalFuncProps) => ReturnType<typeof AntModal.confirm>;
info: (config: ModalFuncProps) => ReturnType<typeof AntModal.info>;
success: (config: ModalFuncProps) => ReturnType<typeof AntModal.success>;
error: (config: ModalFuncProps) => ReturnType<typeof AntModal.error>;
warning: (config: ModalFuncProps) => ReturnType<typeof AntModal.warning>;
}
const Modal: ModalComponent = ({
className,
v21 = true,
children,
...rest
}) => {
const v21Class = classNames(v21 && "xx-modal", className);
return (
<AntModal className={v21Class} {...rest}>
{children}
</AntModal>
);
};
/** 确认对话框快捷方法 */
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;
+25
View File
@@ -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<PaginationProps> = ({
className,
v21 = true,
...rest
}) => {
const v21Class = classNames(v21 && "xx-pagination", className);
return <AntPagination className={v21Class} {...rest} />;
};
export default Pagination;
+35
View File
@@ -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<SelectProps> = ({
className,
dropdownClassName,
...rest
}) => {
const v21Class = classNames("xx-select", className);
const v21DropdownClass = classNames(
"xx-select-dropdown",
dropdownClassName,
);
return (
<AntSelect
className={v21Class}
popupClassName={v21DropdownClass}
{...rest}
/>
);
};
export default Select;
+28
View File
@@ -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<RecordType = unknown>
extends AntTableProps<RecordType> {
/** 使用 V21 样式 */
v21?: boolean;
}
function Table<RecordType extends object = Record<string, unknown>>({
className,
v21 = true,
...rest
}: TableProps<RecordType>) {
const v21Class = classNames(v21 && "xx-table", className);
return <AntTable<RecordType> className={v21Class} {...rest} />;
}
export default Table as <RecordType extends object = Record<string, unknown>>(
props: TableProps<RecordType> & React.RefAttributes<HTMLDivElement>,
) => React.ReactElement;
+43
View File
@@ -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<TagVariant, string> = {
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<TagProps> = ({ className, variant, ...rest }) => {
const v21Class = classNames(
"xx-tag",
variant && VARIANT_CLASS_MAP[variant],
className,
);
return <AntTag className={v21Class} {...rest} />;
};
/** Badge 徽标 */
const Badge: React.FC<AntBadgeProps> = ({ className, ...rest }) => {
const v21Class = classNames("xx-badge", className);
return <AntBadge className={v21Class} {...rest} />;
};
export { Tag, Badge };
export default Tag;
+44
View File
@@ -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<typeof AntTooltip> & {
/** 使用 V21 样式 */
v21?: boolean;
};
/** Tooltip 文字提示 */
const Tooltip: React.FC<TooltipProps> = ({
overlayClassName,
v21 = true,
...rest
}) => {
const v21Class = classNames(v21 && "xx-tooltip", overlayClassName);
return <AntTooltip overlayClassName={v21Class} {...rest} />;
};
export type PopoverProps = React.ComponentProps<typeof AntPopover> & {
/** 使用 V21 样式 */
v21?: boolean;
};
/** Popover 气泡卡片 */
const Popover: React.FC<PopoverProps> = ({
overlayClassName,
v21 = true,
...rest
}) => {
const v21Class = classNames(v21 && "xx-popover", overlayClassName);
return <AntPopover overlayClassName={v21Class} {...rest} />;
};
export { Tooltip, Popover };
export default Tooltip;
+34
View File
@@ -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";
+534
View File
@@ -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;
}
}
+13 -13
View File
@@ -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<typeof authApi.register>[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,
});
+6 -3
View File
@@ -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;
+33 -33
View File
@@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={zhCN} theme={theme}>
<RouterProvider router={router} />
</ConfigProvider>
</QueryClientProvider>
</React.StrictMode>
</React.StrictMode>,
);
+1 -1
View File
@@ -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;
+7 -7
View File
@@ -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={[
<Button
type="primary"
onClick={() => navigate('/')}
<Button
type="primary"
onClick={() => navigate("/")}
className="xx-primary-btn"
>
返回工作空间
+107 -96
View File
@@ -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 {
Card,
Button,
@@ -22,7 +22,7 @@ import {
Tag,
Image,
Popconfirm,
} from 'antd';
} from "antd";
import {
PlusOutlined,
DeleteOutlined,
@@ -30,7 +30,7 @@ import {
PictureOutlined,
AudioOutlined,
InboxOutlined,
} from '@ant-design/icons';
} from "@ant-design/icons";
import {
getAssetLibraries,
createAssetLibrary,
@@ -38,10 +38,11 @@ import {
deleteAsset,
uploadAsset,
uploadAssetDirect,
} from '@/api/assets';
import { getOrCreateDefaultProject } from '@/api/projects';
} from "@/api/assets";
import { getOrCreateDefaultProject } from "@/api/projects";
import PageHead from "@/components/layout/PageHead";
const { Title, Text } = Typography;
const { Text } = Typography;
const { Dragger } = Upload;
/** 最大文件大小:2048MB(与后端 OSS_DIRECT_UPLOAD_MAX_MB=2000 对齐,留少量余量) */
@@ -52,12 +53,12 @@ const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024; // 104857600 bytes
/** 素材库类型图标 */
const KindIcon: React.FC<{ kind: string }> = ({ kind }) => {
switch (kind) {
case 'video':
return <VideoCameraOutlined style={{ color: '#1890ff' }} />;
case 'voice':
return <AudioOutlined style={{ color: '#52c41a' }} />;
case 'image':
return <PictureOutlined style={{ color: '#faad14' }} />;
case "video":
return <VideoCameraOutlined style={{ color: "#1890ff" }} />;
case "voice":
return <AudioOutlined style={{ color: "#52c41a" }} />;
case "image":
return <PictureOutlined style={{ color: "#faad14" }} />;
default:
return <VideoCameraOutlined />;
}
@@ -65,30 +66,30 @@ const KindIcon: React.FC<{ kind: string }> = ({ kind }) => {
/** 素材库类型标签 */
const kindLabel: Record<string, string> = {
video: '视频',
voice: '配音',
image: '图片',
video: "视频",
voice: "配音",
image: "图片",
};
const AssetLibrary: React.FC = () => {
const queryClient = useQueryClient();
const [activeLibrary, setActiveLibrary] = useState<string>('');
const [activeLibrary, setActiveLibrary] = useState<string>("");
const [createModalOpen, setCreateModalOpen] = useState(false);
const [newLibName, setNewLibName] = useState('');
const [newLibKind, setNewLibKind] = useState<'video' | 'voice' | 'image'>(
'video'
const [newLibName, setNewLibName] = useState("");
const [newLibKind, setNewLibKind] = useState<"video" | "voice" | "image">(
"video",
);
const [uploading, setUploading] = useState(false);
// 获取素材库列表
const { data: libraries = [], isLoading: libsLoading } = useQuery({
queryKey: ['asset-libraries'],
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
});
// 获取当前素材库的素材
const { data: assets = [], isLoading: assetsLoading } = useQuery({
queryKey: ['assets', activeLibrary],
queryKey: ["assets", activeLibrary],
queryFn: () => getAssets(activeLibrary),
enabled: !!activeLibrary,
});
@@ -97,57 +98,69 @@ const AssetLibrary: React.FC = () => {
const createLibMutation = useMutation({
mutationFn: createAssetLibrary,
onSuccess: () => {
message.success('素材库创建成功');
message.success("素材库创建成功");
setCreateModalOpen(false);
setNewLibName('');
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] });
setNewLibName("");
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("创建失败");
},
onError: (err: any) => { if (!err?.__msgShown) message.error('创建失败') },
});
// 上传素材(小文件表单上传)
const uploadMutation = useMutation({
mutationFn: uploadAsset,
onSuccess: () => {
message.success('上传成功');
queryClient.invalidateQueries({ queryKey: ['assets', activeLibrary] });
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] });
message.success("上传成功");
queryClient.invalidateQueries({ queryKey: ["assets", activeLibrary] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("上传失败");
},
onError: (err: any) => { if (!err?.__msgShown) message.error('上传失败') },
});
// 大文件直传(OSS 预签名)
const directUploadMutation = useMutation({
mutationFn: uploadAssetDirect,
onSuccess: () => {
message.success('上传成功');
queryClient.invalidateQueries({ queryKey: ['assets', activeLibrary] });
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] });
message.success("上传成功");
queryClient.invalidateQueries({ queryKey: ["assets", activeLibrary] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("上传失败");
},
onError: (err: any) => { if (!err?.__msgShown) message.error('上传失败') },
});
// 删除素材
const deleteMutation = useMutation({
mutationFn: deleteAsset,
onSuccess: () => {
message.success('已删除');
queryClient.invalidateQueries({ queryKey: ['assets', activeLibrary] });
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] });
message.success("已删除");
queryClient.invalidateQueries({ queryKey: ["assets", activeLibrary] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("删除失败");
},
onError: (err: any) => { if (!err?.__msgShown) message.error('删除失败') },
});
/** 处理上传 */
const handleUpload = async (file: File) => {
if (!activeLibrary) {
message.warning('请先选择素材库');
message.warning("请先选择素材库");
return false;
}
// 文件大小校验:上限 2GB
if (file.size > MAX_FILE_SIZE) {
message.error('文件大小不能超过 2GB');
message.error("文件大小不能超过 2GB");
return false;
}
@@ -163,9 +176,9 @@ const AssetLibrary: React.FC = () => {
// 小文件走表单上传
const project = await getOrCreateDefaultProject();
const formData = new FormData();
formData.append('file', file);
formData.append('library_id', activeLibrary);
formData.append('project_id', project.id);
formData.append("file", file);
formData.append("library_id", activeLibrary);
formData.append("project_id", project.id);
await uploadMutation.mutateAsync(formData);
}
} catch {
@@ -180,31 +193,22 @@ const AssetLibrary: React.FC = () => {
const currentLib = libraries.find((l) => l.id === activeLibrary);
return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
flexWrap: 'wrap',
gap: 12,
}}
>
<Title level={3} style={{ margin: 0 }}>
素材库
</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
新建素材库
</Button>
</div>
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<PageHead
title="素材库"
actions={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
新建素材库
</Button>
}
/>
{libsLoading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<div style={{ textAlign: "center", padding: 60 }}>
<Spin size="large" />
</div>
) : libraries.length === 0 ? (
@@ -240,33 +244,40 @@ const AssetLibrary: React.FC = () => {
{activeLibrary && (
<Dragger
accept={
currentLib?.kind === 'video'
? 'video/*'
: currentLib?.kind === 'voice'
? 'audio/*'
: 'image/*'
currentLib?.kind === "video"
? "video/*"
: currentLib?.kind === "voice"
? "audio/*"
: "image/*"
}
beforeUpload={handleUpload}
showUploadList={false}
multiple
disabled={uploading || uploadMutation.isPending || directUploadMutation.isPending}
disabled={
uploading ||
uploadMutation.isPending ||
directUploadMutation.isPending
}
style={{ marginBottom: 24 }}
>
<p className="ant-upload-drag-icon">
{uploading ? <Spin /> : <InboxOutlined />}
</p>
<p className="ant-upload-text">
{uploading ? '正在上传,请稍候...' : '点击或拖拽文件到此区域上传'}
{uploading
? "正在上传,请稍候..."
: "点击或拖拽文件到此区域上传"}
</p>
<p className="ant-upload-hint">
支持 {kindLabel[currentLib?.kind || 'video']} 格式文件,单文件不超过 2GB
支持 {kindLabel[currentLib?.kind || "video"]}{" "}
格式文件,单文件不超过 2GB
</p>
</Dragger>
)}
{/* 素材列表 */}
{assetsLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<div style={{ textAlign: "center", padding: 40 }}>
<Spin />
</div>
) : assets.length === 0 ? (
@@ -279,30 +290,30 @@ const AssetLibrary: React.FC = () => {
hoverable
size="small"
cover={
currentLib?.kind === 'image' ? (
currentLib?.kind === "image" ? (
<Image
src={`/api/v1/assets/${asset.id}/thumbnail`}
alt={asset.name}
style={{ height: 150, objectFit: 'cover' }}
style={{ height: 150, objectFit: "cover" }}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lm77niYfliqDovb3lpLHotKU8L3RleHQ+PC9zdmc+"
/>
) : (
<div
style={{
height: 150,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f5f5f5',
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f5f5f5",
}}
>
{currentLib?.kind === 'video' ? (
{currentLib?.kind === "video" ? (
<VideoCameraOutlined
style={{ fontSize: 40, color: '#bbb' }}
style={{ fontSize: 40, color: "#bbb" }}
/>
) : (
<AudioOutlined
style={{ fontSize: 40, color: '#bbb' }}
style={{ fontSize: 40, color: "#bbb" }}
/>
)}
</div>
@@ -320,7 +331,7 @@ const AssetLibrary: React.FC = () => {
>
<Card.Meta
title={
<Text ellipsis style={{ maxWidth: '100%' }}>
<Text ellipsis style={{ maxWidth: "100%" }}>
{asset.name}
</Text>
}
@@ -329,16 +340,16 @@ const AssetLibrary: React.FC = () => {
<Text type="secondary" style={{ fontSize: 12 }}>
{asset.file_size
? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
: '-'}
: "-"}
</Text>
{asset.status && (
<Tag
color={
asset.status === 'completed'
? 'success'
: asset.status === 'failed'
? 'error'
: 'processing'
asset.status === "completed"
? "success"
: asset.status === "failed"
? "error"
: "processing"
}
style={{ marginTop: 4 }}
>
@@ -366,7 +377,7 @@ const AssetLibrary: React.FC = () => {
}
confirmLoading={createLibMutation.isPending}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Space direction="vertical" style={{ width: "100%" }}>
<Input
placeholder="素材库名称"
value={newLibName}
@@ -375,11 +386,11 @@ const AssetLibrary: React.FC = () => {
<Select
value={newLibKind}
onChange={setNewLibKind}
style={{ width: '100%' }}
style={{ width: "100%" }}
options={[
{ value: 'video', label: '视频素材' },
{ value: 'voice', label: '配音素材' },
{ value: 'image', label: '图片素材' },
{ value: "video", label: "视频素材" },
{ value: "voice", label: "配音素材" },
{ value: "image", label: "图片素材" },
]}
/>
</Space>
+10 -2
View File
@@ -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;
}
+29 -16
View File
@@ -1,13 +1,13 @@
/**
* 忘记密码页面 - V21 完全对标
*/
import React, { useState } from 'react';
import { Form, Input, Button, Result, message } from 'antd';
import { MailOutlined } from '@ant-design/icons';
import { Link } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { requestPasswordReset } from '@/api/auth';
import './ForgotPassword.css';
import React, { useState } from "react";
import { Form, Input, Button, Result, message } from "antd";
import { MailOutlined } from "@ant-design/icons";
import { Link } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { requestPasswordReset } from "@/api/auth";
import "./ForgotPassword.css";
const ForgotPassword: React.FC = () => {
const [form] = Form.useForm();
@@ -17,10 +17,11 @@ const ForgotPassword: React.FC = () => {
mutationFn: (email: string) => requestPasswordReset(email),
onSuccess: () => {
setEmailSent(true);
message.success('重置邮件已发送!');
message.success("重置邮件已发送!");
},
onError: (error: any) => {
if (!error?.__msgShown) message.error(error.response?.data?.message || '发送失败,请重试');
onError: (error: unknown) => {
if (!(error as { __msgShown?: boolean })?.__msgShown)
message.error("发送失败,请重试");
},
});
@@ -56,17 +57,29 @@ const ForgotPassword: React.FC = () => {
<p>重置密码</p>
</div>
<p style={{ marginBottom: '24px', color: '#64748b', fontSize: '14px', textAlign: 'center' }}>
<p
style={{
marginBottom: "24px",
color: "#64748b",
fontSize: "14px",
textAlign: "center",
}}
>
请输入您的邮箱地址,我们将发送重置密码的链接到您的邮箱。
</p>
<Form form={form} name="forgot-password" onFinish={onFinish} layout="vertical">
<Form
form={form}
name="forgot-password"
onFinish={onFinish}
layout="vertical"
>
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
{ required: true, message: "请输入邮箱" },
{ type: "email", message: "请输入有效的邮箱地址" },
]}
>
<Input
@@ -80,10 +93,10 @@ const ForgotPassword: React.FC = () => {
<button
type="submit"
className="btn primary"
style={{ width: '100%' }}
style={{ width: "100%" }}
disabled={resetMutation.isPending}
>
{resetMutation.isPending ? '发送中...' : '发送重置邮件'}
{resetMutation.isPending ? "发送中..." : "发送重置邮件"}
</button>
</Form.Item>
+5 -1
View File
@@ -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;
}
+19 -15
View File
@@ -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: "请输入有效的邮箱" },
]}
>
<Input placeholder="name@example.com" size="large" />
@@ -61,7 +62,7 @@ const Login: React.FC = () => {
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }]}
rules={[{ required: true, message: "请输入密码" }]}
>
<Input.Password placeholder="•••••••••" size="large" />
</Form.Item>
@@ -70,7 +71,10 @@ const Login: React.FC = () => {
<Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox>记住我</Checkbox>
</Form.Item>
<Link to="/forgot-password" style={{ float: 'right', fontSize: '12px' }}>
<Link
to="/forgot-password"
style={{ float: "right", fontSize: "12px" }}
>
忘记密码?
</Link>
</Form.Item>
@@ -79,10 +83,10 @@ const Login: React.FC = () => {
<button
type="submit"
className="btn primary"
style={{ width: '100%' }}
style={{ width: "100%" }}
disabled={loginMutation.isPending}
>
{loginMutation.isPending ? '登录中...' : '登录'}
{loginMutation.isPending ? "登录中..." : "登录"}
</button>
</Form.Item>
+5 -1
View File
@@ -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;
}
+25 -24
View File
@@ -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: "请输入有效的邮箱地址" },
]}
>
<Input
@@ -68,12 +69,12 @@ const Register: React.FC = () => {
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 = () => {
<Form.Item
name="confirmPassword"
label="确认密码"
dependencies={['password']}
dependencies={["password"]}
rules={[
{ required: true, message: '请确认密码' },
{ required: true, message: "请确认密码" },
({ getFieldValue }) => ({
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 = () => {
<button
type="submit"
className="btn primary"
style={{ width: '100%' }}
style={{ width: "100%" }}
disabled={registerMutation.isPending}
>
{registerMutation.isPending ? '注册中...' : '注册'}
{registerMutation.isPending ? "注册中..." : "注册"}
</button>
</Form.Item>
+10 -2
View File
@@ -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;
}
+28 -22
View File
@@ -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 = () => {
<p>设置新密码</p>
</div>
<Form form={form} name="reset-password" onFinish={onFinish} layout="vertical">
<Form
form={form}
name="reset-password"
onFinish={onFinish}
layout="vertical"
>
<Form.Item
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: "密码必须包含大小写字母和数字",
},
]}
>
@@ -100,15 +106,15 @@ const ResetPassword: React.FC = () => {
<Form.Item
name="confirmPassword"
label="确认新密码"
dependencies={['password']}
dependencies={["password"]}
rules={[
{ required: true, message: '请确认新密码' },
{ required: true, message: "请确认新密码" },
({ getFieldValue }) => ({
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 = () => {
<button
type="submit"
className="btn primary"
style={{ width: '100%' }}
style={{ width: "100%" }}
disabled={resetMutation.isPending}
>
{resetMutation.isPending ? '重置中...' : '重置密码'}
{resetMutation.isPending ? "重置中..." : "重置密码"}
</button>
</Form.Item>

Some files were not shown because too many files have changed in this diff Show More