diff --git a/.ci-retrigger b/.ci-retrigger new file mode 100644 index 000000000..081e87ca3 --- /dev/null +++ b/.ci-retrigger @@ -0,0 +1 @@ +re-trigger diff --git a/.ci-trigger b/.ci-trigger index 00a961259..3bf8c28ea 100644 --- a/.ci-trigger +++ b/.ci-trigger @@ -1 +1 @@ -# CI trigger Fri Jun 26 09:53:28 PM CST 2026 +trigger: 1784009947 diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 69868e967..049231d4e 100755 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -1,999 +1,1053 @@ name: CI/CD Pipeline - on: push: branches: - - main - - develop - - 'feature/**' - - 'bugfix/**' - - 'hotfix/**' - - 'release/**' + - main + - develop + - feature/** + - bugfix/** + - hotfix/** + - release/** tags: - - 'v*' + - v* pull_request: branches: - - main - - develop - + - main + - develop permissions: contents: read - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - jobs: validate: name: Validate Code Quality And Tests runs-on: host timeout-minutes: 10 - env: DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas - USE_IN_MEMORY_DB: "false" - - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY - - - name: Verify CI environment - shell: sh - run: | - set -eu - python3 --version - python3 -m pip --version - echo "CI environment is ready" - - - name: Install dependencies - shell: sh - run: | - set -eu - python3 -m pip install --break-system-packages -q -r requirements-base.txt - python3 -m pip install --break-system-packages -q -r requirements.txt - python3 -m pip install --break-system-packages -q -r requirements-dev.txt - python3 -m black --version - python3 -m isort --version-number - python3 -m flake8 --version - bandit --version - pytest --version - - - name: Run code quality checks - shell: sh - run: | - set -eu - python3 -m compileall -q alembic apps packages tests scripts - python3 -m black --check --fast 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 -ll - - - name: Validate release scripts syntax - shell: sh - run: | - set -eu - bash -n scripts/backup_postgres.sh - bash -n scripts/restore_postgres_plan.sh - bash -n scripts/init_production_env.sh - - - name: Validate Alembic migrations - shell: sh - run: | - set -eu - python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql - test -s /tmp/alembic-upgrade.sql - grep -q "Running upgrade" /tmp/alembic-upgrade.sql - python3 scripts/check_schema_metadata.py - - - name: Prepare git for migration diff - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - if command -v git >/dev/null 2>&1; then - git init -q - git config user.email "ci@localhost" - git config user.name "CI" - git add . - git commit -q -m "current" - REPO_URL="https://x-access-token:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" - git remote add origin "$REPO_URL" - git fetch origin main --depth=1 -q 2>/dev/null || echo "WARN: cannot fetch main, will check all migrations" - else - echo "WARN: git not available, will check all migrations" - fi - - - name: Check migration safety - shell: sh - run: | - set -eu - if git rev-parse origin/main >/dev/null 2>&1; then - python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/main - else - python3 scripts/check_migration_safety.py --allow-medium-risk - fi - - unit-tests: - name: Unit Tests - runs-on: host - timeout-minutes: 8 - - env: - USE_IN_MEMORY_DB: "true" - - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY - - - name: Install dependencies - shell: sh - run: | - set -eu - python3 -m pip install --break-system-packages -q -r requirements-base.txt - python3 -m pip install --break-system-packages -q -r requirements.txt - python3 -m pip install --break-system-packages -q -r requirements-dev.txt - pytest --version - - - name: Run unit tests with coverage - shell: sh - run: | - set -eu - PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \ - --source=apps/api/app,packages \ - --omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \ - --branch \ - -m pytest tests/unit -q - python3 -m coverage report --show-missing - python3 -m coverage xml -o coverage.xml - python3 -m coverage report --fail-under=60 > /dev/null - - - name: CI failure notification - if: failure() - shell: sh - env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} - CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }} - run: | - set +e - FAILED_JOB="Unit Tests" python3 scripts/ci_notify_failure.py - - - integration-tests: - name: Integration Tests - runs-on: host - timeout-minutes: 20 - if: always() - needs: validate - - env: - DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas - USE_IN_MEMORY_DB: "false" - - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY - - - name: Verify CI environment - shell: sh - run: | - set -eu - python3 --version - python3 -m pip --version - echo "CI environment is ready" - - - name: Install dependencies - shell: sh - run: | - set -eu - python3 -m pip install --break-system-packages -q -r requirements-base.txt - python3 -m pip install --break-system-packages -q -r requirements.txt - python3 -m pip install --break-system-packages -q -r requirements-dev.txt - pytest --version - - - name: Start Redis - shell: sh - run: | - set -eu - REDIS_CONTAINER="ci-redis-${GITHUB_RUN_ID:-$$}" - echo "REDIS_CONTAINER=$REDIS_CONTAINER" >> "$GITHUB_ENV" - docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true - docker run -d --name "$REDIS_CONTAINER" \ - -P \ - --health-cmd "redis-cli ping" \ - --health-interval 2s \ - --health-timeout 2s \ - --health-retries 10 \ - redis:7-alpine - REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | cut -d: -f2) - echo "Redis port: $REDIS_PORT" - echo "REDIS_URL=redis://127.0.0.1:$REDIS_PORT/0" >> "$GITHUB_ENV" - for i in $(seq 1 15); do - if docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" 2>/dev/null | grep -q healthy; then - echo "Redis is ready on port $REDIS_PORT" - break - fi - echo "Waiting for Redis... ($i/15)" - sleep 2 - done - docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" | grep -q healthy - - - name: Start PostgreSQL for integration tests - shell: sh - run: | - set -eu - PG_CONTAINER="ci-pg-${GITHUB_RUN_ID:-$$}" - echo "PG_CONTAINER=$PG_CONTAINER" >> "$GITHUB_ENV" - docker rm -f "$PG_CONTAINER" 2>/dev/null || true - docker run -d --name "$PG_CONTAINER" \ - --shm-size=256m \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=xiaoxia_saas \ - -P \ - --health-cmd "pg_isready -U postgres" \ - --health-interval 5s \ - --health-timeout 5s \ - --health-retries 12 \ - postgres:16 - # 获取随机映射的端口 - PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2) - echo "PostgreSQL port: $PG_PORT" - echo "DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas" >> "$GITHUB_ENV" - for i in $(seq 1 30); do - if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then - echo "PostgreSQL is ready on port $PG_PORT" - break - fi - echo "Waiting for PostgreSQL... ($i/30)" - sleep 2 - done - docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy - - - name: Apply migrations for integration tests - shell: sh - run: | - set -eu - PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head - - - name: Run integration tests - shell: sh - run: | - set -eu - pip install --break-system-packages -q pytest-rerunfailures - PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run --append \ - --source=apps/api/app,packages \ - --omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \ - --branch \ - -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m "not performance" - python3 -m coverage report --show-missing - python3 -m coverage xml -o coverage.xml - python3 -m coverage report --fail-under=40 > /dev/null # 集成测试覆盖率门槛较低,核心目标是功能验证 - - - name: Run API performance baseline tests - shell: sh - continue-on-error: true - run: | - set +e - echo "=== API 性能基线测试 ===" - PERF_OUTPUT=$(mktemp) - PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration/test_api_performance.py \ - -v --timeout=120 -p no:cacheprovider 2>&1 | tee "$PERF_OUTPUT" - PERF_EXIT=$? - - # 提取性能统计 - echo "" - echo "=== 性能测试摘要 ===" - grep "PERF_STATS:" "$PERF_OUTPUT" || echo "PERF_STATS: 未找到统计数据" - grep "PERF_RESULT:" "$PERF_OUTPUT" || echo "PERF_RESULT: 未找到详细结果" - - # 统计通过率 - TOTAL=$(grep -c "PERF_RESULT:" "$PERF_OUTPUT" || echo 0) - PASSED=$(grep "PERF_RESULT: PASS" "$PERF_OUTPUT" | wc -l) - FAILED=$(grep "PERF_RESULT: FAIL" "$PERF_OUTPUT" | wc -l) - - echo "" - echo "性能测试结果: $PASSED/$TOTAL 通过, $FAILED 未达标" - - if [ "$FAILED" -gt 0 ]; then - echo "" - echo "⚠️ 警告: $FAILED 个接口性能未达标,请关注以下接口:" - grep "PERF_RESULT: FAIL" "$PERF_OUTPUT" | while read line; do - echo " $line" - done - echo "" - echo "性能测试失败不阻塞主流水线,但建议尽快优化。" - else - echo "✅ 所有接口性能达标!" - fi - - rm -f "$PERF_OUTPUT" - # 始终返回 0,不阻塞流水线 - exit 0 - - - - name: Cleanup PostgreSQL & Redis - if: always() - shell: sh - run: | - docker rm -f "${PG_CONTAINER:-ci-pg-validate}" 2>/dev/null || true - docker rm -f "${REDIS_CONTAINER:-ci-redis-int}" 2>/dev/null || true - echo "PostgreSQL container cleaned up" - echo "Redis container cleaned up" - - - name: Coverage summary - if: always() - shell: sh - env: - COVERAGE_THRESHOLD: "40" - run: | - set +e - echo "=== 覆盖率汇总 ===" - python3 scripts/ci_coverage_summary.py - - name: Notify CI failure - if: failure() - shell: sh - run: | - set +e - echo "=== CI 失败通知 ===" - FAILED_JOB="Validate Code Quality And Tests" python3 scripts/ci_notify_failure.py - - - name: Notify CI failure - Integration Tests - if: failure() - shell: sh - run: | - set +e - echo "=== CI 失败通知 ===" - FAILED_JOB="Integration Tests" python3 scripts/ci_notify_failure.py - - - frontend-lint: - name: Frontend Lint - runs-on: host - timeout-minutes: 10 - - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY - - - name: Install dependencies - shell: sh - run: | - set -eu - docker run --rm \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - docker.m.daocloud.io/library/node:20 \ - sh -lc 'npm ci' - - - name: Run ESLint - shell: sh - run: | - set -eu - docker run --rm \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - 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 - run: | - set -eu - docker run --rm \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - docker.m.daocloud.io/library/node:20 \ - sh -lc 'npx tsc --noEmit' - - - name: Run Prettier check - shell: sh - run: | - set -eu - docker run --rm \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - docker.m.daocloud.io/library/node:20 \ - sh -lc 'npx prettier --check "src/**/*.{ts,tsx,md}"' - - - name: Run Vitest tests - shell: sh - run: | - set -eu - docker run --rm \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - docker.m.daocloud.io/library/node:20 \ - sh -lc 'npx vitest run src/test' - - - name: Notify CI failure - if: failure() - shell: sh - run: | - set +e - echo "=== CI 失败通知 ===" - FAILED_JOB="Frontend Lint" python3 scripts/ci_notify_failure.py - - deploy-staging: - name: Build & Push Staging (Watchtower auto-deploy) - runs-on: saas - timeout-minutes: 30 - needs: [validate, frontend-lint] - - if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') - + USE_IN_MEMORY_DB: 'false' steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'INNERPY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - INNERPY + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'set -eu - - name: Build and push all images to Gitea Registry + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' + - name: Verify CI environment + shell: sh + run: 'set -eu + + python3 --version + + python3 -m pip --version + + echo "CI environment is ready" + + ' + - name: Install dependencies + shell: sh + run: 'set -eu + + python3 -m pip install -q -r requirements-base.txt + + python3 -m pip install -q -r requirements.txt + + python3 -m pip install -q -r requirements-dev.txt + + python3 -m black --version + + python3 -m isort --version-number + + python3 -m ruff --version + + bandit --version + + pytest --version + + ' + - name: Secret detection (detect-secrets) + shell: sh + run: "set -eu\necho \"=== Installing detect-secrets ===\"\npython3 -m pip install -q detect-secrets\ndetect-secrets --version\necho \"\"\necho \"=== Running secret scan ===\"\ndetect-secrets scan \\\n --all-files \\\n --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \\\n --exclude-files '\\.(md|rst|txt|lock|example|sample|min\\.js|min\\.css|spec\\.ts|test\\.ts|test\\.py)$' \\\n --exclude-files '(package-lock|yarn\\.lock|poetry\\.lock|Pipfile\\.lock)$' \\\n --disable-plugin Base64HighEntropyString \\\n --disable-plugin HexHighEntropyString \\\n --disable-plugin BasicAuthDetector \\\n --disable-plugin KeywordDetector \\\n --disable-plugin IPPublicDetector \\\n 2>&1 | tee /tmp/secrets-scan.json\n\nFOUND=$(python3 -c \"\nimport json\ntry:\n with open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\n results = data.get('results', {})\n total = sum(len(v) for\ + \ v in results.values())\n print(total)\nexcept Exception:\n print('error')\n\")\necho \"\"\necho \"Secrets detected: $FOUND\"\nif [ \"$FOUND\" != \"0\" ] && [ \"$FOUND\" != \"error\" ]; then\n echo \"\"\n echo \"=== Secret details ===\"\n python3 -c \"\nimport json\nwith open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\nfor fpath, items in data.get('results', {}).items():\n for item in items:\n line = item.get('line_number', '?')\n stype = item.get('type', '?')\n hashed = item.get('hashed_secret', '')[:16]\n print(f' {fpath}:{line} [{stype}] {hashed}...')\n\"\n echo \"\"\n echo \"ERROR: Potential secrets detected in code!\"\n echo \"If these are false positives, add exclusions in the CI workflow.\"\n exit 1\nfi\necho \"Secret scan completed - no secrets detected\"\n" + - name: Calculate changed Python files (incremental scan) shell: sh env: - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -eu - chmod +x scripts/build_release_images.sh - ALLOW_SHARED_PRODUCTION_BUILD_HOST=true REGISTRY_TOKEN="${REGISTRY_TOKEN}" \ - scripts/build_release_images.sh "${GITHUB_SHA}" staging + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\nSCAN_MODE=\"full\"\nCHANGED_PY_FILES=\"\"\n\nif [ \"${GITHUB_EVENT_NAME:-}\" = \"pull_request\" ] && [ -n \"${GITHUB_BASE_REF:-}\" ]; then\n echo \"PR mode (base: ${GITHUB_BASE_REF}) - preparing incremental scan\"\n git init -q\n git config user.email \"ci@localhost\"\n git config user.name \"CI\"\n git add .\n git commit -q -m \"current\"\n REPO_URL=\"https://x-access-token:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git\"\n git remote add origin \"$REPO_URL\"\n if git fetch origin \"${GITHUB_BASE_REF}\" --depth=50 -q 2>/dev/null; then\n CHANGED_PY_FILES=$(git diff --name-only --diff-filter=ACMRT \"origin/${GITHUB_BASE_REF}\" HEAD -- '*.py' 2>/dev/null | tr '\\n' ' ')\n if [ -n \"$CHANGED_PY_FILES\" ]; then\n SCAN_MODE=\"incremental\"\n FILE_COUNT=$(echo \"$CHANGED_PY_FILES\" | wc -w)\n echo \"Changed Python files: ${FILE_COUNT}\"\n echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^$'\n else\n SCAN_MODE=\"skip_py\"\n echo \"No Python files changed in this PR\"\n fi\n else\n echo \"WARN: failed to fetch base branch, falling back to full scan\"\n fi\nelse\n echo \"Full scan mode (not a PR event)\"\nfi\n\necho \"SCAN_MODE=$SCAN_MODE\" >> $GITHUB_ENV\necho \"CHANGED_PY_FILES=$CHANGED_PY_FILES\" >> $GITHUB_ENV\n" + - name: Run code quality checks + shell: sh + run: "set -eu\n\nif [ \"$SCAN_MODE\" = \"incremental\" ]; then\n echo \"=== Incremental scan mode ===\"\n\n python3 -m compileall -q $CHANGED_PY_FILES\n\n python3 -m black --check --fast $CHANGED_PY_FILES\n\n python3 -m isort --check-only $CHANGED_PY_FILES\n\n RUFF_FILES=$(echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^scripts/' | tr '\\n' ' ')\n if [ -n \"$RUFF_FILES\" ]; then\n python3 -m ruff check $RUFF_FILES --statistics\n else\n echo \"No ruff-checkable files changed, skipping\"\n fi\n\nelif [ \"$SCAN_MODE\" = \"skip_py\" ]; then\n echo \"No Python files changed - skipping Python lint checks\"\n\nelse\n echo \"=== Full scan mode ===\"\n\n python3 -m compileall -q alembic apps packages tests scripts\n\n python3 -m black --check --fast alembic apps packages tests scripts\n\n python3 -m isort --check-only alembic apps packages tests scripts\n\n python3 -m ruff check apps packages tests --statistics\nfi\n" + - name: Type check (mypy, advisory mode) + if: always() + shell: sh + run: "set +e\necho \"=== Installing mypy ===\"\npython3 -m pip install -q mypy\nmypy --version\necho \"\"\necho \"=== Running mypy type check (advisory mode) ===\"\necho \"告警模式,不阻断CI\"\necho \"\"\n# 只检查核心业务代码,跳过测试和迁移\nEXIT_CODE=0\nmypy apps/api/app packages --ignore-missing-imports --no-site-packages --no-strict-optional --explicit-package-bases --exclude 'tests/|test_|migrations/|alembic/' --no-error-summary 2>&1 | head -60 || EXIT_CODE=$?\necho \"\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"mypy 发现类型问题(告警模式,不阻断)\"\n echo \"建议后续逐步修复\"\nelse\n echo \"mypy 类型检查通过 ✅\"\nfi\nexit 0\n" + - name: Run security scan (bandit) + shell: sh + run: 'set -eu - - name: Tag and push :staging images (Watchtower auto-update) + bandit -r apps packages -q -ll + + ' + - name: Python dependency vulnerability scan (pip-audit) + shell: sh + run: "set -eu\necho \"=== Installing pip-audit ===\"\npython3 -m pip install -q pip-audit\npip-audit --version\necho \"\"\necho \"=== Scanning Python dependencies ===\"\nEXIT_CODE=0\nfor req_file in requirements.txt requirements-base.txt requirements-dev.txt; do\n if [ -f \"$req_file\" ]; then\n echo \"--- Scanning $req_file ---\"\n pip-audit -r \"$req_file\" --desc on 2>&1 | head -40 || EXIT_CODE=$?\n echo \"\"\n fi\ndone\necho \"pip-audit scan completed (advisory mode - warnings only, not blocking CI)\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"WARNING: Potential vulnerabilities found in dependencies.\"\nfi\nexit 0\n" + - name: Dead code detection (vulture) + if: always() + shell: sh + run: "set +e\necho \"=== Installing vulture ===\"\npython3 -m pip install -q vulture\nvulture --version\necho \"\"\necho \"=== Running vulture dead code scan (confidence >= 70%) ===\"\necho \"告警模式,不阻断CI。置信度>=90%建议尽快确认。\"\necho \"\"\n# 按置信度从高到低输出,便于优先查看高价值条目\nvulture apps packages scripts \\\n --exclude \"tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py\" \\\n --min-confidence 70 \\\n 2>&1 | sort -t'(' -k2 -rn | head -80\nEXIT_CODE=$?\necho \"\"\necho \"=== vulture scan summary ===\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"发现潜在死代码(可能包含框架装饰器注册的函数,为误报)\"\n echo \"建议:定期人工审查高置信度(>=90%)条目\"\nelse\n echo \"未发现明显死代码 ✅\"\nfi\nexit 0\n" + - name: Validate release scripts syntax + shell: sh + run: 'set -eu + + bash -n scripts/backup_postgres.sh + + bash -n scripts/restore_postgres_plan.sh + + bash -n scripts/init_production_env.sh + + ' + - name: Validate Alembic migrations + shell: sh + run: 'set -eu + + python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql + + test -s /tmp/alembic-upgrade.sql + + grep -q "Running upgrade" /tmp/alembic-upgrade.sql + + python3 scripts/check_schema_metadata.py + + ' + - name: Prepare git for migration diff shell: sh env: - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -eu - REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" - if [ -n "${REGISTRY_TOKEN:-}" ]; then - printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin 2>/dev/null - fi - for svc in api worker web; do - docker tag "${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}" "${REGISTRY}/xiaoxia-saas-${svc}:staging" - docker push "${REGISTRY}/xiaoxia-saas-${svc}:staging" - done - echo "All :staging images pushed. Watchtower will auto-deploy within 60s." - - - name: Wait for Watchtower update + smoke test + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\nif command -v git >/dev/null 2>&1; then\n if [ ! -d .git ]; then\n git init -q\n git config user.email \"ci@localhost\"\n git config user.name \"CI\"\n git add .\n git commit -q -m \"current\"\n REPO_URL=\"https://x-access-token:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git\"\n git remote add origin \"$REPO_URL\"\n fi\n git fetch origin main --depth=1 -q 2>/dev/null || echo \"WARN: cannot fetch main, will check all migrations\"\nelse\n echo \"WARN: git not available, will check all migrations\"\nfi\n" + - name: Check migration safety shell: sh - run: | - set -eu - echo "Waiting 90s for Watchtower to detect new image and restart containers..." - sleep 90 - - echo "--- Smoke test 1: Health check ---" - for i in $(seq 1 12); do - HEALTH=$(curl -sf --max-time 10 https://staging-api.xiaoxiajianji.com/health) && break - echo " Attempt $i/12: not ready yet, waiting 5s..." - sleep 5 - done - if [ -z "$HEALTH" ]; then - echo "FAIL: health endpoint unreachable after 60s" - exit 1 - fi - echo "Health OK: $HEALTH" - - echo "--- Smoke test 2: Login API (expect 401) ---" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \ - https://staging-api.xiaoxiajianji.com/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"smoke@test.com","password":"wrong"}') - if [ "$HTTP_CODE" != "401" ] && [ "$HTTP_CODE" != "422" ]; then - echo "FAIL: login returned HTTP $HTTP_CODE (expected 401 or 422)" - exit 1 - fi - echo "Login API OK: HTTP $HTTP_CODE" - - echo "--- Smoke test 3: API docs endpoint ---" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://staging-api.xiaoxiajianji.com/docs) - if [ "$HTTP_CODE" != "200" ]; then - echo "FAIL: /docs returned HTTP $HTTP_CODE (expected 200)" - exit 1 - fi - echo "Docs endpoint OK: HTTP $HTTP_CODE" - - echo "--- Smoke test 4: Web frontend ---" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://staging.xiaoxiajianji.com/) - if [ "$HTTP_CODE" != "200" ]; then - echo "FAIL: web frontend returned HTTP $HTTP_CODE (expected 200)" - exit 1 - fi - echo "Web frontend OK: HTTP $HTTP_CODE" - - echo "" - echo "=== All smoke tests passed! ===" - echo "Branch: ${GITHUB_REF_NAME}" - echo "Commit: ${GITHUB_SHA}" - - - name: Notify CI success - if: success() + run: "set -eu\nif git rev-parse origin/main >/dev/null 2>&1; then\n python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/main\nelse\n python3 scripts/check_migration_safety.py --allow-medium-risk\nfi\n" + - name: Job duration summary + if: always() shell: sh - run: | - set +e - echo "=== CI 成功通知 ===" - SUCCESS_JOB="Staging部署成功" python3 scripts/ci_notify_success.py - - - name: Notify CI failure + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true if: failure() shell: sh - run: | - set +e - echo "=== CI 失败通知 ===" - FAILED_JOB="Build & Push Staging (Watchtower auto-deploy)" python3 scripts/ci_notify_failure.py + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=failure JOB_NAME="Validate Code Quality And Tests" python3 scripts/ci_notify.py + + ' + unit-tests: + name: Unit Tests + runs-on: host + timeout-minutes: 8 + env: + USE_IN_MEMORY_DB: 'true' + OSS_ACCESS_KEY_ID: placeholder + OSS_ACCESS_KEY_SECRET: placeholder + OSS_BUCKET_NAME: xiaoxia-autocut + OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' + - name: Install ffmpeg + shell: sh + run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\nelif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo \"Warning: ffmpeg installation failed or not available, some tests may be skipped\"\nfi\n" + - name: Install dependencies + shell: sh + run: 'set -eu + + python3 -m pip install -q -r requirements-base.txt + + python3 -m pip install -q -r requirements.txt + + python3 -m pip install -q -r requirements-dev.txt + + pytest --version + + ' + - name: Run unit tests with coverage + shell: sh + run: "set -eu\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m coverage run \\\n --source=apps/api/app,packages \\\n --omit=\"*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*\" \\\n --branch \\\n -m pytest tests/unit -q\npython3 -m coverage report --show-missing\npython3 -m coverage xml -o coverage.xml\npython3 -m coverage report --fail-under=60 > /dev/null\n" + - name: CI failure notification + if: failure() + shell: sh + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }} + run: 'set +e + + FAILED_JOB="Unit Tests" python3 scripts/ci_notify_failure.py + + ' + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=failure JOB_NAME="Unit Tests" python3 scripts/ci_notify.py + + ' + integration-tests: + name: Integration Tests + runs-on: host + timeout-minutes: 30 + if: always() + needs: validate + env: + DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas + USE_IN_MEMORY_DB: 'false' + OSS_ACCESS_KEY_ID: placeholder + OSS_ACCESS_KEY_SECRET: placeholder + OSS_BUCKET_NAME: xiaoxia-autocut + OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' + - name: Verify CI environment + shell: sh + run: 'set -eu + + python3 --version + + python3 -m pip --version + + echo "CI environment is ready" + + ' + - name: Install dependencies + shell: sh + run: 'set -eu + + python3 -m pip install -q -r requirements-base.txt + + python3 -m pip install -q -r requirements.txt + + python3 -m pip install -q -r requirements-dev.txt + + pytest --version + + ' + - name: Install ffmpeg + shell: sh + run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\nelif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo \"Warning: ffmpeg installation failed or not available, some tests may be skipped\"\nfi\n" + - name: Start Redis + shell: sh + run: "set -eu\nREDIS_CONTAINER=\"ci-redis-${GITHUB_RUN_ID:-$$}\"\necho \"REDIS_CONTAINER=$REDIS_CONTAINER\" >> \"$GITHUB_ENV\"\ndocker rm -f \"$REDIS_CONTAINER\" 2>/dev/null || true\ndocker run -d --name \"$REDIS_CONTAINER\" \\\n -P \\\n --health-cmd \"redis-cli ping\" \\\n --health-interval 2s \\\n --health-timeout 2s \\\n --health-retries 10 \\\n redis:7-alpine\nREDIS_PORT=$(docker port \"$REDIS_CONTAINER\" 6379/tcp | cut -d: -f2)\necho \"Redis port: $REDIS_PORT\"\necho \"REDIS_URL=redis://127.0.0.1:$REDIS_PORT/0\" >> \"$GITHUB_ENV\"\nfor i in $(seq 1 15); do\n if docker inspect --format='{{.State.Health.Status}}' \"$REDIS_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"Redis is ready on port $REDIS_PORT\"\n break\n fi\n echo \"Waiting for Redis... ($i/15)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}' \"$REDIS_CONTAINER\" | grep -q healthy\n" + - name: Start PostgreSQL for integration tests + shell: sh + run: "set -eu\nPG_CONTAINER=\"ci-pg-${GITHUB_RUN_ID:-$$}\"\necho \"PG_CONTAINER=$PG_CONTAINER\" >> \"$GITHUB_ENV\"\ndocker rm -f \"$PG_CONTAINER\" 2>/dev/null || true\ndocker run -d --name \"$PG_CONTAINER\" \\\n --shm-size=256m \\\n -e POSTGRES_USER=postgres \\\n -e POSTGRES_PASSWORD=postgres \\\n -e POSTGRES_DB=xiaoxia_saas \\\n -P \\\n --health-cmd \"pg_isready -U postgres\" \\\n --health-interval 5s \\\n --health-timeout 5s \\\n --health-retries 12 \\\n postgres:16\n# 获取随机映射的端口\nPG_PORT=$(docker port \"$PG_CONTAINER\" 5432/tcp | cut -d: -f2)\necho \"PostgreSQL port: $PG_PORT\"\necho \"DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas\" >> \"$GITHUB_ENV\"\nfor i in $(seq 1 30); do\n if docker inspect --format='{{.State.Health.Status}}' \"$PG_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"PostgreSQL is ready on port $PG_PORT\"\n break\n fi\n echo \"Waiting for PostgreSQL... ($i/30)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}'\ + \ \"$PG_CONTAINER\" | grep -q healthy\n" + - name: Apply migrations for integration tests + shell: sh + run: 'set -eu + + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head + + ' + - name: Run integration tests + shell: sh + run: "set -eu\npython3 -m pip install -q pytest-rerunfailures\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m coverage run --append \\\n --source=apps/api/app,packages \\\n --omit=\"*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*\" \\\n --branch \\\n -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m \"not performance\"\npython3 -m coverage report --show-missing\npython3 -m coverage xml -o coverage.xml\npython3 -m coverage report --fail-under=40 > /dev/null # 集成测试覆盖率门槛较低,核心目标是功能验证\n" + - name: Run API performance baseline tests + shell: sh + continue-on-error: true + run: "set +e\necho \"=== API 性能基线测试 ===\"\nPERF_OUTPUT=$(mktemp)\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m pytest tests/integration/test_api_performance.py \\\n -v --timeout=120 -p no:cacheprovider 2>&1 | tee \"$PERF_OUTPUT\"\nPERF_EXIT=$?\n\n# 提取性能统计\necho \"\"\necho \"=== 性能测试摘要 ===\"\ngrep \"PERF_STATS:\" \"$PERF_OUTPUT\" || echo \"PERF_STATS: 未找到统计数据\"\ngrep \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo \"PERF_RESULT: 未找到详细结果\"\n\n# 统计通过率\nTOTAL=$(grep -c \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo 0)\nPASSED=$(grep \"PERF_RESULT: PASS\" \"$PERF_OUTPUT\" | wc -l)\nFAILED=$(grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | wc -l)\n\necho \"\"\necho \"性能测试结果: $PASSED/$TOTAL 通过, $FAILED 未达标\"\n\nif [ \"$FAILED\" -gt 0 ]; then\n echo \"\"\n echo \"⚠️ 警告: $FAILED 个接口性能未达标,请关注以下接口:\"\n grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | while read line; do\n echo \" $line\"\n done\n echo \"\"\n echo \"性能测试失败不阻塞主流水线,但建议尽快优化。\"\nelse\n echo \"✅ 所有接口性能达标!\"\nfi\n\nrm -f \"$PERF_OUTPUT\"\ + \n# 始终返回 0,不阻塞流水线\nexit 0\n" + - name: Cleanup PostgreSQL & Redis + if: always() + shell: sh + run: 'docker rm -f "${PG_CONTAINER:-ci-pg-validate}" 2>/dev/null || true + + docker rm -f "${REDIS_CONTAINER:-ci-redis-int}" 2>/dev/null || true + + echo "PostgreSQL container cleaned up" + + echo "Redis container cleaned up" + + ' + - name: Coverage summary + if: always() + shell: sh + env: + COVERAGE_THRESHOLD: '40' + run: 'set +e + + echo "=== 覆盖率汇总 ===" + + python3 scripts/ci_coverage_summary.py + + ' + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=failure JOB_NAME="Integration Tests" python3 scripts/ci_notify.py + + ' + frontend-lint: + name: Frontend Lint + runs-on: host + timeout-minutes: 10 + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' + - name: Install dependencies + shell: sh + run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d\" \" -f1)\nCACHE_HASH_FILE=\"node_modules/.package-lock-hash\"\nCACHE_VALID=false\nif [ -f \"$CACHE_HASH_FILE\" ] && [ \"$(cat \"$CACHE_HASH_FILE\")\" = \"$PACKAGE_LOCK_HASH\" ] && [ -x \"node_modules/.bin/eslint\" ]; then\n CACHE_VALID=true\n echo \"Cache hit: dependencies valid, skipping npm ci\"\nfi\nif [ \"$CACHE_VALID\" = \"false\" ]; then\n echo \"Cache miss or invalid: running npm ci...\"\n if ! npm ci --include=dev; then\n echo \"npm ci failed, cleaning node_modules and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n echo \"$PACKAGE_LOCK_HASH\" > \"$CACHE_HASH_FILE\"\n echo \"Dependencies installed, cache updated\"\nfi'\n" + - name: Run ESLint + shell: sh + run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install eslint src --ext .ts,.tsx --max-warnings 50'\n" + - name: Run TypeScript type check + shell: sh + run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install tsc --noEmit'\n" + - name: Run Prettier check + shell: sh + run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install prettier --check \"src/**/*.{ts,tsx,md}\"'\n" + - name: Run Vitest tests + shell: sh + run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install vitest run src/test'\n" + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=failure JOB_NAME="Frontend Lint" python3 scripts/ci_notify.py + + ' + build-staging-api: + name: Build Staging API Image + runs-on: + - saas + - build-farm + timeout-minutes: 30 + needs: + - validate + - frontend-lint + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' + - name: Docker login to Registry + shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + + echo "Docker login successful" + + ' + - name: Setup cache strategy + shell: sh + run: "set -eu\n# develop/main 分支写回缓存,其他分支只读\nif [ \"${GITHUB_REF_NAME}\" = \"develop\" ] || [ \"${GITHUB_REF_NAME}\" = \"main\" ]; then\n echo \"CACHE_MODE=read-write\" >> $GITHUB_ENV\n echo \"Cache mode: read-write (will push cache)\"\nelse\n echo \"CACHE_MODE=read-only\" >> $GITHUB_ENV\n echo \"Cache mode: read-only\"\nfi\n" + - name: Setup buildx builder (docker-container driver) + shell: sh + run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" + - name: Build and push API image (buildx cache) + shell: sh + run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\nIMAGE_NAME=\"xiaoxia-saas-api\"\nCACHE_REF=\"${REGISTRY}/api-cache:develop\"\n\nCACHE_FROM=\"type=registry,ref=${CACHE_REF},ignore-error=true\"\n\nif [ \"${CACHE_MODE}\" = \"read-write\" ]; then\n CACHE_TO=\"type=registry,ref=${CACHE_REF},mode=max\"\n echo \"Building API image with read-write cache...\"\n docker buildx build --build-arg APP_VERSION=\"${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" --cache-to \"${CACHE_TO}\" -f infra/docker/api.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push .\nelse\n echo \"Building API image with read-only cache...\"\n docker buildx build --build-arg APP_VERSION=\"${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" -f infra/docker/api.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push .\nfi\necho\ + \ \"API image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\n" + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=failure JOB_NAME="Build Staging API Image" python3 scripts/ci_notify.py + + ' + build-staging-worker: + name: Build Staging Worker Image + runs-on: + - saas + - build-farm + timeout-minutes: 40 + needs: + - validate + - frontend-lint + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' + - name: Docker login to Registry + shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + + echo "Docker login successful" + + ' + - name: Setup cache strategy + shell: sh + run: "set -eu\n# develop/main 分支写回缓存,其他分支只读\nif [ \"${GITHUB_REF_NAME}\" = \"develop\" ] || [ \"${GITHUB_REF_NAME}\" = \"main\" ]; then\n echo \"CACHE_MODE=read-write\" >> $GITHUB_ENV\n echo \"Cache mode: read-write (will push cache)\"\nelse\n echo \"CACHE_MODE=read-only\" >> $GITHUB_ENV\n echo \"Cache mode: read-only\"\nfi\n" + - name: Setup buildx builder (docker-container driver) + shell: sh + run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" + - name: Build and push Worker image (buildx cache) + shell: sh + run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\nIMAGE_NAME=\"xiaoxia-saas-worker\"\nCACHE_REF=\"${REGISTRY}/worker-cache:develop\"\n\nCACHE_FROM=\"type=registry,ref=${CACHE_REF},ignore-error=true\"\n\nif [ \"${CACHE_MODE}\" = \"read-write\" ]; then\n CACHE_TO=\"type=registry,ref=${CACHE_REF},mode=min\"\n echo \"Building Worker image with read-write cache...\"\n docker buildx build --build-arg APP_VERSION=\"${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" --cache-to \"${CACHE_TO}\" -f infra/docker/worker.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push .\nelse\n echo \"Building Worker image with read-only cache...\"\n docker buildx build --build-arg APP_VERSION=\"${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" -f infra/docker/worker.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push \ + \ .\nfi\necho \"Worker image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\n" + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=failure JOB_NAME="Build Staging Worker Image" python3 scripts/ci_notify.py + + ' + build-staging-web: + name: Build Staging Web Image + runs-on: + - saas + - build-farm + timeout-minutes: 30 + needs: + - validate + - frontend-lint + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' + - name: Docker login to Registry + shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + + echo "Docker login successful" + + ' + - name: Setup cache strategy + shell: sh + run: "set -eu\n# develop/main 分支写回缓存,其他分支只读\nif [ \"${GITHUB_REF_NAME}\" = \"develop\" ] || [ \"${GITHUB_REF_NAME}\" = \"main\" ]; then\n echo \"CACHE_MODE=read-write\" >> $GITHUB_ENV\n echo \"Cache mode: read-write (will push cache)\"\nelse\n echo \"CACHE_MODE=read-only\" >> $GITHUB_ENV\n echo \"Cache mode: read-only\"\nfi\n" + - name: Build frontend assets (npm build) + shell: sh + run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm -v \"$PWD:/workspace\" -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc \"npm ci && npx tsc --incremental --tsBuildInfoFile node_modules/.tsbuildinfo && npx vite build\"\n\ntest -f apps/web/dist/index.html\necho \"Frontend build complete: $(ls apps/web/dist/ | head -5)\"\n" + - name: Setup buildx builder (docker-container driver) + shell: sh + run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" + - name: Build and push Web image (buildx cache) + shell: sh + run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\nIMAGE_NAME=\"xiaoxia-saas-web\"\nCACHE_REF=\"${REGISTRY}/web-cache:develop\"\nNGINX_CONF=\"infra/docker/nginx-staging.conf\"\n\nCACHE_FROM=\"type=registry,ref=${CACHE_REF},ignore-error=true\"\n\nif [ \"${CACHE_MODE}\" = \"read-write\" ]; then\n CACHE_TO=\"type=registry,ref=${CACHE_REF},mode=max\"\n echo \"Building Web image with read-write cache...\"\n docker buildx build --cache-from \"${CACHE_FROM}\" --cache-to \"${CACHE_TO}\" -f infra/docker/web-artifact.Dockerfile --build-arg \"NGINX_CONF=${NGINX_CONF}\" -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push .\nelse\n echo \"Building Web image with read-only cache...\"\n docker buildx build --cache-from \"${CACHE_FROM}\" -f infra/docker/web-artifact.Dockerfile --build-arg \"NGINX_CONF=${NGINX_CONF}\" -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\ + \ --push .\nfi\necho \"Web image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\n" + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=failure JOB_NAME="Build Staging Web Image" python3 scripts/ci_notify.py + + ' + deploy-staging: + name: Deploy Staging (Watchtower auto-deploy) + runs-on: saas + timeout-minutes: 15 + needs: + - build-staging-api + - build-staging-worker + - build-staging-web + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' + - name: Notify job start + continue-on-error: true + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=start JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py + + ' + - name: Docker login to Registry + shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + + echo "Docker login successful" + + ' + - name: Install SSH client + if: success() + shell: sh + run: "set -eu\napt-get update -qq \u0026\u0026 apt-get install -y -qq openssh-client \u003e/dev/null 2\u003e\u00261\necho \"openssh-client installed\"\n" + + - name: Deploy staging over SSH (Registry pull) + if: success() + shell: sh + env: + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} + STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: "set -eux\nstaging_host=\"${STAGING_SSH_HOST:-47.98.113.167}\"\nstaging_user=\"${STAGING_SSH_USER:-root}\"\nstaging_port=\"${STAGING_SSH_PORT:-22222}\"\necho \"Host: $staging_host\"\necho \"Port: $staging_port\"\n\nmkdir -p ~/.ssh\n\n# 查找可用的SSH密钥\nkey_path=\"\"\nif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then\n key_path=\"/root/.ssh/xiaoxia_runtime_builder\"\n echo \"Using key: $key_path (builder key)\"\nelif [ -f \"$HOME/.ssh/xiaoxia_runtime_builder\" ]; then\n key_path=\"$HOME/.ssh/xiaoxia_runtime_builder\"\n echo \"Using key: $key_path (home key)\"\nelif [ -n \"${STAGING_SSH_KEY:-}\" ]; then\n key_path=\"$HOME/.ssh/id_ed25519\"\n printf \'%s\\n\' \"$STAGING_SSH_KEY\" \u003e \"$key_path\"\n chmod 600 \"$key_path\"\n echo \"Using key from STAGING_SSH_KEY secret\"\nelse\n echo \"ERROR: No SSH key available\"\n ls -la ~/.ssh/ 2\u003e/dev/null || true\n ls -la /root/.ssh/ 2\u003e/dev/null || true\n exit 1\nfi\n\nssh-keyscan -p \"$staging_port\" -H \"$staging_host\" \u003e\u003e ~/.ssh/known_hosts 2\u003e/dev/null\necho \"SSH keyscan done\"\n\n# 测试SSH连接\nssh -p \"$staging_port\" -i \"$key_path\" -o StrictHostKeyChecking=no \"${staging_user}@${staging_host}\" \"echo SSH_CONNECTION_OK \u0026\u0026 hostname\"\necho \"SSH connection verified\"\n\n# 读取部署脚本并通过SSH执行\ncat scripts/ci_staging_deploy.sh | ssh -p \"$staging_port\" -i \"$key_path\" -o StrictHostKeyChecking=no \"${staging_user}@${staging_host}\" \"IMAGE_TAG=\'${GITHUB_SHA}\' REGISTRY_TOKEN=\'${REGISTRY_TOKEN}\' sh\"\n" + - name: Staging health check + auto rollback + if: success() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} + STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: 'set -eu + + echo "==========================================" + + echo " Staging 健康检查(Watchtower 模式)" + + echo "==========================================" + + echo "" + bash scripts/ci_staging_healthcheck.sh + ' + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on success + continue-on-error: true + if: success() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=success JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py + + ' + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=failure JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py + + ' staging-e2e: name: Staging E2E Tests runs-on: saas timeout-minutes: 15 if: github.ref_name == 'develop' || github.ref_name == 'main' needs: deploy-staging - steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' - name: Run Playwright E2E on staging shell: sh - run: | - set -eu - docker run --rm --ipc=host \ - -e E2E_BASE_URL=https://staging.xiaoxiajianji.com \ - -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \ - -e E2E_BROWSER_CHANNEL=chromium \ - -e PLAYWRIGHT_HEADLESS=1 \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \ - sh -lc "npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts" - - - name: Notify CI failure + run: "set -eu\ndocker run --rm --ipc=host \\\n -e E2E_BASE_URL=https://staging.xiaoxiajianji.com \\\n -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \\\n -e E2E_BROWSER_CHANNEL=chromium \\\n -e PLAYWRIGHT_HEADLESS=1 \\\n -v \"$PWD:/workspace\" \\\n -w /workspace/apps/web \\\n git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \\\n sh -lc \"npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts\"\n" + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true if: failure() shell: sh - run: | - set +e - echo "=== CI 失败通知 ===" - FAILED_JOB="Staging E2E Tests" python3 scripts/ci_notify_failure.py + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Staging E2E Tests" python3 scripts/ci_notify.py + ' staging-api-tests: name: Staging API Integration Tests runs-on: saas timeout-minutes: 10 if: github.ref_name == 'develop' || github.ref_name == 'main' needs: deploy-staging - steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' - name: Run API integration tests on staging shell: sh - run: | - set -eu - docker run --rm \ - -e E2E_BASE_URL=https://staging.xiaoxiajianji.com \ - -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \ - sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts' - - - name: Notify CI failure + run: "set -eu\ndocker run --rm \\\n -e E2E_BASE_URL=https://staging.xiaoxiajianji.com \\\n -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \\\n -v \"$PWD:/workspace\" \\\n -w /workspace/apps/web \\\n git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \\\n sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts'\n" + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true if: failure() shell: sh - run: | - set +e - echo "=== CI 失败通知 ===" - FAILED_JOB="Staging API Integration Tests" python3 scripts/ci_notify_failure.py + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Staging API Integration Tests" python3 scripts/ci_notify.py - - build-production-runtime-images: - name: Build Production Runtime Images - runs-on: saas + ' + build-production-api: + name: Build Production API Image + runs-on: + - saas + - build-farm timeout-minutes: 30 - needs: [validate, frontend-lint] - + needs: + - validate + - frontend-lint if: startsWith(github.ref, 'refs/tags/v') - steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'set -eu - - name: Build and push all images (api + worker + web, with buildx cache) + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' + - name: Docker login to Registry shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -eu - chmod +x scripts/build_release_images.sh - REGISTRY_TOKEN="${REGISTRY_TOKEN}" scripts/build_release_images.sh "${GITHUB_REF_NAME}" + run: 'set -eu + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + + echo "Docker login successful" + + ' + - name: Setup buildx builder (docker-container driver) + shell: sh + run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" + - name: Build and push API image (buildx cache) + shell: sh + run: 'set -eu + + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + + IMAGE_NAME="xiaoxia-saas-api" + + VERSION="${GITHUB_REF_NAME}" + + CACHE_REF="${REGISTRY}/api-cache:main" + + + echo "Building Production API image: ${VERSION}" + + docker buildx build --build-arg APP_VERSION="${VERSION}" --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f infra/docker/api.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push . + + echo "Production API image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}" + + ' + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=failure JOB_NAME="Build Production API Image" python3 scripts/ci_notify.py + + ' + build-production-worker: + name: Build Production Worker Image + runs-on: + - saas + - build-farm + timeout-minutes: 40 + needs: + - validate + - frontend-lint + if: startsWith(github.ref, 'refs/tags/v') + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' + - name: Docker login to Registry + shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + + echo "Docker login successful" + + ' + - name: Setup buildx builder (docker-container driver) + shell: sh + run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" + - name: Build and push Worker image (buildx cache) + shell: sh + run: 'set -eu + + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + + IMAGE_NAME="xiaoxia-saas-worker" + + VERSION="${GITHUB_REF_NAME}" + + CACHE_REF="${REGISTRY}/worker-cache:main" + + + echo "Building Production Worker image: ${VERSION}" + + docker buildx build --build-arg APP_VERSION="${VERSION}" --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=min" -f infra/docker/worker.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push . + + echo "Production Worker image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}" + + ' + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=failure JOB_NAME="Build Production Worker Image" python3 scripts/ci_notify.py + + ' + build-production-web: + name: Build Production Web Image + runs-on: + - saas + - build-farm + timeout-minutes: 30 + needs: + - validate + - frontend-lint + if: startsWith(github.ref, 'refs/tags/v') + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' + - name: Docker login to Registry + shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: 'set -eu + + printf ''%s'' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + + echo "Docker login successful" + + ' + - name: Build frontend assets (npm build) + shell: sh + run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\nfi\n\ndocker run --rm -v \"$PWD:/workspace\" -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc \"npm ci && npx tsc --incremental --tsBuildInfoFile node_modules/.tsbuildinfo && npx vite build\"\n\ntest -f apps/web/dist/index.html\necho \"Frontend build complete\"\n" + - name: Setup buildx builder (docker-container driver) + shell: sh + run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" + - name: Build and push Web image (buildx cache) + shell: sh + run: 'set -eu + + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + + IMAGE_NAME="xiaoxia-saas-web" + + VERSION="${GITHUB_REF_NAME}" + + CACHE_REF="${REGISTRY}/web-cache:main" + + NGINX_CONF="infra/docker/nginx-production.conf" + + + echo "Building Production Web image: ${VERSION}" + + docker buildx build --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f infra/docker/web-artifact.Dockerfile --build-arg "NGINX_CONF=${NGINX_CONF}" -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push . + + echo "Production Web image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}" + + ' - name: Cleanup old Docker images if: always() shell: sh - run: | - set -eu - if [ -f scripts/cleanup_old_images.sh ]; then - chmod +x scripts/cleanup_old_images.sh - scripts/cleanup_old_images.sh - else - echo "Cleanup script not found, doing basic prune..." - docker image prune -f 2>/dev/null || true - fi - echo "Disk usage after cleanup:" - df -h / | tail -1 - - - name: Notify CI failure + run: "set -eu\nif [ -f scripts/cleanup_old_images.sh ]; then\n chmod +x scripts/cleanup_old_images.sh\n scripts/cleanup_old_images.sh\nelse\n echo \"Cleanup script not found, doing basic prune...\"\n docker image prune -f 2>/dev/null || true\nfi\necho \"Disk usage after cleanup:\"\ndf -h / | tail -1\n" + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true if: failure() shell: sh - run: | - set +e - echo "=== CI 失败通知 ===" - FAILED_JOB="Build Production Runtime Images" python3 scripts/ci_notify_failure.py + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Build Production Web Image" python3 scripts/ci_notify.py + ' deploy-production: name: Deploy Production runs-on: saas - timeout-minutes: 20 + timeout-minutes: 30 if: startsWith(github.ref, 'refs/tags/v') - needs: build-production-runtime-images - + needs: + - build-production-api + - build-production-worker + - build-production-web steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: "set -eu\npython3 - <<'INNERPY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ + )\n time.sleep(wait)\n continue\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nINNERPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' + - name: Notify job start + continue-on-error: true + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=start JOB_NAME="Deploy Production" python3 scripts/ci_notify.py + + ' - name: Install SSH client shell: sh - run: | - set -eu + 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 (Registry pull) shell: sh env: @@ -1001,140 +1055,96 @@ jobs: PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -eux - production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}" - production_user="${PRODUCTION_SSH_USER:-root}" - echo "Host: $production_host" - echo "User: $production_user" - echo "Home: $HOME" - echo "Whoami: $(whoami)" - - mkdir -p ~/.ssh - - # 查找可用的SSH密钥 - key_path="" - if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then - key_path="/root/.ssh/xiaoxia_runtime_builder" - echo "Using key: $key_path (builder key)" - elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then - key_path="$HOME/.ssh/xiaoxia_runtime_builder" - echo "Using key: $key_path (home key)" - 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 key from PRODUCTION_SSH_KEY secret" - else - echo "ERROR: No SSH key available" - ls -la ~/.ssh/ 2>/dev/null || true - ls -la /root/.ssh/ 2>/dev/null || true - exit 1 - fi - - ssh-keyscan -p 22222 -H "$production_host" >> ~/.ssh/known_hosts 2>/dev/null - echo "SSH keyscan done" - - # 测试SSH连接 - ssh -p 22222 -i "$key_path" -o StrictHostKeyChecking=no "${production_user}@${production_host}" "echo SSH_CONNECTION_OK && hostname" - echo "SSH connection verified" - - # Registry 方式部署脚本(base64 编码避免转义问题) - DEPLOY_B64="IyEvYmluL3NoCnNldCAtZXUKCiMgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0KIyBQcm9kdWN0aW9uIOmDqOe9suiEmuacrCAtIFJlZ2lzdHJ5IOaWueW8jwojIOeUqOazle+8mklNQUdFX1RBRz08dmVyc2lvbj4gUkVHSVNUUllfVE9LRU49PHRva2VuPiBzaCBkZXBsb3ktcHJvZHVjdGlvbi1yZWdpc3RyeS5zaAojID09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CgpJTUFHRV9UQUc9IiR7SU1BR0VfVEFHOi19IgpSRUdJU1RSWT0iJHtSRUdJU1RSWTotZ2l0LnhpYW94aWFqaWFuamkuY29tL3hpYW94aWEveGlhb3hpYS1zYWFzfSIKUkVHSVNUUllfVVNFUj0iJHtSRUdJU1RSWV9VU0VSOi14aWFveGlhfSIKUkVHSVNUUllfVE9LRU49IiR7UkVHSVNUUllfVE9LRU46LX0iCgpFTlZfRklMRT0iJHtFTlZfRklMRTotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vLmVudn0iCkdFTkVSQVRFRF9ESVI9IiR7R0VORVJBVEVEX0RJUjotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vZ2VuZXJhdGVkfSIKTEVHQUNZX0FTU0VUU19ESVI9IiR7TEVHQUNZX0FTU0VUU19ESVI6LS92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL2xlZ2FjeS1hc3NldHN9IgoKaWYgWyAteiAiJElNQUdFX1RBRyIgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBJTUFHRV9UQUcgaXMgcmVxdWlyZWQiCiAgZXhpdCAxCmZpCgp0ZXN0IC1mICIkRU5WX0ZJTEUiCm1rZGlyIC1wICIkR0VORVJBVEVEX0RJUiIKbWtkaXIgLXAgIiRMRUdBQ1lfQVNTRVRTX0RJUiIKCiMgLS0tLSDnmbvlvZUgUmVnaXN0cnkgLS0tLQppZiBbIC1uICIkUkVHSVNUUllfVE9LRU4iIF07IHRoZW4KICBlY2hvICJMb2dnaW5nIGluIHRvIHJlZ2lzdHJ5OiAkUkVHSVNUUlkiCiAgUkVHSVNUUllfSE9TVD0kKGVjaG8gIiRSRUdJU1RSWSIgfCBjdXQgLWQvIC1mMSkKICBwcmludGYgJXMgIiRSRUdJU1RSWV9UT0tFTiIgfCBkb2NrZXIgbG9naW4gIiRSRUdJU1RSWV9IT1NUIiAtdSAiJFJFR0lTVFJZX1VTRVIiIC0tcGFzc3dvcmQtc3RkaW4gMj4vZGV2L251bGwgfHwgewogICAgZWNobyAiV0FSTjogZG9ja2VyIGxvZ2luIGZhaWxlZCwgd2lsbCB0cnkgdG8gcHVsbCBhbnl3YXkiCiAgfQpmaQoKIyAtLS0tIFB1bGwg5LiJ6ZWc5YOPIC0tLS0KUkVHSVNUUllfQVBJPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtYXBpOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV09SS0VSPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd29ya2VyOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV0VCPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd2ViOiR7SU1BR0VfVEFHfSIKCkxPQ0FMX0FQST0ieGlhb3hpYS1zYWFzLWFwaToke0lNQUdFX1RBR30iCkxPQ0FMX1dPUktFUj0ieGlhb3hpYS1zYWFzLXdvcmtlcjoke0lNQUdFX1RBR30iCkxPQ0FMX1dFQj0ieGlhb3hpYS1zYWFzLXdlYjoke0lNQUdFX1RBR30iCgplY2hvICJQdWxsaW5nIEFQSSBpbWFnZS4uLiIKZG9ja2VyIHB1bGwgIiRSRUdJU1RSWV9BUEkiCmVjaG8gIlB1bGxpbmcgV29ya2VyIGltYWdlLi4uIgpkb2NrZXIgcHVsbCAiJFJFR0lTVFJZX1dPUktFUiIKZWNobyAiUHVsbGluZyBXZWIgaW1hZ2UuLi4iCmRvY2tlciBwdWxsICIkUkVHSVNUUllfV0VCIgoKIyAtLS0tIFJlLXRhZyDmiJDmnKzlnLDlkI0gLS0tLQpkb2NrZXIgdGFnICIkUkVHSVNUUllfQVBJIiAiJExPQ0FMX0FQSSIKZG9ja2VyIHRhZyAiJFJFR0lTVFJZX1dPUktFUiIgIiRMT0NBTF9XT1JLRVIiCmRvY2tlciB0YWcgIiRSRUdJU1RSWV9XRUIiICIkTE9DQUxfV0VCIgplY2hvICJBbGwgaW1hZ2VzIHB1bGxlZCBhbmQgdGFnZ2VkLiIKCiMgLS0tLSDlpIfku73ml6fniYggYXNzZXRz77yI6YOo572y5pyf6Ze057yT5a2Y55So5oi35LiNIDQwNO+8iSAtLS0tCmVjaG8gIkJhY2tpbmcgdXAgbGVnYWN5IGFzc2V0cyBmcm9tIGN1cnJlbnQgd2ViIGNvbnRhaW5lci4uLiIKaWYgZG9ja2VyIGluc3BlY3QgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBfdG1wZGlyPSIvdG1wL2xlZ2FjeS1hc3NldHMtJCQiCiAgcm0gLXJmICIkX3RtcGRpciIKICBta2RpciAtcCAiJF90bXBkaXIiCiAgZG9ja2VyIGNwIHhpYW94aWEtd2ViLXByb2R1Y3Rpb246L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy8uICIkX3RtcGRpci8iIDI+L2Rldi9udWxsIHx8IHRydWUKICAjIOWQiOW5tuWIsCBMRUdBQ1lfQVNTRVRTX0RJUu+8iOS/neeVmeaJgOacieWOhuWPsueJiOacrOeahCBhc3NldHPvvIkKICBpZiBbIC1kICIkX3RtcGRpciIgXSAmJiBbICIkKGxzIC1BICIkX3RtcGRpciIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgICBjcCAtYW4gIiRfdG1wZGlyIi8uICIkTEVHQUNZX0FTU0VUU19ESVIiLyAyPi9kZXYvbnVsbCB8fCB0cnVlCiAgICBlY2hvICJMZWdhY3kgYXNzZXRzIGJhY2tlZCB1cDogJChscyAiJF90bXBkaXIiIHwgd2MgLWwpIGZpbGVzIgogIGZpCiAgcm0gLXJmICIkX3RtcGRpciIKZWxzZQogIGVjaG8gIk5vIGV4aXN0aW5nIHdlYiBjb250YWluZXIsIHNraXBwaW5nIGxlZ2FjeSBhc3NldHMgYmFja3VwIgpmaQoKIyDmuIXnkIbotoXov4cgNyDlpKnnmoTml6cgYXNzZXRzIOaWh+S7tu+8iOmBv+WFjeaXoOmZkOWinumVv++8iQppZiBbIC1kICIkTEVHQUNZX0FTU0VUU19ESVIiIF07IHRoZW4KICBmaW5kICIkTEVHQUNZX0FTU0VUU19ESVIiIC10eXBlIGYgLW10aW1lICs3IC1kZWxldGUgMj4vZGV2L251bGwgfHwgdHJ1ZQogIGVjaG8gIkxlZ2FjeSBhc3NldHMgY2xlYW51cCBkb25lIChyZXRhaW4gNyBkYXlzKSIKZmkKCiMgLS0tLSDnoa7kv53ln7rnoYDorr7mlr3lrrnlmajlnKjov5DooYwgLS0tLQplY2hvICJDaGVja2luZyBpbmZyYXN0cnVjdHVyZSBjb250YWluZXJzLi4uIgpmb3IgYyBpbiB4aWFveGlhLXBvc3RncmVzLXByb2R1Y3Rpb24geGlhb3hpYS1yZWRpcy1wcm9kdWN0aW9uOyBkbwogIGlmICEgZG9ja2VyIGluc3BlY3QgIiRjIiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIkVSUk9SOiBSZXF1aXJlZCBjb250YWluZXIgbm90IGZvdW5kOiAkYyIKICAgIGV4aXQgMQogIGZpCiAgc3RhdGU9JChkb2NrZXIgaW5zcGVjdCAtZiAne3suU3RhdGUuU3RhdHVzfX0nICIkYyIpCiAgaWYgWyAiJHN0YXRlIiAhPSAicnVubmluZyIgXTsgdGhlbgogICAgZWNobyAiRVJST1I6IENvbnRhaW5lciBub3QgcnVubmluZzogJGMgKCRzdGF0ZSkiCiAgICBleGl0IDEKICBmaQpkb25lCgojIC0tLS0g56Gu5L+d55Sf5Lqn572R57uc5a2Y5ZyoIC0tLS0KZG9ja2VyIG5ldHdvcmsgY3JlYXRlIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQoKIyAtLS0tIOaJp+ihjOaVsOaNruW6kyBNaWdyYXRpb24gLS0tLQplY2hvICJSdW5uaW5nIGRhdGFiYXNlIG1pZ3JhdGlvbnMuLi4iCmRvY2tlciBydW4gLS1ybSBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgIiRMT0NBTF9BUEkiIHNoIC1jICJjZCAvYXBwICYmIGFsZW1iaWMgdXBncmFkZSBoZWFkIgplY2hvICJNaWdyYXRpb25zIGNvbXBsZXRlZC4iCgojIC0tLS0g5YGc5q2i5pen5a655ZmoIC0tLS0KZWNobyAiU3RvcHBpbmcgb2xkIGNvbnRhaW5lcnMuLi4iCmRvY2tlciBybSAtZiB4aWFveGlhLWFwaS1wcm9kdWN0aW9uIDI+L2Rldi9udWxsIHx8IHRydWUKZG9ja2VyIHJtIC1mIHhpYW94aWEtd29ya2VyLXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgcm0gLWYgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiAyPi9kZXYvbnVsbCB8fCB0cnVlCgojIC0tLS0g5pel5b+X6YWN572u77yI5omA5pyJ5a655Zmo5YWx55So77yJIC0tLS0KTE9HX09QVFM9Ii0tbG9nLWRyaXZlciBqc29uLWZpbGUgLS1sb2ctb3B0IG1heC1zaXplPTUwbSAtLWxvZy1vcHQgbWF4LWZpbGU9MyIKCiMgLS0tLSDlkK/liqggQVBJIC0tLS0KZWNobyAiU3RhcnRpbmcgQVBJIGNvbnRhaW5lci4uLiIKZG9ja2VyIHJ1biAtZCBcCiAgLS1uYW1lIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24gXAogIC0tZW52LWZpbGUgIiRFTlZfRklMRSIgXAogIC0tbmV0d29yayB4aWFveGlhLW5ldC1wcm9kdWN0aW9uIFwKICAtcCAxMjcuMC4wLjE6ODAwMTo4MDAwIFwKICAtZSBBUFBfRU5WPXByb2R1Y3Rpb24gXAogIC1lIEFQUF9WRVJTSU9OPSIkSU1BR0VfVEFHIiBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX0RJUj0vYXBwL2dlbmVyYXRlZCBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX1VSTF9QUkVGSVg9L2dlbmVyYXRlZC1maWxlcyBcCiAgLWUgUFVCTElDX0FQSV9CQVNFX1VSTD1odHRwczovL2FwaS54aWFveGlhamlhbmppLmNvbSBcCiAgLXYgIiRHRU5FUkFURURfRElSOi9hcHAvZ2VuZXJhdGVkIiBcCiAgLS1yZXN0YXJ0IHVubGVzcy1zdG9wcGVkIFwKICAtLWNwdXMgMiBcCiAgLS1tZW1vcnkgMmcgXAogIC0taGVhbHRoLWNtZCAicHl0aG9uIC1jIFwiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJywgdGltZW91dD01KVwiIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDEwcyBcCiAgLS1oZWFsdGgtcmV0cmllcyAzIFwKICAtLWhlYWx0aC1zdGFydC1wZXJpb2QgNDBzIFwKICAkTE9HX09QVFMgXAogICIkTE9DQUxfQVBJIgoKIyAtLS0tIOWQr+WKqCBXb3JrZXIgLS0tLQplY2hvICJTdGFydGluZyBXb3JrZXIgY29udGFpbmVyLi4uIgpkb2NrZXIgcnVuIC1kIFwKICAtLW5hbWUgeGlhb3hpYS13b3JrZXItcHJvZHVjdGlvbiBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgLWUgQVBQX1ZFUlNJT049IiRJTUFHRV9UQUciIFwKICAtZSBXT1JLRVJfQ09OQ1VSUkVOQ1k9MSBcCiAgLWUgV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9MTAwIFwKICAtZSBHRU5FUkFURURfRklMRVNfRElSPS9hcHAvZ2VuZXJhdGVkIFwKICAtZSBHRU5FUkFURURfRklMRVNfVVJMX1BSRUZJWD0vZ2VuZXJhdGVkLWZpbGVzIFwKICAtZSBQVUJMSUNfQVBJX0JBU0VfVVJMPWh0dHBzOi8vYXBpLnhpYW94aWFqaWFuamkuY29tIFwKICAtdiAiJEdFTkVSQVRFRF9ESVI6L2FwcC9nZW5lcmF0ZWQiIFwKICAtLXJlc3RhcnQgdW5sZXNzLXN0b3BwZWQgXAogIC0tY3B1cyAyIFwKICAtLW1lbW9yeSAyZyBcCiAgLS1oZWFsdGgtY21kICJzaCAtYyBcImdyZXAgLXEgY2VsZXJ5IC9wcm9jLzEvY21kbGluZSB8fCBleGl0IDFcIiIgXAogIC0taGVhbHRoLWludGVydmFsIDMwcyBcCiAgLS1oZWFsdGgtdGltZW91dCAxMHMgXAogIC0taGVhbHRoLXJldHJpZXMgMyBcCiAgLS1oZWFsdGgtc3RhcnQtcGVyaW9kIDMwcyBcCiAgJExPR19PUFRTIFwKICAiJExPQ0FMX1dPUktFUiIKCiMgLS0tLSDlkK/liqggV2ViIC0tLS0KIyBMZWdhY3kgYXNzZXRzIOaMgui9veWIsCAvdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLWxlZ2FjeS9hc3NldHMvCiMgbmdpbngg6YWN572u5LitIGFzc2V0cyBsb2NhdGlvbiDmnIkgZmFsbGJhY2sg6YC76L6RCkxFR0FDWV9WT0xVTUU9IiIKaWYgWyAtZCAiJExFR0FDWV9BU1NFVFNfRElSIiBdICYmIFsgIiQobHMgLUEgIiRMRUdBQ1lfQVNTRVRTX0RJUiIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgTEVHQUNZX1ZPTFVNRT0iLXYgJHtMRUdBQ1lfQVNTRVRTX0RJUn06L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy1sZWdhY3kvYXNzZXRzOnJvIgogIGVjaG8gIldlYiBjb250YWluZXI6IGxlZ2FjeSBhc3NldHMgbW91bnRlZCAoZmFsbGJhY2spIgplbHNlCiAgZWNobyAiV2ViIGNvbnRhaW5lcjogbm8gbGVnYWN5IGFzc2V0cyB0byBtb3VudCIKZmkKCmVjaG8gIlN0YXJ0aW5nIFdlYiBjb250YWluZXIuLi4iCmRvY2tlciBydW4gLWQgXAogIC0tbmFtZSB4aWFveGlhLXdlYi1wcm9kdWN0aW9uIFwKICAtLW5ldHdvcmsgeGlhb3hpYS1uZXQtcHJvZHVjdGlvbiBcCiAgLXAgMTI3LjAuMC4xOjMwMDI6ODAgXAogIC0tcmVzdGFydCB1bmxlc3Mtc3RvcHBlZCBcCiAgLS1jcHVzIDAuNSBcCiAgLS1tZW1vcnkgNTEybSBcCiAgJExFR0FDWV9WT0xVTUUgXAogIC0taGVhbHRoLWNtZCAid2dldCAtLXNwaWRlciAtcSBodHRwOi8vMTI3LjAuMC4xOjgwIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDVzIFwKICAtLWhlYWx0aC1yZXRyaWVzIDMgXAogICRMT0dfT1BUUyBcCiAgIiRMT0NBTF9XRUIiCgojIC0tLS0g562J5b6FIEFQSSDlgaXlurcgLS0tLQplY2hvICJXYWl0aW5nIGZvciBBUEkgdG8gYmVjb21lIGhlYWx0aHkuLi4iCmk9MAp3aGlsZSBbICIkaSIgLWx0IDQwIF07IGRvCiAgaWYgY3VybCAtc2YgLS1tYXgtdGltZSA1IGh0dHA6Ly8xMjcuMC4wLjE6ODAwMS9oZWFsdGggPi9kZXYvbnVsbCAyPiYxOyB0aGVuCiAgICBlY2hvICJBUEkgaXMgaGVhbHRoeSEiCiAgICBicmVhawogIGZpCiAgaT0kKChpICsgMSkpCiAgZWNobyAiICBXYWl0aW5nLi4uICgkaS80MCkiCiAgc2xlZXAgMwpkb25lCgppZiBbICIkaSIgLWdlIDQwIF07IHRoZW4KICBlY2hvICJFUlJPUjogQVBJIGRpZCBub3QgYmVjb21lIGhlYWx0aHkgd2l0aGluIDEyMHMiCiAgZG9ja2VyIGxvZ3MgLS10YWlsIDUwIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24KICBleGl0IDEKZmkKCiMgLS0tLSDnrYnlvoUgV2ViIOWBpeW6tyAtLS0tCmVjaG8gIldhaXRpbmcgZm9yIFdlYiB0byBiZWNvbWUgaGVhbHRoeS4uLiIKaT0wCndoaWxlIFsgIiRpIiAtbHQgMTUgXTsgZG8KICBpZiBjdXJsIC1zZiAtLW1heC10aW1lIDUgaHR0cDovLzEyNy4wLjAuMTozMDAyLyA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIldlYiBpcyBoZWFsdGh5ISIKICAgIGJyZWFrCiAgZmkKICBpPSQoKGkgKyAxKSkKICBlY2hvICIgIFdhaXRpbmcuLi4gKCRpLzE1KSIKICBzbGVlcCAyCmRvbmUKCmlmIFsgIiRpIiAtZ2UgMTUgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBXZWIgZGlkIG5vdCBiZWNvbWUgaGVhbHRoeSB3aXRoaW4gMzBzIgogIGRvY2tlciBsb2dzIC0tdGFpbCAzMCB4aWFveGlhLXdlYi1wcm9kdWN0aW9uCiAgZXhpdCAxCmZpCgojIC0tLS0g5riF55CG5pen6ZWc5YOPIC0tLS0KZWNobyAiQ2xlYW5pbmcgdXAgb2xkIGltYWdlcy4uLiIKZG9ja2VyIGltYWdlIHBydW5lIC1hZiAtLWZpbHRlciAidW50aWw9MTY4aCIgMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgYnVpbGRlciBwcnVuZSAtYWYgLS1maWx0ZXIgInVudGlsPTE2OGgiIDI+L2Rldi9udWxsIHx8IHRydWUKCmVjaG8gIiIKZWNobyAiPT09IFByb2R1Y3Rpb24gZGVwbG95bWVudCBjb21wbGV0ZSA9PT0iCmVjaG8gIkFQSTogICAgICBodHRwOi8vMTI3LjAuMC4xOjgwMDEiCmVjaG8gIldlYjogICAgICBodHRwOi8vMTI3LjAuMC4xOjMwMDIiCmVjaG8gIlZlcnNpb246ICAkSU1BR0VfVEFHIgpkb2NrZXIgcHMgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319XHR7ey5JbWFnZX19IiB8IGdyZXAgcHJvZHVjdGlvbgo=" - - echo "$DEPLOY_B64" | base64 -d | ssh -p 22222 -i "$key_path" "$production_user@$production_host" "IMAGE_TAG='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh" - - - name: Notify CI success + run: "set -eux\nproduction_host=\"${PRODUCTION_SSH_HOST:-47.98.113.167}\"\nproduction_user=\"${PRODUCTION_SSH_USER:-root}\"\necho \"Host: $production_host\"\necho \"User: $production_user\"\necho \"Home: $HOME\"\necho \"Whoami: $(whoami)\"\n\nmkdir -p ~/.ssh\n\n# 查找可用的SSH密钥\nkey_path=\"\"\nif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then\n key_path=\"/root/.ssh/xiaoxia_runtime_builder\"\n echo \"Using key: $key_path (builder key)\"\nelif [ -f \"$HOME/.ssh/xiaoxia_runtime_builder\" ]; then\n key_path=\"$HOME/.ssh/xiaoxia_runtime_builder\"\n echo \"Using key: $key_path (home key)\"\nelif [ -n \"${PRODUCTION_SSH_KEY:-}\" ]; then\n key_path=\"$HOME/.ssh/id_ed25519\"\n printf '%s\\n' \"$PRODUCTION_SSH_KEY\" > \"$key_path\"\n chmod 600 \"$key_path\"\n echo \"Using key from PRODUCTION_SSH_KEY secret\"\nelse\n echo \"ERROR: No SSH key available\"\n ls -la ~/.ssh/ 2>/dev/null || true\n ls -la /root/.ssh/ 2>/dev/null || true\n exit 1\nfi\n\nssh-keyscan -p 22222 -H \"$production_host\"\ + \ >> ~/.ssh/known_hosts 2>/dev/null\necho \"SSH keyscan done\"\n\n# 测试SSH连接\nssh -p 22222 -i \"$key_path\" -o StrictHostKeyChecking=no \"${production_user}@${production_host}\" \"echo SSH_CONNECTION_OK && hostname\"\necho \"SSH connection verified\"\n\n# Registry 方式部署脚本(base64 编码避免转义问题)\nDEPLOY_B64=\"IyEvYmluL3NoCnNldCAtZXUKCiMgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0KIyBQcm9kdWN0aW9uIOmDqOe9suiEmuacrCAtIFJlZ2lzdHJ5IOaWueW8jwojIOeUqOazle+8mklNQUdFX1RBRz08dmVyc2lvbj4gUkVHSVNUUllfVE9LRU49PHRva2VuPiBzaCBkZXBsb3ktcHJvZHVjdGlvbi1yZWdpc3RyeS5zaAojID09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CgpJTUFHRV9UQUc9IiR7SU1BR0VfVEFHOi19IgpSRUdJU1RSWT0iJHtSRUdJU1RSWTotZ2l0LnhpYW94aWFqaWFuamkuY29tL3hpYW94aWEveGlhb3hpYS1zYWFzfSIKUkVHSVNUUllfVVNFUj0iJHtSRUdJU1RSWV9VU0VSOi14aWFveGlhfSIKUkVHSVNUUllfVE9LRU49IiR7UkVHSVNUUllfVE9LRU46LX0iCgpFTlZfRklMRT0iJHtFTlZfRklMRTotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vLmVudn0iCkdFTkVSQVRFRF9ESVI9IiR7R0VORVJBVEVEX0RJUjotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vZ2VuZXJhdGVkfSIKTEVHQUNZX0FTU0VUU19ESVI9IiR7TEVHQUNZX0FTU0VUU19ESVI6LS92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL2xlZ2FjeS1hc3NldHN9IgoKaWYgWyAteiAiJElNQUdFX1RBRyIgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBJTUFHRV9UQUcgaXMgcmVxdWlyZWQiCiAgZXhpdCAxCmZpCgp0ZXN0IC1mICIkRU5WX0ZJTEUiCm1rZGlyIC1wICIkR0VORVJBVEVEX0RJUiIKbWtkaXIgLXAgIiRMRUdBQ1lfQVNTRVRTX0RJUiIKCiMgLS0tLSDnmbvlvZUgUmVnaXN0cnkgLS0tLQppZiBbIC1uICIkUkVHSVNUUllfVE9LRU4iIF07IHRoZW4KICBlY2hvICJMb2dnaW5nIGluIHRvIHJlZ2lzdHJ5OiAkUkVHSVNUUlkiCiAgUkVHSVNUUllfSE9TVD0kKGVjaG8gIiRSRUdJU1RSWSIgfCBjdXQgLWQvIC1mMSkKICBwcmludGYgJXMgIiRSRUdJU1RSWV9UT0tFTiIgfCBkb2NrZXIgbG9naW4gIiRSRUdJU1RSWV9IT1NUIiAtdSAiJFJFR0lTVFJZX1VTRVIiIC0tcGFzc3dvcmQtc3RkaW4gMj4vZGV2L251bGwgfHwgewogICAgZWNobyAiV0FSTjogZG9ja2VyIGxvZ2luIGZhaWxlZCwgd2lsbCB0cnkgdG8gcHVsbCBhbnl3YXkiCiAgfQpmaQoKIyAtLS0tIFB1bGwg5LiJ6ZWc5YOPIC0tLS0KUkVHSVNUUllfQVBJPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtYXBpOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV09SS0VSPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd29ya2VyOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV0VCPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd2ViOiR7SU1BR0VfVEFHfSIKCkxPQ0FMX0FQST0ieGlhb3hpYS1zYWFzLWFwaToke0lNQUdFX1RBR30iCkxPQ0FMX1dPUktFUj0ieGlhb3hpYS1zYWFzLXdvcmtlcjoke0lNQUdFX1RBR30iCkxPQ0FMX1dFQj0ieGlhb3hpYS1zYWFzLXdlYjoke0lNQUdFX1RBR30iCgplY2hvICJQdWxsaW5nIEFQSSBpbWFnZS4uLiIKZG9ja2VyIHB1bGwgIiRSRUdJU1RSWV9BUEkiCmVjaG8gIlB1bGxpbmcgV29ya2VyIGltYWdlLi4uIgpkb2NrZXIgcHVsbCAiJFJFR0lTVFJZX1dPUktFUiIKZWNobyAiUHVsbGluZyBXZWIgaW1hZ2UuLi4iCmRvY2tlciBwdWxsICIkUkVHSVNUUllfV0VCIgoKIyAtLS0tIFJlLXRhZyDmiJDmnKzlnLDlkI0gLS0tLQpkb2NrZXIgdGFnICIkUkVHSVNUUllfQVBJIiAiJExPQ0FMX0FQSSIKZG9ja2VyIHRhZyAiJFJFR0lTVFJZX1dPUktFUiIgIiRMT0NBTF9XT1JLRVIiCmRvY2tlciB0YWcgIiRSRUdJU1RSWV9XRUIiICIkTE9DQUxfV0VCIgplY2hvICJBbGwgaW1hZ2VzIHB1bGxlZCBhbmQgdGFnZ2VkLiIKCiMgLS0tLSDlpIfku73ml6fniYggYXNzZXRz77yI6YOo572y5pyf6Ze057yT5a2Y55So5oi35LiNIDQwNO+8iSAtLS0tCmVjaG8gIkJhY2tpbmcgdXAgbGVnYWN5IGFzc2V0cyBmcm9tIGN1cnJlbnQgd2ViIGNvbnRhaW5lci4uLiIKaWYgZG9ja2VyIGluc3BlY3QgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBfdG1wZGlyPSIvdG1wL2xlZ2FjeS1hc3NldHMtJCQiCiAgcm0gLXJmICIkX3RtcGRpciIKICBta2RpciAtcCAiJF90bXBkaXIiCiAgZG9ja2VyIGNwIHhpYW94aWEtd2ViLXByb2R1Y3Rpb246L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy8uICIkX3RtcGRpci8iIDI+L2Rldi9udWxsIHx8IHRydWUKICAjIOWQiOW5tuWIsCBMRUdBQ1lfQVNTRVRTX0RJUu+8iOS/neeVmeaJgOacieWOhuWPsueJiOacrOeahCBhc3NldHPvvIkKICBpZiBbIC1kICIkX3RtcGRpciIgXSAmJiBbICIkKGxzIC1BICIkX3RtcGRpciIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgICBjcCAtYW4gIiRfdG1wZGlyIi8uICIkTEVHQUNZX0FTU0VUU19ESVIiLyAyPi9kZXYvbnVsbCB8fCB0cnVlCiAgICBlY2hvICJMZWdhY3kgYXNzZXRzIGJhY2tlZCB1cDogJChscyAiJF90bXBkaXIiIHwgd2MgLWwpIGZpbGVzIgogIGZpCiAgcm0gLXJmICIkX3RtcGRpciIKZWxzZQogIGVjaG8gIk5vIGV4aXN0aW5nIHdlYiBjb250YWluZXIsIHNraXBwaW5nIGxlZ2FjeSBhc3NldHMgYmFja3VwIgpmaQoKIyDmuIXnkIbotoXov4cgNyDlpKnnmoTml6cgYXNzZXRzIOaWh+S7tu+8iOmBv+WFjeaXoOmZkOWinumVv++8iQppZiBbIC1kICIkTEVHQUNZX0FTU0VUU19ESVIiIF07IHRoZW4KICBmaW5kICIkTEVHQUNZX0FTU0VUU19ESVIiIC10eXBlIGYgLW10aW1lICs3IC1kZWxldGUgMj4vZGV2L251bGwgfHwgdHJ1ZQogIGVjaG8gIkxlZ2FjeSBhc3NldHMgY2xlYW51cCBkb25lIChyZXRhaW4gNyBkYXlzKSIKZmkKCiMgLS0tLSDnoa7kv53ln7rnoYDorr7mlr3lrrnlmajlnKjov5DooYwgLS0tLQplY2hvICJDaGVja2luZyBpbmZyYXN0cnVjdHVyZSBjb250YWluZXJzLi4uIgpmb3IgYyBpbiB4aWFveGlhLXBvc3RncmVzLXByb2R1Y3Rpb24geGlhb3hpYS1yZWRpcy1wcm9kdWN0aW9uOyBkbwogIGlmICEgZG9ja2VyIGluc3BlY3QgIiRjIiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIkVSUk9SOiBSZXF1aXJlZCBjb250YWluZXIgbm90IGZvdW5kOiAkYyIKICAgIGV4aXQgMQogIGZpCiAgc3RhdGU9JChkb2NrZXIgaW5zcGVjdCAtZiAne3suU3RhdGUuU3RhdHVzfX0nICIkYyIpCiAgaWYgWyAiJHN0YXRlIiAhPSAicnVubmluZyIgXTsgdGhlbgogICAgZWNobyAiRVJST1I6IENvbnRhaW5lciBub3QgcnVubmluZzogJGMgKCRzdGF0ZSkiCiAgICBleGl0IDEKICBmaQpkb25lCgojIC0tLS0g56Gu5L+d55Sf5Lqn572R57uc5a2Y5ZyoIC0tLS0KZG9ja2VyIG5ldHdvcmsgY3JlYXRlIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQoKIyAtLS0tIOaJp+ihjOaVsOaNruW6kyBNaWdyYXRpb24gLS0tLQplY2hvICJSdW5uaW5nIGRhdGFiYXNlIG1pZ3JhdGlvbnMuLi4iCmRvY2tlciBydW4gLS1ybSBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgIiRMT0NBTF9BUEkiIHNoIC1jICJjZCAvYXBwICYmIGFsZW1iaWMgdXBncmFkZSBoZWFkIgplY2hvICJNaWdyYXRpb25zIGNvbXBsZXRlZC4iCgojIC0tLS0g5YGc5q2i5pen5a655ZmoIC0tLS0KZWNobyAiU3RvcHBpbmcgb2xkIGNvbnRhaW5lcnMuLi4iCmRvY2tlciBybSAtZiB4aWFveGlhLWFwaS1wcm9kdWN0aW9uIDI+L2Rldi9udWxsIHx8IHRydWUKZG9ja2VyIHJtIC1mIHhpYW94aWEtd29ya2VyLXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgcm0gLWYgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiAyPi9kZXYvbnVsbCB8fCB0cnVlCgojIC0tLS0g5pel5b+X6YWN572u77yI5omA5pyJ5a655Zmo5YWx55So77yJIC0tLS0KTE9HX09QVFM9Ii0tbG9nLWRyaXZlciBqc29uLWZpbGUgLS1sb2ctb3B0IG1heC1zaXplPTUwbSAtLWxvZy1vcHQgbWF4LWZpbGU9MyIKCiMgLS0tLSDlkK/liqggQVBJIC0tLS0KZWNobyAiU3RhcnRpbmcgQVBJIGNvbnRhaW5lci4uLiIKZG9ja2VyIHJ1biAtZCBcCiAgLS1uYW1lIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24gXAogIC0tZW52LWZpbGUgIiRFTlZfRklMRSIgXAogIC0tbmV0d29yayB4aWFveGlhLW5ldC1wcm9kdWN0aW9uIFwKICAtcCAxMjcuMC4wLjE6ODAwMTo4MDAwIFwKICAtZSBBUFBfRU5WPXByb2R1Y3Rpb24gXAogIC1lIEFQUF9WRVJTSU9OPSIkSU1BR0VfVEFHIiBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX0RJUj0vYXBwL2dlbmVyYXRlZCBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX1VSTF9QUkVGSVg9L2dlbmVyYXRlZC1maWxlcyBcCiAgLWUgUFVCTElDX0FQSV9CQVNFX1VSTD1odHRwczovL2FwaS54aWFveGlhamlhbmppLmNvbSBcCiAgLXYgIiRHRU5FUkFURURfRElSOi9hcHAvZ2VuZXJhdGVkIiBcCiAgLS1yZXN0YXJ0IHVubGVzcy1zdG9wcGVkIFwKICAtLWNwdXMgMiBcCiAgLS1tZW1vcnkgMmcgXAogIC0taGVhbHRoLWNtZCAicHl0aG9uIC1jIFwiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJywgdGltZW91dD01KVwiIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDEwcyBcCiAgLS1oZWFsdGgtcmV0cmllcyAzIFwKICAtLWhlYWx0aC1zdGFydC1wZXJpb2QgNDBzIFwKICAkTE9HX09QVFMgXAogICIkTE9DQUxfQVBJIgoKIyAtLS0tIOWQr+WKqCBXb3JrZXIgLS0tLQplY2hvICJTdGFydGluZyBXb3JrZXIgY29udGFpbmVyLi4uIgpkb2NrZXIgcnVuIC1kIFwKICAtLW5hbWUgeGlhb3hpYS13b3JrZXItcHJvZHVjdGlvbiBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgLWUgQVBQX1ZFUlNJT049IiRJTUFHRV9UQUciIFwKICAtZSBXT1JLRVJfQ09OQ1VSUkVOQ1k9MSBcCiAgLWUgV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9MTAwIFwKICAtZSBHRU5FUkFURURfRklMRVNfRElSPS9hcHAvZ2VuZXJhdGVkIFwKICAtZSBHRU5FUkFURURfRklMRVNfVVJMX1BSRUZJWD0vZ2VuZXJhdGVkLWZpbGVzIFwKICAtZSBQVUJMSUNfQVBJX0JBU0VfVVJMPWh0dHBzOi8vYXBpLnhpYW94aWFqaWFuamkuY29tIFwKICAtdiAiJEdFTkVSQVRFRF9ESVI6L2FwcC9nZW5lcmF0ZWQiIFwKICAtLXJlc3RhcnQgdW5sZXNzLXN0b3BwZWQgXAogIC0tY3B1cyAyIFwKICAtLW1lbW9yeSAyZyBcCiAgLS1oZWFsdGgtY21kICJzaCAtYyBcImdyZXAgLXEgY2VsZXJ5IC9wcm9jLzEvY21kbGluZSB8fCBleGl0IDFcIiIgXAogIC0taGVhbHRoLWludGVydmFsIDMwcyBcCiAgLS1oZWFsdGgtdGltZW91dCAxMHMgXAogIC0taGVhbHRoLXJldHJpZXMgMyBcCiAgLS1oZWFsdGgtc3RhcnQtcGVyaW9kIDMwcyBcCiAgJExPR19PUFRTIFwKICAiJExPQ0FMX1dPUktFUiIKCiMgLS0tLSDlkK/liqggV2ViIC0tLS0KIyBMZWdhY3kgYXNzZXRzIOaMgui9veWIsCAvdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLWxlZ2FjeS9hc3NldHMvCiMgbmdpbngg6YWN572u5LitIGFzc2V0cyBsb2NhdGlvbiDmnIkgZmFsbGJhY2sg6YC76L6RCkxFR0FDWV9WT0xVTUU9IiIKaWYgWyAtZCAiJExFR0FDWV9BU1NFVFNfRElSIiBdICYmIFsgIiQobHMgLUEgIiRMRUdBQ1lfQVNTRVRTX0RJUiIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgTEVHQUNZX1ZPTFVNRT0iLXYgJHtMRUdBQ1lfQVNTRVRTX0RJUn06L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy1sZWdhY3kvYXNzZXRzOnJvIgogIGVjaG8gIldlYiBjb250YWluZXI6IGxlZ2FjeSBhc3NldHMgbW91bnRlZCAoZmFsbGJhY2spIgplbHNlCiAgZWNobyAiV2ViIGNvbnRhaW5lcjogbm8gbGVnYWN5IGFzc2V0cyB0byBtb3VudCIKZmkKCmVjaG8gIlN0YXJ0aW5nIFdlYiBjb250YWluZXIuLi4iCmRvY2tlciBydW4gLWQgXAogIC0tbmFtZSB4aWFveGlhLXdlYi1wcm9kdWN0aW9uIFwKICAtLW5ldHdvcmsgeGlhb3hpYS1uZXQtcHJvZHVjdGlvbiBcCiAgLXAgMTI3LjAuMC4xOjMwMDI6ODAgXAogIC0tcmVzdGFydCB1bmxlc3Mtc3RvcHBlZCBcCiAgLS1jcHVzIDAuNSBcCiAgLS1tZW1vcnkgNTEybSBcCiAgJExFR0FDWV9WT0xVTUUgXAogIC0taGVhbHRoLWNtZCAid2dldCAtLXNwaWRlciAtcSBodHRwOi8vMTI3LjAuMC4xOjgwIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDVzIFwKICAtLWhlYWx0aC1yZXRyaWVzIDMgXAogICRMT0dfT1BUUyBcCiAgIiRMT0NBTF9XRUIiCgojIC0tLS0g562J5b6FIEFQSSDlgaXlurcgLS0tLQplY2hvICJXYWl0aW5nIGZvciBBUEkgdG8gYmVjb21lIGhlYWx0aHkuLi4iCmk9MAp3aGlsZSBbICIkaSIgLWx0IDQwIF07IGRvCiAgaWYgY3VybCAtc2YgLS1tYXgtdGltZSA1IGh0dHA6Ly8xMjcuMC4wLjE6ODAwMS9oZWFsdGggPi9kZXYvbnVsbCAyPiYxOyB0aGVuCiAgICBlY2hvICJBUEkgaXMgaGVhbHRoeSEiCiAgICBicmVhawogIGZpCiAgaT0kKChpICsgMSkpCiAgZWNobyAiICBXYWl0aW5nLi4uICgkaS80MCkiCiAgc2xlZXAgMwpkb25lCgppZiBbICIkaSIgLWdlIDQwIF07IHRoZW4KICBlY2hvICJFUlJPUjogQVBJIGRpZCBub3QgYmVjb21lIGhlYWx0aHkgd2l0aGluIDEyMHMiCiAgZG9ja2VyIGxvZ3MgLS10YWlsIDUwIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24KICBleGl0IDEKZmkKCiMgLS0tLSDnrYnlvoUgV2ViIOWBpeW6tyAtLS0tCmVjaG8gIldhaXRpbmcgZm9yIFdlYiB0byBiZWNvbWUgaGVhbHRoeS4uLiIKaT0wCndoaWxlIFsgIiRpIiAtbHQgMTUgXTsgZG8KICBpZiBjdXJsIC1zZiAtLW1heC10aW1lIDUgaHR0cDovLzEyNy4wLjAuMTozMDAyLyA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIldlYiBpcyBoZWFsdGh5ISIKICAgIGJyZWFrCiAgZmkKICBpPSQoKGkgKyAxKSkKICBlY2hvICIgIFdhaXRpbmcuLi4gKCRpLzE1KSIKICBzbGVlcCAyCmRvbmUKCmlmIFsgIiRpIiAtZ2UgMTUgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBXZWIgZGlkIG5vdCBiZWNvbWUgaGVhbHRoeSB3aXRoaW4gMzBzIgogIGRvY2tlciBsb2dzIC0tdGFpbCAzMCB4aWFveGlhLXdlYi1wcm9kdWN0aW9uCiAgZXhpdCAxCmZpCgojIC0tLS0g5riF55CG5pen6ZWc5YOPIC0tLS0KZWNobyAiQ2xlYW5pbmcgdXAgb2xkIGltYWdlcy4uLiIKZG9ja2VyIGltYWdlIHBydW5lIC1hZiAtLWZpbHRlciAidW50aWw9MTY4aCIgMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgYnVpbGRlciBwcnVuZSAtYWYgLS1maWx0ZXIgInVudGlsPTE2OGgiIDI+L2Rldi9udWxsIHx8IHRydWUKCmVjaG8gIiIKZWNobyAiPT09IFByb2R1Y3Rpb24gZGVwbG95bWVudCBjb21wbGV0ZSA9PT0iCmVjaG8gIkFQSTogICAgICBodHRwOi8vMTI3LjAuMC4xOjgwMDEiCmVjaG8gIldlYjogICAgICBodHRwOi8vMTI3LjAuMC4xOjMwMDIiCmVjaG8gIlZlcnNpb246ICAkSU1BR0VfVEFHIgpkb2NrZXIgcHMgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319XHR7ey5JbWFnZX19IiB8IGdyZXAgcHJvZHVjdGlvbgo=\"\ + \n\necho \"$DEPLOY_B64\" | base64 -d | ssh -p 22222 -i \"$key_path\" \"$production_user@$production_host\" \"IMAGE_TAG='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh\"\n" + - name: Production health check + auto rollback if: success() shell: sh - run: | - set +e - echo "=== CI 成功通知 ===" - SUCCESS_JOB="生产部署成功" python3 scripts/ci_notify_success.py + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} + PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} + PRODUCTION_SSH_PORT: ${{ secrets.PRODUCTION_SSH_PORT }} + PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: 'set -eu - - name: Notify CI failure + echo "==========================================" + + echo " Production 健康检查 + 自动回滚" + + echo "==========================================" + + echo "" + + + bash scripts/ci_production_healthcheck.sh + + ' + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on success + continue-on-error: true + if: success() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + + NOTIFY_MODE=success JOB_NAME="Deploy Production" python3 scripts/ci_notify.py + + ' + - name: Notify on failure + continue-on-error: true if: failure() shell: sh - run: | - set +e - echo "=== CI 失败通知 ===" - FAILED_JOB="Deploy Production" python3 scripts/ci_notify_failure.py + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Deploy Production" python3 scripts/ci_notify.py + ' production-e2e: name: Production Browser E2E runs-on: saas timeout-minutes: 15 if: startsWith(github.ref, 'refs/tags/v') needs: deploy-production - steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - python3 - <<'PY' - import io, os, tarfile, time, urllib.request, urllib.error - url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" - request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - # Retry up to 5 times with backoff for transient 5xx errors - last_err = None - for attempt in range(5): - try: - with urllib.request.urlopen(request, timeout=120) as response: - archive = response.read() - break - except urllib.error.HTTPError as e: - last_err = e - if e.code >= 500 and attempt < 4: - wait = 2 ** attempt - print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - except Exception as e: - last_err = e - if attempt < 4: - wait = 2 ** attempt - print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") - time.sleep(wait) - continue - raise - else: - raise last_err - with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: - root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' - for member in tar.getmembers(): - name = member.name - if name == root_prefix[:-1]: - continue - if name.startswith(root_prefix): - member.name = name[len(root_prefix):] - if member.name: - tar.extract(member, '.') - PY + run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\n# Retry up to 5 times with backoff for transient 5xx errors\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"\ + Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" + - name: Record job start time + shell: sh + run: 'set -eu + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + + echo "Job started at $(date)" + + ' - name: Run production browser E2E shell: sh - run: | - set -eu - docker run --rm --ipc=host \ - -e E2E_BASE_URL=https://saas.xiaoxiajianji.com \ - -e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \ - -e E2E_BROWSER_CHANNEL=chromium \ - -e PLAYWRIGHT_HEADLESS=1 \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \ - sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' - - - name: Notify CI failure + run: "set -eu\ndocker run --rm --ipc=host \\\n -e E2E_BASE_URL=https://saas.xiaoxiajianji.com \\\n -e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \\\n -e E2E_BROWSER_CHANNEL=chromium \\\n -e PLAYWRIGHT_HEADLESS=1 \\\n -v \"$PWD:/workspace\" \\\n -w /workspace/apps/web \\\n git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \\\n sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'\n" + - name: Job duration summary + if: always() + shell: sh + run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" + - name: Notify on failure + continue-on-error: true if: failure() shell: sh - run: | - set +e - echo "=== CI 失败通知 ===" - FAILED_JOB="Production Browser E2E" python3 scripts/ci_notify_failure.py + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Production Browser E2E" python3 scripts/ci_notify.py + + ' diff --git a/alembic/versions/038_add_error_info_and_retry_to_generation_tasks.py b/alembic/versions/038_add_error_info_and_retry_to_generation_tasks.py new file mode 100644 index 000000000..02ee2cef5 --- /dev/null +++ b/alembic/versions/038_add_error_info_and_retry_to_generation_tasks.py @@ -0,0 +1,47 @@ +"""add error_info and retry fields to generation_tasks + +Revision ID: 038_error_retry +Revises: 037_generation_logs +Create Date: 2026-07-13 22:15:00.000000 +""" + +import sqlalchemy as sa +from sqlalchemy.dialects.mysql import JSON as MySQLJSON + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "038_error_retry" +down_revision = "037_generation_logs" +branch_labels = None +depends_on = None + + +def upgrade(): + # error_info: 结构化错误信息(error_type, message, stack_trace, failed_at, stage等) + op.add_column( + "generation_tasks", + sa.Column("error_info", sa.JSON(), nullable=True), + ) + # retry_count: 重试次数 + op.add_column( + "generation_tasks", + sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"), + ) + # auto_retry_enabled: 是否开启自动重试 + op.add_column( + "generation_tasks", + sa.Column("auto_retry_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + # auto_retry_max: 最大自动重试次数 + op.add_column( + "generation_tasks", + sa.Column("auto_retry_max", sa.Integer(), nullable=False, server_default="0"), + ) + + +def downgrade(): + op.drop_column("generation_tasks", "auto_retry_max") + op.drop_column("generation_tasks", "auto_retry_enabled") + op.drop_column("generation_tasks", "retry_count") + op.drop_column("generation_tasks", "error_info") diff --git a/alembic/versions/039_add_transition_duration.py b/alembic/versions/039_add_transition_duration.py new file mode 100644 index 000000000..6e9f2c36d --- /dev/null +++ b/alembic/versions/039_add_transition_duration.py @@ -0,0 +1,34 @@ +"""add transition_duration to edit_plan_clips + +Revision ID: 039_transition_duration +Revises: 038_error_retry +Create Date: 2026-07-14 09:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "039_transition_duration" +down_revision = "038_error_retry" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "edit_plan_clips", + sa.Column( + "transition_duration", + sa.Float(), + nullable=False, + server_default="0.0", + ), + ) + + +def downgrade() -> None: + op.drop_column("edit_plan_clips", "transition_duration") diff --git a/alembic/versions/040_add_playback_speed_to_edit_plan_clips.py b/alembic/versions/040_add_playback_speed_to_edit_plan_clips.py new file mode 100755 index 000000000..21f6431cc --- /dev/null +++ b/alembic/versions/040_add_playback_speed_to_edit_plan_clips.py @@ -0,0 +1,29 @@ +"""add playback_speed to edit_plan_clips + +Revision ID: 040_playback_speed +Revises: 039_transition_duration +Create Date: 2026-07-14 10:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "040_playback_speed" +down_revision = "039_transition_duration" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "edit_plan_clips", + sa.Column("playback_speed", sa.Float(), nullable=False, server_default="1.0"), + ) + + +def downgrade() -> None: + op.drop_column("edit_plan_clips", "playback_speed") diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index c0711f12d..9bbabcd16 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -19,6 +19,7 @@ from app.api.routes.templates import router as templates_router from app.api.routes.titles import router as titles_router from app.api.routes.tts import router as tts_router from app.api.routes.upload import router as upload_router +from app.api.routes.videos import router as videos_router from app.api.routes.voice_clones import router as voice_clones_router from app.api.routes.voices import router as voices_router from fastapi import APIRouter @@ -99,6 +100,10 @@ api_router.include_router( prefix="/voice-clones", tags=["VoiceClone"], ) +api_router.include_router( + videos_router, + tags=["VideoCenter"], +) api_router.include_router( duplication_router, prefix="/duplication", diff --git a/apps/api/app/api/routes/asset_libraries.py b/apps/api/app/api/routes/asset_libraries.py old mode 100644 new mode 100755 index 5b9841c0c..7b75e54c2 --- a/apps/api/app/api/routes/asset_libraries.py +++ b/apps/api/app/api/routes/asset_libraries.py @@ -12,7 +12,7 @@ from app.schemas.asset_library import ( EnsureDefaultLibraryRequest, ListAssetLibrariesResponse, ) -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from packages.application import ( CreateAssetLibraryCommand, @@ -146,7 +146,7 @@ def ensure_default_library( return _to_asset_library_response(created) -@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) def delete_asset_library( library_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -163,11 +163,10 @@ def delete_asset_library( # 权限校验:检查用户是否有项目访问权限 check_project_access(library.project_id, authenticated_user.user.id, project_repository) - # 删除库内所有素材(无 FK 级联,需手动清理) + # 删除库内所有素材(硬删除,素材库已删除,无需保留软删除状态) assets_in_library = asset_repository.find_by_library(library_id) - if assets_in_library: - asset_ids_to_delete = [a.id for a in assets_in_library] - asset_repository.batch_delete(asset_ids_to_delete) + for asset in assets_in_library: + asset_repository.delete(asset.id) # 删除素材库本身 asset_library_repository.delete(library_id) diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py old mode 100644 new mode 100755 index 1ec5e7239..7e2f9b1a0 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -1,6 +1,7 @@ import logging from typing import Any, Optional +from app.api.routes._helpers import check_project_access from app.auth import AuthenticatedUser, get_current_user from app.core.storage import get_storage_service from app.dependencies import ( @@ -11,15 +12,18 @@ from app.dependencies import ( ) from app.schemas.asset import ( AssetResponse, + BatchClassifyRequest, BatchDeleteRequest, - BatchDeleteResponse, + BatchMarkRequest, + BatchOperationResponse, + BatchTagRequest, CreateAssetRequest, ListAssetsResponse, UpdateAssetRequest, UpdateAssetReviewRequest, ) from app.schemas.tag import TagAssetsRequest -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Response from packages.application import ( CreateAssetCommand, @@ -27,8 +31,6 @@ from packages.application import ( ) from packages.domain import AssetStatus, ClassificationStatus -from app.api.routes._helpers import check_project_access - logger = logging.getLogger(__name__) router = APIRouter() @@ -74,7 +76,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse: ) - @router.get("", response_model=ListAssetsResponse) def list_assets( library_id: Optional[str] = Query(None), @@ -84,6 +85,15 @@ def list_assets( gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"), style: Optional[str] = Query(None, description="按 metadata.style 筛选"), tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"), + smart_view: Optional[str] = Query( + None, + description="智能视图筛选:recommended=推荐(质量分≥80)、cautious=慎用(60-79)、risky=高风险(<60或已驳回)、unused=未使用、used=已使用、pending_review=待复核", + pattern="^(recommended|cautious|risky|unused|used|pending_review)$", + ), + classification: Optional[str] = Query( + None, + description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他", + ), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -103,11 +113,11 @@ def list_assets( if not filter_tag_ids: filter_tag_ids = None - # 需要内存过滤的标志(keyword/gender/style/tag_ids 无法在 DB 层过滤) - needs_memory_filter = bool(keyword or gender or style or filter_tag_ids) + # 需要内存过滤的标志(keyword/gender/style/tag_ids/smart_view/classification 无法在 DB 层过滤) + needs_memory_filter = bool(keyword or gender or style or filter_tag_ids or smart_view or classification) def _apply_memory_filters(items): - """应用 keyword / gender / style / tag_ids 内存过滤。""" + """应用 keyword / gender / style / tag_ids / smart_view / classification 内存过滤。""" result = items if keyword: kw = keyword.lower() @@ -116,9 +126,38 @@ def list_assets( result = [i for i in result if (i.metadata or {}).get("gender") == gender] if style: result = [i for i in result if (i.metadata or {}).get("style") == style] + if classification: + result = [i for i in result if (i.metadata or {}).get("classification") == classification] if filter_tag_ids: tag_set = set(filter_tag_ids) result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))] + if smart_view: + + def __meta(a): + return a.metadata or {} + + def __use_count(a): + return int(__meta(a).get("generation_use_count") or 0) + + def __review_status(a): + return __meta(a).get("review_status", "") + + if smart_view == "recommended": + result = [i for i in result if i.quality_score is not None and i.quality_score >= 80] + elif smart_view == "cautious": + result = [i for i in result if i.quality_score is not None and 60 <= i.quality_score < 80] + elif smart_view == "risky": + result = [ + i + for i in result + if (i.quality_score is not None and i.quality_score < 60) or __review_status(i) == "rejected" + ] + elif smart_view == "unused": + result = [i for i in result if __use_count(i) == 0] + elif smart_view == "used": + result = [i for i in result if __use_count(i) > 0] + elif smart_view == "pending_review": + result = [i for i in result if __review_status(i) == "pending_review"] return result # ── 优化路径:无内存过滤时,使用 DB 级分页 ── @@ -262,33 +301,157 @@ def update_asset_review_status( return _to_asset_response(updated) -@router.post("/batch-delete", response_model=BatchDeleteResponse) +@router.post("/batch-delete", response_model=BatchOperationResponse) def batch_delete_assets( request: BatchDeleteRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), asset_repository: Any = Depends(get_asset_repository), project_repository: Any = Depends(get_project_repository), -) -> BatchDeleteResponse: - """批量删除素材(配音素材等),需逐项校验项目权限。""" +) -> BatchOperationResponse: + """批量删除素材(软删除,标记 status=deleted),需逐项校验项目权限。""" user_id = authenticated_user.user.id - deleted_ids: list[str] = [] - failed_ids: list[str] = [] + success_ids: list[str] = [] + failed_details: dict[str, str] = {} - for asset_id in request.ids: + for asset_id in request.asset_ids: item = asset_repository.find_by_id(asset_id) if item is None: - failed_ids.append(asset_id) + failed_details[asset_id] = "not_found" continue try: check_project_access(item.project_id, user_id, project_repository) - deleted_ids.append(asset_id) + success_ids.append(asset_id) except HTTPException: - failed_ids.append(asset_id) + failed_details[asset_id] = "access_denied" - if deleted_ids: - asset_repository.batch_delete(deleted_ids) + if success_ids: + asset_repository.batch_delete(success_ids) - return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_ids) + return BatchOperationResponse( + success_count=len(success_ids), + failed_ids=list(failed_details.keys()), + failed_details=failed_details, + ) + + +@router.post("/batch-tag", response_model=BatchOperationResponse) +def batch_tag_assets( + request: BatchTagRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + asset_repository: Any = Depends(get_asset_repository), + project_repository: Any = Depends(get_project_repository), + tag_repository: Any = Depends(get_tag_repository), +) -> BatchOperationResponse: + """批量打标签(添加或替换模式),需逐项校验项目权限和标签权限。""" + user_id = authenticated_user.user.id + success_ids: list[str] = [] + failed_details: dict[str, str] = {} + + # 校验标签存在且属于当前用户 + for tag_id in request.tag_ids: + tag = tag_repository.get(tag_id) + if tag is None: + return BatchOperationResponse( + success_count=0, + failed_ids=list(request.asset_ids), + failed_details={aid: f"tag_not_found:{tag_id}" for aid in request.asset_ids}, + ) + if tag.user_id != user_id: + return BatchOperationResponse( + success_count=0, + failed_ids=list(request.asset_ids), + failed_details={aid: f"tag_access_denied:{tag_id}" for aid in request.asset_ids}, + ) + + # 校验素材权限 + for asset_id in request.asset_ids: + item = asset_repository.find_by_id(asset_id) + if item is None: + failed_details[asset_id] = "not_found" + continue + try: + check_project_access(item.project_id, user_id, project_repository) + success_ids.append(asset_id) + except HTTPException: + failed_details[asset_id] = "access_denied" + + if success_ids: + if request.mode == "replace": + asset_repository.batch_replace_tags(success_ids, request.tag_ids) + else: + asset_repository.batch_add_tags(success_ids, request.tag_ids) + + return BatchOperationResponse( + success_count=len(success_ids), + failed_ids=list(failed_details.keys()), + failed_details=failed_details, + ) + + +@router.post("/batch-classify", response_model=BatchOperationResponse) +def batch_classify_assets( + request: BatchClassifyRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + asset_repository: Any = Depends(get_asset_repository), + project_repository: Any = Depends(get_project_repository), +) -> BatchOperationResponse: + """批量修改素材内容分类(person/scenic/product等),存在metadata.category中。""" + user_id = authenticated_user.user.id + success_ids: list[str] = [] + failed_details: dict[str, str] = {} + + for asset_id in request.asset_ids: + item = asset_repository.find_by_id(asset_id) + if item is None: + failed_details[asset_id] = "not_found" + continue + try: + check_project_access(item.project_id, user_id, project_repository) + success_ids.append(asset_id) + except HTTPException: + failed_details[asset_id] = "access_denied" + + if success_ids: + asset_repository.batch_update_metadata(success_ids, {"category": request.category}) + + return BatchOperationResponse( + success_count=len(success_ids), + failed_ids=list(failed_details.keys()), + failed_details=failed_details, + ) + + +@router.post("/batch-mark", response_model=BatchOperationResponse) +def batch_mark_assets( + request: BatchMarkRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + asset_repository: Any = Depends(get_asset_repository), + project_repository: Any = Depends(get_project_repository), +) -> BatchOperationResponse: + """批量设置智能视图标记(recommended/caution/high_risk),存在metadata.smart_view中。""" + user_id = authenticated_user.user.id + success_ids: list[str] = [] + failed_details: dict[str, str] = {} + + for asset_id in request.asset_ids: + item = asset_repository.find_by_id(asset_id) + if item is None: + failed_details[asset_id] = "not_found" + continue + try: + check_project_access(item.project_id, user_id, project_repository) + success_ids.append(asset_id) + except HTTPException: + failed_details[asset_id] = "access_denied" + + if success_ids: + asset_repository.batch_update_metadata(success_ids, {"smart_view": request.smart_view}) + + return BatchOperationResponse( + success_count=len(success_ids), + failed_ids=list(failed_details.keys()), + failed_details=failed_details, + ) @router.get("/{asset_id}", response_model=AssetResponse) @@ -330,7 +493,7 @@ def update_asset( return _to_asset_response(updated) -@router.delete("/{asset_id}", status_code=204) +@router.delete("/{asset_id}", status_code=204, response_class=Response) def delete_asset( asset_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -369,7 +532,7 @@ def tag_asset( return _to_asset_response(updated) -@router.delete("/{asset_id}/tags/{tag_id}", status_code=204) +@router.delete("/{asset_id}/tags/{tag_id}", status_code=204, response_class=Response) def untag_asset( asset_id: str, tag_id: str, diff --git a/apps/api/app/api/routes/chunked_upload.py b/apps/api/app/api/routes/chunked_upload.py index c9cd064b5..1db5a1647 100644 --- a/apps/api/app/api/routes/chunked_upload.py +++ b/apps/api/app/api/routes/chunked_upload.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Any from uuid import uuid4 +from app.api.routes._helpers import require_project_and_library from app.auth import AuthenticatedUser, get_current_user from app.core.celery_app import celery_app from app.core.storage import OSSStorageService, get_storage_service @@ -34,8 +35,6 @@ from fastapi.params import File from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase -from app.api.routes._helpers import require_project_and_library - router = APIRouter() logger = logging.getLogger(__name__) diff --git a/apps/api/app/api/routes/duplication.py b/apps/api/app/api/routes/duplication.py index 427d369fa..bdf9094f9 100644 --- a/apps/api/app/api/routes/duplication.py +++ b/apps/api/app/api/routes/duplication.py @@ -239,7 +239,7 @@ def get_duplication_detail( return _to_detail_response(record) -@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) def delete_duplication_record( record_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -257,7 +257,7 @@ def delete_duplication_record( use_case = DeleteDuplicationRecordUseCase(duplication_repository) use_case.execute(record_id) - return Response(status_code=204) + return @router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse) diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py old mode 100755 new mode 100644 index 9d7c9f2d3..a830cd193 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -25,14 +25,15 @@ from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session, get_project_repository from app.schemas.generation_task import GenerationTaskResponse from app.services import EditPlanService -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session -from ._helpers import check_project_access from packages.domain.config_schemas import normalize_plan_config from packages.domain.edit_plan import EditPlan, EditPlanStatus +from ._helpers import check_project_access + logger = logging.getLogger(__name__) router = APIRouter() @@ -145,6 +146,7 @@ class AIRecommendClipItem(BaseModel): text_content: str = Field(default="", description="文字内容") duration: float = Field(..., ge=0.0, description="片段时长(秒)") transition_effect: str = Field(default="cut", description="转场效果") + transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值") asset_id: str = Field(default="", description="关联素材 ID") start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)") config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置") @@ -208,6 +210,8 @@ class _PlanClipItem(BaseModel): start_time: float duration: float transition_effect: str + transition_duration: float + playback_speed: float = 1.0 status: str config: Optional[dict[str, Any]] = None created_at: datetime @@ -421,7 +425,7 @@ def update_plan( return _to_response(result) -@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) def delete_plan( plan_id: str, db: Session = Depends(get_db_session), diff --git a/apps/api/app/api/routes/edit_plans_generation.py b/apps/api/app/api/routes/edit_plans_generation.py index 3aae7abfa..7d7e37039 100644 --- a/apps/api/app/api/routes/edit_plans_generation.py +++ b/apps/api/app/api/routes/edit_plans_generation.py @@ -15,8 +15,8 @@ from app.api.routes._helpers import check_project_access from app.api.routes.edit_plans import ( ClipStatusItem, EditPlanGenerateResponse, - EditPlanGenerationStatusResponse, EditPlanGenerationsResponse, + EditPlanGenerationStatusResponse, ) from app.auth import AuthenticatedUser, get_current_user from app.core.celery_app import celery_app diff --git a/apps/api/app/api/routes/edit_plans_timeline.py b/apps/api/app/api/routes/edit_plans_timeline.py old mode 100644 new mode 100755 index 3d343ad06..c1e6f1249 --- a/apps/api/app/api/routes/edit_plans_timeline.py +++ b/apps/api/app/api/routes/edit_plans_timeline.py @@ -8,12 +8,10 @@ from __future__ import annotations import logging -from datetime import datetime -from typing import Any, List, Optional +from typing import Any, List from app.api.routes._helpers import check_project_access from app.api.routes.edit_plans import ( - EditPlanResponse, GenerateFromTemplateRequest, GenerateFromTemplateResponse, _PlanClipItem, @@ -212,6 +210,7 @@ def generate_from_template( start_time=c.start_time, duration=c.duration, transition_effect=c.transition_effect, + transition_duration=c.transition_duration, status=c.status.value if hasattr(c.status, "value") else c.status, config=c.config, created_at=c.created_at, diff --git a/apps/api/app/api/routes/feature_flags.py b/apps/api/app/api/routes/feature_flags.py index 20a2dc53e..a3dff32f0 100755 --- a/apps/api/app/api/routes/feature_flags.py +++ b/apps/api/app/api/routes/feature_flags.py @@ -19,7 +19,7 @@ from typing import Optional from app.api.routes.auth import _verify_internal_api_key from app.config import settings -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from pydantic import BaseModel, Field from packages.adapters.redis.feature_flag_store import ( @@ -173,12 +173,12 @@ async def update_feature_flag( raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}") -@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) async def delete_feature_flag( name: str, _: bool = Depends(_verify_internal_api_key), store: RedisFeatureFlagStore = Depends(_get_feature_flag_store), -) -> None: +) : """删除 Feature Flag。 只允许删除 ALLOWED_FLAGS 列表中的 flag。 @@ -188,7 +188,7 @@ async def delete_feature_flag( try: deleted = store.delete(name) logger.info("Feature flag deleted: name=%s deleted=%s", name, deleted) - return None + pass except Exception as exc: logger.error("Failed to delete feature flag %s: %s", name, exc) raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py old mode 100755 new mode 100644 index 04d509b02..ccc3e9a55 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -3,6 +3,7 @@ import random import uuid from typing import Any +from app.api.routes._helpers import check_project_access from app.auth import AuthenticatedUser, get_current_user from app.core.storage import OSSStorageService, get_storage_service from app.core.task_enqueue import ( @@ -31,8 +32,6 @@ from app.schemas.generation_task import ( ) from fastapi import APIRouter, Depends, HTTPException -from app.api.routes._helpers import check_project_access - from packages.application import ( CreateGenerationTaskCommand, CreateGenerationTaskUseCase, @@ -268,6 +267,8 @@ def create_generation_task( source_edit_plan_id=request.source_edit_plan_id, asset_select_mode=request.asset_select_mode, batch_id=batch_id, + auto_retry_enabled=request.auto_retry_enabled, + auto_retry_max=request.auto_retry_max, ) ) try: diff --git a/apps/api/app/api/routes/projects.py b/apps/api/app/api/routes/projects.py old mode 100755 new mode 100644 index 17be8f438..b873d5557 --- a/apps/api/app/api/routes/projects.py +++ b/apps/api/app/api/routes/projects.py @@ -7,7 +7,7 @@ from app.schemas.project import ( ListProjectsResponse, ProjectResponse, ) -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from packages.application import ( CreateProjectCommand, @@ -72,7 +72,7 @@ def create_project( return _to_project_response(project) -@router.delete("/{project_id}") +@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) def delete_project( project_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -88,4 +88,4 @@ def delete_project( ) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") - return {"message": "Project deleted successfully"} + return diff --git a/apps/api/app/api/routes/tags.py b/apps/api/app/api/routes/tags.py index 7de3e278b..45da212a5 100644 --- a/apps/api/app/api/routes/tags.py +++ b/apps/api/app/api/routes/tags.py @@ -10,7 +10,7 @@ from app.schemas.tag import ( ListTagsResponse, TagResponse, ) -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Response from packages.domain import Tag @@ -52,7 +52,7 @@ def create_tag( return TagResponse(id=created.id, name=created.name, created_at=created.created_at) -@router.delete("/{tag_id}", status_code=204) +@router.delete("/{tag_id}", status_code=204, response_class=Response) def delete_tag( tag_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/task_center.py b/apps/api/app/api/routes/task_center.py index c796fd58d..e968a0561 100755 --- a/apps/api/app/api/routes/task_center.py +++ b/apps/api/app/api/routes/task_center.py @@ -21,11 +21,12 @@ from app.schemas.task_center import ( ProjectTaskResponse, UserTaskResponse, ) -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from packages.application import ( CreateGenerationTaskCommand, CreateGenerationTaskUseCase, + RetryGenerationTaskUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase, ) @@ -34,6 +35,10 @@ logger = logging.getLogger(__name__) router = APIRouter() +DEFAULT_PAGE_SIZE = 50 +MAX_PAGE_SIZE = 200 + + def _humanize_task_error(error_message: str) -> str: raw = (error_message or "").strip() if not raw: @@ -63,6 +68,8 @@ def _generation_step(task) -> str: return "生成完成" if s == "failed": return "生成失败" + if s == "cancelled": + return "已取消" return s @@ -79,6 +86,26 @@ def _ingest_step(job) -> str: return s +def _generation_task_to_user_response(task) -> UserTaskResponse: + return UserTaskResponse( + id=f"generation:{task.id}", + task_type="generation", + project_id=task.project_id, + template_id=task.template_id, + status=_status_value(task.status), + progress=task.progress, + current_step=_generation_step(task), + error_message=task.error_message, + error_info=task.error_info or {}, + user_message=_humanize_task_error(task.error_message), + retryable=_status_value(task.status) == "failed", + retry_count=task.retry_count or 0, + source_id=task.id, + created_at=task.created_at, + updated_at=task.completed_at or task.started_at or task.created_at, + ) + + def _generation_task_to_project_response(task) -> ProjectTaskResponse: return ProjectTaskResponse( id=f"generation:{task.id}", @@ -88,8 +115,10 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse: progress=task.progress, current_step=_generation_step(task), error_message=task.error_message, + error_info=task.error_info or {}, user_message=_humanize_task_error(task.error_message), retryable=_status_value(task.status) == "failed", + retry_count=task.retry_count or 0, source_id=task.id, template_id=task.template_id, created_at=task.created_at, @@ -97,40 +126,66 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse: ) +def _validate_status(status: str | None) -> str | None: + """校验状态值合法性。""" + if status is None: + return None + valid = {"pending", "running", "completed", "failed", "cancelled"} + if status not in valid: + raise HTTPException( + status_code=400, + detail=f"无效的状态筛选值: {status},允许值: {', '.join(sorted(valid))}", + ) + return status + + +def _clamp_page_size(page_size: int) -> int: + if page_size <= 0: + return DEFAULT_PAGE_SIZE + if page_size > MAX_PAGE_SIZE: + return MAX_PAGE_SIZE + return page_size + + # ── 用户级端点(放在项目级端点之前,避免路由冲突) ── @router.get("/tasks", response_model=ListTasksResponse) def list_user_tasks( + status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"), + task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"), + page: int = Query(1, ge=1, description="页码,从1开始"), + page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"), authenticated_user: AuthenticatedUser = Depends(get_current_user), ingest_job_repository: Any = Depends(get_ingest_job_repository), generation_task_repository: Any = Depends(get_generation_task_repository), ) -> ListTasksResponse: - """用户级任务列表(跨 project),合并 ingest + generation 任务。""" + """用户级任务列表(跨 project),支持状态/类型筛选和分页。""" + status = _validate_status(status) + page_size = _clamp_page_size(page_size) user_id = authenticated_user.user.id + offset = (page - 1) * page_size + items: list[UserTaskResponse] = [] - for task in generation_task_repository.list_by_user(user_id): - items.append( - UserTaskResponse( - id=f"generation:{task.id}", - task_type="generation", - project_id=task.project_id, - template_id=task.template_id, - status=_status_value(task.status), - progress=task.progress, - current_step=_generation_step(task), - error_message=task.error_message, - user_message=_humanize_task_error(task.error_message), - retryable=_status_value(task.status) == "failed", - source_id=task.id, - created_at=task.created_at, - updated_at=task.completed_at or task.started_at or task.created_at, - ) + # 生成任务 + if task_type is None or task_type == "generation": + gen_result = generation_task_repository.list_by_user_filtered( + user_id, + status=status, + limit=page_size + 1, # 多取一条判断是否还有下一页(简单起见这里用offset) + offset=offset, ) + for task in gen_result: + items.append(_generation_task_to_user_response(task)) + # 按时间倒序 items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True) - return ListTasksResponse(items=items) + + # 总数(仅generation,ingest暂不计入总数以保持简单) + total = generation_task_repository.count_by_user_filtered(user_id, status=status) + + return ListTasksResponse(items=items[:page_size], total=total) @router.post("/tasks/{task_id}/retry", response_model=UserTaskResponse) @@ -139,7 +194,7 @@ def retry_task_by_id( authenticated_user: AuthenticatedUser = Depends(get_current_user), generation_task_repository: Any = Depends(get_generation_task_repository), ) -> UserTaskResponse: - """简化重试:通过 task_id 直接重试失败的生成任务。""" + """原地重试失败的生成任务(复用同一个task_id,retry_count+1)。""" task = generation_task_repository.get(task_id) if task is None: raise HTTPException(status_code=404, detail="Generation task not found") @@ -149,6 +204,7 @@ def retry_task_by_id( raise HTTPException(status_code=409, detail="Only failed tasks can be retried") user_id = authenticated_user.user.id + # 预检查 user_pending = generation_task_repository.count_pending_by_user(user_id) global_pending = generation_task_repository.count_pending_total() @@ -163,20 +219,11 @@ def retry_task_by_id( detail="系统繁忙,请稍后再试", ) - use_case = CreateGenerationTaskUseCase(generation_task_repository) - retried = use_case.execute( - CreateGenerationTaskCommand( - project_id=task.project_id, - asset_library_id=task.asset_library_id, - strategy_id=task.strategy_id, - voice_library_id=task.voice_library_id, - template_id=task.template_id, - asset_ids=task.asset_ids, - title_ids=task.title_ids, - voice_ids=task.voice_ids, - created_by_user_id=user_id, - ) - ) + # 原地重试 + use_case = RetryGenerationTaskUseCase(generation_task_repository) + retried = use_case.execute(task_id) + + # 重新入队 try: if not safe_enqueue_generation_task( retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]" @@ -192,18 +239,8 @@ def retry_task_by_id( status_code=503, detail="系统繁忙,请稍后再试", ) from None - return UserTaskResponse( - id=f"generation:{retried.id}", - task_type="generation", - project_id=retried.project_id, - template_id=retried.template_id, - status=_status_value(retried.status), - progress=retried.progress, - current_step=_generation_step(retried), - source_id=retried.id, - created_at=retried.created_at, - updated_at=retried.created_at, - ) + + return _generation_task_to_user_response(retried) # ── 项目级端点 ── @@ -212,37 +249,64 @@ def retry_task_by_id( @router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse) def list_project_tasks( project_id: str, + status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"), + task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"), + page: int = Query(1, ge=1, description="页码,从1开始"), + page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"), authenticated_user: AuthenticatedUser = Depends(get_current_user), project_repository: Any = Depends(get_project_repository), ingest_job_repository: Any = Depends(get_ingest_job_repository), generation_task_repository: Any = Depends(get_generation_task_repository), ) -> ListProjectTasksResponse: + """项目级任务列表,支持状态/类型筛选和分页。""" project = project_repository.find_by_id(project_id) if project is None: raise HTTPException(status_code=404, detail="Project not found") + status = _validate_status(status) + page_size = _clamp_page_size(page_size) + offset = (page - 1) * page_size + items: list[ProjectTaskResponse] = [] - for job in ingest_job_repository.list_by_project(project_id): - items.append( - ProjectTaskResponse( - id=f"ingest:{job.id}", - task_type="ingest", - project_id=job.project_id, - status=_status_value(job.status), - progress=100.0 if _status_value(job.status) == "completed" else 0.0, - current_step=_ingest_step(job), - error_message=job.error_message, - user_message=_humanize_task_error(job.error_message), - retryable=_status_value(job.status) == "failed", - source_id=job.id, - created_at=job.created_at, - updated_at=job.updated_at, + + # 导入任务 + if task_type is None or task_type == "ingest": + for job in ingest_job_repository.list_by_project(project_id): + if status and _status_value(job.status) != status: + continue + items.append( + ProjectTaskResponse( + id=f"ingest:{job.id}", + task_type="ingest", + project_id=job.project_id, + status=_status_value(job.status), + progress=100.0 if _status_value(job.status) == "completed" else 0.0, + current_step=_ingest_step(job), + error_message=job.error_message, + user_message=_humanize_task_error(job.error_message), + retryable=_status_value(job.status) == "failed", + source_id=job.id, + created_at=job.created_at, + updated_at=job.updated_at, + ) ) + + # 生成任务 + if task_type is None or task_type == "generation": + gen_items = generation_task_repository.list_by_project_filtered( + project_id, + status=status, + limit=page_size + 1, + offset=offset, ) - for task in generation_task_repository.list_by_project(project_id): - items.append(_generation_task_to_project_response(task)) + for task in gen_items: + items.append(_generation_task_to_project_response(task)) + items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True) - return ListProjectTasksResponse(items=items) + + total = generation_task_repository.count_by_project_filtered(project_id, status=status) + + return ListProjectTasksResponse(items=items[:page_size], total=total) @router.post("/tasks/{task_type}/{source_id}/retry", response_model=ProjectTaskResponse) @@ -253,6 +317,7 @@ def retry_project_task( ingest_job_repository: Any = Depends(get_ingest_job_repository), generation_task_repository: Any = Depends(get_generation_task_repository), ) -> ProjectTaskResponse: + """项目级任务重试。""" if task_type == "generation": task = generation_task_repository.get(source_id) if task is None: @@ -261,6 +326,7 @@ def retry_project_task( raise HTTPException(status_code=409, detail="Only failed tasks can be retried") user_id = authenticated_user.user.id + # 预检查 user_pending = generation_task_repository.count_pending_by_user(user_id) global_pending = generation_task_repository.count_pending_total() @@ -275,20 +341,10 @@ def retry_project_task( detail="系统繁忙,请稍后再试", ) - use_case = CreateGenerationTaskUseCase(generation_task_repository) - retried = use_case.execute( - CreateGenerationTaskCommand( - project_id=task.project_id, - asset_library_id=task.asset_library_id, - strategy_id=task.strategy_id, - voice_library_id=task.voice_library_id, - template_id=task.template_id, - asset_ids=task.asset_ids, - title_ids=task.title_ids, - voice_ids=task.voice_ids, - created_by_user_id=user_id, - ) - ) + # 原地重试 + use_case = RetryGenerationTaskUseCase(generation_task_repository) + retried = use_case.execute(source_id) + try: if not safe_enqueue_generation_task( retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]" @@ -305,6 +361,7 @@ def retry_project_task( detail="系统繁忙,请稍后再试", ) from None return _generation_task_to_project_response(retried) + if task_type == "ingest": job = ingest_job_repository.get(source_id) if job is None: diff --git a/apps/api/app/api/routes/templates.py b/apps/api/app/api/routes/templates.py index 6d5c5185f..8cfc465be 100644 --- a/apps/api/app/api/routes/templates.py +++ b/apps/api/app/api/routes/templates.py @@ -8,13 +8,16 @@ from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session from app.schemas.template import ( CategoryResponse, + CopyTemplateRequest, CreateCategoryRequest, CreateTemplateRequest, GenerateWarningResponse, ListCategoriesResponse, + ListTagsResponse, ListTemplatesResponse, SegmentResponse, TemplateResponse, + TemplateUsageResponse, ToggleFavoriteResponse, UpdateTemplateRequest, ValidateTemplateRequest, @@ -27,19 +30,24 @@ logger = logging.getLogger(__name__) from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository from packages.application.template.commands import ( + CopyTemplateCommand, CreateCategoryCommand, CreateTemplateCommand, + ListTemplatesFilter, SegmentCommand, UpdateTemplateCommand, ValidateTemplateCommand, ) from packages.application.template.use_cases import ( + CopyTemplateUseCase, + CountTemplatesUseCase, CreateCategoryUseCase, CreateTemplateUseCase, DeleteCategoryUseCase, DeleteTemplateUseCase, GetTemplateUseCase, ListCategoriesUseCase, + ListTagsUseCase, ListTemplatesUseCase, NotFoundError, UpdateTemplateUseCase, @@ -67,7 +75,7 @@ def _segment_to_response(seg) -> SegmentResponse: ) -def _to_response(template) -> TemplateResponse: +def _to_response(template, usage_count: int = 0) -> TemplateResponse: return TemplateResponse( id=template.id, user_id=template.user_id, @@ -81,6 +89,7 @@ def _to_response(template) -> TemplateResponse: estimated_duration=template.estimated_duration, segments=[_segment_to_response(s) for s in getattr(template, "segments", [])], is_active=template.is_active, + usage_count=usage_count, created_at=template.created_at, updated_at=template.updated_at, ) @@ -93,19 +102,36 @@ def _to_response(template) -> TemplateResponse: def list_templates( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), + category: str | None = Query(None, description="按分类筛选"), + tag: str | None = Query(None, description="按标签筛选"), + keyword: str | None = Query(None, description="按名称关键词搜索"), + mode: str | None = Query(None, description="按剪辑模式筛选"), authenticated_user: AuthenticatedUser = Depends(get_current_user), template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), ) -> ListTemplatesResponse: user_id = authenticated_user.user.id try: + tpl_filter = ListTemplatesFilter( + category=category, + tag=tag, + keyword=keyword, + mode=mode, + ) use_case = ListTemplatesUseCase(template_repository) - templates = use_case.execute(user_id, skip=skip, limit=limit) - total = template_repository.count_by_user(user_id) + templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter) + count_use_case = CountTemplatesUseCase(template_repository) + total = count_use_case.execute(user_id, filter=tpl_filter) + + # 批量查询使用次数 + items = [] + for t in templates: + usage = template_repository.get_usage_count(t.id) + items.append(_to_response(t, usage_count=usage)) except Exception: logger.exception("list_templates 查询失败: user_id=%s", user_id) return ListTemplatesResponse(items=[], total=0) return ListTemplatesResponse( - items=[_to_response(t) for t in templates], + items=items, total=total, ) @@ -120,12 +146,13 @@ def get_template( try: use_case = GetTemplateUseCase(template_repository) template = use_case.execute(template_id, user_id) + usage = template_repository.get_usage_count(template_id) except Exception: logger.exception("get_template 查询失败: template_id=%s", template_id) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") if template is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") - return _to_response(template) + return _to_response(template, usage_count=usage) @router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED) @@ -206,7 +233,7 @@ def update_template( return _to_response(template) -@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) def delete_template( template_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -217,7 +244,48 @@ def delete_template( deleted = use_case.execute(template_id, user_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") - return Response(status_code=204) + return + + +@router.post("/{template_id}/copy", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED) +def copy_template( + template_id: str, + request: CopyTemplateRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), +) -> TemplateResponse: + """复制模板(含所有片段配置)""" + user_id = authenticated_user.user.id + command = CopyTemplateCommand( + template_id=template_id, + user_id=user_id, + new_name=request.new_name, + ) + use_case = CopyTemplateUseCase(template_repository) + try: + template = use_case.execute(command) + except NotFoundError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") + except ValidationError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) + return _to_response(template) + + +@router.get("/{template_id}/usage", response_model=TemplateUsageResponse) +def get_template_usage( + template_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), +) -> TemplateUsageResponse: + """获取模板使用次数(关联的剪辑计划数量)""" + user_id = authenticated_user.user.id + # 鉴权:确保模板存在且属于当前用户 + use_case = GetTemplateUseCase(template_repository) + template = use_case.execute(template_id, user_id) + if template is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") + usage = template_repository.get_usage_count(template_id) + return TemplateUsageResponse(template_id=template_id, usage_count=usage) @router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse) @@ -307,7 +375,7 @@ def create_category( ) -@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) def delete_category( category_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -319,3 +387,22 @@ def delete_category( if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") return Response(status_code=204) + + +# ── Tags ── + + +@router.get("/tags/list", response_model=ListTagsResponse) +def list_tags( + authenticated_user: AuthenticatedUser = Depends(get_current_user), + template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), +) -> ListTagsResponse: + """获取用户所有模板标签(去重排序)""" + user_id = authenticated_user.user.id + try: + use_case = ListTagsUseCase(template_repository) + tags = use_case.execute(user_id) + except Exception: + logger.exception("list_tags 查询失败: user_id=%s", user_id) + return ListTagsResponse(items=[]) + return ListTagsResponse(items=tags) diff --git a/apps/api/app/api/routes/titles.py b/apps/api/app/api/routes/titles.py old mode 100644 new mode 100755 index f65f3cdea..0237f5d43 --- a/apps/api/app/api/routes/titles.py +++ b/apps/api/app/api/routes/titles.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Optional +from app.api.routes._helpers import get_user_plan from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session, get_user_repository from app.schemas.title_library import ( @@ -16,20 +17,23 @@ 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.commands import ( + CreateTitleLibraryCommand, + PickTitleCommand, + UpdateTitleLibraryCommand, +) from packages.application.title_library.use_cases import ( CreateTitleLibraryUseCase, DeleteTitleLibraryUseCase, GetTitleLibraryUseCase, ListTitleLibraryUseCase, NotFoundError, + PickTitleUseCase, QuotaExceededError, UpdateTitleLibraryUseCase, ) from packages.ports.user_repository import UserRepository -from app.api.routes._helpers import get_user_plan - router = APIRouter() @@ -71,6 +75,41 @@ def list_titles( ) +@router.post("/pick", response_model=TitleLibraryItemResponse) +def pick_title( + category: Optional[str] = Query(None, description="按分类筛选,不填则从全部标题中选"), + exclude_ids: Optional[str] = Query( + None, + description="排除的标题ID(逗号分隔),用于批量生成时避免重复", + ), + authenticated_user: AuthenticatedUser = Depends(get_current_user), + title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository), +) -> TitleLibraryItemResponse: + """智能选择一个标题。 + + 策略:优先使用次数少的,从最少的前5个中随机选一个,兼顾公平和多样性。 + """ + user_id = authenticated_user.user.id + exclude_list: list[str] = [] + if exclude_ids: + exclude_list = [t.strip() for t in exclude_ids.split(",") if t.strip()] + + use_case = PickTitleUseCase(title_repository) + item = use_case.execute( + PickTitleCommand( + user_id=user_id, + category=category, + exclude_ids=exclude_list, + ) + ) + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="标题库为空,请先添加标题", + ) + return _to_response(item) + + @router.get("/{title_id}", response_model=TitleLibraryItemResponse) def get_title( title_id: str, @@ -138,7 +177,7 @@ def update_title( return _to_response(item) -@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) def delete_title( title_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -149,4 +188,4 @@ def delete_title( deleted = use_case.execute(title_id, user_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") - return Response(status_code=204) + return diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index 77f9991e2..2a01f06b2 100755 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -46,6 +46,7 @@ from packages.application.voice_library.use_cases import ( CreateVoiceLibraryUseCase, QuotaExceededError, ) +from packages.domain.voice_presets import list_voices from packages.ports.user_repository import UserRepository logger = logging.getLogger(__name__) @@ -53,6 +54,34 @@ logger = logging.getLogger(__name__) router = APIRouter() +@router.get("/presets", summary="获取预设音色列表") +def list_preset_voices( + gender: Optional[str] = Query(None, description="按性别筛选: male/female/child"), + style: Optional[str] = Query(None, description="按风格筛选: stable/lively/customer_service/narration/news/story"), + keyword: Optional[str] = Query(None, description="按关键词搜索"), + _user: AuthenticatedUser = Depends(get_current_user), +) -> list[dict]: + """获取可用的预设音色列表。 + + 用于配音功能的音色选择。 + """ + voices = list_voices(gender=gender, style=style, keyword=keyword) + return [ + { + "voice_id": v.voice_id, + "name": v.name, + "gender": v.gender.value, + "style": v.style.value, + "description": v.description, + "default_speed": v.default_speed, + "default_pitch": v.default_pitch, + "sample_rate": v.sample_rate, + "language": v.language, + } + for v in voices + ] + + def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTSJobRepository: return SQLAlchemyTTSJobRepository(session) @@ -241,7 +270,7 @@ def get_tts_job_status( ) -@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) def delete_tts_job( job_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -253,7 +282,7 @@ def delete_tts_job( deleted = use_case.execute(job_id, user_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") - return Response(status_code=204) + return @router.post( diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index 97a83bfcd..c67add1eb 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -2,6 +2,7 @@ import logging from typing import Any from uuid import uuid4 +from app.api.routes._helpers import require_project_and_library from app.auth import AuthenticatedUser, get_current_user from app.config import get_settings from app.core.celery_app import celery_app @@ -23,8 +24,6 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, s from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase -from app.api.routes._helpers import require_project_and_library - logger = logging.getLogger(__name__) router = APIRouter() diff --git a/apps/api/app/api/routes/videos.py b/apps/api/app/api/routes/videos.py new file mode 100755 index 000000000..932c1672b --- /dev/null +++ b/apps/api/app/api/routes/videos.py @@ -0,0 +1,179 @@ +import logging +import uuid + +from app.api.routes._helpers import check_project_access +from app.auth import AuthenticatedUser, get_current_user +from app.core.celery_app import celery_app +from app.core.storage import OSSStorageService, get_storage_service +from app.dependencies import get_generated_video_repository +from app.schemas.video_center import ( + BatchDownloadRequest, + BatchDownloadResponse, + ListVideosResponse, + UpdateVideoReviewRequest, + VideoItemResponse, +) +from fastapi import APIRouter, Depends, HTTPException, Query + +from packages.application import ( + GetGeneratedVideoUseCase, + GetVideosByIdsUseCase, + ListGeneratedVideosPaginatedUseCase, + UpdateVideoReviewStatusUseCase, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoItemResponse: + download_url = None + if storage and item.file_url: + try: + download_url = storage.get_download_url(item.file_url) + except Exception: + download_url = item.file_url + return VideoItemResponse( + id=item.id, + project_id=item.project_id, + generation_task_id=item.generation_task_id, + name=item.name, + file_url=item.file_url, + file_size=item.file_size, + duration=item.duration, + thumbnail_url=item.thumbnail_url, + width=item.width, + height=item.height, + fps=item.fps, + status=item.status, + review_status=item.review_status, + generation_params=item.generation_params, + download_url=download_url, + generated_at=item.generated_at.isoformat() if hasattr(item, "generated_at") and item.generated_at else "", + ) + + +@router.get("/videos", response_model=ListVideosResponse) +def list_videos( + project_id: str | None = Query(None, description="项目ID,不传则返回所有项目"), + status: str | None = Query(None, description="按状态筛选"), + review_status: str | None = Query(None, description="按复核状态筛选"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + repo=Depends(get_generated_video_repository), + storage: OSSStorageService = Depends(get_storage_service), + current_user: AuthenticatedUser = Depends(get_current_user), +): + """成片列表,支持分页、按项目/状态/复核状态筛选。""" + use_case = ListGeneratedVideosPaginatedUseCase(repo) + items, total = use_case.execute( + project_id=project_id, + status=status, + review_status=review_status, + page=page, + page_size=page_size, + ) + return ListVideosResponse( + items=[_to_video_response(item, storage) for item in items], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/videos/{video_id}", response_model=VideoItemResponse) +def get_video( + video_id: str, + repo=Depends(get_generated_video_repository), + storage: OSSStorageService = Depends(get_storage_service), + current_user: AuthenticatedUser = Depends(get_current_user), +): + """获取单个成片详情。""" + use_case = GetGeneratedVideoUseCase(repo) + item = use_case.execute(video_id) + if item is None: + raise HTTPException(status_code=404, detail="Video not found") + return _to_video_response(item, storage) + + +@router.patch("/videos/{video_id}/review", response_model=VideoItemResponse) +def update_video_review_status( + video_id: str, + request: UpdateVideoReviewRequest, + repo=Depends(get_generated_video_repository), + storage: OSSStorageService = Depends(get_storage_service), + current_user: AuthenticatedUser = Depends(get_current_user), +): + """更新成片复核状态:pending_review / approved / rejected。""" + use_case = UpdateVideoReviewStatusUseCase(repo) + item = use_case.execute(video_id, request.review_status) + if item is None: + raise HTTPException(status_code=404, detail="Video not found") + logger.info("Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user_id) + return _to_video_response(item, storage) + + +@router.post("/videos/batch-download", response_model=BatchDownloadResponse) +def batch_download_videos( + request: BatchDownloadRequest, + repo=Depends(get_generated_video_repository), + current_user: AuthenticatedUser = Depends(get_current_user), +): + """批量下载成片,异步打包 zip。 + + 传入 video_ids 列表,创建一个批量下载任务,任务完成后返回 zip 下载链接。 + """ + if not request.video_ids: + raise HTTPException(status_code=400, detail="video_ids cannot be empty") + if len(request.video_ids) > 50: + raise HTTPException(status_code=400, detail="Maximum 50 videos per batch download") + + # 校验视频都存在 + use_case = GetVideosByIdsUseCase(repo) + videos = use_case.execute(request.video_ids) + if len(videos) != len(request.video_ids): + raise HTTPException(status_code=404, detail="Some videos not found") + + # 发送 celery 任务 + task = celery_app.send_task( + "worker.batch_download_videos", + args=[request.video_ids, current_user.user_id], + ) + + logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids)) + return BatchDownloadResponse(job_id=task.id, status="pending") + + +@router.get("/videos/batch-download/{job_id}", response_model=BatchDownloadResponse) +def get_batch_download_status( + job_id: str, + current_user: AuthenticatedUser = Depends(get_current_user), +): + """查询批量下载任务状态。""" + from celery.result import AsyncResult + + task = AsyncResult(job_id, app=celery_app) + + status_map = { + "PENDING": "pending", + "STARTED": "running", + "SUCCESS": "success", + "FAILURE": "failed", + "RETRY": "pending", + "REVOKED": "cancelled", + } + api_status = status_map.get(task.state, "pending") + + download_url = None + if task.state == "SUCCESS" and task.result: + if isinstance(task.result, dict): + download_url = task.result.get("download_url") + elif isinstance(task.result, str): + download_url = task.result + + return BatchDownloadResponse( + job_id=job_id, + status=api_status, + download_url=download_url, + ) diff --git a/apps/api/app/api/routes/voice_clones.py b/apps/api/app/api/routes/voice_clones.py index 8a138c12f..19fcf8a21 100644 --- a/apps/api/app/api/routes/voice_clones.py +++ b/apps/api/app/api/routes/voice_clones.py @@ -172,6 +172,7 @@ def get_voice_clone_status( "/{clone_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, + response_class=Response, ) def delete_voice_clone( clone_id: str, @@ -184,7 +185,7 @@ def delete_voice_clone( deleted = use_case.execute(clone_id, user_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") - return Response(status_code=204) + return @router.post("/{clone_id}/retry", response_model=VoiceCloneProfileResponse) diff --git a/apps/api/app/api/routes/voices.py b/apps/api/app/api/routes/voices.py old mode 100755 new mode 100644 index a8830a709..9a7d8e5d4 --- a/apps/api/app/api/routes/voices.py +++ b/apps/api/app/api/routes/voices.py @@ -7,6 +7,7 @@ from __future__ import annotations from typing import Literal, Optional +from app.api.routes._helpers import get_user_plan from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository from app.schemas.voice import ( @@ -39,8 +40,6 @@ from packages.application.voice_library.use_cases import ( from packages.domain.preset_voices import PRESET_VOICES from packages.ports.user_repository import UserRepository -from app.api.routes._helpers import get_user_plan - router = APIRouter() @@ -323,7 +322,7 @@ def update_voice( return _to_response(item, sign_url) -@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) def delete_voice( voice_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -334,4 +333,4 @@ def delete_voice( deleted = use_case.execute(voice_id, user_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") - return Response(status_code=204) + return diff --git a/apps/api/app/core/storage.py b/apps/api/app/core/storage.py index 8c39c13f8..af20e14b9 100644 --- a/apps/api/app/core/storage.py +++ b/apps/api/app/core/storage.py @@ -5,8 +5,8 @@ This module keeps old import paths working so existing code does not need to change. """ +from packages.shared.storage import SharedStorageService as OSSStorageService from packages.shared.storage import ( - SharedStorageService as OSSStorageService, get_shared_storage_service, get_storage_service, ) diff --git a/apps/api/app/middleware/versioning.py b/apps/api/app/middleware/versioning.py index 48078fd7f..2563713a5 100644 --- a/apps/api/app/middleware/versioning.py +++ b/apps/api/app/middleware/versioning.py @@ -61,7 +61,7 @@ class APIVersionMiddleware(BaseHTTPMiddleware): class VersionNotFoundMiddleware(BaseHTTPMiddleware): """处理已下线的 API 版本""" - SUNSET_VERSIONS = [] # 已下线的版本列表 + SUNSET_VERSIONS: list[str] = [] # 已下线的版本列表 async def dispatch(self, request: Request, call_next): version = self._extract_version(request.url.path) diff --git a/apps/api/app/schemas/asset.py b/apps/api/app/schemas/asset.py old mode 100644 new mode 100755 index 1c686772f..afd5f74be --- a/apps/api/app/schemas/asset.py +++ b/apps/api/app/schemas/asset.py @@ -54,17 +54,45 @@ class AssetResponse(BaseModel): tag_ids: list[str] = Field(default_factory=list) +MAX_BATCH_SIZE = 200 + + class BatchDeleteRequest(BaseModel): - """批量删除请求。""" + """批量删除请求(软删除)。""" - ids: list[str] = Field(..., min_length=1, max_length=100, description="要删除的素材 ID 列表") + asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="要删除的素材 ID 列表") -class BatchDeleteResponse(BaseModel): - """批量删除响应。""" +class BatchOperationResponse(BaseModel): + """批量操作通用响应。""" - deleted_count: int = Field(..., ge=0, description="实际删除数量") - failed_ids: list[str] = Field(default_factory=list, description="删除失败的 ID 列表") + success_count: int = Field(..., ge=0, description="成功数量") + failed_ids: list[str] = Field(default_factory=list, description="失败的 ID 列表") + failed_details: dict[str, str] = Field(default_factory=dict, description="失败详情 {asset_id: reason}") + + +class BatchTagRequest(BaseModel): + """批量打标签请求。""" + + asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表") + tag_ids: list[str] = Field(..., min_length=1, max_length=50, description="标签 ID 列表") + mode: str = Field(default="add", pattern="^(add|replace)$", description="add=添加合并,replace=全量替换") + + +class BatchClassifyRequest(BaseModel): + """批量修改分类请求。""" + + asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表") + category: str = Field(..., min_length=1, max_length=50, description="内容分类,如 person/scenic/product") + + +class BatchMarkRequest(BaseModel): + """批量设置智能视图标记请求。""" + + asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表") + smart_view: str = Field( + ..., pattern="^(recommended|caution|high_risk)$", description="智能视图标记:recommended/caution/high_risk" + ) class ListAssetsResponse(BaseModel): diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py old mode 100644 new mode 100755 index 1724d3f74..10543d0f1 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -33,6 +33,17 @@ class CreateGenerationTaskRequest(BaseModel): asset_select_count: int = Field( default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)" ) + # ── 自动重试 ── + auto_retry_enabled: bool = Field( + default=False, + description="是否开启失败自动重试,默认关闭", + ) + auto_retry_max: int = Field( + default=0, + ge=0, + le=5, + description="最大自动重试次数,0表示不自动重试,最大5次", + ) @model_validator(mode="after") def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest": @@ -64,6 +75,10 @@ class GenerationTaskResponse(BaseModel): progress: float result_count: int error_message: str + error_info: dict = Field(default_factory=dict) + retry_count: int = 0 + auto_retry_enabled: bool = False + auto_retry_max: int = 0 logs: list[dict] = Field(default_factory=list) @field_validator("logs", mode="before") diff --git a/apps/api/app/schemas/task_center.py b/apps/api/app/schemas/task_center.py old mode 100644 new mode 100755 index 931fae17e..c2e29f06e --- a/apps/api/app/schemas/task_center.py +++ b/apps/api/app/schemas/task_center.py @@ -11,8 +11,10 @@ class ProjectTaskResponse(BaseModel): progress: float current_step: str error_message: str = "" + error_info: dict = Field(default_factory=dict) user_message: str = "" retryable: bool = False + retry_count: int = 0 source_id: str = "" template_id: str = "" created_at: datetime | None = None @@ -21,6 +23,7 @@ class ProjectTaskResponse(BaseModel): class ListProjectTasksResponse(BaseModel): items: list[ProjectTaskResponse] = Field(default_factory=list) + total: int = 0 class UserTaskResponse(BaseModel): @@ -34,8 +37,10 @@ class UserTaskResponse(BaseModel): progress: float current_step: str error_message: str = "" + error_info: dict = Field(default_factory=dict) user_message: str = "" retryable: bool = False + retry_count: int = 0 source_id: str = "" created_at: datetime | None = None updated_at: datetime | None = None @@ -45,3 +50,4 @@ class ListTasksResponse(BaseModel): """用户级任务列表响应(GET /api/v1/tasks)。""" items: list[UserTaskResponse] = Field(default_factory=list) + total: int = 0 diff --git a/apps/api/app/schemas/template.py b/apps/api/app/schemas/template.py old mode 100644 new mode 100755 index 8519a623b..6255f9d84 --- a/apps/api/app/schemas/template.py +++ b/apps/api/app/schemas/template.py @@ -45,6 +45,7 @@ class TemplateResponse(BaseModel): segments: List[SegmentResponse] = Field(default_factory=list) is_active: bool = True is_favorite: bool = False + usage_count: int = 0 created_at: datetime updated_at: datetime @@ -120,3 +121,25 @@ class CreateCategoryRequest(BaseModel): class ListCategoriesResponse(BaseModel): items: List[CategoryResponse] + + +# ── Copy Template ── + + +class CopyTemplateRequest(BaseModel): + new_name: str + + +# ── Tags ── + + +class ListTagsResponse(BaseModel): + items: List[str] + + +# ── Usage Stats ── + + +class TemplateUsageResponse(BaseModel): + template_id: str + usage_count: int diff --git a/apps/api/app/schemas/video_center.py b/apps/api/app/schemas/video_center.py new file mode 100755 index 000000000..664769d7a --- /dev/null +++ b/apps/api/app/schemas/video_center.py @@ -0,0 +1,45 @@ +from typing import Literal + +from pydantic import BaseModel, Field + +VideoReviewStatus = Literal["pending_review", "approved", "rejected"] + + +class VideoItemResponse(BaseModel): + id: str + project_id: str + generation_task_id: str + name: str + file_url: str + file_size: int + duration: float + thumbnail_url: str | None = None + width: int + height: int + fps: float + status: str = "completed" + review_status: str = "pending_review" + generation_params: dict = Field(default_factory=dict) + download_url: str | None = None + generated_at: str = "" + + +class ListVideosResponse(BaseModel): + items: list[VideoItemResponse] + total: int + page: int + page_size: int + + +class UpdateVideoReviewRequest(BaseModel): + review_status: VideoReviewStatus + + +class BatchDownloadRequest(BaseModel): + video_ids: list[str] + + +class BatchDownloadResponse(BaseModel): + job_id: str + status: str = "pending" + download_url: str | None = None diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py old mode 100644 new mode 100755 index bf8122705..6ff73b7a6 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -281,6 +281,8 @@ class EditPlanService: start_time: float = 0.0, duration: float = 0.0, transition_effect: str = "cut", + transition_duration: float = 0.0, + playback_speed: float = 1.0, config: Optional[dict[str, Any]] = None, ) -> EditPlanClip: """创建片段 @@ -301,6 +303,8 @@ class EditPlanService: start_time=start_time, duration=duration, transition_effect=transition_effect, + transition_duration=transition_duration, + playback_speed=playback_speed, config=config, ) created = self._clip_repo.create(clip) @@ -324,6 +328,8 @@ class EditPlanService: start_time: Optional[float] = None, duration: Optional[float] = None, transition_effect: Optional[str] = None, + transition_duration: Optional[float] = None, + playback_speed: Optional[float] = None, config: Optional[dict[str, Any]] = None, ) -> EditPlanClip: """更新片段 @@ -333,6 +339,15 @@ class EditPlanService: """ existing = self.get_clip_or_raise(clip_id) + # 速度边界钳制 + if playback_speed is not None: + if playback_speed <= 0: + playback_speed = 1.0 + elif playback_speed < 0.25: + playback_speed = 0.25 + elif playback_speed > 4.0: + playback_speed = 4.0 + updated = EditPlanClip( id=existing.id, plan_id=existing.plan_id, @@ -346,6 +361,10 @@ class EditPlanService: transition_effect=( transition_effect.strip() if transition_effect is not None else existing.transition_effect ), + transition_duration=( + transition_duration if transition_duration is not None else existing.transition_duration + ), + playback_speed=playback_speed if playback_speed is not None else existing.playback_speed, status=existing.status, config=config if config is not None else existing.config, created_at=existing.created_at, diff --git a/apps/api/app/services/job_service.py b/apps/api/app/services/job_service.py old mode 100755 new mode 100644 index d24ee32eb..ee80937e2 --- a/apps/api/app/services/job_service.py +++ b/apps/api/app/services/job_service.py @@ -13,7 +13,6 @@ from __future__ import annotations import logging from typing import Any - from packages.application.jobs import ( CancelJobUseCase, CompleteJobCommand, diff --git a/apps/web/e2e/assets-full.spec.ts b/apps/web/e2e/assets-full.spec.ts index 1f362547b..6adf5c254 100644 --- a/apps/web/e2e/assets-full.spec.ts +++ b/apps/web/e2e/assets-full.spec.ts @@ -86,7 +86,10 @@ async function createProject( ): Promise { const resp = await request.post(`${apiBase}/projects`, { headers, - data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" }, + data: { + name: `Assets Test Proj ${suffix}`, + description: "E2E assets test", + }, }); expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy(); const data = await resp.json(); @@ -178,20 +181,30 @@ test.describe("素材库页面 - 完整交互测试", () => { test("素材库列表页面加载", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-load"); + const projectId = await createProject( request, - "assets-load", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); await createLibrary(request, headers, projectId, "默认视频库", "video"); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); // 页面布局容器 - await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 }); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-page")).toBeVisible({ + timeout: 20_000, + }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); // 左侧素材库列表 await expect(page.locator(".xx-asset-library-list")).toBeVisible(); @@ -211,23 +224,33 @@ test.describe("素材库页面 - 完整交互测试", () => { test("创建新素材库 - 通过 UI", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-create"); + const projectId = await createProject( request, - "assets-create", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); await createLibrary(request, headers, projectId, "初始库", "video"); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); // 点击新建素材库 await page.locator(".xx-asset-library-add").click(); // 弹窗出现 - const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" }); + const modal = page + .locator(".ant-modal-content") + .filter({ hasText: "新建素材库" }); await expect(modal).toBeVisible(); // 填写表单 @@ -259,11 +282,13 @@ test.describe("素材库页面 - 完整交互测试", () => { test("切换不同素材库", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-switch"); + const projectId = await createProject( request, - "assets-switch", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); const videoLibName = "视频素材库 A"; const imageLibName = "图片素材库 B"; @@ -292,10 +317,16 @@ test.describe("素材库页面 - 完整交互测试", () => { "demo_video.mp4", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); // 点击视频库,应显示素材 const videoLibItem = page @@ -305,7 +336,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await expect(videoLibItem).toHaveClass(/active/); // 验证视频素材出现 - await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("demo_video.mp4")).toBeVisible({ + timeout: 10_000, + }); // 点击图片库,应切换且不显示视频 const imageLibItem = page @@ -315,18 +348,22 @@ test.describe("素材库页面 - 完整交互测试", () => { await expect(imageLibItem).toHaveClass(/active/); // 空状态或图片库内容 - await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 }); + await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { + timeout: 5_000, + }); }); // ─── 素材搜索 ────────────────────────────────────── test("素材搜索功能", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-search"); + const projectId = await createProject( request, - "assets-search", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); const libraryId = await createLibrary( request, headers, @@ -336,13 +373,33 @@ test.describe("素材库页面 - 完整交互测试", () => { ); // 创建两个不同名称的素材 - await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4"); - await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4"); + await createAsset( + request, + headers, + projectId, + libraryId, + userId, + "apple_clip.mp4", + ); + await createAsset( + request, + headers, + projectId, + libraryId, + userId, + "banana_clip.mp4", + ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); // 确保在测试库中 const libItem = page @@ -351,7 +408,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await libItem.click({ force: true }); // 两个素材都应可见 - await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("apple_clip.mp4")).toBeVisible({ + timeout: 10_000, + }); await expect(page.getByText("banana_clip.mp4")).toBeVisible(); // 搜索 apple,只显示 apple @@ -361,7 +420,9 @@ test.describe("素材库页面 - 完整交互测试", () => { // 清空搜索,两个都显示 await page.getByPlaceholder("搜索素材名称...").fill(""); - await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText("apple_clip.mp4")).toBeVisible({ + timeout: 5_000, + }); await expect(page.getByText("banana_clip.mp4")).toBeVisible(); }); @@ -369,11 +430,13 @@ test.describe("素材库页面 - 完整交互测试", () => { test("素材类型筛选", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-filter"); + const projectId = await createProject( request, - "assets-filter", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); const libraryId = await createLibrary( request, headers, @@ -383,12 +446,25 @@ test.describe("素材库页面 - 完整交互测试", () => { ); // 创建视频素材 - await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4"); + await createAsset( + request, + headers, + projectId, + libraryId, + userId, + "video_clip.mp4", + ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); const libItem = page .locator(".xx-asset-library-item") @@ -396,7 +472,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await libItem.click({ force: true }); // 素材应可见 - await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("video_clip.mp4")).toBeVisible({ + timeout: 10_000, + }); // 筛选类型下拉存在 const filterSelect = page.locator(".xx-assets-filters-left select").first(); @@ -407,11 +485,13 @@ test.describe("素材库页面 - 完整交互测试", () => { test("素材详情查看 - 播放弹窗", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-detail"); + const projectId = await createProject( request, - "assets-detail", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); const libraryId = await createLibrary( request, headers, @@ -419,12 +499,25 @@ test.describe("素材库页面 - 完整交互测试", () => { "详情测试库", "video", ); - await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4"); + await createAsset( + request, + headers, + projectId, + libraryId, + userId, + "play_test.mp4", + ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); const libItem = page .locator(".xx-asset-library-item") @@ -441,7 +534,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await assetCard.locator(".xx-asset-play").click({ force: true }); // 播放弹窗出现 - const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" }); + const modal = page + .locator(".ant-modal-content") + .filter({ hasText: "播放" }); await expect(modal).toBeVisible(); // 关闭弹窗 @@ -453,11 +548,13 @@ test.describe("素材库页面 - 完整交互测试", () => { test("删除素材 - 带确认对话框", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-delete"); + const projectId = await createProject( request, - "assets-delete", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); const libraryId = await createLibrary( request, headers, @@ -465,12 +562,25 @@ test.describe("素材库页面 - 完整交互测试", () => { "删除测试库", "video", ); - await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4"); + await createAsset( + request, + headers, + projectId, + libraryId, + userId, + "to_delete.mp4", + ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); const libItem = page .locator(".xx-asset-library-item") @@ -491,14 +601,15 @@ test.describe("素材库页面 - 完整交互测试", () => { await deleteBtn.click({ force: true }); // 确认对话框出现 - const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" }); + const confirmModal = page + .locator(".ant-popover") + .filter({ hasText: "确认删除" }); await expect(confirmModal).toBeVisible(); // 监听删除请求 const deletePromise = page.waitForResponse( (resp) => - resp.url().includes("/assets/") && - resp.request().method() === "DELETE", + resp.url().includes("/assets/") && resp.request().method() === "DELETE", { timeout: 10_000 }, ); @@ -518,11 +629,13 @@ test.describe("素材库页面 - 完整交互测试", () => { test("批量删除素材", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-batch"); + const projectId = await createProject( request, - "assets-batch", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); const libraryId = await createLibrary( request, headers, @@ -532,14 +645,41 @@ test.describe("素材库页面 - 完整交互测试", () => { ); // 创建多个素材 - await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4"); - await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4"); - await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4"); + await createAsset( + request, + headers, + projectId, + libraryId, + userId, + "batch_1.mp4", + ); + await createAsset( + request, + headers, + projectId, + libraryId, + userId, + "batch_2.mp4", + ); + await createAsset( + request, + headers, + projectId, + libraryId, + userId, + "batch_3.mp4", + ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); const libItem = page .locator(".xx-asset-library-item") @@ -547,7 +687,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await libItem.click({ force: true }); // 所有素材应可见 - await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("batch_1.mp4")).toBeVisible({ + timeout: 10_000, + }); await expect(page.getByText("batch_2.mp4")).toBeVisible(); await expect(page.getByText("batch_3.mp4")).toBeVisible(); @@ -567,7 +709,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await batchDeleteBtn.click(); // 确认对话框 - const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" }); + const confirmPop = page + .locator(".ant-popover") + .filter({ hasText: "确定删除" }); await expect(confirmPop).toBeVisible(); // 确认删除 @@ -595,17 +739,25 @@ test.describe("素材库页面 - 完整交互测试", () => { test("空素材库展示空状态", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-empty"); + const projectId = await createProject( request, - "assets-empty", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); await createLibrary(request, headers, projectId, "空素材库", "video"); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); const libItem = page .locator(".xx-asset-library-item") @@ -613,7 +765,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await libItem.click({ force: true }); // 空状态应显示 - await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 }); + await expect(page.locator(".xx-assets-empty")).toBeVisible({ + timeout: 10_000, + }); await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible(); }); diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index 8e7c63e13..3db8d3981 100755 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -251,6 +251,9 @@ test.describe("Core generation flow", () => { await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 15_000, }); + + // 清理所有路由,避免页面关闭时飞地API请求导致测试报错 + await page.unrouteAll({ behavior: "ignoreErrors" }); }); test("generation task API creates and lists tasks", async ({ request }) => { diff --git a/apps/web/e2e/core-upload.spec.ts b/apps/web/e2e/core-upload.spec.ts index cbaafc072..619149ee2 100755 --- a/apps/web/e2e/core-upload.spec.ts +++ b/apps/web/e2e/core-upload.spec.ts @@ -180,9 +180,11 @@ test.describe("Core media upload flow", () => { await expect(page.locator(".xx-assets-content")).toBeVisible({ timeout: 20_000, }); - await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({ - timeout: 20_000, - }); + await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible( + { + timeout: 20_000, + }, + ); // Verify asset card shows status const assetCard = page diff --git a/apps/web/e2e/duplication.spec.ts b/apps/web/e2e/duplication.spec.ts index 3733d719c..811a971df 100644 --- a/apps/web/e2e/duplication.spec.ts +++ b/apps/web/e2e/duplication.spec.ts @@ -121,7 +121,11 @@ test.describe("去重流程", () => { "dup-load", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication"); @@ -146,7 +150,11 @@ test.describe("去重流程", () => { "dup-upload-zone", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); @@ -156,12 +164,12 @@ test.describe("去重流程", () => { await expect(uploadZone).toBeVisible(); // 上传图标和文字 - await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible(); + await expect( + uploadZone.getByText("点击或拖拽视频文件到此区域"), + ).toBeVisible(); // 格式提示 - await expect( - uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/), - ).toBeVisible(); + await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible(); // 格式标签 await expect(page.locator(".dup-upload-formats")).toBeVisible(); @@ -184,7 +192,11 @@ test.describe("去重流程", () => { "dup-info", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); @@ -215,7 +227,11 @@ test.describe("去重流程", () => { "dup-list", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication/results"); @@ -239,7 +255,11 @@ test.describe("去重流程", () => { "dup-list-empty", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); @@ -257,7 +277,11 @@ test.describe("去重流程", () => { "dup-filter", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); @@ -287,7 +311,11 @@ test.describe("去重流程", () => { "dup-nav", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); @@ -306,10 +334,8 @@ test.describe("去重流程", () => { request, }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-detail", - ); + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "dup-detail"); // 先上传一个文件进行查重,获取 record id const uploadResp = await request.post(`${apiBase}/duplication/upload`, { @@ -335,7 +361,11 @@ test.describe("去重流程", () => { const recordId = uploadData.id; expect(recordId, "应返回查重记录 ID").toBeTruthy(); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); // 访问详情页 await page.goto(`/app/duplication/${recordId}`); @@ -353,10 +383,8 @@ test.describe("去重流程", () => { test("去重记录删除 - API 验证", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-delete", - ); + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "dup-delete"); // 创建查重记录 const uploadResp = await request.post(`${apiBase}/duplication/upload`, { @@ -419,10 +447,8 @@ test.describe("去重流程", () => { test("去重记录删除 - UI 验证", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-delete-ui", - ); + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "dup-delete-ui"); // 创建查重记录 const uploadResp = await request.post(`${apiBase}/duplication/upload`, { @@ -443,14 +469,20 @@ test.describe("去重流程", () => { return; } - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); // 记录卡片应存在 const resultCard = page.locator(".dup-result-card").first(); - const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false); + const cardVisible = await resultCard + .isVisible({ timeout: 10_000 }) + .catch(() => false); if (cardVisible) { // 删除按钮存在 @@ -467,12 +499,14 @@ test.describe("去重流程", () => { }); // 监听删除请求 - const deletePromise = page.waitForResponse( - (resp) => - resp.url().includes("/duplication/records/") && - resp.request().method() === "DELETE", - { timeout: 10_000 }, - ).catch(() => null); + const deletePromise = page + .waitForResponse( + (resp) => + resp.url().includes("/duplication/records/") && + resp.request().method() === "DELETE", + { timeout: 10_000 }, + ) + .catch(() => null); await deleteBtn.click(); @@ -487,10 +521,8 @@ test.describe("去重流程", () => { test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-retry", - ); + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "dup-retry"); // 创建查重记录 const uploadResp = await request.post(`${apiBase}/duplication/upload`, { @@ -511,14 +543,20 @@ test.describe("去重流程", () => { return; } - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); // 记录列表中至少有一条记录 const resultCard = page.locator(".dup-result-card").first(); - const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false); + const cardVisible = await resultCard + .isVisible({ timeout: 10_000 }) + .catch(() => false); if (cardVisible) { // 验证记录卡片基本结构 diff --git a/apps/web/e2e/editing-planner.spec.ts b/apps/web/e2e/editing-planner.spec.ts index 83b9ca914..38c5dcd33 100644 --- a/apps/web/e2e/editing-planner.spec.ts +++ b/apps/web/e2e/editing-planner.spec.ts @@ -6,7 +6,12 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; +import { + expect, + test, + type APIRequestContext, + type Page, +} from "@playwright/test"; const PASSWORD = "Test123456!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -258,7 +263,12 @@ test.describe("剪辑计划 - API 操作", () => { mode: "pip", estimated_duration: 30, segments: [ - { segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" }, + { + segment_order: 1, + duration_min: 5, + duration_max: 10, + material_type: "video", + }, ], }, }); @@ -269,7 +279,12 @@ test.describe("剪辑计划 - API 操作", () => { mode: "voice_over", estimated_duration: 60, segments: [ - { segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" }, + { + segment_order: 1, + duration_min: 10, + duration_max: 30, + material_type: "video", + }, ], }, }); diff --git a/apps/web/e2e/products.spec.ts b/apps/web/e2e/products.spec.ts index e8f31b331..32e8516bc 100644 --- a/apps/web/e2e/products.spec.ts +++ b/apps/web/e2e/products.spec.ts @@ -93,7 +93,8 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) { resolution: "1080x1920", file_size: (5 + i) * 1024 * 1024, duplicate_rate: i * 5, - video_url: status === "completed" ? "https://example.com/video.mp4" : undefined, + video_url: + status === "completed" ? "https://example.com/video.mp4" : undefined, thumbnail_url: undefined, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), @@ -218,7 +219,11 @@ test.describe("作品库页面", () => { const products = mockProducts(3, ["completed", "processing", "failed"]); await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); @@ -255,12 +260,24 @@ test.describe("作品库页面", () => { const products = [ { ...mockProducts(1, ["completed"])[0], title: "已完成作品" }, - { ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` }, - { ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` }, + { + ...mockProducts(1, ["processing"])[0], + title: "处理中作品", + id: `mock-prod-${Date.now()}-p`, + }, + { + ...mockProducts(1, ["failed"])[0], + title: "失败作品", + id: `mock-prod-${Date.now()}-f`, + }, ]; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-page")).toBeVisible({ @@ -276,9 +293,9 @@ test.describe("作品库页面", () => { const completedCard = page .locator(".xx-product-card") .filter({ hasText: "已完成作品" }); - await expect(completedCard.locator(".xx-product-status.completed")).toHaveText( - "已完成", - ); + await expect( + completedCard.locator(".xx-product-status.completed"), + ).toHaveText("已完成"); const processingCard = page .locator(".xx-product-card") @@ -309,7 +326,11 @@ test.describe("作品库页面", () => { const productId = products[0].id; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); // 直接访问详情页 await page.goto(`/app/products/${productId}`); @@ -337,7 +358,11 @@ test.describe("作品库页面", () => { products[0].video_url = "https://example.com/test-video.mp4"; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -356,7 +381,10 @@ test.describe("作品库页面", () => { // 播放弹窗出现 - 验证有视频元素或播放器容器 // (通过 Mock 的 video_url,video 元素应能渲染) const videoEl = page.locator("video"); - const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false); + const videoVisible = await videoEl + .first() + .isVisible({ timeout: 5000 }) + .catch(() => false); // 或弹窗容器可见 const modalVisible = await page .locator(".ant-modal-content") @@ -380,7 +408,11 @@ test.describe("作品库页面", () => { products[0].title = "下载测试作品"; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -409,7 +441,11 @@ test.describe("作品库页面", () => { products[0].title = "处理中下载测试"; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -484,7 +520,11 @@ test.describe("作品库页面", () => { route.continue(); }); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -507,9 +547,12 @@ test.describe("作品库页面", () => { const { headers } = await createAuthedUser(request, "products-del-api"); // 测试删除不存在的产品,验证 API 端点存在 - const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, { - headers, - }); + const resp = await request.delete( + `${apiBase}/products/nonexistent-test-id`, + { + headers, + }, + ); // 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在) // 404 表示资源不存在但端点存在 @@ -529,7 +572,11 @@ test.describe("作品库页面", () => { // Mock 空列表 await mockProductsApi(page, []); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-page")).toBeVisible({ @@ -553,12 +600,24 @@ test.describe("作品库页面", () => { ); const products = [ - { ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` }, - { ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` }, + { + ...mockProducts(1, ["completed"])[0], + title: "苹果宣传视频", + id: `mock-prod-${Date.now()}-apple`, + }, + { + ...mockProducts(1, ["completed"])[0], + title: "香蕉推广视频", + id: `mock-prod-${Date.now()}-banana`, + }, ]; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -566,7 +625,9 @@ test.describe("作品库页面", () => { }); // 两个作品都可见 - await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText("苹果宣传视频")).toBeVisible({ + timeout: 5_000, + }); await expect(page.getByText("香蕉推广视频")).toBeVisible(); // 搜索"苹果" @@ -576,7 +637,9 @@ test.describe("作品库页面", () => { // 清空搜索 await page.getByPlaceholder("搜索成片名称...").fill(""); - await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText("香蕉推广视频")).toBeVisible({ + timeout: 5_000, + }); }); test("作品状态筛选", async ({ page, request }) => { @@ -587,12 +650,24 @@ test.describe("作品库页面", () => { ); const products = [ - { ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` }, - { ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` }, + { + ...mockProducts(1, ["completed"])[0], + title: "已完成筛选", + id: `mock-prod-${Date.now()}-done`, + }, + { + ...mockProducts(1, ["processing"])[0], + title: "处理中筛选", + id: `mock-prod-${Date.now()}-proc`, + }, ]; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -629,7 +704,11 @@ test.describe("作品库页面", () => { products[2].title = "批量测试 3"; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -653,8 +732,12 @@ test.describe("作品库页面", () => { await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible(); // 批量按钮存在 - await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible(); - await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible(); + await expect( + batchBar.getByRole("button", { name: "批量下载" }), + ).toBeVisible(); + await expect( + batchBar.getByRole("button", { name: "批量删除" }), + ).toBeVisible(); // 取消选择 await batchBar.getByRole("button", { name: "取消选择" }).click(); diff --git a/apps/web/e2e/profile.spec.ts b/apps/web/e2e/profile.spec.ts index ca00d5463..4d14e2262 100644 --- a/apps/web/e2e/profile.spec.ts +++ b/apps/web/e2e/profile.spec.ts @@ -6,7 +6,12 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; +import { + expect, + test, + type APIRequestContext, + type Page, +} from "@playwright/test"; const PASSWORD = "Test123456!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -401,7 +406,10 @@ test.describe("个人设置 - 退出登录", () => { test.describe.configure({ timeout: 120_000 }); test("登出 API - 正向", async ({ request }) => { - const { headers, email } = await createAuthedUser(request, "profile-logout"); + const { headers, email } = await createAuthedUser( + request, + "profile-logout", + ); const response = await request.post(`${apiBase}/auth/logout`, { headers, diff --git a/apps/web/e2e/register.spec.ts b/apps/web/e2e/register.spec.ts index 17b7217c7..672924cc0 100644 --- a/apps/web/e2e/register.spec.ts +++ b/apps/web/e2e/register.spec.ts @@ -49,7 +49,9 @@ test.describe("注册页面", () => { await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪"); // 标题/描述 - await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible(); + await expect( + page.getByText("创建账户,开启智能视频创作之旅"), + ).toBeVisible(); // 表单字段 await expect(page.getByLabel("邮箱")).toBeVisible(); @@ -69,7 +71,10 @@ test.describe("注册页面", () => { await page.goto("/register"); // 直接点击注册按钮 - await page.locator("button[type='submit']").filter({ hasText: "注册" }).click(); + await page + .locator("button[type='submit']") + .filter({ hasText: "注册" }) + .click(); // 应显示必填错误 await expect(page.getByText("请输入邮箱")).toBeVisible(); @@ -86,7 +91,10 @@ test.describe("注册页面", () => { await page.getByLabel("密码").fill(PASSWORD); await page.getByLabel("确认密码").fill(PASSWORD); - await page.locator("button[type='submit']").filter({ hasText: "注册" }).click(); + await page + .locator("button[type='submit']") + .filter({ hasText: "注册" }) + .click(); // 应显示邮箱格式错误 await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible(); @@ -100,7 +108,10 @@ test.describe("注册页面", () => { await page.getByLabel("密码").fill("123"); await page.getByLabel("确认密码").fill("123"); - await page.locator("button[type='submit']").filter({ hasText: "注册" }).click(); + await page + .locator("button[type='submit']") + .filter({ hasText: "注册" }) + .click(); // 应显示密码长度错误 await expect(page.getByText("密码至少 8 个字符")).toBeVisible(); @@ -114,7 +125,10 @@ test.describe("注册页面", () => { await page.getByLabel("密码").fill(PASSWORD); await page.getByLabel("确认密码").fill("Different123!"); - await page.locator("button[type='submit']").filter({ hasText: "注册" }).click(); + await page + .locator("button[type='submit']") + .filter({ hasText: "注册" }) + .click(); // 应显示密码不一致错误 await expect(page.getByText("两次输入的密码不一致")).toBeVisible(); @@ -128,7 +142,10 @@ test.describe("注册页面", () => { await page.getByLabel("密码").fill(PASSWORD); await page.getByLabel("确认密码").fill(PASSWORD); - await page.locator("button[type='submit']").filter({ hasText: "注册" }).click(); + await page + .locator("button[type='submit']") + .filter({ hasText: "注册" }) + .click(); await expect(page.getByText("请输入用户名")).toBeVisible(); }); @@ -154,10 +171,16 @@ test.describe("注册页面", () => { { timeout: 15_000 }, ); - await page.locator("button[type='submit']").filter({ hasText: "注册" }).click(); + await page + .locator("button[type='submit']") + .filter({ hasText: "注册" }) + .click(); const resp = await registerResponse; - expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy(); + expect( + resp.ok(), + `注册请求应返回 2xx,实际: ${resp.status()}`, + ).toBeTruthy(); // 注册成功后应跳转到登录页或显示成功消息 // 页面应停留在可识别的状态(成功提示或跳转) @@ -199,14 +222,19 @@ test.describe("注册页面", () => { await page.getByLabel("密码").fill(PASSWORD); await page.getByLabel("确认密码").fill(PASSWORD); - await page.locator("button[type='submit']").filter({ hasText: "注册" }).click(); + await page + .locator("button[type='submit']") + .filter({ hasText: "注册" }) + .click(); // 应显示错误提示(通过 antd message 或表单错误) await expect .poll( async () => { // 检查是否有错误消息 - const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible(); + const hasError = await page + .getByText(/注册失败|已注册|已存在|exists/) + .isVisible(); return hasError ? "error_shown" : "waiting"; }, { timeout: 10_000 }, @@ -254,7 +282,12 @@ test.describe("注册页面", () => { // 注册 await request.post(`${apiBase}/auth/register`, { - data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" }, + data: { + email, + password: PASSWORD, + username, + display_name: "Reg Auth Test", + }, }); // 登录 @@ -293,6 +326,8 @@ test.describe("注册页面", () => { // 注册页对已登录用户也可访问(注册页是公开页面) // 验证页面正常渲染 await expect(page.getByLabel("邮箱")).toBeVisible(); - await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible(); + await expect( + page.locator("button[type='submit']").filter({ hasText: "注册" }), + ).toBeVisible(); }); }); diff --git a/apps/web/e2e/subscription-full.spec.ts b/apps/web/e2e/subscription-full.spec.ts index 871a8f8da..27aeff2c5 100644 --- a/apps/web/e2e/subscription-full.spec.ts +++ b/apps/web/e2e/subscription-full.spec.ts @@ -9,7 +9,12 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; +import { + expect, + test, + type APIRequestContext, + type Page, +} from "@playwright/test"; const PASSWORD = "Test123456!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -243,8 +248,13 @@ test.describe("订阅套餐页 - 升级交互", () => { const url = page.url(); // 验证页面有响应(跳转到支付或保持在订阅页但有弹窗) expect( - url.includes("/subscription/upgrade") || url.includes("/subscription") || - (await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)), + url.includes("/subscription/upgrade") || + url.includes("/subscription") || + (await page + .locator(".ant-modal, [role='dialog']") + .first() + .isVisible() + .catch(() => false)), ).toBeTruthy(); } }); @@ -538,13 +548,16 @@ test.describe("订阅 - 支付流程", () => { test("创建支付订单 - 正向 API", async ({ request }) => { const { headers } = await createAuthedUser(request, "sub-pay-api"); - const response = await request.post(`${apiBase}/subscription/create-order`, { - headers, - data: { - plan_id: "pro", - billing_cycle: "monthly", + const response = await request.post( + `${apiBase}/subscription/create-order`, + { + headers, + data: { + plan_id: "pro", + billing_cycle: "monthly", + }, }, - }); + ); // 创建支付订单可能成功或接口不存在 expect( @@ -560,12 +573,15 @@ test.describe("订阅 - 支付流程", () => { }); test("未登录创建订单 - 反向", async ({ request }) => { - const response = await request.post(`${apiBase}/subscription/create-order`, { - data: { - plan_id: "pro", - billing_cycle: "monthly", + const response = await request.post( + `${apiBase}/subscription/create-order`, + { + data: { + plan_id: "pro", + billing_cycle: "monthly", + }, }, - }); + ); expect([401, 403, 404]).toContain(response.status()); }); }); diff --git a/apps/web/e2e/subscription.spec.ts b/apps/web/e2e/subscription.spec.ts index 2e8075c83..705909cbd 100755 --- a/apps/web/e2e/subscription.spec.ts +++ b/apps/web/e2e/subscription.spec.ts @@ -178,7 +178,10 @@ test.describe("订阅过期处理", () => { // 免费用户可能不需要取消,返回 400 或类似错误 if (!response.ok()) { const data = await response.json(); - expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy(); + expect( + data.error?.message || data.detail || data.message, + "应返回错误信息", + ).toBeTruthy(); } }); diff --git a/apps/web/e2e/templates.spec.ts b/apps/web/e2e/templates.spec.ts index 9c1245cbf..1a4899479 100644 --- a/apps/web/e2e/templates.spec.ts +++ b/apps/web/e2e/templates.spec.ts @@ -6,7 +6,12 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; +import { + expect, + test, + type APIRequestContext, + type Page, +} from "@playwright/test"; const PASSWORD = "Test123456!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -326,7 +331,9 @@ test.describe("模板库 - 模板展示", () => { if (await modal.isVisible({ timeout: 5_000 })) { await expect(modal).toBeVisible(); // 验证预览内容存在 - await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible(); + await expect( + modal.locator(".xx-template-modal-title-row"), + ).toBeVisible(); } } }); @@ -487,7 +494,10 @@ test.describe("模板库 - API 操作", () => { `${apiBase}/templates/${templateId}/favorite`, { headers }, ); - expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy(); + expect( + unfavResp.status() < 500, + "取消收藏请求应返回 2xx 或 4xx", + ).toBeTruthy(); }); test("获取模板详情 - 正向", async ({ request }) => { @@ -515,10 +525,9 @@ test.describe("模板库 - API 操作", () => { expect(createResp.ok()).toBeTruthy(); const created = await createResp.json(); - const detailResp = await request.get( - `${apiBase}/templates/${created.id}`, - { headers }, - ); + const detailResp = await request.get(`${apiBase}/templates/${created.id}`, { + headers, + }); expect(detailResp.ok(), "获取详情应成功").toBeTruthy(); const detail = await detailResp.json(); expect(detail.id).toBe(created.id); diff --git a/apps/web/e2e/test_auth.spec.ts b/apps/web/e2e/test_auth.spec.ts index 6132d6f90..611c1a108 100755 --- a/apps/web/e2e/test_auth.spec.ts +++ b/apps/web/e2e/test_auth.spec.ts @@ -175,10 +175,9 @@ test.describe("认证流程", () => { }, }); - expect( - [400, 422], - "缺少用户名字段应返回 4xx 校验错误", - ).toContain(response.status()); + expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain( + response.status(), + ); }); // ─── 登录 ──────────────────────────────────────────── @@ -230,7 +229,9 @@ test.describe("认证流程", () => { data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD }, }); if (response.status() !== 429) break; - console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`); + console.log( + `[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`, + ); await new Promise((r) => setTimeout(r, 65_000)); } diff --git a/apps/web/e2e/titles-full.spec.ts b/apps/web/e2e/titles-full.spec.ts index ba377f494..b5bde078d 100644 --- a/apps/web/e2e/titles-full.spec.ts +++ b/apps/web/e2e/titles-full.spec.ts @@ -9,7 +9,12 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; +import { + expect, + test, + type APIRequestContext, + type Page, +} from "@playwright/test"; const PASSWORD = "Test123456!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -224,7 +229,11 @@ test.describe("标题库 - API 完整操作", () => { test("编辑标题 - 正向", async ({ request }) => { const { headers } = await createAuthedUser(request, "title-update"); - const titleId = await createTitle(request, headers, Date.now().toString(36)); + const titleId = await createTitle( + request, + headers, + Date.now().toString(36), + ); const newName = `更新后的标题 ${Date.now()}`; const newText = "这是更新后的标题内容"; @@ -256,7 +265,11 @@ test.describe("标题库 - API 完整操作", () => { test("删除标题 - 正向", async ({ request }) => { const { headers } = await createAuthedUser(request, "title-delete"); - const titleId = await createTitle(request, headers, Date.now().toString(36)); + const titleId = await createTitle( + request, + headers, + Date.now().toString(36), + ); // 删除 const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, { @@ -279,9 +292,21 @@ test.describe("标题库 - API 完整操作", () => { const suffix = Date.now().toString(36); const titles = [ - { name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" }, - { name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" }, - { name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" }, + { + name: `批量标题 1 ${suffix}`, + text: `内容 1 ${suffix}`, + category: "default", + }, + { + name: `批量标题 2 ${suffix}`, + text: `内容 2 ${suffix}`, + category: "种草", + }, + { + name: `批量标题 3 ${suffix}`, + text: `内容 3 ${suffix}`, + category: "知识", + }, ]; const response = await request.post(`${apiBase}/titles/batch-import`, { @@ -297,7 +322,9 @@ test.describe("标题库 - API 完整操作", () => { if (response.ok()) { const data = await response.json(); - expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy(); + expect( + Array.isArray(data) || data.success_count !== undefined, + ).toBeTruthy(); } }); diff --git a/apps/web/e2e/voice-clone.spec.ts b/apps/web/e2e/voice-clone.spec.ts index 03a3b109b..69f4793b9 100644 --- a/apps/web/e2e/voice-clone.spec.ts +++ b/apps/web/e2e/voice-clone.spec.ts @@ -6,7 +6,12 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; +import { + expect, + test, + type APIRequestContext, + type Page, +} from "@playwright/test"; const PASSWORD = "Test123456!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -326,10 +331,9 @@ test.describe("声音克隆 - API 操作", () => { ).toBeTruthy(); // 验证已删除 - const getResp = await request.get( - `${apiBase}/voice-clones/${cloneId}`, - { headers }, - ); + const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, { + headers, + }); expect([404, 410]).toContain(getResp.status()); } // 如果创建失败(比如音频格式问题),测试也通过 @@ -491,11 +495,15 @@ test.describe("声音克隆 - 上传区域", () => { }); // 尝试点击克隆新音色按钮 - const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ }); + const cloneBtn = page.getByRole("button", { + name: /克隆新音色|立即克隆|新建/, + }); if (await cloneBtn.isVisible()) { await cloneBtn.click(); // 弹窗应该出现 - const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']"); + const modal = page.locator( + ".ant-modal, .vc-edit-dialog, [role='dialog']", + ); if (await modal.first().isVisible({ timeout: 5_000 })) { await expect(modal.first()).toBeVisible(); } diff --git a/apps/web/e2e/voices.spec.ts b/apps/web/e2e/voices.spec.ts index 35a08df24..762e7c924 100644 --- a/apps/web/e2e/voices.spec.ts +++ b/apps/web/e2e/voices.spec.ts @@ -6,7 +6,12 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; +import { + expect, + test, + type APIRequestContext, + type Page, +} from "@playwright/test"; const PASSWORD = "Test123456!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -157,7 +162,9 @@ test.describe("音色库页面 - 页面加载", () => { }); // 验证搜索框存在 - const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']"); + const searchInput = page.locator( + "input[type='search'], .xx-voices-search input, input[placeholder*='搜索']", + ); await expect(searchInput.first()).toBeVisible({ timeout: 10_000 }); }); }); diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 0eefe34a8..49eda5fe0 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -33,6 +33,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", "jsdom": "^24.1.0", + "prettier": "^3.0.0", "typescript": "^5.5.3", "vite": "^5.3.1", "vitest": "^1.6.0" @@ -4828,6 +4829,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 8fe1514e2..313634fa1 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -42,6 +42,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", "jsdom": "^24.1.0", + "prettier": "^3.0.0", "typescript": "^5.5.3", "vite": "^5.3.1", "vitest": "^1.6.0" diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index b7a93b241..bbf0ec2a4 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -26,6 +26,9 @@ export default defineConfig({ use: { ...devices["Desktop Chrome"], channel: process.env.E2E_BROWSER_CHANNEL || "msedge", + launchOptions: { + args: ["--disable-gpu", "--disable-software-rasterizer"], + }, }, }, { @@ -51,6 +54,9 @@ export default defineConfig({ use: { ...devices["Desktop Chrome"], channel: process.env.E2E_BROWSER_CHANNEL || "msedge", + launchOptions: { + args: ["--disable-gpu", "--disable-software-rasterizer"], + }, }, }, ], diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index 1ccaf9bee..987590a79 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -5,6 +5,32 @@ import apiClient from "./client"; import { getOrCreateDefaultProject } from "./projects"; +/** 素材元数据 */ +export interface AssetMetadata { + /** 时长(秒) */ + duration?: number; + /** 宽度(像素) */ + width?: number; + /** 高度(像素) */ + height?: number; + /** 比特率(bps) */ + bitrate?: number; + /** 编码格式 */ + codec?: string; + /** 帧率 */ + fps?: number; + /** 采样率(Hz) */ + sample_rate?: number; + /** 声道数 */ + channels?: number; + /** 其他扩展字段 */ + [key: string]: unknown; +} + +/** 素材分类状态 */ +export type AssetClassificationStatus = + "pending" | "processing" | "completed" | "failed"; + /** 素材条目 */ export interface AssetItem { id: string; @@ -12,12 +38,14 @@ export interface AssetItem { name: string; storage_key: string; mime_type: string; - metadata: Record; + metadata: AssetMetadata; file_size?: number; file_url?: string; thumbnail_url?: string; + /** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */ + duration?: number; status?: string; - classification_status?: string | null; + classification_status?: AssetClassificationStatus | null; quality_score?: number | null; tag_ids?: string[]; created_at?: string; @@ -167,7 +195,7 @@ export const createAsset = async (data: { name: string; storage_key: string; mime_type: string; - metadata?: Record; + metadata?: AssetMetadata; }): Promise => { const response = await apiClient.post("/assets", data); return response.data; @@ -176,7 +204,7 @@ export const createAsset = async (data: { /** 更新素材(名称、metadata 等) */ export const updateAsset = async ( assetId: string, - data: { name?: string; metadata?: Record }, + data: { name?: string; metadata?: AssetMetadata }, ): Promise => { const response = await apiClient.put(`/assets/${assetId}`, data); return response.data; @@ -350,3 +378,52 @@ export const getClassificationJob = async ( const response = await apiClient.get(`/classification-jobs/${jobId}`); return response.data; }; + +// ─── 批量操作 ─────────────────────────────────────────────── + +/** 批量操作结果 */ +export interface BatchOperationResult { + succeeded: string[]; + failed: string[]; + total: number; + success_count: number; + failure_count: number; +} + +/** 批量删除素材 */ +export const batchDeleteAssets = async ( + assetIds: string[], +): Promise => { + const response = await apiClient.post("/assets/batch-delete", { + asset_ids: assetIds, + }); + return response.data; +}; + +/** 批量打标签 */ +export const batchTagAssets = async (data: { + asset_ids: string[]; + tags: string[]; + mode: "add" | "replace"; +}): Promise => { + const response = await apiClient.post("/assets/batch-tag", data); + return response.data; +}; + +/** 批量改分类 */ +export const batchClassifyAssets = async (data: { + asset_ids: string[]; + category: string; +}): Promise => { + const response = await apiClient.post("/assets/batch-classify", data); + return response.data; +}; + +/** 批量智能标记 */ +export const batchMarkAssets = async (data: { + asset_ids: string[]; + smart_view: "recommended" | "caution" | "high_risk"; +}): Promise => { + const response = await apiClient.post("/assets/batch-mark", data); + return response.data; +}; diff --git a/apps/web/src/api/bgm.ts b/apps/web/src/api/bgm.ts new file mode 100644 index 000000000..ed4d33737 --- /dev/null +++ b/apps/web/src/api/bgm.ts @@ -0,0 +1,70 @@ +/** + * BGM 预设音乐 API + * 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索) + */ +import apiClient from "./client"; + +/* ──────────── 类型 ──────────── */ + +/** BGM 风格分类 */ +export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"; + +/** BGM 预设项 */ +export interface BgmPreset { + id: string; + name: string; + category: BgmCategory; + /** 音频文件 URL */ + url: string; + /** 时长(秒) */ + duration: number; + /** 关键词标签 */ + tags: string[]; + /** 封面图 URL */ + cover_url?: string; +} + +/** BGM 预设列表查询参数 */ +export interface BgmPresetsQuery { + category?: BgmCategory | string; + keyword?: string; +} + +/** BGM 混音配置(嵌入剪辑计划) */ +export interface BgmMixConfig { + /** 是否启用 BGM */ + enabled: boolean; + /** 选中的 BGM ID */ + music_id: string; + /** BGM 音量 0-100 */ + volume: number; + /** 淡入时长(秒) 0-3 */ + fade_in: number; + /** 淡出时长(秒) 0-3 */ + fade_out: number; + /** 人声闪避(sidechain) */ + voice_dodge: boolean; +} + +/** 默认 BGM 混音配置 */ +export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = { + enabled: false, + music_id: "", + volume: 50, + fade_in: 0.5, + fade_out: 0.5, + voice_dodge: true, +}; + +/* ──────────── API ──────────── */ + +/** 获取 BGM 预设列表 */ +export const getBgmPresets = async ( + params?: BgmPresetsQuery, +): Promise => { + const searchParams: Record = {}; + if (params?.category) searchParams.category = params.category; + if (params?.keyword) searchParams.keyword = params.keyword; + const res = await apiClient.get("/bgm/presets", { params: searchParams }); + return res.data?.data ?? res.data ?? []; +}; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index a4f7caada..0fe4a7dea 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -129,7 +129,8 @@ apiClient.interceptors.response.use( const safeExtractString = (val: unknown): string => { if (typeof val === "string") return val; if (typeof val === "object" && val !== null) { - const obj = val as Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 + const obj = val as Record; if (typeof obj.message === "string") return obj.message; if (typeof obj.msg === "string") return obj.msg; if (typeof obj.detail === "string") return obj.detail; diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts index 2280d7282..1ea05ca86 100644 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -4,6 +4,15 @@ */ import apiClient from "./client"; import type { AssetItem } from "./assets"; +import type { + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, + CoverConfig, +} from "@/pages/editing-planner/types"; /* ============================================================ * 后端 API 类型(严格匹配后端 Schema) @@ -13,6 +22,107 @@ import type { AssetItem } from "./assets"; export type EditPlanStatus = "draft" | "editing" | "rendering" | "completed" | "failed"; +/** 标题配置(对齐后端 title_config) */ +export interface TitleConfig { + ai_auto_select: boolean; + content: string; + font_preset: string; + font_color: string; + font_size: number; + position: string; +} + +/** 字幕配置 */ +export interface SubtitleConfig { + enabled: boolean; + position: string; + font: string; + color: string; + size: number; + animation: string; +} + +/** BGM 配置 */ +export interface BgmConfig { + enabled: boolean; + music_id: string; +} + +/** 片段 TTS 配置 */ +export interface SegmentTtsConfig { + mode: string; + text: string; + voice_id: string; + speed: number; + pitch: number; + volume: number; + subtitle_sync: boolean; +} + +/** 片段裁剪配置 */ +export interface SegmentTrimConfig { + start_time: number; + end_time: number; +} + +/** 片段转场配置 */ +export interface SegmentTransitionConfig { + type: string; + duration: number; +} + +/** 剪辑计划中的单个片段(config 内部 segments 项) */ +export interface EditPlanSegment { + segment_order: number; + duration_min: number; + duration_max: number; + material_type: string; + transition?: SegmentTransitionConfig; + playback_speed?: number; + tts_config?: SegmentTtsConfig; + trim_config?: SegmentTrimConfig; +} + +/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */ +export interface EditPlanConfig { + title_config?: TitleConfig; + subtitle_config?: SubtitleConfig; + bgm_config?: BgmConfig; + estimated_duration?: number; + segments?: EditPlanSegment[]; + watermark_config?: WatermarkConfig; + intro_outro_config?: IntroOutroConfig; + pip_config?: PipConfig; + filter_config?: FilterConfig; + green_screen_config?: ChromaKeyConfig; + sticker_config?: StickerConfig; + cover_config?: CoverConfig; + /** 前端扩展:关联的素材 ID 列表 */ + asset_ids?: string[]; + /** 配音 ID */ + voice_id?: string; + /** 克隆音色档案 ID */ + voice_clone_profile_id?: string; + /** 自定义配音音频 URL */ + custom_audio_url?: string; + /** 自定义配音文本 */ + custom_text?: string; + /** 视频比例 */ + ratio?: string; + /** 视频风格 */ + style?: string; + /** 目标时长(秒) */ + duration?: number; + /** 是否自动生成字幕 */ + auto_subtitles?: boolean; + /** 是否启用 BGM */ + bgm?: boolean; + /** 生成数量 */ + generate_count?: number; + /** 素材模式 */ + material_mode?: string; +} + /** 剪辑计划(后端响应) */ export interface EditPlan { id: string; @@ -20,7 +130,7 @@ export interface EditPlan { name: string; status: EditPlanStatus; total_duration: number; - config: Record; + config: EditPlanConfig; created_at: string; updated_at: string; } @@ -29,7 +139,7 @@ export interface EditPlan { export interface CreateEditPlanRequest { template_id: string; name: string; - config?: Record; + config?: EditPlanConfig; total_duration?: number; /** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */ source_edit_plan_id?: string; @@ -38,7 +148,7 @@ export interface CreateEditPlanRequest { /** 更新剪辑计划请求 */ export interface UpdateEditPlanRequest { name?: string; - config?: Record; + config?: EditPlanConfig; total_duration?: number; status?: EditPlanStatus; } @@ -80,6 +190,26 @@ export interface GenerationStatusResponse { clips: ClipStatusItem[]; } +/** 生成视频详情(对应后端 GeneratedVideoResponse) */ +export interface GeneratedVideo { + id: string; + project_id?: string; + generation_task_id?: string; + name: string; + file_url: string; + file_size?: number; + duration?: number; + thumbnail_url?: string; + width?: number; + height?: number; + fps?: number; + status: string; + review_status?: string; + download_url?: string; + created_at?: string; + updated_at?: string; +} + /* ============================================================ * AI 推荐 & 封面生成(任务 3.09) * ============================================================ */ @@ -100,14 +230,14 @@ export interface AIRecommendClipItem { transition_effect: string; asset_id: string; start_time: number; - config: Record; + config: EditPlanConfig; } /** AI 推荐响应 */ export interface AIRecommendResponse { plan_id: string; clips: AIRecommendClipItem[]; - config: Record; + config: EditPlanConfig; total_duration: number; confidence: number; } @@ -122,7 +252,15 @@ export interface GenerateCoverRequest { /** AI 封面生成响应 */ export interface GenerateCoverResponse { plan_id: string; - cover: Record; + cover: CoverResult; +} + +/** 封面生成结果 */ +export interface CoverResult { + scheme?: string; + asset_id?: string; + frame_time?: number; + thumbnail_url?: string; } /* ============================================================ @@ -147,10 +285,27 @@ export interface EditPlanClip { order: number; } -/** 转场效果 */ +/** 转场效果(14 种预设) */ export interface TransitionEffect { - type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide"; + type: + | "none" + | "cut" + | "fade" + | "dissolve" + | "zoom" + | "slide_left" + | "slide_right" + | "slide_up" + | "slide_down" + | "wipe_left" + | "wipe_right" + | "wipe_up" + | "wipe_down" + | "circlecrop" + | "rectcrop"; duration: number; // 转场时长(秒) + /** 播放速度倍率 */ + playback_speed?: number; } /** 素材库资产(UI 层类型,映射自后端 AssetResponse) */ @@ -177,15 +332,30 @@ export interface MediaAsset { * API 函数 — 严格对接后端 * ============================================================ */ -/** 获取剪辑计划列表 */ -export async function getEditPlans(params?: { +/** 剪辑计划列表查询参数 */ +export interface EditPlanListParams { page?: number; page_size?: number; template_id?: string; status?: string; -}): Promise { - const response = await apiClient.get("/edit-plans", { params }); - return response.data.items || []; +} + +/** 剪辑计划列表分页响应 */ +export interface EditPlanListResponse { + items: EditPlan[]; + total: number; + page: number; + page_size: number; +} + +/** 获取剪辑计划列表(支持分页和筛选) */ +export async function getEditPlans( + params?: EditPlanListParams, +): Promise { + const response = await apiClient.get("/edit-plans", { + params, + }); + return response.data; } /** 获取单个剪辑计划 */ @@ -266,6 +436,14 @@ export async function getEditPlanGenerations( return response.data.items || []; } +/** 获取生成任务的视频结果列表 */ +export async function getGenerationTaskResults( + taskId: string, +): Promise { + const response = await apiClient.get(`/generation/tasks/${taskId}/results`); + return response.data.items || response.data || []; +} + /** * 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx * 将后端 AssetResponse 映射为前端 MediaAsset 类型 @@ -297,26 +475,22 @@ function inferMediaType(mimeType: string): "video" | "image" | "audio" { } function mapAssetToMediaAsset(asset: AssetItem): MediaAsset { - const meta = (asset.metadata || {}) as Record; - const ext = asset as AssetItem & Record; + // 优先取顶层 duration,其次从 metadata 回退 + const metaDuration = + typeof asset.metadata?.duration === "number" + ? asset.metadata.duration + : undefined; return { id: asset.id, name: asset.name, type: inferMediaType(asset.mime_type || ""), - thumbnail_url: - typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined, - duration: - typeof ext.duration === "number" - ? ext.duration - : typeof meta.duration === "number" - ? (meta.duration as number) - : undefined, + thumbnail_url: asset.thumbnail_url, + duration: asset.duration ?? metaDuration, size: asset.file_size ?? undefined, tags: [], created_at: asset.created_at ?? "", quality_score: asset.quality_score ?? undefined, - classification_status: (asset.classification_status ?? - undefined) as MediaAsset["classification_status"], + classification_status: asset.classification_status ?? undefined, }; } @@ -324,17 +498,27 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset { * 常量 * ============================================================ */ -/** 转场效果选项 */ +/** 转场效果选项(14 种预设) */ export const TRANSITION_OPTIONS: { value: TransitionEffect["type"]; label: string; + icon: string; }[] = [ - { value: "none", label: "无转场" }, - { value: "fade", label: "淡入淡出" }, - { value: "dissolve", label: "溶解" }, - { value: "wipe", label: "擦除" }, - { value: "zoom", label: "缩放" }, - { value: "slide", label: "滑动" }, + { value: "none", label: "无转场", icon: "⊘" }, + { value: "cut", label: "硬切", icon: "✂" }, + { value: "fade", label: "淡入淡出", icon: "◐" }, + { value: "dissolve", label: "溶解", icon: "◈" }, + { value: "zoom", label: "缩放", icon: "⊕" }, + { value: "slide_left", label: "左滑", icon: "←" }, + { value: "slide_right", label: "右滑", icon: "→" }, + { value: "slide_up", label: "上滑", icon: "↑" }, + { value: "slide_down", label: "下滑", icon: "↓" }, + { value: "wipe_left", label: "左擦除", icon: "▸|" }, + { value: "wipe_right", label: "右擦除", icon: "|◂" }, + { value: "wipe_up", label: "上擦除", icon: "▴̄" }, + { value: "wipe_down", label: "下擦除", icon: "▾̄" }, + { value: "circlecrop", label: "圆形裁切", icon: "●" }, + { value: "rectcrop", label: "矩形裁切", icon: "■" }, ]; /** 素材类型标签 */ diff --git a/apps/web/src/api/editingPlanner.ts b/apps/web/src/api/editingPlanner.ts index 79d01189a..9b41fff37 100644 --- a/apps/web/src/api/editingPlanner.ts +++ b/apps/web/src/api/editingPlanner.ts @@ -3,6 +3,15 @@ * 对接后端 /api/v1/templates 路由 */ import apiClient from "./client"; +import type { + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, + CoverConfig, +} from "@/pages/editing-planner/types"; /* ──────────── 类型定义 ──────────── */ @@ -72,6 +81,20 @@ export interface EditingTemplate { bgm_config: BgmConfig; estimated_duration: number; segments: TemplateSegment[]; + /** 水印配置(后端就绪后启用) */ + watermark_config?: WatermarkConfig; + /** 片头片尾配置(后端就绪后启用) */ + intro_outro_config?: IntroOutroConfig; + /** 画中画配置 */ + pip_config?: PipConfig; + /** 滤镜调色配置 */ + filter_config?: FilterConfig; + /** 绿幕抠像配置 */ + green_screen_config?: ChromaKeyConfig; + /** 贴纸配置 */ + sticker_config?: StickerConfig; + /** 封面配置 */ + cover_config?: CoverConfig; is_active?: boolean; created_at: string; updated_at: string; @@ -95,6 +118,20 @@ export interface SaveTemplatePayload { bgm_config: BgmConfig; estimated_duration: number; segments: Omit[]; + /** 水印配置(后端就绪后启用) */ + watermark_config?: WatermarkConfig; + /** 片头片尾配置(后端就绪后启用) */ + intro_outro_config?: IntroOutroConfig; + /** 画中画配置 */ + pip_config?: PipConfig; + /** 滤镜调色配置 */ + filter_config?: FilterConfig; + /** 绿幕抠像配置 */ + green_screen_config?: ChromaKeyConfig; + /** 贴纸配置 */ + sticker_config?: StickerConfig; + /** 封面配置 */ + cover_config?: CoverConfig; } /** 使用模板生成请求体 */ @@ -102,11 +139,23 @@ export interface GenerateFromTemplatePayload { voiceover_duration: number; } +/** 验证警告详情 */ +export interface ValidationWarningDetails { + /** 相关字段名 */ + field?: string; + /** 期望值 */ + expected?: string | number; + /** 实际值 */ + actual?: string | number; + /** 建议值 */ + suggested?: string | number; +} + /** 验证/生成响应 */ export interface ValidateWarning { code: string; message: string; - details?: Record; + details?: ValidationWarningDetails; } /** 使用模板生成响应 */ diff --git a/apps/web/src/api/products.ts b/apps/web/src/api/products.ts index 8b5dc4375..594519d56 100644 --- a/apps/web/src/api/products.ts +++ b/apps/web/src/api/products.ts @@ -1,8 +1,13 @@ /** - * 成品相关 API - * Phase 1 重构:去掉 projectId,成品直接归属用户 + * 成品 / 视频相关 API + * 后端无 /products 路由,实际从 /generation/tasks 端点获取数据 */ import apiClient from "./client"; +import { getGenerationTaskResults } from "./editPlans"; +import type { GeneratedVideo } from "./editPlans"; + +/** 复核状态 */ +export type ReviewStatus = "pending_review" | "approved" | "rejected"; /** 成品条目 */ export interface ProductItem { @@ -14,33 +19,127 @@ export interface ProductItem { file_size?: number; resolution?: string; status: "processing" | "completed" | "failed"; + /** 复核状态 */ + review_status?: ReviewStatus; + /** 所属项目 ID */ + project_id?: string; + /** 所属项目名称 */ + project_name?: string; /** 查重率(百分比) */ duplicate_rate?: number; created_at?: string; updated_at?: string; } -/** 获取当前用户的所有成品 */ -export const getProducts = async (): Promise => { - const response = await apiClient.get("/products"); - return response.data.items || response.data || []; +/** 列表查询参数 */ +export interface ProductListParams { + page?: number; + page_size?: number; + project_id?: string; + review_status?: ReviewStatus | "all"; +} + +/** 分页响应 */ +export interface ProductListResponse { + items: ProductItem[]; + total: number; + page: number; + page_size: number; +} + +/** 批量下载任务状态 */ +export interface BatchDownloadStatus { + job_id: string; + status: "processing" | "completed" | "failed"; + /** 完成后返回的下载 URL */ + download_url?: string; + /** 进度百分比 */ + progress?: number; +} + +/** + * 将 generation task 数据映射为 ProductItem 格式 + */ +function mapTaskToProductItem(task: GeneratedVideo): ProductItem { + return { + id: task.id, + title: task.name || "未命名视频", + video_url: task.file_url, + thumbnail_url: task.thumbnail_url, + duration_seconds: task.duration, + file_size: task.file_size, + resolution: + task.width && task.height ? `${task.width}x${task.height}` : undefined, + status: + task.status === "completed" + ? "completed" + : task.status === "failed" + ? "failed" + : "processing", + review_status: task.review_status as ReviewStatus | undefined, + project_id: task.project_id, + created_at: task.created_at, + updated_at: task.updated_at, + }; +} + +/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */ +export const getProducts = async ( + params?: ProductListParams, +): Promise => { + const response = await apiClient.get("/generation/tasks", { params }); + const tasks = response.data.items || response.data || []; + return tasks.map(mapTaskToProductItem); }; -/** 获取单个成品详情 */ +/** 获取单个成品详情 — 通过 task ID 获取结果 */ export const getProduct = async (productId: string): Promise => { - const response = await apiClient.get(`/products/${productId}`); - return response.data; + const response = await apiClient.get(`/generation/tasks/${productId}`); + return mapTaskToProductItem(response.data); }; -/** 删除成品 */ +/** 删除成品 — 删除 generation task */ export const deleteProduct = async (productId: string): Promise => { - await apiClient.delete(`/products/${productId}`); + await apiClient.delete(`/generation/tasks/${productId}`); }; -/** 获取成品下载链接 */ +/** 获取成品下载链接 — 从 generation task results 获取 */ export const getProductDownloadUrl = async ( productId: string, ): Promise<{ url: string; expires_at: string }> => { - const response = await apiClient.get(`/products/${productId}/download-url`); - return response.data; + const videos = await getGenerationTaskResults(productId); + const video = videos[0]; + if (!video?.download_url) throw new Error("下载链接不可用"); + return { url: video.download_url, expires_at: "" }; +}; + +/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */ +export const updateReviewStatus = async ( + productId: string, + status: ReviewStatus, +): Promise => { + // 后端暂无 /generation/tasks/{id}/review 端点 + // 暂时返回当前状态,后续可扩展 + const product = await getProduct(productId); + return { ...product, review_status: status }; +}; + +/** 发起批量下载 — TODO: 后端暂无对应端点 */ +export const batchDownload = async ( + videoIds: string[], +): Promise<{ job_id: string }> => { + // 后端暂无 /generation/tasks/batch-download 端点 + // 暂时返回模拟 job_id,后续可扩展 + console.warn("[batchDownload] 后端暂无批量下载端点", videoIds); + return { job_id: `mock-${Date.now()}` }; +}; + +/** 查询批量下载状态 — TODO: 后端暂无对应端点 */ +export const getBatchDownloadStatus = async ( + jobId: string, +): Promise => { + // 后端暂无 /generation/tasks/batch-download/{jobId} 端点 + // 暂时返回模拟状态,后续可扩展 + console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId); + return { job_id: jobId, status: "processing", progress: 0 }; }; diff --git a/apps/web/src/api/tasks.ts b/apps/web/src/api/tasks.ts index 4e80832f5..1c4419b03 100644 --- a/apps/web/src/api/tasks.ts +++ b/apps/web/src/api/tasks.ts @@ -1,31 +1,67 @@ /** * 任务相关 API - * 对接后端方案 A 扩展后的端点(PR #109) - * - POST /api/v1/generation/tasks — 创建生成任务(template_id + asset_ids 细粒度模式) - * - GET /api/v1/tasks — 用户级任务列表(跨 project) - * - POST /api/v1/tasks/{task_id}/retry — 简化重试 + * 对接后端任务中心 API: + * - POST /api/v1/generation/tasks — 创建生成任务 + * - GET /api/v1/tasks — 用户级任务列表(支持分页/筛选) + * - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info) + * - POST /api/v1/tasks/{task_id}/retry — 重试失败任务 */ import apiClient from "./client"; /* ──────────── 类型定义 ──────────── */ +/** 任务状态 */ +export type TaskStatus = + "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"; + +/** 任务类型 */ +export type TaskType = "ingest" | "generation" | string; + +/** 错误详情 */ +export interface TaskErrorInfo { + error_type: string; + error_message: string; + failed_step: string; + stack_trace?: string; +} + /** 任务条目(对应用户级 UserTaskResponse) */ export interface TaskItem { id: string; - task_type: "ingest" | "generation" | string; + task_type: TaskType; project_id: string; - template_id: string; - status: string; + template_id?: string; + status: TaskStatus; progress: number; current_step: string; error_message: string; user_message: string; retryable: boolean; source_id: string; + /** 错误详情(失败任务) */ + error_info?: TaskErrorInfo; + /** 耗时(秒) */ + duration_seconds?: number; created_at?: string | null; updated_at?: string | null; } +/** 任务列表查询参数 */ +export interface TaskListParams { + page?: number; + page_size?: number; + status?: TaskStatus | "all"; + task_type?: TaskType | "all"; +} + +/** 任务列表分页响应 */ +export interface TaskListResponse { + items: TaskItem[]; + total: number; + page: number; + page_size: number; +} + /** 创建生成任务请求参数 */ export interface CreateGenerationTaskRequest { template_id: string; @@ -64,13 +100,23 @@ export const createGenerationTask = async ( return data; }; -/** 获取当前用户的所有任务(跨 project) */ -export const getUserTasks = async (): Promise => { - const { data } = await apiClient.get("/tasks"); - return data.items || []; +/** 获取任务列表(支持分页和筛选) */ +export const getTasks = async ( + params?: TaskListParams, +): Promise => { + const { data } = await apiClient.get("/tasks", { + params, + }); + return data; }; -/** 获取单个任务详情(用于轮询进度) */ +/** 获取当前用户的所有任务(兼容旧接口,跨 project) */ +export const getUserTasks = async (): Promise => { + const { data } = await apiClient.get("/tasks"); + return data.items || data || []; +}; + +/** 获取单个任务详情(含 error_info) */ export const getTask = async (taskId: string): Promise => { const { data } = await apiClient.get(`/tasks/${taskId}`); return data; diff --git a/apps/web/src/api/templates.ts b/apps/web/src/api/templates.ts index d1889e442..626a196a8 100644 --- a/apps/web/src/api/templates.ts +++ b/apps/web/src/api/templates.ts @@ -1,26 +1,112 @@ /** * 模板相关 API - * Phase 1 新增:全局模板库 + * 对接后端模板管理接口: + * - GET /api/v1/templates — 模板列表(分页/筛选) + * - GET /api/v1/templates/{id} — 模板详情 + * - POST /api/v1/templates/{id}/copy — 复制模板 + * - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划 + * - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏 */ import apiClient from "./client"; +import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner"; +import type { EditPlanConfig } from "./editPlans"; -/** 模板条目 */ +/* ──────────── 类型定义 ──────────── */ + +/** 模板条目(后端 TemplateResponse) */ export interface TemplateItem { id: string; name: string; description: string; category: string; + tags?: string[]; target_duration: number; clip_count: number; + /** 使用次数 */ + usage_count?: number; thumbnail_url?: string; preview_url?: string; is_active: boolean; is_favorite?: boolean; + /** 素材规则(片段配置) */ + segments?: TemplateSegment[]; + /** 字幕样式 */ + subtitle_config?: SubtitleConfig; + /** BGM 配置 */ + bgm_config?: BgmConfig; + /** 标题配置 */ + title_config?: TitleConfig; + /** 视频比例 */ + aspect_ratio?: string; created_at?: string; + updated_at?: string; } -/** 获取全局模板列表 */ -export const getTemplates = async (): Promise => { +/** 模板片段(素材规则) */ +export interface TemplateSegment { + id?: string; + segment_order: number; + duration_min: number; + duration_max: number; + material_type: string | null; + description?: string; +} + +/** 模板列表查询参数 */ +export interface TemplateListParams { + page?: number; + page_size?: number; + category?: string; + tags?: string; + keyword?: string; + /** 时长筛选(秒):short < 30, medium 30-120, long > 120 */ + duration_range?: "short" | "medium" | "long"; +} + +/** 模板列表分页响应 */ +export interface TemplateListResponse { + items: TemplateItem[]; + total: number; + page: number; + page_size: number; +} + +/** 从模板生成剪辑计划请求 */ +export interface GenerateFromTemplateRequest { + asset_ids?: string[]; + name?: string; + config?: EditPlanConfig; +} + +/** 从模板生成剪辑计划响应 */ +export interface GenerateFromTemplateResponse { + plan_id: string; + template_id: string; + status: string; + name: string; +} + +/** 复制模板响应 */ +export interface CopyTemplateResponse { + id: string; + name: string; + source_template_id: string; +} + +/* ──────────── API 函数 ──────────── */ + +/** 获取模板列表(支持分页和筛选) */ +export const getTemplates = async ( + params?: TemplateListParams, +): Promise => { + const { data } = await apiClient.get("/templates", { + params, + }); + return data; +}; + +/** 获取模板列表(兼容旧接口,返回数组) */ +export const getTemplatesList = async (): Promise => { const response = await apiClient.get("/templates"); return response.data.items || response.data || []; }; @@ -42,3 +128,46 @@ export const toggleFavoriteTemplate = async ( ); return response.data; }; + +/** 复制模板(创建副本到我的模板) */ +export const copyTemplate = async ( + templateId: string, +): Promise => { + const response = await apiClient.post( + `/templates/${templateId}/copy`, + ); + return response.data; +}; + +/** 从模板生成剪辑计划 */ +export const generateFromTemplate = async ( + templateId: string, + data?: GenerateFromTemplateRequest, +): Promise => { + const response = await apiClient.post( + `/templates/${templateId}/generate`, + data, + ); + return response.data; +}; + +/* ──────────── 常量 ──────────── */ + +/** 模板分类选项 */ +export const TEMPLATE_CATEGORY_OPTIONS = [ + { value: "", label: "全部分类" }, + { value: "口播", label: "口播" }, + { value: "种草", label: "种草" }, + { value: "产品", label: "产品" }, + { value: "品牌", label: "品牌" }, + { value: "混剪", label: "混剪" }, + { value: "Vlog", label: "Vlog" }, +]; + +/** 时长筛选选项 */ +export const TEMPLATE_DURATION_OPTIONS = [ + { value: "", label: "全部时长" }, + { value: "short", label: "30秒以内" }, + { value: "medium", label: "30秒-2分钟" }, + { value: "long", label: "2分钟以上" }, +]; diff --git a/apps/web/src/api/tts.ts b/apps/web/src/api/tts.ts index 05b028def..e59c7fbc9 100644 --- a/apps/web/src/api/tts.ts +++ b/apps/web/src/api/tts.ts @@ -8,6 +8,18 @@ import apiClient from "./client"; /* ── 类型定义 ──────────────────────────────────── */ +/** TTS 元数据(合成时附带的扩展信息) */ +export interface TTSMetadata { + /** 语音时长(秒) */ + duration?: number; + /** 采样率(Hz) */ + sample_rate?: number; + /** 语言 */ + language?: string; + /** 其他扩展字段 */ + [key: string]: unknown; +} + /** TTS 合成请求参数 */ export interface TTSSynthesizeRequest { text: string; @@ -18,7 +30,7 @@ export interface TTSSynthesizeRequest { voice_model?: string; voice_clone_profile_id?: string; format?: string; - metadata?: Record; + metadata?: TTSMetadata; } /** TTS 合成创建响应 */ @@ -49,7 +61,7 @@ export interface TTSJob { error_message: string | null; retry_count: number; max_retries: number; - metadata_: Record | null; + metadata_: TTSMetadata | null; created_at: string; updated_at: string; } @@ -140,3 +152,52 @@ export const saveTtsToLibrary = async ( export const deleteTTSJob = async (jobId: string): Promise => { await apiClient.delete(`/tts/jobs/${jobId}`); }; + +/* ── 音色列表 ──────────────────────────────────── */ + +/** TTS 音色 */ +export interface TTSVoice { + id: string; + name: string; + /** 音色分类标签:male/female/young/service/news/emotion */ + category?: string; + /** 语言 */ + language?: string; + /** 试听 URL */ + preview_url?: string; + /** 描述 */ + description?: string; +} + +/** 获取 TTS 音色列表 */ +export const getTtsVoices = async (): Promise => { + const response = await apiClient.get("/tts/voices"); + return response.data; +}; + +/* ── TTS 试听 ──────────────────────────────────── */ + +/** TTS 试听请求参数 */ +export interface TTSPreviewRequest { + text: string; + voice_id: string; + speed?: number; + pitch?: number; +} + +/** TTS 试听响应 */ +export interface TTSPreviewResponse { + audio_url: string; + duration?: number; +} + +/** TTS 试听 */ +export const previewTts = async ( + data: TTSPreviewRequest, +): Promise => { + const response = await apiClient.post( + "/tts/preview", + data, + ); + return response.data; +}; diff --git a/apps/web/src/api/voiceClone.ts b/apps/web/src/api/voiceClone.ts index 43072d812..c0d5690f1 100644 --- a/apps/web/src/api/voiceClone.ts +++ b/apps/web/src/api/voiceClone.ts @@ -36,6 +36,18 @@ export interface CreateVoiceCloneRequest { /* ── 后端 API 类型 ────────────────────────────────────── */ +/** 音色克隆元数据(克隆时附带的扩展信息) */ +export interface VoiceCloneMetadata { + /** 语音时长(秒) */ + duration?: number; + /** 采样率(Hz) */ + sample_rate?: number; + /** 音色 ID(克隆完成后分配) */ + voice_id?: string; + /** 其他扩展字段 */ + [key: string]: unknown; +} + /** 后端克隆档案响应 */ export interface VoiceCloneProfile { id: string; @@ -51,7 +63,7 @@ export interface VoiceCloneProfile { error_message: string | null; retry_count: number; max_retries: number; - metadata_: Record | null; + metadata_: VoiceCloneMetadata | null; created_at: string; updated_at: string; } @@ -80,7 +92,7 @@ export interface CreateVoiceCloneRequestFull { language?: string; gender?: string; max_retries?: number; - metadata_?: Record; + metadata_?: VoiceCloneMetadata; } /* ── 辅助函数 ─────────────────────────────────────────── */ diff --git a/apps/web/src/components/ui/Form.tsx b/apps/web/src/components/ui/Form.tsx deleted file mode 100644 index 50e1f9ecf..000000000 --- a/apps/web/src/components/ui/Form.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * V21 Form 表单 - * 封装 Ant Design Form,应用 V21 设计系统样式 - */ -import React from "react"; -import { Form as AntForm } from "antd"; -import type { FormProps as AntFormProps } from "antd"; -import classNames from "classnames"; -import "./ui.css"; - -export interface FormProps extends AntFormProps { - /** 紧凑模式(减小表单项间距) */ - compact?: boolean; -} - -const Form = ({ className, compact, children, ...rest }: FormProps) => { - const v21Class = classNames( - "xx-form", - compact && "xx-form-compact", - className, - ); - return ( - )} - > - {children as React.ReactNode} - - ); -}; - -/** 导出 Form 的子组件(保持 antd API 一致) */ -export const FormItem = AntForm.Item; -export const FormList = AntForm.List; -export const FormProvider = AntForm.Provider; - -export default Form; diff --git a/apps/web/src/components/ui/Pagination.tsx b/apps/web/src/components/ui/Pagination.tsx deleted file mode 100644 index 4bfad7270..000000000 --- a/apps/web/src/components/ui/Pagination.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * V21 Pagination 分页 - * 封装 Ant Design Pagination,应用 V21 设计系统样式 - */ -import React from "react"; -import { Pagination as AntPagination } from "antd"; -import type { PaginationProps as AntPaginationProps } from "antd"; -import classNames from "classnames"; -import "./ui.css"; - -export interface PaginationProps extends AntPaginationProps { - /** 使用 V21 样式 */ - v21?: boolean; -} - -const Pagination: React.FC = ({ - className, - v21 = true, - ...rest -}) => { - const v21Class = classNames(v21 && "xx-pagination", className); - return ; -}; - -export default Pagination; diff --git a/apps/web/src/components/ui/Table.tsx b/apps/web/src/components/ui/Table.tsx deleted file mode 100644 index da909f86b..000000000 --- a/apps/web/src/components/ui/Table.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/** - * 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 { - /** 使用 V21 样式 */ - v21?: boolean; -} - -function Table>({ - className, - v21 = true, - ...rest -}: TableProps) { - const v21Class = classNames(v21 && "xx-table", className); - return className={v21Class} {...rest} />; -} - -export default Table as >( - props: TableProps & React.RefAttributes, -) => React.ReactElement; diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts index 98f8f77da..67ea0fc08 100644 --- a/apps/web/src/components/ui/index.ts +++ b/apps/web/src/components/ui/index.ts @@ -15,9 +15,6 @@ 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"; @@ -26,9 +23,3 @@ export type { TagProps, TagVariant } from "./Tag"; export { Tooltip, Popover } from "./Tooltip"; export type { TooltipProps, PopoverProps } from "./Tooltip"; - -export { default as Form, FormItem, FormList, FormProvider } from "./Form"; -export type { FormProps } from "./Form"; - -export { default as Pagination } from "./Pagination"; -export type { PaginationProps } from "./Pagination"; diff --git a/apps/web/src/components/ui/ui.css b/apps/web/src/components/ui/ui.css index 292852393..6599f4fd5 100644 --- a/apps/web/src/components/ui/ui.css +++ b/apps/web/src/components/ui/ui.css @@ -258,51 +258,6 @@ 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 卡片 ============================================================ */ @@ -429,69 +384,6 @@ 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; -} - /* ============================================================ 响应式 ============================================================ */ @@ -516,10 +408,6 @@ .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) { @@ -533,7 +421,6 @@ } } - /* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */ /* AdminComingSoon 等页面使用 时需要 */ /* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */ diff --git a/apps/web/src/config/navigation.ts b/apps/web/src/config/navigation.ts index a32cf0f96..729aa52bf 100644 --- a/apps/web/src/config/navigation.ts +++ b/apps/web/src/config/navigation.ts @@ -17,6 +17,7 @@ import { ScanOutlined, ControlOutlined, CrownOutlined, + UnorderedListOutlined, } from "@ant-design/icons"; /** 导航项类型 */ @@ -80,6 +81,12 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/my-templates", icon: React.createElement(FolderOutlined), }, + { + key: "edit-plans", + label: "剪辑计划", + path: "/app/edit-plans", + icon: React.createElement(UnorderedListOutlined), + }, { key: "generate", label: "一键生成", @@ -104,6 +111,12 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/duplication", icon: React.createElement(ScanOutlined), }, + { + key: "tasks", + label: "任务中心", + path: "/app/tasks", + icon: React.createElement(UnorderedListOutlined), + }, ]; /** 侧边栏导航分组(Sidebar 分组列表使用) */ @@ -129,6 +142,12 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/editing-planner", icon: React.createElement(EditOutlined), }, + { + key: "edit-plans", + label: "剪辑计划", + path: "/app/edit-plans", + icon: React.createElement(UnorderedListOutlined), + }, ], }, { @@ -181,6 +200,12 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/history", icon: React.createElement(HistoryOutlined), }, + { + key: "tasks", + label: "任务中心", + path: "/app/tasks", + icon: React.createElement(UnorderedListOutlined), + }, { key: "duplication", label: "查重", diff --git a/apps/web/src/pages/accounts/accounts.css b/apps/web/src/pages/accounts/accounts.css index 2952690d6..bbad96f7c 100755 --- a/apps/web/src/pages/accounts/accounts.css +++ b/apps/web/src/pages/accounts/accounts.css @@ -82,77 +82,6 @@ gap: 10px; } -/* ── 账号行 ─────────────────────────────────────────────── */ - -.acc-account-row { - display: flex; - align-items: center; - gap: 10px; - padding: 12px; - background: var(--bg-secondary); - border-radius: var(--radius-md); - transition: var(--transition-fast); -} - -.acc-account-row:hover { - background: var(--primary-soft); -} - -.acc-account-avatar { - width: 36px; - height: 36px; - border-radius: 50%; - display: grid; - place-items: center; - color: var(--text-inverse); - font-weight: var(--font-weight-bold); - font-size: var(--font-size-sm); - flex-shrink: 0; -} - -.acc-account-info { - flex: 1; - min-width: 0; -} - -.acc-account-name { - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* ── 状态标签 ───────────────────────────────────────────── */ - -.acc-status-pill { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - border-radius: var(--radius-full); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); - line-height: 1.6; - margin-top: 2px; -} - -.acc-status--active { - background: var(--success-soft); - color: var(--secondary-color); -} - -.acc-status--expired { - background: var(--error-soft); - color: var(--error-color); -} - -.acc-status--limited { - background: var(--warning-soft); - color: var(--accent-color); -} - /* ── 空状态 ─────────────────────────────────────────────── */ .acc-empty { @@ -235,17 +164,6 @@ font-size: var(--font-size-base); } - .acc-account-row { - padding: 10px; - gap: 8px; - } - - .acc-account-avatar { - width: 32px; - height: 32px; - font-size: var(--font-size-xs); - } - .acc-stats-bar { padding: var(--space-sm) var(--space-md); font-size: var(--font-size-sm); diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index 95ffc2654..d10f881c3 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -4,7 +4,17 @@ * 使用 useQuery 对接后端真实 API(api/assets.ts) */ import React, { useMemo, useState } from "react"; -import { Upload, Modal as AntModal, message, Popconfirm } from "antd"; +import { + Upload, + Modal as AntModal, + message, + Popconfirm, + Drawer, + Tag, + Input as AntInput, + Radio, + Select as AntSelect, +} from "antd"; import { PlusOutlined, SearchOutlined, @@ -17,6 +27,11 @@ import { ExperimentOutlined, LoadingOutlined, ExclamationCircleOutlined, + TagsOutlined, + FolderOutlined, + ThunderboltOutlined, + CheckCircleOutlined, + CloseCircleOutlined, } from "@ant-design/icons"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -27,8 +42,13 @@ import { deleteAsset, uploadAssetDirect, getAssetDiagnosis, + batchDeleteAssets, + batchTagAssets, + batchClassifyAssets, + batchMarkAssets, type AssetLibraryItem, type AssetItem as ApiAssetItem, + type BatchOperationResult, } from "@/api/assets"; import { Button, Input, Select } from "@/components/ui"; import "./assets.css"; @@ -407,6 +427,33 @@ const AssetLibrary: React.FC = () => { /* 诊断中状态 — 记录正在诊断的素材 ID */ const [diagnosingId, setDiagnosingId] = useState(null); + /* ── 批量操作弹窗状态 ── */ + const [tagModalOpen, setTagModalOpen] = useState(false); + const [classifyModalOpen, setClassifyModalOpen] = useState(false); + const [markModalOpen, setMarkModalOpen] = useState(false); + const [resultDrawerOpen, setResultDrawerOpen] = useState(false); + + /* 批量打标签 */ + const [batchTagInput, setBatchTagInput] = useState(""); + const [batchTags, setBatchTags] = useState([]); + const [tagMode, setTagMode] = useState<"add" | "replace">("add"); + + /* 批量改分类 */ + const [batchCategory, setBatchCategory] = useState(""); + + /* 批量智能标记 */ + const [batchSmartView, setBatchSmartView] = useState< + "recommended" | "caution" | "high_risk" + >("recommended"); + + /* 操作结果 */ + const [operationResult, setOperationResult] = + useState(null); + const [operationTitle, setOperationTitle] = useState(""); + + /* 批量操作 loading */ + const [batchLoading, setBatchLoading] = useState(false); + /* 派生数据 */ const filteredAssets = useMemo(() => { let list = assets; @@ -563,19 +610,152 @@ const AssetLibrary: React.FC = () => { const handleBatchDelete = async () => { const ids = Array.from(selectedIds); - let successCount = 0; - for (const id of ids) { - try { - await deleteAsset(id); - successCount++; - } catch { - // 忽略单个失败 + setBatchLoading(true); + try { + const result = await batchDeleteAssets(ids); + setOperationResult(result); + setOperationTitle("批量删除"); + setResultDrawerOpen(true); + queryClient.invalidateQueries({ queryKey: ["assets"] }); + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); + setSelectedIds(new Set()); + if (result.failure_count === 0) { + message.success(`成功删除 ${result.success_count} 个素材`); + } else { + message.warning( + `删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, + ); } + } catch { + message.error("批量删除失败,请重试"); + } finally { + setBatchLoading(false); } - queryClient.invalidateQueries({ queryKey: ["assets"] }); - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); - setSelectedIds(new Set()); - message.success(`已删除 ${successCount}/${ids.length} 个素材`); + }; + + /* 批量打标签 */ + const handleBatchTag = async () => { + if (batchTags.length === 0) { + message.warning("请至少输入一个标签"); + return; + } + const ids = Array.from(selectedIds); + setBatchLoading(true); + try { + const result = await batchTagAssets({ + asset_ids: ids, + tags: batchTags, + mode: tagMode, + }); + setOperationResult(result); + setOperationTitle("批量打标签"); + setResultDrawerOpen(true); + setTagModalOpen(false); + setBatchTags([]); + setBatchTagInput(""); + setTagMode("add"); + queryClient.invalidateQueries({ queryKey: ["assets"] }); + setSelectedIds(new Set()); + if (result.failure_count === 0) { + message.success(`成功为 ${result.success_count} 个素材打标签`); + } else { + message.warning( + `打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, + ); + } + } catch { + message.error("批量打标签失败,请重试"); + } finally { + setBatchLoading(false); + } + }; + + /* 批量改分类 */ + const handleBatchClassify = async () => { + if (!batchCategory) { + message.warning("请选择分类"); + return; + } + const ids = Array.from(selectedIds); + setBatchLoading(true); + try { + const result = await batchClassifyAssets({ + asset_ids: ids, + category: batchCategory, + }); + setOperationResult(result); + setOperationTitle("批量改分类"); + setResultDrawerOpen(true); + setClassifyModalOpen(false); + setBatchCategory(""); + queryClient.invalidateQueries({ queryKey: ["assets"] }); + setSelectedIds(new Set()); + if (result.failure_count === 0) { + message.success( + `成功将 ${result.success_count} 个素材改为「${batchCategory}」`, + ); + } else { + message.warning( + `改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, + ); + } + } catch { + message.error("批量改分类失败,请重试"); + } finally { + setBatchLoading(false); + } + }; + + /* 批量智能标记 */ + const handleBatchMark = async () => { + const ids = Array.from(selectedIds); + setBatchLoading(true); + try { + const result = await batchMarkAssets({ + asset_ids: ids, + smart_view: batchSmartView, + }); + setOperationResult(result); + setOperationTitle("批量智能标记"); + setResultDrawerOpen(true); + setMarkModalOpen(false); + queryClient.invalidateQueries({ queryKey: ["assets"] }); + setSelectedIds(new Set()); + const labelMap = { + recommended: "推荐", + caution: "慎用", + high_risk: "高风险", + }; + if (result.failure_count === 0) { + message.success( + `成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`, + ); + } else { + message.warning( + `智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, + ); + } + } catch { + message.error("批量智能标记失败,请重试"); + } finally { + setBatchLoading(false); + } + }; + + /* 标签输入处理 */ + const handleTagInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && batchTagInput.trim()) { + e.preventDefault(); + const tag = batchTagInput.trim(); + if (!batchTags.includes(tag)) { + setBatchTags([...batchTags, tag]); + } + setBatchTagInput(""); + } + }; + + const removeBatchTag = (tag: string) => { + setBatchTags(batchTags.filter((t) => t !== tag)); }; // ── Loading 状态 ── @@ -769,6 +949,30 @@ const AssetLibrary: React.FC = () => { + + + { )} + + {/* ─── 批量打标签弹窗 ─── */} + { + setTagModalOpen(false); + setBatchTags([]); + setBatchTagInput(""); + }} + onOk={handleBatchTag} + confirmLoading={batchLoading} + okText="确认打标签" + cancelText="取消" + > +
+
+ 模式: + setTagMode(e.target.value)} + > + 追加标签 + 替换全部标签 + +
+
+ setBatchTagInput(e.target.value)} + onKeyDown={handleTagInputKeyDown} + style={{ flex: 1 }} + /> +
+ {batchTags.length > 0 && ( +
+ {batchTags.map((tag) => ( + removeBatchTag(tag)} + color="blue" + > + {tag} + + ))} +
+ )} + {tagMode === "replace" && batchTags.length > 0 && ( +
+ 替换模式将清除素材原有全部标签 +
+ )} +
+
+ + {/* ─── 批量改分类弹窗 ─── */} + { + setClassifyModalOpen(false); + setBatchCategory(""); + }} + onOk={handleBatchClassify} + confirmLoading={batchLoading} + okText="确认修改" + cancelText="取消" + > +
+

+ 将选中的 {selectedIds.size} 个素材统一修改为以下分类: +

+ setBatchCategory(v)} + placeholder="请选择分类" + style={{ width: "100%" }} + options={[ + { value: "person", label: "人物" }, + { value: "scenic", label: "风景" }, + { value: "product", label: "产品" }, + { value: "food", label: "美食" }, + { value: "animal", label: "动物" }, + { value: "architecture", label: "建筑" }, + { value: "other", label: "其他" }, + ]} + /> +
+
+ + {/* ─── 批量智能标记弹窗 ─── */} + setMarkModalOpen(false)} + onOk={handleBatchMark} + confirmLoading={batchLoading} + okText="确认标记" + cancelText="取消" + > +
+

+ 将选中的 {selectedIds.size} 个素材标记为: +

+ setBatchSmartView(e.target.value)} + className="xx-batch-mark-options" + > +
+ + 推荐 + + 质量优良,可直接用于生产 + + +
+
+ + 慎用 + + 存在一定问题,需人工审核后再使用 + + +
+
+ + 高风险 + + 存在严重问题,不建议使用 + + +
+
+
+
+ + {/* ─── 操作结果 Drawer ─── */} + { + setResultDrawerOpen(false); + setOperationResult(null); + }} + width={420} + > + {operationResult && ( +
+
+
+ + 总计 {operationResult.total} 个 + +
+
+ + 成功 {operationResult.success_count} 个 +
+ {operationResult.failure_count > 0 && ( +
+ + 失败 {operationResult.failure_count} 个 +
+ )} +
+ + {operationResult.succeeded.length > 0 && ( +
+

+ 成功列表 +

+
+ {operationResult.succeeded.map((id) => ( +
+ {id} +
+ ))} +
+
+ )} + + {operationResult.failed.length > 0 && ( +
+

+ 失败列表 +

+
+ {operationResult.failed.map((id) => ( +
+ {id} +
+ ))} +
+
+ )} +
+ )} +
); }; diff --git a/apps/web/src/pages/assets/assets.css b/apps/web/src/pages/assets/assets.css index 202de4a89..63dda02f3 100644 --- a/apps/web/src/pages/assets/assets.css +++ b/apps/web/src/pages/assets/assets.css @@ -653,3 +653,175 @@ font-size: 13px; color: var(--text-secondary, #6b7280); } + +/* ─── 批量打标签弹窗 ─── */ + +.xx-batch-tag-modal { + display: flex; + flex-direction: column; + gap: 16px; +} + +.xx-batch-tag-mode { + display: flex; + align-items: center; + gap: 8px; +} + +.xx-batch-tag-mode-label { + font-size: 14px; + color: var(--text-primary, #111827); + font-weight: 500; +} + +.xx-batch-tag-input-row { + display: flex; + gap: 8px; +} + +.xx-batch-tag-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.xx-batch-tag-warning { + padding: 10px 12px; + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: var(--radius-md, 8px); + color: #c2410c; + font-size: 13px; + display: flex; + align-items: center; + gap: 6px; +} + +/* ─── 批量改分类弹窗 ─── */ + +.xx-batch-classify-modal { + display: flex; + flex-direction: column; + gap: 12px; +} + +.xx-batch-classify-hint { + font-size: 14px; + color: var(--text-secondary, #6b7280); + margin: 0; +} + +/* ─── 批量智能标记弹窗 ─── */ + +.xx-batch-mark-modal { + display: flex; + flex-direction: column; + gap: 12px; +} + +.xx-batch-mark-hint { + font-size: 14px; + color: var(--text-secondary, #6b7280); + margin: 0; +} + +.xx-batch-mark-options { + display: flex; + flex-direction: column; + gap: 12px; +} + +.xx-batch-mark-option { + display: flex; + flex-direction: column; +} + +.xx-batch-mark-desc { + margin-left: 8px; + font-size: 13px; + color: var(--text-secondary, #6b7280); +} + +/* ─── 操作结果 Drawer ─── */ + +.xx-batch-result { + display: flex; + flex-direction: column; + gap: 20px; +} + +.xx-batch-result-summary { + display: flex; + gap: 16px; + padding: 16px; + background: var(--bg-secondary, #f9fafb); + border-radius: var(--radius-md, 8px); +} + +.xx-batch-result-stat { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: var(--text-primary, #111827); +} + +.xx-batch-result-stat.success { + color: #059669; +} + +.xx-batch-result-stat.fail { + color: #dc2626; +} + +.xx-batch-result-total { + font-weight: 600; +} + +.xx-batch-result-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.xx-batch-result-section-title { + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; + margin: 0; +} + +.xx-batch-result-section-title.success { + color: #059669; +} + +.xx-batch-result-section-title.fail { + color: #dc2626; +} + +.xx-batch-result-ids { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 300px; + overflow-y: auto; +} + +.xx-batch-result-id { + padding: 6px 10px; + background: var(--bg-surface, #fff); + border: 1px solid var(--border-primary, #e5e7eb); + border-radius: var(--radius-sm, 4px); + font-size: 12px; + font-family: monospace; + color: var(--text-secondary, #6b7280); + word-break: break-all; +} + +.xx-batch-result-id.fail { + border-color: #fecaca; + background: #fef2f2; + color: #dc2626; +} diff --git a/apps/web/src/pages/dashboard/Dashboard.tsx b/apps/web/src/pages/dashboard/Dashboard.tsx index 86ebcec88..95239e755 100644 --- a/apps/web/src/pages/dashboard/Dashboard.tsx +++ b/apps/web/src/pages/dashboard/Dashboard.tsx @@ -5,7 +5,7 @@ */ import React from "react"; import { useNavigate } from "react-router-dom"; -import { Button, Tag } from "@/components/ui"; +import { Button } from "@/components/ui"; import { DatabaseOutlined } from "@ant-design/icons"; import "./dashboard.css"; diff --git a/apps/web/src/pages/dashboard/dashboard.css b/apps/web/src/pages/dashboard/dashboard.css index 02d386666..ccffc0e0d 100644 --- a/apps/web/src/pages/dashboard/dashboard.css +++ b/apps/web/src/pages/dashboard/dashboard.css @@ -1,6 +1,6 @@ /** * 控制台页面 - V21 设计系统样式 - * KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告 + * KPI 卡片网格 + 快速入口 + 最近任务 + 使用统计图表 + 公告 * 统一使用 CSS 变量,支持深色/浅色主题 */ @import "../../styles/global.css"; @@ -13,26 +13,6 @@ padding: var(--space-xl); } -/* ============================================================ - 欢迎头部 - ============================================================ */ -.xx-dashboard-welcome { - margin-bottom: var(--space-lg); -} - -.xx-dashboard-welcome h2 { - margin: 0 0 var(--space-xs); - font-size: var(--font-size-xl); - font-weight: var(--font-weight-bold); - color: var(--text-primary); -} - -.xx-dashboard-welcome p { - margin: 0; - font-size: var(--font-size-sm); - color: var(--text-secondary); -} - /* ============================================================ KPI 卡片网格 ============================================================ */ @@ -43,76 +23,6 @@ margin-bottom: var(--space-lg); } -.xx-kpi-card { - background: linear-gradient(180deg, var(--bg-primary), var(--bg-secondary)); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: 20px; - transition: 0.18s ease; - position: relative; - overflow: hidden; -} - -.xx-kpi-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-sm); -} - -.xx-kpi-icon { - width: 40px; - height: 40px; - border-radius: var(--radius-md); - display: flex; - align-items: center; - justify-content: center; - font-size: var(--font-size-lg); - margin-bottom: var(--space-sm); -} - -.xx-kpi-value { - font-size: 30px; - font-weight: 800; - color: var(--text-primary); - line-height: 1.2; - margin-bottom: var(--space-xs); -} - -.xx-kpi-label { - font-size: var(--font-size-sm); - color: var(--text-secondary); - margin-bottom: var(--space-sm); -} - -.xx-kpi-trend { - font-size: 12px; - margin-top: 8px; - display: inline-flex; - align-items: center; - gap: 4px; -} - -.xx-kpi-trend--up { - color: var(--success-color); -} - -.xx-kpi-trend--down { - color: var(--error-color); -} - -.xx-kpi-trend--neutral { - color: var(--text-tertiary); -} - -/* ============================================================ - 主内容区两栏布局 - ============================================================ */ -.xx-dashboard-main { - display: grid; - grid-template-columns: 1fr 320px; - gap: var(--space-md); - margin-bottom: var(--space-lg); -} - /* ============================================================ 区块卡片 ============================================================ */ @@ -161,7 +71,25 @@ } /* ============================================================ - 最近任务卡片列表 + 空状态 + ============================================================ */ +.xx-dashboard-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-xl) var(--space-md); + text-align: center; + color: var(--text-tertiary); +} + +.xx-dashboard-empty p { + margin: 0; + font-size: var(--font-size-sm); +} + +/* ============================================================ + 最近任务列表 ============================================================ */ .xx-task-list { display: flex; @@ -169,86 +97,6 @@ gap: var(--space-sm); } -.xx-task-item { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: var(--space-sm) var(--space-md); - display: grid; - grid-template-columns: 1fr auto auto auto; - gap: var(--space-md); - align-items: center; - transition: var(--transition-all); -} - -.xx-task-item:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-sm); -} - -.xx-task-info h4 { - margin: 0 0 var(--space-xs); - font-size: var(--font-size-sm); - font-weight: 600; - color: var(--text-primary); -} - -.xx-task-info span { - font-size: var(--font-size-xs); - color: var(--text-secondary); -} - -.xx-task-status { - display: inline-flex; - align-items: center; - gap: var(--space-xs); - padding: 3px 10px; - border-radius: var(--radius-sm); - font-size: var(--font-size-xs); - font-weight: 500; - white-space: nowrap; -} - -.xx-task-status--completed { - color: var(--success-color); - background: var(--success-soft); - border: 1px solid var(--success-border); -} - -.xx-task-status--processing { - color: var(--info-color); - background: var(--primary-soft); - border: 1px solid var(--color-primary-200); -} - -.xx-task-status--pending { - color: var(--text-secondary); - background: var(--bg-secondary); - border: 1px solid var(--border-color); -} - -.xx-task-status--failed { - color: var(--error-color); - background: var(--error-soft); - border: 1px solid var(--error-border); -} - -.xx-task-time { - text-align: right; - font-size: var(--font-size-xs); - color: var(--text-secondary); - min-width: 100px; -} - -.xx-task-time span { - display: block; - margin-bottom: var(--space-xxs); -} - -.xx-task-action { - font-size: var(--font-size-xs); -} - /* ============================================================ 使用统计图表(纯 CSS 柱状图) ============================================================ */ @@ -264,69 +112,27 @@ padding-top: var(--space-sm); } -.xx-chart-bar-wrapper { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - justify-content: flex-end; -} - -.xx-chart-bar { - width: 100%; - max-width: 36px; - border-radius: var(--radius-sm) var(--radius-sm) 0 0; - background: var(--gradient-primary); - transition: height 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); - position: relative; - min-height: 4px; - cursor: pointer; -} - -.xx-chart-bar:hover { - opacity: 0.85; -} - -.xx-chart-bar:active { - opacity: 0.7; - transform: scaleY(0.97); - transform-origin: bottom; -} - -.xx-chart-bar-value { - position: absolute; - top: -20px; - left: 50%; - transform: translateX(-50%); - font-size: var(--font-size-xs); - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - opacity: 0; - transition: var(--transition-opacity); -} - -.xx-chart-bar:hover .xx-chart-bar-value { - opacity: 1; -} - -.xx-chart-labels { - display: flex; - gap: var(--space-sm); - margin-top: var(--space-sm); -} - -.xx-chart-label { - flex: 1; - text-align: center; - font-size: var(--font-size-xs); - color: var(--text-secondary); -} - /* ============================================================ - 快速入口卡片网格 + 快速入口 ============================================================ */ +.xx-quick-entry-section { + margin-bottom: var(--space-md); +} + +.xx-quick-entry-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-md); +} + +.xx-quick-entry-title { + margin: 0; + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + .xx-quick-grid { display: grid; grid-template-columns: repeat(4, 1fr); @@ -334,53 +140,6 @@ margin-bottom: var(--space-lg); } -.xx-quick-card { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: var(--space-lg); - cursor: pointer; - transition: var(--transition-all); - text-align: center; -} - -.xx-quick-card:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-md); - transform: translateY(-2px); -} - -.xx-quick-card:active { - transform: translateY(0) scale(0.98); - box-shadow: var(--shadow-sm); - transition-duration: 0.1s; -} - -.xx-quick-card-icon { - width: 52px; - height: 52px; - border-radius: var(--radius-md); - display: flex; - align-items: center; - justify-content: center; - font-size: var(--font-size-xl); - margin: 0 auto var(--space-md); - color: var(--text-inverse); -} - -.xx-quick-card h3 { - margin: 0 0 var(--space-sm); - font-size: var(--font-size-md); - font-weight: var(--font-weight-semibold); - color: var(--text-primary); -} - -.xx-quick-card p { - margin: 0; - color: var(--text-secondary); - font-size: var(--font-size-sm); -} - /* ============================================================ 公告区域 ============================================================ */ @@ -451,90 +210,10 @@ color: var(--text-secondary); } -/* ============================================================ - 存储用量条 - ============================================================ */ -.xx-storage-bar { - margin-top: var(--space-sm); -} - -.xx-storage-bar-track { - height: var(--space-sm); - background: var(--bg-secondary); - border-radius: var(--space-xs); - overflow: hidden; -} - -.xx-storage-bar-fill { - height: 100%; - border-radius: var(--space-xs); - background: var(--gradient-primary); - transition: width 0.6s ease; -} - -.xx-storage-bar-label { - display: flex; - justify-content: space-between; - font-size: var(--font-size-xs); - color: var(--text-secondary); - margin-top: var(--space-xs); -} - -/* ============================================================ - 迁移自内联样式的工具类 - ============================================================ */ -.xx-dashboard-left-col { - display: flex; - flex-direction: column; - gap: var(--space-md); -} - -.xx-chart-total { - font-size: var(--font-size-xs); - color: var(--text-secondary); -} - -.xx-dashboard-section--start { - align-self: start; -} - -.xx-storage-section { - margin-top: var(--space-md); -} - -.xx-storage-section-title { - font-size: 13px; - font-weight: 500; - color: var(--text-primary); - margin-bottom: 4px; -} - -.xx-quick-entry-section { - margin-bottom: var(--space-md); -} - -.xx-quick-entry-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--space-md); -} - -.xx-quick-entry-title { - margin: 0; - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - color: var(--text-primary); -} - /* ============================================================ 响应式 ============================================================ */ @media (max-width: 1200px) { - .xx-dashboard-main { - grid-template-columns: 1fr; - } - .xx-kpi-grid { grid-template-columns: repeat(2, 1fr); } @@ -557,15 +236,6 @@ grid-template-columns: 1fr; } - .xx-task-item { - grid-template-columns: 1fr; - gap: var(--space-sm); - } - - .xx-task-time { - text-align: left; - } - .xx-chart-bars { height: 120px; } diff --git a/apps/web/src/pages/edit-plans/EditPlans.tsx b/apps/web/src/pages/edit-plans/EditPlans.tsx new file mode 100644 index 000000000..89e590526 --- /dev/null +++ b/apps/web/src/pages/edit-plans/EditPlans.tsx @@ -0,0 +1,433 @@ +/** + * 剪辑计划管理页面 + * 展示用户的所有剪辑计划,支持状态筛选、模板筛选、分页、一键重新生成 + */ +import { useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Table, + Tabs, + Select, + Tag, + Button, + message, + Popconfirm, + Tooltip, +} from "antd"; +import { + CheckCircleOutlined, + ClockCircleOutlined, + SyncOutlined, + CloseCircleOutlined, + EditOutlined, + DeleteOutlined, + FileTextOutlined, + ThunderboltOutlined, +} from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import { + getEditPlans, + deleteEditPlan, + generateEditPlan, + type EditPlan, + type EditPlanStatus, + type EditPlanListParams, +} from "@/api/editPlans"; +import { getTemplatesList, type TemplateItem } from "@/api/templates"; +import "./edit-plans.css"; + +/* ──────────── 常量 ──────────── */ + +/** 状态 Tab 配置 */ +const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [ + { key: "all", label: "全部" }, + { key: "draft", label: "草稿" }, + { key: "editing", label: "编辑中" }, + { key: "rendering", label: "渲染中" }, + { key: "completed", label: "已完成" }, + { key: "failed", label: "失败" }, +]; + +/** 状态标签配置 */ +const STATUS_CONFIG: Record< + EditPlanStatus, + { label: string; color: string; icon: React.ReactNode } +> = { + draft: { + label: "草稿", + color: "default", + icon: , + }, + editing: { + label: "编辑中", + color: "processing", + icon: , + }, + rendering: { + label: "渲染中", + color: "warning", + icon: , + }, + completed: { + label: "已完成", + color: "success", + icon: , + }, + failed: { + label: "失败", + color: "error", + icon: , + }, +}; + +/* ──────────── 工具函数 ──────────── */ + +/** 格式化时长 */ +const formatDuration = (seconds: number): string => { + if (seconds <= 0) return "-"; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + if (m === 0) return `${s}秒`; + return `${m}分${s > 0 ? `${s}秒` : ""}`; +}; + +/** 格式化时间 */ +const formatTime = (dateStr?: string | null): string => { + if (!dateStr) return "-"; + const date = new Date(dateStr); + return date.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +}; + +/* ──────────── 主组件 ──────────── */ + +export default function EditPlans() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + // 筛选状态 + const [statusFilter, setStatusFilter] = useState( + "all", + ); + const [templateFilter, setTemplateFilter] = useState("all"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + + // 查询参数 + const queryParams: EditPlanListParams = { + page, + page_size: pageSize, + ...(statusFilter !== "all" && { status: statusFilter }), + ...(templateFilter !== "all" && { template_id: templateFilter }), + }; + + // 获取剪辑计划列表 + const { + data: planData, + isLoading, + error, + } = useQuery({ + queryKey: ["edit-plans", queryParams], + queryFn: () => getEditPlans(queryParams), + refetchInterval: (query) => { + // 有进行中的计划时自动刷新 + const plans = query.state.data?.items ?? []; + const hasRunning = plans.some( + (p) => p.status === "rendering" || p.status === "editing", + ); + return hasRunning ? 5000 : false; + }, + }); + + // 获取模板列表(用于筛选下拉) + const { data: templates } = useQuery({ + queryKey: ["templates-list-simple"], + queryFn: getTemplatesList, + }); + + const plans = planData?.items ?? []; + const total = planData?.total ?? 0; + + // 模板名称映射 + const templateNameMap = new Map(); + (templates ?? []).forEach((t: TemplateItem) => { + templateNameMap.set(t.id, t.name); + }); + + // 删除计划 + const deleteMutation = useMutation({ + mutationFn: deleteEditPlan, + onSuccess: () => { + message.success("剪辑计划已删除"); + queryClient.invalidateQueries({ queryKey: ["edit-plans"] }); + }, + onError: () => { + message.error("删除失败,请稍后重试"); + }, + }); + + // 重新生成 + const regenerateMutation = useMutation({ + mutationFn: generateEditPlan, + onSuccess: () => { + message.success("已重新提交生成"); + queryClient.invalidateQueries({ queryKey: ["edit-plans"] }); + }, + onError: () => { + message.error("重新生成失败,请稍后重试"); + }, + }); + + // 跳转到剪辑编辑器 + const handleEdit = useCallback( + (plan: EditPlan) => { + navigate(`/app/editing-planner?planId=${plan.id}`); + }, + [navigate], + ); + + // 表格列定义 + const columns: ColumnsType = [ + { + title: "计划名称", + dataIndex: "name", + key: "name", + width: 240, + ellipsis: true, + render: (name: string, record: EditPlan) => ( + + handleEdit(record)}> + {name} + + + ), + }, + { + title: "模板", + dataIndex: "template_id", + key: "template_id", + width: 140, + ellipsis: true, + render: (templateId: string) => { + const name = templateNameMap.get(templateId); + return ( + + {name || templateId.slice(0, 8)} + + ); + }, + }, + { + title: "状态", + dataIndex: "status", + key: "status", + width: 120, + render: (status: EditPlanStatus) => { + const config = STATUS_CONFIG[status] || { + label: status, + color: "default", + icon: null, + }; + return ( + + {config.label} + + ); + }, + }, + { + title: "时长", + dataIndex: "total_duration", + key: "total_duration", + width: 100, + render: (seconds: number) => ( + {formatDuration(seconds)} + ), + }, + { + title: "创建时间", + dataIndex: "created_at", + key: "created_at", + width: 130, + render: (time: string) => ( + {formatTime(time)} + ), + }, + { + title: "更新时间", + dataIndex: "updated_at", + key: "updated_at", + width: 130, + render: (time: string) => ( + {formatTime(time)} + ), + }, + { + title: "操作", + key: "action", + width: 180, + fixed: "right", + render: (_: unknown, record: EditPlan) => ( +
+ + {(record.status === "failed" || record.status === "completed") && ( + regenerateMutation.mutate(record.id)} + okText="确定" + cancelText="取消" + > + + + )} + deleteMutation.mutate(record.id)} + okText="确定" + cancelText="取消" + okButtonProps={{ danger: true }} + > + + +
+ ), + }, + ]; + + // 错误处理 + if (error) { + return ( +
+
+ +

加载剪辑计划失败

+ +
+
+ ); + } + + return ( +
+ {/* 页面标题 */} +
+
+

剪辑计划

+

管理所有剪辑计划,支持重新生成和编辑

+
+ +
+ + {/* 筛选栏 */} +
+ {/* 状态 Tab */} + { + setStatusFilter(key as EditPlanStatus | "all"); + setPage(1); + }} + items={STATUS_TABS.map((tab) => ({ + key: tab.key, + label: tab.label, + }))} + className="edit-plans-status-tabs" + /> + + {/* 模板筛选 */} + - onSubtitleSettingsChange({ size: Number(e.target.value) }) + onSubtitleSettingsChange({ + fontSize: Number(e.target.value), + }) } /> - {subtitleSettings.size}px + {subtitleSettings.fontSize}px
@@ -563,6 +591,16 @@ const ClipPropertiesPanel: React.FC = ({ ))} + + {/* 高级配置按钮 */} + {onOpenSubtitleDrawer && ( + + )} )} @@ -574,20 +612,130 @@ const ClipPropertiesPanel: React.FC = ({ BGM 设置 -
- - + {bgmSettings.enabled && bgmSettings.music_id ? ( +
+ 🎵 已选择 BGM + {bgmSettings.music_id} + {bgmSettings.volume !== undefined && ( + + 音量 {bgmSettings.volume}% + + )} +
+ ) : ( +
未选择背景音乐
+ )} + + {onOpenBgmDrawer && ( + + )} +
+ + {/* ═══ 水印设置 ═══ */} +
+
+ 🔖 + 水印设置
+ {onOpenWatermarkDrawer && ( + + )} +
+ + {/* ═══ 片头片尾设置 ═══ */} +
+
+ 🎬 + 片头片尾 +
+ {onOpenIntroOutroDrawer && ( + + )} +
+ + {/* ═══ 画中画 ═══ */} +
+
+ 🖼️ + 画中画 +
+ {onOpenPipDrawer && ( + + )} +
+ + {/* ═══ 滤镜调色 ═══ */} +
+
+ 🎨 + 滤镜调色 +
+ {onOpenFilterDrawer && ( + + )} +
+ + {/* ═══ 绿幕抠像 ═══ */} +
+
+ 🟩 + 绿幕抠像 +
+ {onOpenGreenScreenDrawer && ( + + )} +
+ + {/* ═══ 贴纸 ═══ */} +
+
+ 🏷️ + 贴纸 +
+ {onOpenStickerDrawer && ( + + )} +
+ + {/* ═══ 封面 ═══ */} +
+
+ 🖼️ + 封面 +
+ {onOpenCoverDrawer && ( + + )}
{/* ═══ 片段详情(选中时显示) ═══ */} @@ -649,6 +797,71 @@ const ClipPropertiesPanel: React.FC = ({ + {/* 转场效果入口 */} + {onOpenTransitionDrawer && ( +
+ +
+ )} + + {/* 播放速度入口 */} + {onOpenSpeedDrawer && ( +
+ +
+ )} + + {/* TTS 配音入口 */} + {onOpenTtsDrawer && ( +
+ +
+ )} + {/* 素材起始时间 — 仅 voice 类型显示 */} {selectedClip.type === "voice" && (
diff --git a/apps/web/src/pages/editing-planner/components/CoverSelector.tsx b/apps/web/src/pages/editing-planner/components/CoverSelector.tsx new file mode 100644 index 000000000..88778b988 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/CoverSelector.tsx @@ -0,0 +1,301 @@ +/** + * 封面选择器 + * 抽帧选封面 + 上传自定义封面 + 智能封面推荐 + */ +import React, { useCallback, useRef, useState } from "react"; +import { Drawer } from "antd"; +import type { CoverConfig, CoverMode } from "../types"; +import { DEFAULT_COVER_CONFIG } from "../types"; + +interface CoverSelectorProps { + open: boolean; + onClose: () => void; + config: CoverConfig; + onChange: (config: CoverConfig) => void; + totalDuration: number; +} + +/** 封面模式标签 */ +const MODE_LABELS: Record = { + auto: "智能封面", + frame: "抽帧选封面", + upload: "上传封面", +}; + +/** 封面模式图标 */ +const MODE_ICONS: Record = { + auto: "🤖", + frame: "🎞️", + upload: "📤", +}; + +const CoverSelector: React.FC = ({ + open, + onClose, + config, + onChange, + totalDuration, +}) => { + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const update = useCallback( + (partial: Partial) => { + onChange({ ...config, ...partial }); + }, + [config, onChange], + ); + + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled }); + }, [config.enabled, onChange]); + + /** 切换模式 */ + const handleModeChange = useCallback( + (mode: CoverMode) => { + update({ mode }); + }, + [update], + ); + + /** 处理文件上传 */ + const handleFileUpload = useCallback( + (file: File) => { + if (!file.type.startsWith("image/")) return; + const reader = new FileReader(); + reader.onload = (e) => { + const url = e.target?.result as string; + update({ upload_url: url, thumbnail_url: url, mode: "upload" }); + }; + reader.readAsDataURL(file); + }, + [update], + ); + + /** 拖拽上传 */ + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFileUpload(file); + }, + [handleFileUpload], + ); + + /** 使用 AI 推荐时间 */ + const handleUseAiSuggestion = useCallback(() => { + if (config.ai_suggested_time !== null) { + update({ frame_time: config.ai_suggested_time, mode: "frame" }); + } + }, [config.ai_suggested_time, update]); + + /** 格式化时间 */ + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + const ms = Math.floor((seconds % 1) * 10); + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`; + }; + + return ( + + {/* 顶部开关 */} +
+ 启用自定义封面 + +
+ + {/* 模式选择 */} +
+
封面来源
+
+ {(["auto", "frame", "upload"] as CoverMode[]).map((m) => ( + + ))} +
+
+ + {/* 模式内容区 */} +
+ {/* 智能封面 */} + {config.mode === "auto" && ( +
+
+ AI 将分析视频内容,自动选择最具吸引力的画面作为封面。 +
+ {config.ai_suggested_time !== null ? ( +
+
AI 推荐
+
+ 推荐时间点:{formatTime(config.ai_suggested_time)} +
+ +
+ ) : ( +
+
+ AI 分析中...(生成视频后自动推荐) +
+ )} +
+ )} + + {/* 抽帧选封面 */} + {config.mode === "frame" && ( +
+
+
+ 🎞️ + + {formatTime(config.frame_time)} + +
+
+
+
+ 拖动选择封面帧 + + {formatTime(config.frame_time)} + +
+ update({ frame_time: Number(e.target.value) })} + /> +
+ 00:00 + {formatTime(totalDuration)} +
+
+ {/* 快捷时间点 */} +
+ 快捷选帧: + {[0, 0.25, 0.5, 0.75].map((ratio) => { + const t = totalDuration * ratio; + return ( + + ); + })} +
+
+ )} + + {/* 上传封面 */} + {config.mode === "upload" && ( +
+
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + > + {config.upload_url ? ( +
+ 封面预览 +
点击更换
+
+ ) : ( +
+ 📤 + + 点击或拖拽上传封面图片 + + + 支持 JPG / PNG,建议 16:9 比例 + +
+ )} + { + const file = e.target.files?.[0]; + if (file) handleFileUpload(file); + }} + /> +
+
+ )} +
+ + {/* 封面预览 */} +
+
封面预览
+
+ {config.upload_url ? ( + 封面预览 + ) : ( +
+ 🖼️ + + {config.mode === "auto" + ? "AI 智能选择" + : config.mode === "frame" + ? `帧 ${formatTime(config.frame_time)}` + : "未上传封面"} + +
+ )} +
16:9
+
+
+ + {/* 底部 */} +
+ +
+ + ); +}; + +export default CoverSelector; diff --git a/apps/web/src/pages/editing-planner/components/FilterPanel.tsx b/apps/web/src/pages/editing-planner/components/FilterPanel.tsx new file mode 100644 index 000000000..617112b34 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/FilterPanel.tsx @@ -0,0 +1,234 @@ +/** + * 滤镜调色配置面板 + * 预设滤镜 + 手动调节(亮度/对比度/饱和度/色温/色调/锐度) + */ +import React, { useCallback } from "react"; +import { Drawer, Switch } from "antd"; +import type { FilterConfig, FilterPreset } from "../types"; +import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types"; + +interface FilterPanelProps { + open: boolean; + onClose: () => void; + config: FilterConfig; + onChange: (config: FilterConfig) => void; +} + +/** 所有预设列表 */ +const PRESET_LIST: FilterPreset[] = [ + "none", + "original", + "fresh", + "warm", + "cool", + "vintage", + "cinema", + "bw", + "sunshine", + "film", +]; + +/** 预设对应的示例渐变色(用于视觉预览) */ +const PRESET_GRADIENTS: Record = { + none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)", + warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)", + cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)", + vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)", + cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)", + bw: "linear-gradient(135deg, #434343 0%, #000000 100%)", + sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)", + film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)", +}; + +const FilterPanel: React.FC = ({ + open, + onClose, + config, + onChange, +}) => { + const update = useCallback( + (partial: Partial) => { + onChange({ ...config, ...partial }); + }, + [config, onChange], + ); + + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled }); + }, [config.enabled, onChange]); + + /** 选择预设时重置手动参数 */ + const handlePresetSelect = useCallback( + (preset: FilterPreset) => { + if (preset === "none") { + onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled }); + } else { + onChange({ + ...DEFAULT_FILTER_CONFIG, + enabled: config.enabled, + preset, + }); + } + }, + [config.enabled, onChange], + ); + + return ( + + {/* 顶部开关 */} +
+ 启用滤镜 + update({ enabled: checked })} + /> +
+ + {/* 预设滤镜选择 */} +
+
预设滤镜
+
+ {PRESET_LIST.map((p) => ( + + ))} +
+
+ + {/* 手动调节 */} +
+
手动调节
+ + {/* 亮度 */} +
+ 亮度 + update({ brightness: Number(e.target.value) })} + /> + {config.brightness} +
+ + {/* 对比度 */} +
+ 对比度 + update({ contrast: Number(e.target.value) })} + /> + {config.contrast} +
+ + {/* 饱和度 */} +
+ 饱和度 + update({ saturation: Number(e.target.value) })} + /> + {config.saturation} +
+ + {/* 色温 */} +
+ 色温 + update({ temperature: Number(e.target.value) })} + /> + {config.temperature} +
+ + {/* 色调 */} +
+ 色调 + update({ tint: Number(e.target.value) })} + /> + {config.tint} +
+ + {/* 锐度 */} +
+ 锐度 + update({ sharpness: Number(e.target.value) })} + /> + {config.sharpness} +
+
+ + {/* 预览色块 */} +
+
效果预览
+
+
+ + {/* 底部重置 */} +
+ +
+ + ); +}; + +export default FilterPanel; diff --git a/apps/web/src/pages/editing-planner/components/GreenScreenPanel.tsx b/apps/web/src/pages/editing-planner/components/GreenScreenPanel.tsx new file mode 100644 index 000000000..36cd45f9e --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/GreenScreenPanel.tsx @@ -0,0 +1,219 @@ +/** + * 绿幕抠像配置面板 + * 5 种颜色预设 + 自定义颜色 + 相似度/边缘平滑/溢色抑制 + */ +import React, { useCallback } from "react"; +import { Drawer, Switch } from "antd"; +import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types"; +import { + DEFAULT_CHROMA_KEY_CONFIG, + CHROMA_KEY_PRESET_LABELS, + CHROMA_KEY_PRESET_COLORS, +} from "../types"; + +interface GreenScreenPanelProps { + open: boolean; + onClose: () => void; + config: ChromaKeyConfig; + onChange: (config: ChromaKeyConfig) => void; +} + +/** 预设列表 */ +const PRESET_LIST: ChromaKeyColorPreset[] = [ + "green", + "blue", + "red", + "pure_green", + "soft_green", +]; + +const GreenScreenPanel: React.FC = ({ + open, + onClose, + config, + onChange, +}) => { + const update = useCallback( + (partial: Partial) => { + onChange({ ...config, ...partial }); + }, + [config, onChange], + ); + + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled }); + }, [config.enabled, onChange]); + + /** 选择颜色预设时同步更新 color 字段 */ + const handlePresetSelect = useCallback( + (preset: ChromaKeyColorPreset) => { + update({ + color_preset: preset, + color: CHROMA_KEY_PRESET_COLORS[preset], + }); + }, + [update], + ); + + /** 自定义颜色变化时清除预设标记 */ + const handleColorChange = useCallback( + (e: React.ChangeEvent) => { + update({ color: e.target.value }); + }, + [update], + ); + + return ( + + {/* 顶部开关 */} +
+ 启用绿幕抠像 + update({ enabled: checked })} + /> +
+ + {/* 颜色预设 */} +
+
颜色预设
+
+ {PRESET_LIST.map((p) => ( + + ))} +
+
+ + {/* 自定义颜色 */} +
+
自定义颜色
+
+ + +
+
+
+ + {/* 参数调节 */} +
+
参数调节
+ + {/* 相似度 */} +
+
+ 相似度 + {config.similarity}% +
+ update({ similarity: Number(e.target.value) })} + /> +
越大容忍的色差范围越广
+
+ + {/* 边缘平滑 */} +
+
+ 边缘平滑 + {config.blend}% +
+ update({ blend: Number(e.target.value) })} + /> +
越大边缘越柔和自然
+
+ + {/* 溢色抑制 */} +
+
+ 溢色抑制 + {config.spill}% +
+ update({ spill: Number(e.target.value) })} + /> +
去除边缘颜色溢出
+
+
+ + {/* 预览 */} +
+
效果预览
+
+
+
+
+
主体
+
+
+
+
+ + {/* 底部重置 */} +
+ +
+ + ); +}; + +export default GreenScreenPanel; diff --git a/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx b/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx new file mode 100644 index 000000000..abb12d237 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx @@ -0,0 +1,309 @@ +/** + * 片头片尾配置面板 — Drawer 形式 + * 两个区块:片头(Intro)/ 片尾(Outro) + * 每个区块支持:类型选择(无/视频/图片)、素材 URL、时长、过渡动画 + */ +import React, { useCallback } from "react"; +import { Drawer } from "antd"; +import type { + IntroOutroConfig, + IntroOutroItem, + IntroOutroKind, + TransitionType, +} from "../types"; +import { DEFAULT_INTRO_OUTRO } from "../types"; +import { TRANSITION_OPTIONS } from "@/api/editPlans"; + +/* ──────────── 常量 ──────────── */ + +const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [ + { value: "none", label: "无", icon: "🚫" }, + { value: "video", label: "视频", icon: "🎬" }, + { value: "image", label: "图片", icon: "🖼️" }, +]; + +/* ──────────── Props ──────────── */ +interface IntroOutroPanelProps { + open: boolean; + onClose: () => void; + config: IntroOutroConfig; + onChange: (config: IntroOutroConfig) => void; +} + +const IntroOutroPanel: React.FC = ({ + open, + onClose, + config, + onChange, +}) => { + /* ── 更新片头 ── */ + const handleIntroChange = useCallback( + (partial: Partial) => { + onChange({ ...config, intro: { ...config.intro, ...partial } }); + }, + [config, onChange], + ); + + /* ── 更新片尾 ── */ + const handleOutroChange = useCallback( + (partial: Partial) => { + onChange({ ...config, outro: { ...config.outro, ...partial } }); + }, + [config, onChange], + ); + + /* ── 切换片头类型 ── */ + const handleIntroKindChange = useCallback( + (kind: IntroOutroKind) => { + handleIntroChange({ kind, url: kind === "none" ? undefined : "" }); + }, + [handleIntroChange], + ); + + /* ── 切换片尾类型 ── */ + const handleOutroKindChange = useCallback( + (kind: IntroOutroKind) => { + handleOutroChange({ kind, url: kind === "none" ? undefined : "" }); + }, + [handleOutroChange], + ); + + /* ── 重置 ── */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_INTRO_OUTRO }); + }, [onChange]); + + return ( + + {/* ═══ 片头区块 ═══ */} +
+
+ 🎞️ + 片头 +
+ + {/* 类型选择 */} +
+ {KIND_OPTIONS.map((opt) => ( + + ))} +
+ + {/* 视频/图片配置 */} + {config.intro.kind !== "none" && ( + <> +
+ + handleIntroChange({ url: e.target.value })} + /> +
+ +
+ +
+ + handleIntroChange({ duration: Number(e.target.value) }) + } + /> + + {config.intro.duration}s + +
+
+ +
+ + +
+ + {config.intro.transition && config.intro.transition !== "none" && ( +
+ +
+ + handleIntroChange({ + transition_duration: Number(e.target.value), + }) + } + /> + + {(config.intro.transition_duration ?? 0.5).toFixed(1)}s + +
+
+ )} + + )} +
+ + {/* ═══ 片尾区块 ═══ */} +
+
+ 🏁 + 片尾 +
+ + {/* 类型选择 */} +
+ {KIND_OPTIONS.map((opt) => ( + + ))} +
+ + {/* 视频/图片配置 */} + {config.outro.kind !== "none" && ( + <> +
+ + handleOutroChange({ url: e.target.value })} + /> +
+ +
+ +
+ + handleOutroChange({ duration: Number(e.target.value) }) + } + /> + + {config.outro.duration}s + +
+
+ +
+ + +
+ + {config.outro.transition && config.outro.transition !== "none" && ( +
+ +
+ + handleOutroChange({ + transition_duration: Number(e.target.value), + }) + } + /> + + {(config.outro.transition_duration ?? 0.5).toFixed(1)}s + +
+
+ )} + + )} +
+ + {/* ── 底部操作 ── */} +
+ +
+
+ ); +}; + +export default IntroOutroPanel; diff --git a/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx b/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx new file mode 100644 index 000000000..c1fbfbee0 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx @@ -0,0 +1,582 @@ +/** + * 画中画配置面板 — Drawer 形式 + * 左侧图层列表 + 右侧单图层配置 + 迷你预览区 + */ +import React, { useCallback, useMemo } from "react"; +import { Drawer, Switch } from "antd"; +import type { + PipConfig, + PipLayer, + PipGridPosition, + PipAnimType, + PipSlideDirection, +} from "../types"; +import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types"; + +/* ──────────── 常量 ──────────── */ + +/** 九宫格位置 → 百分比坐标映射 */ +const GRID_POSITION_MAP: Record = { + top_left: { x: 5, y: 5 }, + top_center: { x: 37.5, y: 5 }, + top_right: { x: 70, y: 5 }, + center_left: { x: 5, y: 37.5 }, + center: { x: 37.5, y: 37.5 }, + center_right: { x: 70, y: 37.5 }, + bottom_left: { x: 5, y: 70 }, + bottom_center: { x: 37.5, y: 70 }, + bottom_right: { x: 70, y: 70 }, +}; + +/** 九宫格位置选项 */ +const GRID_POSITIONS: PipGridPosition[] = [ + "top_left", + "top_center", + "top_right", + "center_left", + "center", + "center_right", + "bottom_left", + "bottom_center", + "bottom_right", +]; + +/** 入场动画选项 */ +const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [ + { value: "none", label: "无" }, + { value: "fade_in", label: "淡入" }, + { value: "slide_in", label: "滑入" }, +]; + +/** 滑入方向选项 */ +const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [ + { value: "left", label: "← 左" }, + { value: "right", label: "→ 右" }, + { value: "up", label: "↑ 上" }, + { value: "down", label: "↓ 下" }, +]; + +/** 预览图层颜色池 */ +const LAYER_COLORS = [ + "rgba(22,119,255,0.5)", + "rgba(82,196,26,0.5)", + "rgba(250,173,20,0.5)", + "rgba(255,77,79,0.5)", + "rgba(114,46,209,0.5)", + "rgba(19,194,194,0.5)", +]; + +/* ──────────── Props ──────────── */ + +interface PipConfigPanelProps { + open: boolean; + onClose: () => void; + config: PipConfig; + onChange: (config: PipConfig) => void; + totalDuration: number; +} + +/* ──────────── 辅助函数 ──────────── */ + +let layerIdCounter = 0; +const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`; + +/* ──────────── 组件 ──────────── */ + +const PipConfigPanel: React.FC = ({ + open, + onClose, + config, + onChange, + totalDuration, +}) => { + /** 当前选中图层 ID */ + const [selectedId, setSelectedId] = React.useState(""); + + /** 当前选中图层 */ + const selectedLayer = useMemo( + () => config.layers.find((l) => l.id === selectedId) ?? null, + [config.layers, selectedId], + ); + + /* ── 添加图层 ── */ + const handleAddLayer = useCallback(() => { + const newLayer: PipLayer = { + ...DEFAULT_PIP_LAYER, + id: genLayerId(), + name: `图层 ${config.layers.length + 1}`, + z_index: config.layers.length + 1, + }; + onChange({ + ...config, + layers: [...config.layers, newLayer], + }); + setSelectedId(newLayer.id); + }, [config, onChange]); + + /* ── 删除图层 ── */ + const handleDeleteLayer = useCallback( + (id: string) => { + const newLayers = config.layers.filter((l) => l.id !== id); + onChange({ ...config, layers: newLayers }); + if (selectedId === id) { + setSelectedId(newLayers.length > 0 ? newLayers[0].id : ""); + } + }, + [config, onChange, selectedId], + ); + + /* ── 更新图层 ── */ + const updateLayer = useCallback( + (id: string, partial: Partial) => { + onChange({ + ...config, + layers: config.layers.map((l) => + l.id === id ? { ...l, ...partial } : l, + ), + }); + }, + [config, onChange], + ); + + /* ── 切换启用 ── */ + const handleEnableToggle = useCallback( + (checked: boolean) => { + onChange({ ...config, enabled: checked }); + }, + [config, onChange], + ); + + /* ── 重置 ── */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_PIP_CONFIG }); + setSelectedId(""); + }, [onChange]); + + /* ── 九宫格点击 ── */ + const handleGridClick = useCallback( + (pos: PipGridPosition) => { + if (!selectedLayer) return; + const coords = GRID_POSITION_MAP[pos]; + updateLayer(selectedLayer.id, { + grid_position: pos, + x: coords.x, + y: coords.y, + }); + }, + [selectedLayer, updateLayer], + ); + + /* ── 宽高比锁定 ── */ + const handleWidthChange = useCallback( + (val: number) => { + if (!selectedLayer) return; + const partial: Partial = { width: val }; + if (selectedLayer.aspect_lock) { + // 保持宽高比 1:1(百分比相同) + partial.height = val; + } + updateLayer(selectedLayer.id, partial); + }, + [selectedLayer, updateLayer], + ); + + const handleHeightChange = useCallback( + (val: number) => { + if (!selectedLayer) return; + const partial: Partial = { height: val }; + if (selectedLayer.aspect_lock) { + partial.width = val; + } + updateLayer(selectedLayer.id, partial); + }, + [selectedLayer, updateLayer], + ); + + return ( + + {/* ═══ 顶部工具栏 ═══ */} +
+
+ +
+
+ 启用 + +
+
+ + {/* ═══ 主体:图层列表 + 配置区 ═══ */} +
+ {/* 左侧图层列表 */} +
+ {config.layers.length === 0 ? ( +
暂无图层,点击上方添加
+ ) : ( + config.layers.map((layer, idx) => ( +
setSelectedId(layer.id)} + > + {layer.thumbnail_url || layer.material_url ? ( + {layer.name} + ) : ( +
+ )} + {layer.name} + +
+ )) + )} +
+ + {/* 右侧配置区 */} +
+ {!selectedLayer ? ( +
选择或添加图层以配置
+ ) : ( + <> + {/* ── 迷你预览 ── */} +
+ {config.layers.map((layer, idx) => ( +
+ {layer.name} +
+ ))} +
+ + {/* ── 素材类型 ── */} +
+ +
+ + +
+
+ + {/* ── 素材 URL ── */} +
+ + + updateLayer(selectedLayer.id, { + material_url: e.target.value, + }) + } + /> +
+ + {/* ── 位置:九宫格 + 坐标 ── */} +
+ +
+
+ {GRID_POSITIONS.map((pos) => ( + + ))} +
+
+
+ + + updateLayer(selectedLayer.id, { + x: Number(e.target.value), + }) + } + /> +
+
+ + + updateLayer(selectedLayer.id, { + y: Number(e.target.value), + }) + } + /> +
+
+
+
+ + {/* ── 尺寸 ── */} +
+ +
+ + 宽 + + handleWidthChange(Number(e.target.value))} + /> + + {selectedLayer.width}% + +
+
+ + 高 + + handleHeightChange(Number(e.target.value))} + /> + + {selectedLayer.height}% + +
+
+ updateLayer(selectedLayer.id, { + aspect_lock: !selectedLayer.aspect_lock, + }) + } + > + + {selectedLayer.aspect_lock ? "🔒" : "🔓"} + + + {selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"} + +
+
+ + {/* ── 圆角 ── */} +
+ +
+ + updateLayer(selectedLayer.id, { + border_radius: Number(e.target.value), + }) + } + /> + + {selectedLayer.border_radius}% + +
+
+ + {/* ── 透明度 ── */} +
+ +
+ + updateLayer(selectedLayer.id, { + opacity: Number(e.target.value), + }) + } + /> + + {selectedLayer.opacity}% + +
+
+ + {/* ── 时间 ── */} +
+ +
+
+ + + updateLayer(selectedLayer.id, { + start_time: Number(e.target.value), + }) + } + /> +
+
+ + + updateLayer(selectedLayer.id, { + duration: Number(e.target.value), + }) + } + /> +
+
+
+ + {/* ── 入场动画 ── */} +
+ + +
+ + {/* 滑入方向(仅 slide_in 时显示) */} + {selectedLayer.animation === "slide_in" && ( +
+ + +
+ )} + + )} +
+
+ + {/* ── 底部操作 ── */} +
+ +
+ + ); +}; + +export default PipConfigPanel; diff --git a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx index 72d47046e..e33175f49 100644 --- a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx +++ b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx @@ -4,25 +4,13 @@ * 封面右侧竖排4个方案按钮 */ import React from "react"; -import type { ClipData, ClipType } from "../types"; +import type { ClipData, ClipType, TitleSettings } from "../types"; interface CoverScheme { key: string; label: string; } -interface TitleSettings { - aiAutoSelect: boolean; - title: string; - position: string; - font: string; - size: number; - bold: boolean; - italic: boolean; - stroke: boolean; - shadow: boolean; -} - interface SubtitleSettings { enabled: boolean; position: string; diff --git a/apps/web/src/pages/editing-planner/components/SpeedPanel.tsx b/apps/web/src/pages/editing-planner/components/SpeedPanel.tsx new file mode 100644 index 000000000..d6265d895 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/SpeedPanel.tsx @@ -0,0 +1,157 @@ +/** + * 片段调速面板 — Drawer 形式 + * 速度滑块(0.25x ~ 4x)+ 预设快捷按钮 + 音调修正开关 + * 支持应用到当前片段 / 所有片段 + */ +import React, { useCallback } from "react"; +import { Drawer, Slider } from "antd"; +import type { SpeedConfig } from "../types"; +import { DEFAULT_SPEED } from "../types"; + +/* ──────────── 预设速度 ──────────── */ +const SPEED_PRESETS: { rate: number; label: string }[] = [ + { rate: 0.5, label: "0.5x" }, + { rate: 1.0, label: "1x" }, + { rate: 1.5, label: "1.5x" }, + { rate: 2.0, label: "2x" }, +]; + +/* ──────────── Props ──────────── */ +interface SpeedPanelProps { + open: boolean; + onClose: () => void; + /** 当前片段调速配置 */ + config: SpeedConfig; + onChange: (config: SpeedConfig) => void; + /** 应用到所有片段 */ + onApplyAll?: (config: SpeedConfig) => void; +} + +const SpeedPanel: React.FC = ({ + open, + onClose, + config, + onChange, + onApplyAll, +}) => { + /* ── 修改速度 ── */ + const handleChangeRate = useCallback( + (rate: number) => { + onChange({ ...config, rate }); + }, + [config, onChange], + ); + + /* ── 切换音调修正 ── */ + const handleTogglePitch = useCallback(() => { + onChange({ ...config, pitchCorrection: !config.pitchCorrection }); + }, [config, onChange]); + + /* ── 选择预设 ── */ + const handlePreset = useCallback( + (rate: number) => { + onChange({ ...config, rate }); + }, + [config, onChange], + ); + + /* ── 应用到所有片段 ── */ + const handleApplyAll = useCallback(() => { + onApplyAll?.(config); + }, [config, onApplyAll]); + + /* ── 重置 ── */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_SPEED }); + }, [onChange]); + + /* ── 速度描述文字 ── */ + const speedLabel = + config.rate < 1 + ? "慢速(慢动作)" + : config.rate === 1 + ? "原速" + : config.rate < 2 + ? "快速" + : "极速"; + + return ( + + {/* ── 速度滑块 ── */} +
+
+ 播放速度 + {config.rate.toFixed(2)}x +
+ `${(v as number).toFixed(2)}x` }} + /> +
+ 0.25x + 1x + 2x + 4x +
+
{speedLabel}
+
+ + {/* ── 预设快捷按钮 ── */} +
+
快捷预设
+
+ {SPEED_PRESETS.map((p) => ( + + ))} +
+
+ + {/* ── 音调修正开关 ── */} +
+
+ 音调修正 + + {config.pitchCorrection ? "变速不变调(推荐)" : "变速同时变调"} + +
+
+
+
+
+ + {/* ── 底部操作 ── */} +
+ + {onApplyAll && ( + + )} +
+ + ); +}; + +export default SpeedPanel; diff --git a/apps/web/src/pages/editing-planner/components/StickerPanel.tsx b/apps/web/src/pages/editing-planner/components/StickerPanel.tsx new file mode 100644 index 000000000..883c044d0 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/StickerPanel.tsx @@ -0,0 +1,561 @@ +/** + * 贴纸配置面板 + * 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整 + */ +import React, { useCallback, useState } from "react"; +import { Drawer, Switch } from "antd"; +import type { + StickerConfig, + StickerItem, + StickerType, + TextStickerPreset, +} from "../types"; +import { + DEFAULT_STICKER_CONFIG, + DEFAULT_STICKER_ITEM, + TEXT_STICKER_PRESET_LABELS, +} from "../types"; + +interface StickerPanelProps { + open: boolean; + onClose: () => void; + config: StickerConfig; + onChange: (config: StickerConfig) => void; + totalDuration: number; +} + +/** 常用 emoji 素材 */ +const EMOJI_LIST = [ + "😀", + "😂", + "🥰", + "😎", + "🤩", + "😱", + "🤔", + "😴", + "🥳", + "😍", + "❤️", + "🔥", + "⭐", + "✨", + "💯", + "👍", + "👏", + "🎉", + "🎵", + "💪", + "📌", + "💡", + "🎯", + "✅", + "❌", + "⬆️", + "⬇️", + "➡️", + "⭕", + "🔔", +]; + +/** 文字花字预设对应的 CSS 样式预览 */ +const TEXT_PRESET_STYLES: Record = { + normal: { color: "#fff", textShadow: "none" }, + highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" }, + bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 }, + neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" }, + shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" }, + outline: { color: "#fff", WebkitTextStroke: "1px #000" }, + gradient: { + color: "transparent", + background: "linear-gradient(90deg,#f093fb,#f5576c)", + WebkitBackgroundClip: "text", + }, + handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" }, +}; + +/** 生成唯一 ID */ +const genId = () => + `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +const StickerPanel: React.FC = ({ + open, + onClose, + config, + onChange, + totalDuration, +}) => { + const [selectedId, setSelectedId] = useState(null); + const [activeTab, setActiveTab] = useState("emoji"); + + const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null; + + /** 更新单个贴纸 */ + const updateItem = useCallback( + (id: string, partial: Partial) => { + onChange({ + ...config, + items: config.items.map((s) => + s.id === id ? { ...s, ...partial } : s, + ), + }); + }, + [config, onChange], + ); + + /** 添加贴纸 */ + const addSticker = useCallback( + (type: StickerType, content: string) => { + const newItem: StickerItem = { + ...DEFAULT_STICKER_ITEM, + id: genId(), + type, + content, + duration: totalDuration > 0 ? totalDuration : 5, + z_index: config.items.length + 1, + }; + onChange({ + ...config, + enabled: true, + items: [...config.items, newItem], + }); + setSelectedId(newItem.id); + }, + [config, onChange, totalDuration], + ); + + /** 删除贴纸 */ + const removeSticker = useCallback( + (id: string) => { + onChange({ + ...config, + items: config.items.filter((s) => s.id !== id), + }); + if (selectedId === id) setSelectedId(null); + }, + [config, onChange, selectedId], + ); + + /** 重置所有 */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled }); + setSelectedId(null); + }, [config.enabled, onChange]); + + /** 文字花字输入 */ + const [textInput, setTextInput] = useState(""); + + return ( + + {/* 顶部开关 */} +
+ 启用贴纸 + onChange({ ...config, enabled: checked })} + /> +
+ + {/* 类型 Tab */} +
+ {(["emoji", "image", "text"] as StickerType[]).map((t) => ( + + ))} +
+ + {/* Tab 内容区 */} +
+ {/* Emoji 素材库 */} + {activeTab === "emoji" && ( +
+ {EMOJI_LIST.map((emoji) => ( + + ))} +
+ )} + + {/* 图片贴纸 */} + {activeTab === "image" && ( +
+ { + if (e.key === "Enter" && e.currentTarget.value.trim()) { + addSticker("image", e.currentTarget.value.trim()); + e.currentTarget.value = ""; + } + }} + /> + +
+ )} + + {/* 文字花字 */} + {activeTab === "text" && ( +
+
+ setTextInput(e.target.value)} + /> + +
+
+
花字预设预览
+
+ {( + Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[] + ).map((p) => ( +
+ 示例 +
+ {TEXT_STICKER_PRESET_LABELS[p]} +
+
+ ))} +
+
+
+ )} +
+ + {/* 已添加贴纸列表 */} + {config.items.length > 0 && ( +
+
+ 已添加贴纸 ({config.items.length}) +
+
+ {config.items.map((item) => ( +
setSelectedId(item.id)} + > + + {item.type === "emoji" + ? item.content + : item.type === "text" + ? "T" + : "🖼"} + + + {item.type === "text" + ? item.content.slice(0, 10) + : item.type === "emoji" + ? "表情贴纸" + : "图片贴纸"} + + +
+ ))} +
+
+ )} + + {/* 选中贴纸的属性编辑 */} + {selectedSticker && ( +
+
属性调整
+ + {/* 位置 */} +
+ 位置 X + + updateItem(selectedSticker.id, { x: Number(e.target.value) }) + } + /> + {selectedSticker.x}% +
+
+ 位置 Y + + updateItem(selectedSticker.id, { y: Number(e.target.value) }) + } + /> + {selectedSticker.y}% +
+ + {/* 尺寸 */} +
+ 大小 + + updateItem(selectedSticker.id, { + width: Number(e.target.value), + height: Number(e.target.value), + }) + } + /> + {selectedSticker.width}% +
+ + {/* 旋转 */} +
+ 旋转 + + updateItem(selectedSticker.id, { + rotation: Number(e.target.value), + }) + } + /> + + {selectedSticker.rotation}° + +
+ + {/* 透明度 */} +
+ 透明度 + + updateItem(selectedSticker.id, { + opacity: Number(e.target.value), + }) + } + /> + + {selectedSticker.opacity}% + +
+ + {/* 时间 */} +
+ 开始 + + updateItem(selectedSticker.id, { + start_time: Number(e.target.value), + }) + } + /> + 时长 + + updateItem(selectedSticker.id, { + duration: Number(e.target.value), + }) + } + /> +
+ + {/* 文字贴纸特有属性 */} + {selectedSticker.type === "text" && ( + <> +
+ 花字 + +
+
+ 字号 + + updateItem(selectedSticker.id, { + font_size: Number(e.target.value), + }) + } + /> + + {selectedSticker.font_size}px + +
+
+ 颜色 + + updateItem(selectedSticker.id, { + text_color: e.target.value, + }) + } + /> +
+ + )} + + {/* 预览 */} +
+
+ {selectedSticker.type === "emoji" && selectedSticker.content} + {selectedSticker.type === "text" && selectedSticker.content} + {selectedSticker.type === "image" && ( + sticker + )} +
+
+
+ )} + + {/* 底部重置 */} +
+ +
+
+ ); +}; + +export default StickerPanel; diff --git a/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx b/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx new file mode 100644 index 000000000..5f75536cf --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx @@ -0,0 +1,274 @@ +/** + * 字幕样式配置面板 — Drawer 形式 + * 字幕开关(手动 / ASR 自动识别)、字体大小、颜色、描边/阴影、位置、ASR 语言 + */ +import React from "react"; +import { Drawer, Slider, ColorPicker, Select } from "antd"; +import type { Color } from "antd/es/color-picker"; + +/* ──────────── 类型 ──────────── */ + +export type SubtitleMode = "manual" | "asr"; + +export interface SubtitleStyleConfig { + /** 是否启用字幕 */ + enabled: boolean; + /** 字幕模式:手动输入 / ASR 自动识别 */ + mode: SubtitleMode; + /** 字体大小 px */ + fontSize: number; + /** 字体颜色 */ + fontColor: string; + /** 描边 */ + stroke: boolean; + /** 阴影 */ + shadow: boolean; + /** 字幕位置 */ + position: "top" | "center" | "bottom"; + /** 字体 */ + font: string; + /** 动画效果 */ + animation: string; + /** ASR 语言(仅 ASR 模式) */ + asrLanguage: "zh" | "en"; +} + +export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = { + enabled: true, + mode: "asr", + fontSize: 16, + fontColor: "#ffffff", + stroke: true, + shadow: false, + position: "bottom", + font: "思源黑体", + animation: "none", + asrLanguage: "zh", +}; + +/* ──────────── 选项常量 ──────────── */ + +const POSITION_OPTIONS = [ + { value: "top", label: "顶部" }, + { value: "center", label: "居中" }, + { value: "bottom", label: "底部" }, +]; + +const FONT_OPTIONS = [ + "思源黑体", + "思源宋体", + "苹方", + "PingFang", + "微软雅黑", + "楷体", + "华康俪金黑", +]; + +const ANIMATION_OPTIONS = [ + { value: "none", label: "无" }, + { value: "fade", label: "淡入淡出" }, + { value: "slide", label: "滑动" }, + { value: "typewriter", label: "打字机" }, +]; + +const ASR_LANGUAGE_OPTIONS = [ + { value: "zh", label: "中文" }, + { value: "en", label: "English" }, +]; + +/* ──────────── Props ──────────── */ + +interface SubtitleStylePanelProps { + open: boolean; + onClose: () => void; + config: SubtitleStyleConfig; + onChange: (config: SubtitleStyleConfig) => void; +} + +const SubtitleStylePanel: React.FC = ({ + open, + onClose, + config, + onChange, +}) => { + const update = (partial: Partial) => { + onChange({ ...config, ...partial }); + }; + + return ( + + {/* ── 字幕开关 ── */} +
+
+ 启用字幕 +
update({ enabled: !config.enabled })} + > +
+
+
+
+ + {config.enabled && ( + <> + {/* ── 模式切换 ── */} +
+ +
+ + +
+
+ + {/* ── ASR 语言(仅 ASR 模式) ── */} + {config.mode === "asr" && ( +
+ + update({ font: v })} + options={FONT_OPTIONS.map((f) => ({ value: f, label: f }))} + popupMatchSelectWidth={false} + /> +
+ + {/* ── 字幕位置 ── */} +
+ +
+ {POSITION_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* ── 描边 / 阴影 ── */} +
+ +
+ + +
+
+ + {/* ── 动画 ── */} +
+ + onZoomChange?.(Number(e.target.value))} + /> + + {pps}px/s +
-
- )) + /* 裁剪状态 */ + const hasTrim = !!clip.trim_config; + const isHovered = hoveredClipId === clip.id; + + return ( + + {/* 转场指示器 */} + {showTransition && transOpt && ( +
+ {transOpt.icon} + + {trans!.duration.toFixed(1)}s + +
+ )} + +
handleDragStart(e, idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDragEnd={handleDragEnd} + onDrop={(e) => handleDrop(e, idx)} + onClick={() => onClipSelect(clip.id)} + onContextMenu={(e) => handleContextMenu(e, clip.id)} + onMouseEnter={() => setHoveredClipId(clip.id)} + onMouseLeave={() => setHoveredClipId(null)} + > + {/* 左裁剪手柄 */} + {isHovered && onClipTrim && ( +
+ handleTrimHandleMouseDown(e, clip.id, "left") + } + title="拖动调整入点" + > +
+
+ )} + + {/* 类型图标 */} +
+ {CLIP_TYPE_ICONS[clip.type] || "🎬"} +
+ + {/* 片段信息 */} +
+ + {CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1} + + + {clip.duration}s + {hasTrim && ( + + ✂ + + )} + +
+ + {/* 速度徽章 */} + {showSpeed && ( + + {speed!.rate.toFixed(1)}x + + )} + + {/* 裁剪徽章 */} + {hasTrim && ( + + ✂ + + )} + + {/* 右裁剪手柄 */} + {isHovered && onClipTrim && ( +
+ handleTrimHandleMouseDown(e, clip.id, "right") + } + title="拖动调整出点" + > +
+
+ )} + + {/* 删除按钮 */} + +
+ + ); + }) )} - {/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */} + {/* ── 轨道末尾 "+" 添加卡片 ── */}
= ({
)} - {/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */} + {/* 裁剪预览 tooltip */} + {trimPreview && ( +
+
+ 入点 + + {formatTrimTime(trimPreview.startTime)} + +
+
+ 出点 + + {formatTrimTime(trimPreview.endTime)} + +
+
+ 时长 + + {formatTrimTime(trimPreview.duration)} + +
+
+ )} + + {/* 右键菜单 */} + {contextMenu && ( +
+
+ ✂️ + 分割片段 +
+ {clips.find((c) => c.id === contextMenu.clipId)?.trim_config && ( +
+ ↩️ + 恢复原始长度 +
+ )} +
+
+ 🗑️ + 删除片段 +
+
+ )} + + {/* 类型+时长选择面板 */} {showAddPicker && (
void; + /** 当前转场配置 */ + config: TransitionConfig; + onChange: (config: TransitionConfig) => void; + /** 标题提示(区分全局 / 片段间) */ + title?: string; +} + +const TransitionSelector: React.FC = ({ + open, + onClose, + config, + onChange, + title = "转场特效", +}) => { + /* ── 选择转场类型 ── */ + const handleSelectType = useCallback( + (type: TransitionType) => { + onChange({ ...config, type }); + }, + [config, onChange], + ); + + /* ── 修改时长 ── */ + const handleChangeDuration = useCallback( + (duration: number) => { + onChange({ ...config, duration }); + }, + [config, onChange], + ); + + /* ── 重置为无转场 ── */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_TRANSITION }); + }, [onChange]); + + return ( + + {/* ── 时长滑块 ── */} +
+
+ 转场时长 + + {config.duration.toFixed(1)}s + +
+ `${(v as number).toFixed(1)}s` }} + /> +
+ 0.3s + 1.0s + 2.0s +
+
+ + {/* ── 转场类型卡片网格 ── */} +
+ {TRANSITION_OPTIONS.map((opt) => { + const isActive = config.type === opt.value; + return ( +
handleSelectType(opt.value)} + > +
{opt.icon}
+
{opt.label}
+ {isActive && ✓} +
+ ); + })} +
+ + {/* ── 底部操作 ── */} +
+ +
+ 当前: + {TRANSITION_OPTIONS.find((o) => o.value === config.type)?.label ?? + "无转场"} + {" · "} + {config.duration.toFixed(1)}s +
+
+
+ ); +}; + +export default TransitionSelector; diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel.tsx new file mode 100644 index 000000000..1fbe04bf3 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/TtsPanel.tsx @@ -0,0 +1,349 @@ +/** + * TTS 配音面板 — Drawer 形式 + * 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动 + */ +import React, { useState, useCallback, useEffect, useRef } from "react"; +import { Drawer, Slider, message } from "antd"; +import type { TtsConfig, TtsMode } from "../types"; +import { DEFAULT_TTS_CONFIG } from "../types"; +import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts"; + +/* ──────────── 音色卡片分类图标 ──────────── */ +const VOICE_CATEGORY_MAP: Record = { + male: { icon: "👨", label: "男声" }, + female: { icon: "👩", label: "女声" }, + young: { icon: "🧑", label: "少年" }, + service: { icon: "🎧", label: "客服" }, + news: { icon: "📰", label: "新闻" }, + emotion: { icon: "🎭", label: "情感" }, +}; + +/* ──────────── Props ──────────── */ +interface TtsPanelProps { + open: boolean; + onClose: () => void; + /** 当前片段 TTS 配置 */ + config: TtsConfig; + onChange: (config: TtsConfig) => void; +} + +const TtsPanel: React.FC = ({ + open, + onClose, + config, + onChange, +}) => { + /* ── 音色列表 ── */ + const [voices, setVoices] = useState([]); + const [voicesLoading, setVoicesLoading] = useState(false); + + /* ── 试听状态 ── */ + const [previewLoading, setPreviewLoading] = useState(false); + const audioRef = useRef(null); + + /* ── 加载音色列表 ── */ + useEffect(() => { + if (!open) return; + setVoicesLoading(true); + getTtsVoices() + .then((v) => setVoices(v)) + .catch(() => message.error("加载音色列表失败")) + .finally(() => setVoicesLoading(false)); + }, [open]); + + /* ── 切换配音模式 ── */ + const handleModeChange = useCallback( + (mode: TtsMode) => { + onChange({ ...config, mode }); + }, + [config, onChange], + ); + + /* ── 文本输入 ── */ + const handleTextChange = useCallback( + (e: React.ChangeEvent) => { + const text = e.target.value.slice(0, 5000); + onChange({ ...config, text }); + }, + [config, onChange], + ); + + /* ── 选择音色 ── */ + const handleVoiceSelect = useCallback( + (voiceId: string) => { + onChange({ ...config, voice_id: voiceId }); + }, + [config, onChange], + ); + + /* ── 语速 ── */ + const handleSpeedChange = useCallback( + (speed: number) => { + onChange({ ...config, speed }); + }, + [config, onChange], + ); + + /* ── 语调 ── */ + const handlePitchChange = useCallback( + (pitch: number) => { + onChange({ ...config, pitch }); + }, + [config, onChange], + ); + + /* ── 音量 ── */ + const handleVolumeChange = useCallback( + (volume: number) => { + onChange({ ...config, volume }); + }, + [config, onChange], + ); + + /* ── 字幕联动 ── */ + const handleSubtitleSyncToggle = useCallback(() => { + onChange({ ...config, subtitle_sync: !config.subtitle_sync }); + }, [config, onChange]); + + /* ── 试听 ── */ + const handlePreview = useCallback(async () => { + if (!config.text.trim()) { + message.warning("请先输入合成文本"); + return; + } + if (!config.voice_id) { + message.warning("请先选择音色"); + return; + } + setPreviewLoading(true); + try { + const res = await previewTts({ + text: config.text.slice(0, 200), // 试听截取前200字 + voice_id: config.voice_id, + speed: config.speed, + pitch: config.pitch, + }); + // 停止上一个 + audioRef.current?.pause(); + const audio = new Audio(res.audio_url); + audioRef.current = audio; + audio.play().catch(() => message.error("播放失败")); + audio.onended = () => { + audioRef.current = null; + }; + message.success("试听播放中"); + } catch { + message.error("试听生成失败"); + } finally { + setPreviewLoading(false); + } + }, [config]); + + /* ── 重置 ── */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_TTS_CONFIG }); + }, [onChange]); + + /* ── 关闭时停止音频 ── */ + const handleClose = useCallback(() => { + audioRef.current?.pause(); + audioRef.current = null; + onClose(); + }, [onClose]); + + /* ── 音色分类分组 ── */ + const voiceCategories = Object.entries(VOICE_CATEGORY_MAP); + + return ( + + {/* ── 配音模式切换 ── */} +
+
配音模式
+
+ {[ + { mode: "none" as TtsMode, icon: "🔇", label: "无配音" }, + { mode: "upload" as TtsMode, icon: "📁", label: "上传配音" }, + { mode: "tts" as TtsMode, icon: "🤖", label: "TTS 合成" }, + ].map((m) => ( + + ))} +
+
+ + {/* ── TTS 配置(仅 tts 模式显示) ── */} + {config.mode === "tts" && ( + <> + {/* 文本输入 */} +
+
+ 合成文本 + {config.text.length}/5000 +
+