Compare commits

..

236 Commits

Author SHA1 Message Date
CI Bot 0623d700b7 style: prettier格式化测试文件
AI Code Review / AI Code Review (pull_request) Failing after 34s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 58s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m16s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 2m18s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 2m25s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1420h16m24s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1420h16m36s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1420h16m39s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1420h16m40s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1420h16m43s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1420h16m25s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1420h16m26s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1420h16m40s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1420h16m40s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1420h16m40s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1420h16m41s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1420h16m41s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1420h16m42s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1420h48m48s
2026-07-19 11:12:33 +08:00
CI Bot c70403f4c3 test: 前端单测覆盖率Phase2,行覆盖率22%→39.3%
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m11s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m15s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 1m18s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 1m21s
AI Code Review / AI Code Review (pull_request) Successful in 4m22s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1420h37m28s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1420h37m32s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1420h38m18s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1420h38m19s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1420h38m19s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1420h38m20s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1420h38m20s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1420h38m21s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1420h38m22s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1420h38m22s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1421h9m35s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1421h10m24s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1421h10m25s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1421h10m26s
- 新增5个Layout组件测试(AppLayout/Header/MainLayout/PageHead/Sidebar)
- 新增AssetSelector业务组件测试
- 新增~18个页面smoke test(查重系列/任务系列/资源库/音色/订阅/产品等)
- 新增useUndoRedo hook测试 + useAuth测试增强
- 新增config/navigation配置测试 + auth-normalize纯函数测试
- 71个测试文件,420个用例全通过,行覆盖率39.3%
2026-07-19 10:50:21 +08:00
xiaoxia 0e7498d8aa chore: 测试目录添加eslint配置,放宽any和未使用变量限制
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m30s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m38s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 1m44s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 1m48s
AI Code Review / AI Code Review (pull_request) Successful in 4m33s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1423h49m48s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1423h49m49s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1423h49m50s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1423h49m50s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1423h49m51s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1423h49m51s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1423h49m52s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1423h49m52s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1423h49m52s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1423h54m16s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1423h54m44s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1423h54m46s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1424h21m56s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1424h21m53s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
2026-07-19 07:33:58 +08:00
xiaoxia a5430a4738 fix: 移除未使用的import,修复Frontend Lint报错
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 18s
AI Code Review / AI Code Review (pull_request) Failing after 29s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 46s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 52s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 52s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1423h59m27s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1423h59m28s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1423h59m28s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
2026-07-19 07:29:30 +08:00
xiaoxia ac1d6f2e4b test: 前端单测覆盖率Phase1,行覆盖率0.84%→22.35%
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 38s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 54s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 58s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 58s
AI Code Review / AI Code Review (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1423h49m50s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1423h49m53s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1424h1m0s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1424h1m28s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1424h1m28s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1424h1m2s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1424h1m30s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1424h1m2s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1424h1m30s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1424h1m32s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1424h1m32s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 11m36s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1424h33m34s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1424h33m36s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
- 新增47个测试文件,316个测试用例全部通过
- API层:17个文件全覆盖(assets/auth/bgm/duplication/editPlans等)
- UI组件:7个基础组件 + Login组件
- Hooks:useAuth / useCloneProgress
- Pages:11个页面渲染测试(auth组、dashboard、home、billing等)
- editing-planner子组件:10个组件渲染测试
- Store:authStore全覆盖

行覆盖率 22.35% / 函数 65.9% / 分支 53.62%
2026-07-19 07:27:47 +08:00
xiaoxia ac4514c16a fix: 降低前端单测覆盖率门槛,修复CI挂红 (#546)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m14s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1432h10m28s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1432h10m31s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1432h10m31s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1432h10m30s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1432h11m34s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1432h42m35s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 23:18:01 +08:00
xiaoxia a728951490 style: prettier格式化修复css文件格式 (#544)
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 41s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 7m27s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1432h10m31s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1432h10m32s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1432h10m31s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1432h20m13s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1432h20m14s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1432h20m15s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1432h20m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1432h21m20s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1432h20m15s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 23:08:15 +08:00
xiaoxia 96fc4eca05 fix: vitest排除e2e测试,限定只跑src/test下的单元测试 (#543)
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 40s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1432h23m2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 23:06:31 +08:00
xiaoxia 62a974a836 fix(P0): 修复一键生成视频成片库不显示 - project_id为空时跳过写表 (#541)
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m19s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 3m43s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1432h20m15s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1432h20m15s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1432h20m15s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1432h27m55s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1432h27m56s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1432h27m56s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1432h27m56s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1432h27m58s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1433h0m0s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 23:01:36 +08:00
xiaoxia bb84ede208 ci: 修复preview-cleanup checkout不兼容Gitea Actions问题 (#545)
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m12s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1432h36m56s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1432h36m59s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1432h36m58s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1432h37m0s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1432h38m6s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 6m20s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1433h9m3s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 22:51:28 +08:00
xiaoxia 8df8255521 ci: P3-6 前端单测接入CI + 覆盖率门禁 (#542)
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m17s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 4m29s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1433h49m21s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1433h49m25s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1433h49m25s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1433h49m25s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1433h51m58s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1434h21m27s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 21:37:35 +08:00
xiaoxia 229b25514d fix(P0): 修复worker启动崩溃 - celery信号注册方式错误导致死循环重启 (#538)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 4m28s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1434h25m54s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1434h25m55s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1434h25m55s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1434h31m33s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1434h25m55s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1434h57m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 20:58:02 +08:00
xiaoxia efc79b4457 feat(ci): P2-3 Runner监控告警 - 离线/磁盘/内存/队列积压主动告警 (#537)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1434h33m15s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1434h33m17s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1434h33m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1434h33m51s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1434h33m17s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m55s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1435h5m20s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 20:55:43 +08:00
xiaoxia 0b60c43818 ci: 修复auto-merge 405连续失败问题 (#539)
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 3m8s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1434h36m28s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1434h36m28s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1434h36m29s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1434h41m6s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1434h41m7s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1434h41m8s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1434h41m9s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1434h44m18s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1435h13m11s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 20:45:18 +08:00
xiaoxia 48c8d1fe3e fix(P0+P1): 孤儿任务清理 + 时间时区统一 (#535)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 3m17s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1435h30m58s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1435h31m28s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1435h30m59s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1435h30m59s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1435h30m59s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1435h30m59s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 19:58:08 +08:00
xiaoxia be660bd19a feat(P1): 素材库状态视觉区分 + 上传后立即可见 (#536)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m39s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1435h55m4s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1435h55m5s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1435h55m5s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1435h56m27s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1435h55m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1436h27m8s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 19:33:07 +08:00
xiaoxia 470119d060 fix(P1): 素材列表默认status排除deleted,uploading立即可见 (#534)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m25s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1436h24m48s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1436h24m49s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1436h24m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1436h24m50s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1436h25m43s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1436h56m51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 19:03:53 +08:00
xiaoxia 12c48e4fd1 fix(P1): 素材列表status默认过滤 + page/page_size分页支持 (#532)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1436h24m54s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1436h24m56s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1436h36m43s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1436h36m43s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1436h41m56s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m44s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 11m47s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 18:47:38 +08:00
xiaoxia a117158168 feat(ci): P2-2 ChatOps集成 - 飞书机器人对接Gitea Actions (#533)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 3m15s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1436h43m52s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1436h43m53s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1436h43m54s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1436h43m55s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1436h43m55s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1436h47m2s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 18:42:36 +08:00
xiaoxia 2e9478bb18 fix(P0): 修复素材库删除后仍显示 + 成片库排序和时间显示 (#528)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1436h59m21s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1436h59m23s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1436h59m25s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1436h59m25s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1437h0m49s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 3m4s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1437h31m28s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 18:28:46 +08:00
xiaoxia 40cb1ab8f3 fix(P0): 成片库download_url签名修复 + 删除接口补齐 (#531)
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1437h6m35s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 18:23:01 +08:00
xiaoxia bd9fc1d51f feat(ci): P2-1 CI可观测性看板脚本 (#530)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1437h7m19s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1437h7m21s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1437h7m22s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1437h7m54s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1437h7m22s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m33s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1437h39m23s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 18:21:43 +08:00
xiaoxia 0fcb77b991 ci: Prettier纳入两层防御体系 (#520)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1437h19m24s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1437h19m26s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1437h19m25s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1437h19m26s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1437h21m16s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1437h51m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 18:08:19 +08:00
xiaoxia 1ee779a566 fix(P0): 全面修复删除后白屏崩溃 - 4个页面加防御性检查 (#525)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m21s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1438h5m30s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1438h5m31s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1438h5m31s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1438h7m32s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1438h5m31s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1438h37m32s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 17:22:04 +08:00
xiaoxia ce8f6b7398 fix(P0): unified横屏pad取整+错误日志增强+legacy导入+素材created_at (#522)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m35s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1438h33m42s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1438h33m45s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1438h33m45s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1438h33m46s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1438h34m11s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1439h5m47s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 16:55:27 +08:00
xiaoxia 12b285c42a fix(ci): buildx本地缓存损坏自动检测+清理重试,根治snapshot not found连锁失败 (#523)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m11s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1439h11m31s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1439h11m32s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1439h11m33s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1439h11m33s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1439h13m57s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1439h43m34s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 16:15:39 +08:00
xiaoxia b9df592148 fix(ci): Web构建改用layer缓存替代cache mount,根治node_modules缓存损坏 (#521)
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1m56s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1439h15m40s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1439h15m39s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1439h22m27s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1439h22m28s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1439h22m28s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1439h22m28s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1439h24m26s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1439h22m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1439h47m42s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 16:05:11 +08:00
xiaoxia 97c1e5e8a8 fix: 模板生成计划时配置字段漏传 - export水印 + 调速speed_ratio + clip config透传 (#501)
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 19s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1439h42m35s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1439h42m55s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1439h42m56s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1439h42m56s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1439h53m5s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1440h14m37s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1440h14m57s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1440h14m57s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1440h14m38s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 15:36:34 +08:00
xiaoxia 57d708d8c5 fix: 修复素材库批量删除后白屏崩溃,批量操作结果归一化 + 列表防御性检查 (#509)
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1m56s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1439h59m29s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1440h8m6s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1439h59m29s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1440h8m7s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1440h8m7s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1440h8m7s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1440h8m36s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1440h8m7s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1440h31m31s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
2026-07-18 15:21:03 +08:00
xiaoxia 7378ef378e fix(P0): legacy渲染引擎5项normalize补全 + ffmpeg错误日志增强 (#511)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m17s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1440h15m39s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1440h15m40s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1440h15m41s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1440h15m42s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1440h21m39s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1440h47m42s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 15:07:58 +08:00
xiaoxia e724225f8a fix: 素材列表默认按created_at倒序,新上传素材在最前面 (#505)
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1440h24m30s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 15:05:08 +08:00
xiaoxia be68bb9adf chore: 清理预览环境测试文件 (#518)
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 23s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1440h15m40s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1440h15m41s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1440h15m42s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1440h25m24s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1440h25m25s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1440h25m25s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1440h25m25s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1440h32m25s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1440h57m25s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 14:57:13 +08:00
xiaoxia 6aa1e73316 fix(P0): unified渲染引擎4项normalize补全,修复concat exit=234 (#517)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1440h33m0s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1440h33m2s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1440h33m3s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1440h33m3s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1440h37m28s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1441h5m3s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 14:52:10 +08:00
xiaoxia 9a66fe5833 test: preview deploy + cleanup 全链路验证 v2 (#516)
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1440h40m46s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1440h40m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1440h40m49s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1440h41m6s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1440h40m47s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1441h12m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 14:48:19 +08:00
xiaoxia c7733a6974 fix(P0): 放宽ingest视频编码拦截 + 自动选素材去掉编码过滤 (#508)
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1440h43m45s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 14:45:53 +08:00
xiaoxia bb4a1dc1d9 fix(ci): 调整preview SSH key优先级,优先使用PREVIEW_SSH_KEY secret (#515)
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1440h47m26s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1440h47m30s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1440h47m27s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1440h47m30s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1440h53m54s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1441h19m32s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 14:35:46 +08:00
xiaoxia de874e3338 test: preview deploy + cleanup 全链路验证 (#510)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1440h55m12s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1440h55m16s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1440h55m14s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1440h56m15s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1440h55m16s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1441h27m17s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 14:33:25 +08:00
xiaoxia 0b01e41261 fix(ci): cleanup workflow缺少checkout步骤导致评论脚本找不到 (#507)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1441h2m48s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1441h2m50s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1441h2m50s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1441h3m26s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1441h2m50s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1441h34m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 14:26:12 +08:00
xiaoxia 7c884e5298 fix(ci): 修复preview cleanup的PR号提取和SSH安装问题 (#504)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m4s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1441h25m56s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1441h25m59s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1441h25m59s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1441h25m59s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1441h28m16s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1441h57m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 14:01:24 +08:00
xiaoxia c08ef726f7 fix: legacy渲染引擎concat前音频不归一化exit=234 + 视频改为pad留黑边 (#503)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1441h36m59s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1441h37m1s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1441h37m1s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1441h37m27s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1441h37m1s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m19s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1442h9m1s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 13:52:11 +08:00
xiaoxia 2983d76a11 ci: 代码格式自动修复 + pre-commit配置 (#502)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m12s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1442h6m26s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1442h6m28s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1442h6m55s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1442h6m27s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1442h6m27s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1442h38m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 13:22:44 +08:00
xiaoxia 8f5176aadb fix: 渲染失败三重修复 - legacy fps滤镜 + 横屏过滤 + legacy分辨率读取 (#500)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1442h17m1s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1442h17m4s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1442h17m4s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1442h17m41s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1442h17m4s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1442h49m3s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 13:11:59 +08:00
xiaoxia 9cf48d493d fix(ci): 修复预览环境workflow YAML解析错误 (#499)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1442h24m30s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1442h24m31s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1442h24m31s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1442h24m32s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1442h24m54s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m14s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1442h56m31s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 13:04:47 +08:00
xiaoxia 854522986e fix: 自动选素材过滤HEVC等不支持编码 + ingest编码格式校验 (#494)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m3s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1444h1m56s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1444h1m58s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1444h1m58s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1444h2m24s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1444h1m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1444h33m57s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 11:27:16 +08:00
xiaoxia 32009f3998 ci(preview): 预览环境切换专用SSH密钥(deploy用户+内网IP) (#493)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m12s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1444h13m39s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1444h13m41s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1444h13m42s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1444h14m4s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1444h13m42s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1444h45m41s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 11:15:35 +08:00
xiaoxia 3613d74e89 fix(api): 剪辑计划详情/列表接口rendered_url签名转换,修复OSS 403 (#492)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m57s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1444h36m12s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1444h36m13s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1444h36m13s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1444h36m13s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1444h36m31s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1445h8m14s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 10:53:10 +08:00
xiaoxia 810096bf23 fix(api): 一键生成自动选素材全链路修复,4个核心根因一次性解决 (#491)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m9s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1446h7m46s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1446h7m50s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1446h7m50s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1446h8m28s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1446h7m50s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1446h39m49s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 09:21:11 +08:00
xiaoxia d86246a295 fix(api): 一键生成自动选素材,创建计划时从项目视频素材库自动分配ready视频素材 (#490)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m8s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1446h50m11s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1446h50m12s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1446h50m13s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1446h50m42s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1446h50m13s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1447h22m13s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 08:38:59 +08:00
xiaoxia d25c7e4a75 fix(worker): ingest先下载OSS文件再提取元数据,修复有效性校验全部误判ERROR (#489)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 4m51s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1455h3m59s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1455h4m1s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1455h4m1s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1455h4m1s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1455h4m27s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1455h36m1s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 00:25:13 +08:00
xiaoxia f435a4d3a5 fix(worker): ingest增加素材有效性校验,坏文件标记为ERROR (#488)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 3m11s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1457h8m18s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1457h8m19s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1457h8m19s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1457h8m43s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1457h8m19s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1457h40m19s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 22:20:57 +08:00
xiaoxia 840fd43cb3 fix(worker): download_asset支持HTTP URL下载,兼容预签名file_url (#487)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1458h35m14s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1458h35m16s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1458h35m16s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1458h35m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1458h43m12s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1459h7m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 20:46:29 +08:00
xiaoxia 7c0d33984e fix(ci): 修复CI状态检查误判导致提前合并的bug (#486)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1458h42m48s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1458h42m49s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1458h42m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1458h42m50s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1458h43m17s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 4m2s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1459h14m50s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 20:46:23 +08:00
xiaoxia f1d3d4847c fix(ci): 修复auto-merge checkout缓存损坏 + 清理老合并脚本 (#484)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1459h5m43s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1459h5m45s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1459h5m45s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1459h5m45s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1459h6m57s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1459h37m44s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 20:22:43 +08:00
xiaoxia b3728b6a27 fix(worker): 素材下载storage_key为空时fallback到file_url,兼容存量数据 (#485)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 4m8s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1459h20m5s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1459h20m8s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1459h20m7s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1459h20m8s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1459h21m15s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1459h52m8s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 20:08:26 +08:00
auto-approve-bot eecdbf7059 Merge pull request 'feat(ci): P1-4 前端预览环境实施(轻量版)' (#482) from ci/p1-4-preview-environment into develop
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1459h45m29s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1459h45m31s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1459h45m31s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1459h45m59s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1459h45m31s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1460h17m31s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
2026-07-17 19:43:42 +08:00
xiaoxia 5a91e3679a feat(ci): add preview nginx configuration template
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Failing after 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m5s
AI Code Review / AI Code Review (pull_request) Successful in 2m17s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m36s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m22s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 4m41s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1460h14m21s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1460h14m21s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1460h14m23s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1460h14m23s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1460h14m25s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1460h14m25s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1460h14m23s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1460h14m23s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1460h14m25s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1460h46m21s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1460h46m23s
2026-07-17 19:15:00 +08:00
xiaoxia b69e3375a5 feat(ci): add preview server initialization script 2026-07-17 19:14:53 +08:00
xiaoxia 5823917841 feat(ci): add preview cleanup workflow for closed PRs 2026-07-17 19:14:44 +08:00
xiaoxia 897340c488 Merge pull request 'fix(worker): 素材下载改用storage_key字段,修复file_url为URL导致下载无效文件' (#480) from fix/download-asset-storage-key into develop
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1460h14m34s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1460h14m36s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1460h15m5s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1460h14m35s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1460h14m36s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1460h46m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
2026-07-17 19:14:37 +08:00
xiaoxia c43e09d6c4 feat(ci): add preview deploy workflow for PR previews 2026-07-17 19:14:36 +08:00
CI 24555d1115 fix(ci): 修复diff-cover no merge base错误,基于base分支建分支后再覆盖PR代码
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 9s
AI Code Review / AI Code Review (pull_request) Failing after 36s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 44s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m28s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m45s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m21s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 4m9s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1460h21m17s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1460h21m17s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1460h21m18s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1460h21m17s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1460h21m19s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1460h21m19s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1460h21m19s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1460h21m19s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1460h53m19s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1460h53m17s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1460h53m19s
2026-07-17 19:07:25 +08:00
CI Bot d7aa767611 revert: 回滚误改的CI配置,#480只做后端修复
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 49s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m10s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 2m40s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1460h23m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1460h23m51s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1460h23m51s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1460h23m53s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1460h23m53s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1460h23m53s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1460h23m53s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1460h23m53s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1460h55m51s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1460h55m52s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1460h55m53s
2026-07-17 19:04:11 +08:00
CI Bot 344516a6a2 fix(ci): 修复diff-cover no merge base错误,基于base分支创建当前分支
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Failing after 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 38s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m16s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m20s
AI Code Review / AI Code Review (pull_request) Successful in 4m3s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 4m35s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 5m1s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1460h36m43s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1460h36m43s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1460h36m43s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1460h36m45s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1460h36m46s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1460h36m45s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1460h36m47s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1460h36m45s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1460h36m47s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1461h8m43s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1461h8m45s
2026-07-17 18:52:37 +08:00
xiaoxia ccc47e70fa fix(ci): auto-approve轮询等待所有CI跑完再判断结果 (#481)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1460h57m34s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1460h57m36s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1460h57m35s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1460h57m36s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1460h59m55s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1461h29m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 18:29:47 +08:00
CI Bot 0b073a8648 fix(worker): 补alembic迁移+schema快照+asset_repository单测,修复CI 3红
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Failing after 0s
AI Code Review / AI Code Review (pull_request) Failing after 33s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 4m9s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 42s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1460h59m41s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1460h59m42s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1460h59m42s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1460h59m43s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1460h59m43s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1460h59m44s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1460h59m43s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1460h59m44s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1460h59m44s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1461h31m44s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1461h31m42s
- 新增042号迁移:assets表加storage_key字段
- 更新schema-metadata-snapshot.json快照
- 新增SQLAlchemyAssetRepository的storage_key单测(create/update/to_domain fallback)
2026-07-17 18:28:54 +08:00
xiaoxia a0957e7293 feat(ci): P1-2 Unit Tests增量执行 - 按改动文件映射相关测试 (#472)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1461h9m57s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1461h9m59s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1461h9m59s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1461h10m27s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1461h9m59s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1461h41m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 18:19:14 +08:00
CI Bot 8e2e352cfe fix(worker): 素材下载改用storage_key字段,修复file_url为URL导致下载无效文件
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 40s
AI Code Review / AI Code Review (pull_request) Failing after 1m23s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 2m47s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 3m6s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m27s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m5s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1461h24m12s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1461h24m13s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1461h24m13s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1461h24m14s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1461h24m13s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1461h24m14s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1461h24m15s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1461h24m16s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1461h56m14s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1461h56m16s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1461h56m13s
- AssetModel新增storage_key字段(OSS存储键)
- render_adapter _download_assets改用a.storage_key
- render_adapter BGM下载改用storage_key
- edit_plan_generation legacy路径同步修复
- generation.py BGM下载同步修复
- asset_repository映射同步更新storage_key字段
- 单元测试同步更新
2026-07-17 18:04:51 +08:00
xiaoxia 91ba55e210 Merge pull request 'feat(ci): P1-3 增量覆盖率门禁 - diff-cover接入+全量门槛65%' (#474) from ci/p1-3-coverage-gate into develop
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1461h43m50s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1461h43m52s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1461h43m52s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1461h43m52s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1461h44m18s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1462h15m51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
合并PR #474
2026-07-17 17:45:22 +08:00
xiaoxia 2a12532fb4 fix(worker): render_adapter分辨率读取 + ffmpeg错误捕获增强 (#479)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1461h57m50s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1461h57m52s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1461h57m52s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1461h57m53s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1461h58m15s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1462h29m51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 17:31:26 +08:00
xiaoxia eb7eb6f317 perf(ci): ci-l1→ci-check 充分利用全部runner
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 30s
AI Code Review / AI Code Review (pull_request) Failing after 30s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m27s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m50s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m37s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 3m24s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 3m5s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m36s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1462h3m39s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1462h3m38s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1462h3m38s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1462h3m40s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1462h3m38s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1462h3m41s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1462h3m42s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1462h3m41s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1462h35m40s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1462h35m41s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1462h35m41s
2026-07-17 17:26:00 +08:00
xiaoxia 84f383c6cb fix(ci): diff-cover前初始化git仓库(CI tarball checkout无.git)
AI Code Review / AI Code Review (pull_request) Successful in 50s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 27s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 26s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1462h20m7s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1462h20m10s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1462h20m10s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1462h20m10s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1462h20m11s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1462h20m13s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1462h20m14s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1462h20m13s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1462h52m9s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1462h52m10s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1462h52m11s
2026-07-17 17:09:27 +08:00
xiaoxia 8c30f67900 fix(ci): 修复AI代码审查脚本缺失build_review_prompt函数 (#473)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1462h17m51s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1462h17m55s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1462h17m57s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1462h17m57s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1462h24m12s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1462h49m53s
2026-07-17 17:05:30 +08:00
xiaoxia a1960cae93 fix(ci): 修复调度死锁+auto-approve/merge SHA错误 (#478)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1m47s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m5s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1462h26m16s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1462h26m18s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1462h26m18s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1462h26m53s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1462h26m18s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1462h58m18s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 17:02:48 +08:00
xiaoxia ede6f53428 fix(api): create_plan接口增加asset_ids参数,修复一键生成片段无素材问题 (#477)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1463h23m43s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1463h25m28s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1463h23m43s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1463h23m44s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1463h23m44s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1463h23m44s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 16:04:15 +08:00
xiaoxia 41e3fc7a58 fix(worker): 修复素材下载——asset_id查assets表拿file_url + 统一OSS配置读取 (#471)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1463h51m28s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1463h51m29s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1463h51m29s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1463h52m2s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1463h51m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1464h23m27s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 15:37:41 +08:00
xiaoxia 31e29e1aed feat(ci): P1-3 增量覆盖率门禁 - diff-cover接入+全量门槛提至65%
AI Code Review / AI Code Review (pull_request) Successful in 37s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 31s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m50s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 3m12s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 3m14s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m6s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m17s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1464h20m51s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1464h20m51s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1464h20m53s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1464h20m52s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1464h20m53s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1464h20m55s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1464h20m54s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1464h20m56s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1464h20m56s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1464h52m51s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1464h52m53s
- 新增diff-cover增量行覆盖率检查,PR改动代码需>=60%覆盖率
- 增量测试模式下门槛调低至40%(覆盖不全)
- 全量覆盖率门槛从60%提升至65%(当前68%,留3%缓冲)
- 覆盖率不达标直接阻塞合并,倒逼新代码补单元测试
2026-07-17 15:08:26 +08:00
xiaoxia 09d2b12ea8 feat(ci): P1-1 Phase2 后端启用F401+F841并修复存量 (#470)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1465h20m6s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1465h20m14s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1465h20m14s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1465h20m14s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1465h22m53s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1465h52m11s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 14:06:50 +08:00
xiaoxia b7a0d29b0d P1-1: 一键生成P1级效果层补齐 - clip级转场/滤镜/片头片尾从模板映射 (#468)
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1465h30m41s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 13:59:04 +08:00
xiaoxia 3afec86a4f P1-1: 一键生成P0效果层补齐 - 模板配置注入(BGM/字幕/ASR) + 直通模式调速修复 (#465)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1465h35m16s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1465h35m18s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1465h35m19s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1465h35m19s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1465h50m4s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1466h7m15s
2026-07-17 13:39:39 +08:00
xiaoxia 3aa0be36a2 #454 剪辑计划生成状态接口video_url改为签名URL
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1465h50m32s
closes #454
2026-07-17 13:39:12 +08:00
xiaoxia 17720a2484 feat(ci): P1-1 Phase2 前端no-unused-vars从warn升级为error (#469)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1465h56m19s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1465h56m21s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1465h56m21s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1465h56m21s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1465h57m5s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1466h28m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 13:32:38 +08:00
xiaoxia 8a9cd95787 feat(ci): P1-1 代码质量门禁 Phase 1 - ESLint收紧 + Prettier配置 + ruff配置清理 (#466)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 4m20s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1466h11m55s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1466h11m58s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1466h11m57s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1466h11m58s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1466h13m48s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1466h11m58s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 13:15:56 +08:00
xiaoxia f425ef09c7 #456 修复创建剪辑计划时传template_id不生成clips的问题 (#460)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 3m48s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1466h57m19s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1466h57m23s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1466h57m23s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1466h57m31s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1466h57m23s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1467h29m20s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 12:32:14 +08:00
xiaoxia 60699b0b0c Merge pull request 'P1-2: 剪辑计划统一渲染链路打通 - 收敛RenderAdapter + 补齐BGM和ASR自动字幕' (#442) from feature/p1-2-edit-plan-render-link into develop
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1466h58m41s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
2026-07-17 12:31:05 +08:00
CI Bot d1627d5aaf fix: black format code quality issues
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 10s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 36s
AI Code Review / AI Code Review (pull_request) Successful in 1m5s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m3s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m44s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 2m58s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m18s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1467h8m37s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1467h8m37s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1467h8m37s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1467h8m39s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1467h8m39s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1467h8m41s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1467h8m41s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1467h8m41s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1467h40m36s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1467h40m38s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1467h40m38s
2026-07-17 12:20:53 +08:00
xiaoxia 492a84afe2 fix(ci): 修复auto-approve审批状态为PENDING的问题 (#461)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1467h10m47s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1467h10m49s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1467h10m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1467h10m50s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1467h11m22s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1467h42m48s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 12:18:22 +08:00
xiaoxia b55a28683e feat(ci): auto-merge从定时触发改为PR事件即时合并 (#441)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1467h38m49s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1467h38m51s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1467h38m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1467h38m51s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1467h39m17s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1468h10m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 11:50:27 +08:00
CI Bot 6bdab42224 P1-2: 剪辑计划统一渲染链路打通 - 收敛RenderAdapter + 补齐BGM和ASR自动字幕
AI Code Review / AI Code Review (pull_request) Failing after 31s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 38s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 39s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 2m55s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m35s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m44s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1467h42m47s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1467h42m47s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1467h42m48s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1467h42m48s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1467h42m48s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1467h42m48s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1467h42m49s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1467h42m49s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1467h42m49s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1468h14m46s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1468h14m47s
## 改动内容

### 1. RenderAdapter 增强(render_adapter.py)
- 新增 BGM 准备逻辑:支持 URL/素材库/预设库三种来源,失败降级不阻断
- 新增 ASR 服务初始化:自动字幕支持,初始化失败降级
- _download_assets 返回值扩展为 (asset_map, rendered_ids, failed_ids)
- RenderAdapterResult 增加 rendered_clip_ids / failed_clip_ids 字段
- render_plan 调用 UnifiedRenderService 时传入 bgm_path 和 asr_service

### 2. 剪辑计划渲染收敛(edit_plan_generation.py)
- _render_with_unified 从直接调 UnifiedRenderService 改为通过 RenderAdapter
- 消除与 compose_video 的重复代码(素材下载+渲染+上传)
- 主流程按引擎分支:unified 走 RenderAdapter,legacy 保留原逻辑
- 移除顶部不再使用的 UnifiedRenderService 直接导入

### 3. 单元测试(test_render_adapter.py)
- 修复 _download_assets 返回值变化的测试
- 新增 TestPrepareBgm:7 个用例覆盖三种来源+优先级+失败降级
- 新增 TestGetAsrService:3 个用例覆盖可用/导入失败/初始化失败
- 新增 TestRenderPlanWithBgmAsr:3 个集成用例
- 共 31 个测试全部通过

## 三个 P0 断点修复
✅ BGM 未接入 → RenderAdapter._prepare_bgm 自动从 plan.config.bgm 读取
✅ ASR 自动字幕未接入 → RenderAdapter._get_asr_service 自动初始化
✅ 代码重复 → edit_plan_generation 与 compose_video 统一走 RenderAdapter
2026-07-17 11:46:19 +08:00
xiaoxia 3d12c32ff5 fix(editor): 片段同步失败提示 + 删除片段二次确认 (#440)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1467h55m34s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1467h55m36s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1467h55m38s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1467h55m39s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1467h56m36s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1468h27m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
fix(editor): 片段同步失败提示 + 删除片段二次确认 (#440)
2026-07-17 11:33:09 +08:00
xiaoxia 6aec0ec6f4 feat: 转场特效 - 预设库 + 单片段设置 + 批量应用 (#398)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1468h33m50s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1468h33m52s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1468h33m54s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1468h33m54s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1468h37m34s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1469h5m52s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
feat: 转场特效 - 预设库 + 单片段设置 + 批量应用 (#398)
2026-07-17 10:52:12 +08:00
xiaoxia ed705187ca feat: 字幕管理 - 增删改查 + 批量导入(基于片段config) (#394)
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1468h39m0s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
feat: 字幕管理 - 增删改查 + 批量导入(基于片段config) (#394)
2026-07-17 10:50:45 +08:00
xiaoxia 3c9509d373 feat(ci): P1-5 CI触发可靠性监控 - 定时检测PR未触发CI并告警 (#435)
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1468h41m39s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
2026-07-17 10:48:06 +08:00
xiaoxia fbb4f2f4c0 fix(ci): 修复auto-approve YAML语法问题 - 内联Python多行缩进导致workflow不触发 (#438)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1468h41m22s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1468h41m24s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1468h41m24s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1468h41m25s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1468h41m53s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1469h13m21s
2026-07-17 10:47:52 +08:00
xiaoxia 61c41e8755 feat: 片段调整 - 调速 + 音量 + 裁剪 + 批量调速 (#400)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1469h21m58s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1469h22m1s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1469h22m1s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1469h22m1s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1469h28m12s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1469h53m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
feat: 片段调整 - 调速 + 音量 + 裁剪 + 批量调速 (#400)
2026-07-17 10:01:35 +08:00
xiaoxia 7599e20cfa feat(ci): P1 自动审批CI内置化 - CI全绿自动审批
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1469h28m31s
新增auto-approve.yml workflow,CI全绿后自动审批PR
- PR触发(opened/synchronize/ready_for_review)
- 草稿PR自动跳过
- 纯前端改动只检查Frontend Lint
- 全量改动检查Validate/Unit Tests/Frontend Lint三项
- 全绿后自动打APPROVED,附言CI全绿自动审批通过
- 已有审批则跳过
- 单job串行轮询,最多等20分钟
2026-07-17 10:01:15 +08:00
xiaoxia a1f9557dc5 feat: 封面管理 - 配置读写 + 片段抽帧 + 智能选帧 (#396)
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1469h29m28s
feat: 封面管理 - 配置读写 + 片段抽帧 + 智能选帧 (#396)
2026-07-17 10:00:18 +08:00
xiaoxia abe854be76 feat: 剪辑计划片段CRUD前端全链路对接 (#403)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1469h29m4s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1469h29m6s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1469h29m6s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1469h29m6s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1469h30m33s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1470h1m2s
feat: 剪辑计划片段CRUD前端全链路对接 (#403)
2026-07-17 09:59:13 +08:00
xiaoxia 47e7018f2a feat: BGM背景音乐管理 - 配置API + 预设库API (#395)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1469h49m8s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1469h49m10s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1469h49m10s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1469h49m10s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1469h54m25s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1470h21m7s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
feat: BGM背景音乐管理 - 配置API + 预设库API (#395)
2026-07-17 09:35:21 +08:00
xiaoxia ad089ebc12 fix(ci): 修复matrix并发构建时buildx builder命名冲突
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1469h56m57s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1469h56m59s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1469h56m59s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1469h56m59s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1470h0m46s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 52s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 2m8s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1470h28m56s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
三个matrix job(API/Web/Worker)共用GITHUB_JOB名称,并发运行时争抢同一个buildx builder实例导致失败。在builder名称中加入matrix.cache_name区分不同服务。
2026-07-17 09:29:01 +08:00
xiaoxia ee8c6c1a3b pr_title
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1470h9m55s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1470h9m57s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1470h9m58s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1470h9m58s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1470h11m14s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1470h41m55s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
pr_body
2026-07-17 09:18:34 +08:00
xiaoxia 4159e95f10 pr_title
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1470h11m17s
pr_body
2026-07-17 09:18:30 +08:00
xiaoxia e8312482d5 pr_title
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 2s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 2s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1470h9m57s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1470h9m55s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1470h9m59s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1470h11m19s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1470h11m21s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1470h11m22s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1470h11m24s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1470h11m22s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1470h43m17s
pr_body
2026-07-17 09:18:23 +08:00
xiaoxia 00b6488b1b fix(ci): 修复YAML折叠导致两行docker login被合并为一行
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 14s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1470h32m8s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1470h32m9s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1470h47m50s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1470h47m51s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1470h47m52s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1470h47m52s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1470h48m19s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1470h47m52s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1471h4m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
修复YAML单引号多行字符串折叠导致两行docker login被合并为一行的问题,在Gitea Registry登录行前后补空行以保留换行符。
2026-07-17 08:41:27 +08:00
xiaoxia 42bf441fa6 fix(ci): 修复Gitea Registry登录命令写法(改用-p参数) (#432)
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 24s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 26s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 34s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1471h25m1s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1471h25m12s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1471h25m1s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1471h25m2s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1471h25m13s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1471h25m13s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1471h25m37s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1471h57m8s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1471h57m9s
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
fix(ci): 修复Gitea Registry登录命令写法(改用-p参数)
2026-07-17 08:04:12 +08:00
xiaoxia 678f7f90fd fix(ci): 添加Gitea Container Registry登录,修复base镜像拉取401 (#431)
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 27s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 30s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 36s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1471h34m45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1471h34m46s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1471h34m56s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1471h34m57s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1471h34m58s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1471h34m58s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1471h34m58s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1471h35m25s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1472h6m43s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
fix(ci): 添加Gitea Container Registry登录,修复base镜像拉取401
2026-07-17 07:54:25 +08:00
xiaoxia 92855189ad pr_title
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 19s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 25s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 29s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1472h11m50s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1472h12m19s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1472h12m20s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1472h11m50s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1472h12m21s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1472h12m22s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1472h12m24s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1472h12m22s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1472h43m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
pr_body
2026-07-17 07:17:26 +08:00
xiaoxia 50adbed970 pr_title
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 23s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 28s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 23s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1472h12m20s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1472h12m22s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1472h12m21s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1472h12m25s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1472h12m23s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1472h12m26s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1472h12m26s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1472h12m50s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1472h44m21s
pr_body
2026-07-17 07:16:59 +08:00
auto-approve-bot b891eced23 feat(ci): AI代码审查Bot - 修复3个bug
CI/CD Pipeline / Unit Tests (push) Failing after 14m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 14m39s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 14m41s
CI/CD Pipeline / Frontend Lint (push) Failing after 14m41s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 14m41s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 14m41s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1477h50m6s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1477h50m8s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1477h50m7s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1477h50m9s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1477h50m10s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1477h50m10s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1478h4m51s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1478h22m3s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1478h22m5s
CI/CD Pipeline / Integration Tests (push) Has been skipped
feat(ci): AI代码审查Bot - 修复3个bug(过滤生效/评论去重/异常退出码)

Co-authored-by: auto-approve-bot <auto-approve-bot@xiaoxiajianji.com>
Co-committed-by: auto-approve-bot <auto-approve-bot@xiaoxiajianji.com>
2026-07-17 01:24:59 +08:00
xiaoxia 84d1673d31 feat(ci): 前端构建缓存优化 - 去重+BuildKit cache mount
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1478h29m57s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
PR #427: 删除host上重复的前端构建步骤,web.Dockerfile加BuildKit cache mount(npm下载+node_modules+TS增量编译三级缓存),预计Web构建降40%-60%。
2026-07-17 00:59:54 +08:00
xiaoxia 81f9a47210 pr_title
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1478h32m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
pr_body
2026-07-17 00:56:52 +08:00
xiaoxia 68d15d8731 feat(ci): 强制源码安装black+isort,根治格式化行为不一致
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1481h10m34s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
PR #404: black和isort改为pip源码安装(--no-binary),确保所有环境格式化行为一致,避免预编译版本差异导致的black格式化结果不一致问题。
2026-07-16 22:19:16 +08:00
xiaoxia ac2662b693 fix(ci): push触发只保留main/develop,根治PR双触发重复run
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1481h11m3s
PR #425: 删除push.branches中的feature/feat/bugfix/fix等11个通配符分支,只保留main和develop。PR分支CI完全靠pull_request触发,将每个PR的run数量从4个降到2个。
2026-07-16 22:18:48 +08:00
xiaoxia f89ce9f3e2 feat(ci): 纯前端PR自动跳过后端检查,节省CI时间 (#424)
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1481h29m56s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Merge PR #424
2026-07-16 21:59:56 +08:00
xiaoxia 4c662cca32 feat(ci): staging部署脚本增加本地自动回滚能力
CI/CD Pipeline / Frontend Lint (push) Successful in 39s
CI/CD Pipeline / Unit Tests (push) Successful in 2m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m46s
CI/CD Pipeline / Integration Tests (push) Successful in 4m18s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
PR #385: Staging本地自动回滚

- 部署前自动备份当前版本容器
- 健康检查失败自动回滚到上一版本
- 无需人工介入,提升部署安全性
2026-07-16 21:26:44 +08:00
xiaoxia 28e2bba5c3 feat(ci): Runner分层调度 - L1/L2/L3按任务类型路由
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
PR #401: Runner分层调度

- L1层(轻量): Validate/Frontend Lint,快速反馈
- L2层(标准): Unit Tests/Integration Tests
- L3层(重): Build/Deploy/E2E,独占资源
- 按任务类型路由到对应标签的runner池
2026-07-16 21:26:26 +08:00
xiaoxia c19e0d9387 feat(ci): staging部署脚本增加Registry登录和拉镜像重试机制
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
PR #390: Registry登录与拉镜像重试

- staging部署前自动登录本地Registry
- docker pull增加重试机制,应对网络抖动
- 提升部署稳定性
2026-07-16 21:26:11 +08:00
xiaoxia d9000fee54 feat(ci): deploy job增加独立concurrency,部署排队不打断
CI/CD Pipeline / Frontend Lint (push) Successful in 59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 7m40s
CI/CD Pipeline / Unit Tests (push) Successful in 8m12s
CI/CD Pipeline / Integration Tests (push) Successful in 1m58s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
PR #423: deploy job独立concurrency

- deploy-staging和deploy-production各加独立concurrency group
- cancel-in-progress: false,部署排队不打断
- 全局CI构建仍保持打断模式高效迭代
2026-07-16 21:04:06 +08:00
xiaoxia ffc9706a85 fix(ci): 修复concurrency group变量名,根治run堆积
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
PR #422: 修复concurrency group变量名

- 顶层concurrency group从github.workflow改为硬编码ci-cd前缀
- 使用gitea.event_name和gitea.ref确保跨workflow一致性
- 新push自动取消旧run,根治run堆积问题
2026-07-16 21:03:21 +08:00
xiaoxia bfce6f48ce Merge pull request 'fix(ci): Validate增加独立PG容器,修复alembic迁移检查全部失败' (#405) from fix/ci-validate-pg-container into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1482h32m14s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1482h32m14s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1482h32m15s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1482h32m15s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1482h32m15s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1482h32m16s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1482h32m16s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1482h32m16s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1482h32m16s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1483h4m8s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1483h4m9s
2026-07-16 20:06:09 +08:00
XiaoXia Bot 71d1a8e78d test: trigger CI to validate runner log fix
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m50s
CI/CD Pipeline / Unit Tests (push) Successful in 1m55s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m3s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m7s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m5s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 13m51s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m10s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1483h28m30s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1483h28m30s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1483h28m32s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1483h28m32s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1483h28m34s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1483h28m34s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1483h28m34s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1483h32m11s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1483h32m11s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1483h32m18s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1483h28m34s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1483h32m19s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1483h28m34s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1483h32m20s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1483h28m34s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1483h32m21s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1483h32m24s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1483h32m13s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1483h32m26s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1483h32m19s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1484h4m8s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1484h4m14s
2026-07-16 19:57:28 +08:00
XiaoXia Bot a3df4fb324 ci: use refspec to create origin/develop remote branch after fetch
CI/CD Pipeline / Frontend Lint (push) Successful in 1m45s
CI/CD Pipeline / Unit Tests (push) Successful in 3m7s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 16m14s
CI/CD Pipeline / Integration Tests (push) Successful in 1m31s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m41s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1484h45m49s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1484h45m53s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1484h45m53s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1484h45m54s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1484h45m54s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1484h45m52s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1484h45m54s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1484h45m55s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1484h45m54s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1484h45m55s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1484h45m55s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1484h45m56s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1484h45m56s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1484h45m55s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1484h45m56s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1484h45m57s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1484h45m57s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1484h45m57s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1484h45m57s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1484h45m58s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1484h45m58s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1485h17m46s
2026-07-16 18:43:56 +08:00
XiaoXia Bot e8e4928062 ci: fix migration safety check for tar.gz checkout environment
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m14s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m16s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m18s
CI/CD Pipeline / Unit Tests (push) Successful in 3m48s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 14m42s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 14m42s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m51s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1485h4m19s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1485h4m21s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1485h4m21s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1485h4m21s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1485h4m20s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1485h4m21s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1485h4m21s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1485h4m21s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1485h4m23s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1485h4m22s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1485h4m23s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1485h4m23s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1485h4m23s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1485h4m24s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1485h4m25s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1485h4m25s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1485h4m25s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1485h4m25s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1485h4m25s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1485h36m13s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1485h36m15s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1485h36m17s
2026-07-16 18:25:28 +08:00
XiaoXia Bot cf6b625ffb ci: init git repo before migration safety check for diff support
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m18s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m21s
CI/CD Pipeline / Unit Tests (push) Successful in 3m55s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m59s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 16m35s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 16m36s
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m30s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1485h26m13s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1485h26m14s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1485h26m14s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1485h26m14s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1485h26m14s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1485h26m15s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1485h26m15s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1485h26m15s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1485h26m17s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1485h26m17s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1485h26m17s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1485h26m17s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1485h26m19s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1485h26m19s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1485h26m19s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1485h26m19s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1485h26m19s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1485h58m9s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1485h58m11s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1485h58m6s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1485h58m6s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1485h58m9s
2026-07-16 18:03:35 +08:00
XiaoXia Bot 188fec52c6 ci: revert detect-secrets args, use simple redirect
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m29s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m37s
CI/CD Pipeline / Unit Tests (push) Successful in 4m2s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1485h34m40s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1485h34m40s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1485h34m41s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1485h34m41s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1485h34m41s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1485h34m41s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1485h34m41s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1485h34m41s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1485h34m42s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1485h34m42s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1485h40m8s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1485h40m11s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1485h40m11s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1485h40m12s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1485h40m12s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1485h40m12s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1485h40m13s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1485h40m13s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1486h6m34s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1486h12m1s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1486h12m2s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1486h12m4s
2026-07-16 17:49:42 +08:00
XiaoXia Bot 2a6c7d4086 ci: silence detect-secrets stdout to preserve log output
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 26s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 41s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m16s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m15s
CI/CD Pipeline / Integration Tests (push) Successful in 2m20s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m14s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m22s
CI/CD Pipeline / Unit Tests (push) Successful in 3m23s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1485h34m42s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1485h34m42s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1485h46m25s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1485h46m27s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1485h46m27s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1485h46m27s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1485h46m27s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1485h46m27s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1485h46m27s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1485h46m29s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1485h46m28s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1485h46m29s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1485h46m29s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1485h46m29s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1485h46m30s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1485h46m30s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1485h46m31s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1485h46m31s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1485h46m31s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1485h46m31s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1486h18m20s
2026-07-16 17:43:24 +08:00
XiaoXia Bot 715e663f8b ci: reduce detect-secrets stdout output to avoid log truncation
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m18s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m22s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m32s
CI/CD Pipeline / Unit Tests (push) Successful in 3m37s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 14m11s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 14m12s
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m30s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1486h43m51s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1486h43m51s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1486h43m51s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1486h43m53s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1486h43m53s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1486h43m53s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1486h43m53s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1486h43m53s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1486h43m55s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1486h43m53s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1486h43m55s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1486h43m55s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1486h43m55s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1486h43m55s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1486h43m57s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1486h43m57s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1486h43m57s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1486h43m57s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1486h43m57s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1487h15m44s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1487h15m46s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1487h15m48s
2026-07-16 16:45:57 +08:00
XiaoXia Bot ee6034b5fd fix(ci): 修复YAML语法,alembic步骤改用literal block scalar
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m3s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m3s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m24s
CI/CD Pipeline / Unit Tests (push) Successful in 2m41s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 13m33s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 13m33s
CI/CD Pipeline / Integration Tests (push) Successful in 1m28s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m33s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1487h18m9s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1487h18m11s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1487h18m13s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1487h18m11s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1487h18m13s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1487h18m13s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1487h18m13s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1487h18m13s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1487h18m15s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1487h18m15s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1487h18m15s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1487h18m15s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1487h18m17s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1487h18m17s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1487h18m17s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1487h18m17s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1487h18m17s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1487h50m4s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1487h50m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1487h50m8s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1487h50m4s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1487h50m6s
2026-07-16 16:11:35 +08:00
XiaoXia Bot 6f0bb8c25e chore: trigger CI re-run
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1487h21m12s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1487h21m12s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1487h21m13s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1487h21m13s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1487h21m13s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1487h21m15s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1487h21m13s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1487h21m15s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1487h21m13s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1487h21m15s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1487h21m13s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1487h21m15s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1487h21m15s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1487h21m15s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1487h21m17s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1487h21m17s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1487h21m17s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1487h21m17s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1487h21m17s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1487h53m6s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1487h53m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1487h53m8s
2026-07-16 16:08:38 +08:00
XiaoXia Bot f4b7bec0cb fix(ci): PG启动+alembic合并为单步,修复GITHUB_ENV传参失效
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1487h22m14s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1487h22m14s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1487h22m16s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1487h22m16s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1487h22m16s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1487h22m16s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1487h22m17s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1487h22m17s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1487h22m17s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1487h22m17s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1487h22m17s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1487h22m17s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1487h22m19s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1487h22m19s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1487h22m19s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1487h22m19s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1487h22m19s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1487h54m7s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1487h54m8s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1487h54m10s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1487h54m7s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1487h54m8s
2026-07-16 16:07:21 +08:00
XiaoXia Bot 31aa3fbb86 fix(ci): 修复develop分支遗留的isort import排序问题
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 46s
CI/CD Pipeline / Frontend Lint (push) Successful in 54s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m17s
CI/CD Pipeline / Unit Tests (push) Successful in 2m26s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 14m11s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 14m13s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m22s
CI/CD Pipeline / Integration Tests (push) Successful in 1m23s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1487h53m47s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1487h53m51s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1487h53m51s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1487h53m49s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1487h53m53s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1487h53m53s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1487h53m53s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1487h53m55s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1487h53m55s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1487h53m53s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1487h53m55s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1487h53m53s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1487h53m55s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1487h53m55s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1487h53m57s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1487h53m57s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1487h53m57s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1487h53m57s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1487h53m57s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1488h25m44s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1488h25m46s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1488h25m48s
修复edit_plans_clips.py和edit_plans_clips_batch.py的import排序,
确保全量isort扫描通过。
2026-07-16 15:35:58 +08:00
XiaoXia Bot 7ca0014da2 fix(ci): Validate job增加独立PG容器,修复alembic迁移检查失败\n\n根因:alembic upgrade head --sql需要连接数据库读取alembic_version表,\n但Validate job没有启动PostgreSQL容器,DATABASE_URL指向不存在的端口。\n\n修复:在Validate Alembic migrations步骤前启动一个独立PG容器,\n用随机端口映射,通过GITHUB_ENV更新DATABASE_URL。\n步骤结束后自动清理容器(always()确保失败也清理)。\n\n参考PR #399的实现,最小改动版本,不拆分job。
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 43s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m17s
CI/CD Pipeline / Frontend Lint (push) Successful in 35s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m1s
CI/CD Pipeline / Unit Tests (push) Successful in 2m8s
CI/CD Pipeline / Integration Tests (push) Successful in 1m21s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 16m56s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m18s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1488h16m35s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1488h16m37s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1488h16m38s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1488h16m37s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1488h16m38s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1488h16m38s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1488h16m40s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1488h16m40s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1488h17m10s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1488h17m11s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1488h17m11s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1488h17m12s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1488h17m12s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1488h17m12s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1488h17m13s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1488h17m13s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1488h17m13s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1488h48m31s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1488h48m28s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1488h48m29s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1488h49m2s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1488h49m3s
2026-07-16 15:12:42 +08:00
xiaoxia 4148ae29a4 feat: 剪辑计划保存为模板 - API + Service + 11个单测 (#392)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m35s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 2m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m36s
CI/CD Pipeline / Unit Tests (push) Successful in 3m27s
CI/CD Pipeline / Integration Tests (push) Successful in 3m31s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1489h22m51s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1489h22m53s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1489h23m41s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1489h23m43s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1489h23m46s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1489h23m46s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1489h23m48s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1489h54m41s
2026-07-16 13:52:58 +08:00
xiaoxia 828b37bce1 feat: 剪辑计划后端补齐 - 片段CRUD + 批量操作 + 复制计划 + 素材批量导入 (#389)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m31s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m5s
CI/CD Pipeline / Unit Tests (push) Successful in 2m48s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1490h22m36s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1490h22m36s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1490h22m37s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1490h22m38s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1490h54m26s
2026-07-16 12:58:59 +08:00
xiaoxia 061b9cfcd6 fix(security): 第三轮审计P1修复 - 音色克隆SSRF防护 + 支付接口异常信息脱敏 (#388)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 2m31s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1490h22m37s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1490h22m37s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1490h22m38s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1490h28m32s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1490h28m29s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1490h28m33s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1490h28m33s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1491h0m19s
2026-07-16 12:58:50 +08:00
xiaoxia 26eedfae3d Merge pull request 'fix(ci): migration safety检查稳定性加固' (#384) from fix/migration-safety-reliability into develop
CI/CD Pipeline / Frontend Lint (push) Successful in 1m4s
CI/CD Pipeline / Unit Tests (push) Successful in 3m45s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 16m43s
CI/CD Pipeline / Integration Tests (push) Successful in 1m38s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1494h26m59s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1494h27m3s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1494h27m5s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1494h27m7s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1494h58m47s
2026-07-16 09:01:07 +08:00
XiaoXia Bot 0386b9b34e fix(ci): migration safety检查稳定性加固
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m7s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m8s
CI/CD Pipeline / Unit Tests (push) Successful in 2m27s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m33s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 16m38s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 16m38s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m55s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1494h39m59s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1494h40m0s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1494h40m0s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1494h40m2s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1494h40m1s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1494h40m2s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1494h40m4s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1494h40m4s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1494h40m6s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1494h41m12s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1494h41m12s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1494h41m12s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1494h41m14s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1494h41m12s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1494h41m14s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1494h41m15s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1494h41m16s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1494h41m16s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1494h41m16s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1494h41m16s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1495h13m0s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1495h11m51s
- API调用增加3次重试+指数退避,避免网络抖动导致降级全量扫
- 同时支持Gitea/GitHub环境变量命名(GITEA_* / GITHUB_*)
- API失败时fallback到git diff方式找新增迁移
- 最后兜底才全量扫,并输出明确警告和排查建议
- 彻底解决历史迁移drop_column被误扫为高风险的flaky问题
2026-07-16 08:45:56 +08:00
xiaoxia 731d82412b fix: 剪辑计划编辑再生成 - completed/failed后编辑自动切回editing (#383)
CI/CD Pipeline / Frontend Lint (push) Successful in 44s
CI/CD Pipeline / Unit Tests (push) Successful in 2m47s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 15m35s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1494h59m44s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1494h59m45s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1495h7m21s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1495h7m22s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1495h7m24s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1495h7m25s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1495h7m27s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1495h31m29s
2026-07-16 08:18:19 +08:00
xiaoxia 290b6c7b7c Merge pull request 'feat(ci): mypy升级为硬门禁 + 修复存量类型错误' (#382) from fix/mypy-hardgate into develop
CI/CD Pipeline / Frontend Lint (push) Successful in 1m37s
CI/CD Pipeline / Unit Tests (push) Successful in 2m34s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1495h21m13s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1495h21m14s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1495h21m18s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1495h21m20s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1495h53m1s
2026-07-16 08:03:57 +08:00
CI Bot e9d2831850 feat(ci): mypy upgrade to hard gate + fix type errors
CI/CD Pipeline / Frontend Lint (push) Successful in 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m30s
CI/CD Pipeline / Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m18s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 16m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 16m20s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m24s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1495h29m17s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1495h29m18s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1495h29m20s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1495h29m22s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1495h30m51s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1495h30m53s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1495h30m53s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1495h30m53s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1495h30m53s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1495h30m54s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1495h30m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1495h30m55s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1495h30m55s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1495h30m56s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1495h30m56s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1495h30m57s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1495h30m56s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1495h30m58s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1496h1m7s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1496h2m38s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1496h2m39s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1496h2m42s
2026-07-16 07:59:07 +08:00
xiaoxia 7f490b4140 Merge pull request 'perf(ci): mypy增量扫描优化' (#354) from feat/mypy-incremental into develop
CI/CD Pipeline / Frontend Lint (push) Failing after 23s
CI/CD Pipeline / Unit Tests (push) Successful in 2m27s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 19m36s
CI/CD Pipeline / Integration Tests (push) Successful in 1m19s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1502h49m31s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1502h49m32s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1502h49m33s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1502h49m34s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1503h21m18s
2026-07-16 00:35:51 +08:00
CI Bot 53e570a903 fix(ci): ruff排除hostexecutor目录 - 避免host executor模式下扫到runner脚本目录
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m30s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m30s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m50s
CI/CD Pipeline / Unit Tests (push) Successful in 2m55s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 15m0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 15m2s
CI/CD Pipeline / Integration Tests (push) Successful in 1m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m33s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1503h13m1s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1503h13m4s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1503h13m6s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1503h14m36s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1503h14m36s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1503h14m36s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1503h14m36s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1503h14m36s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1503h14m38s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1503h14m38s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1503h14m38s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1503h14m38s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1503h14m38s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1503h14m41s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1503h14m38s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1503h14m41s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1503h14m40s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1503h14m41s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1503h14m41s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1503h14m41s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1503h44m47s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1503h44m51s
2026-07-16 00:15:24 +08:00
xiaoxia 7a0f1537af perf(ci): mypy增量扫描优化 2026-07-15 23:40:14 +08:00
xiaoxia 00522c9e98 perf(ci): 添加mypy增量扫描脚本 2026-07-15 23:40:12 +08:00
xiaoxia 9c71951cf2 Merge pull request 'docs: CI失败排查报告 + 三台服务器Runner分工规划' (#266) from docs/ci-runner-planning into develop
CI/CD Pipeline / Frontend Lint (push) Successful in 1m14s
CI/CD Pipeline / Unit Tests (push) Successful in 3m7s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m7s
CI/CD Pipeline / Integration Tests (push) Successful in 1m43s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1504h9m41s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1504h9m43s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1504h11m3s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1504h11m5s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1504h11m5s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1504h11m6s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1504h41m21s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1504h42m50s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1504h9m35s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1504h9m39s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1504h11m3s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1504h11m7s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1504h9m42s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1504h9m44s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1504h9m45s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1504h9m49s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1504h11m3s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1504h11m3s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1504h11m5s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1504h11m7s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1504h41m31s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1504h42m51s
2026-07-15 23:14:07 +08:00
xiaoxia d2ce73184a fix(auto-merge): 合并PR前验证CI状态 (#253)
CI/CD Pipeline / Unit Tests (push) Successful in 2m18s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m40s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m0s
CI/CD Pipeline / Integration Tests (push) Successful in 1m23s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 2m28s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m35s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1504h24m41s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1504h24m43s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1504h24m45s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1504h24m47s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1504h56m25s
2026-07-15 22:57:18 +08:00
xiaoxia dad02788e3 fix(ci): 修复check_migration_safety.py的import排序 (#371)
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
2026-07-15 22:57:07 +08:00
xiaoxia 902fe5d461 Merge pull request 'feat: Feature Flag调试日志增强 + fallback逻辑统一' (#352) from fix/flag-debug-logging into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1504h28m37s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1504h28m40s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1504h28m38s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1504h28m39s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1505h0m23s
2026-07-15 22:56:54 +08:00
xiaoxia b8dbdb9fd8 fix(ci): Web构建传NGINX_CONF使用staging nginx配置 (#380)
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m14s
CI/CD Pipeline / Unit Tests (push) Successful in 2m15s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m44s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1504h46m19s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1504h48m31s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1505h8m32s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1505h8m33s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1505h8m36s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1505h8m38s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1505h18m4s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1505h40m18s
Web镜像构建时未传NGINX_CONF,默认用了production配置导致Staging nginx启动失败。修复:staging构建时为web服务传入NGINX_CONF=infra/docker/nginx-staging.conf。
2026-07-15 22:13:48 +08:00
xiaoxia 28ca8c5ca7 fix: 修复生成链路三个阻塞问题(状态栏/toast/failed状态生成) (#379)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m30s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1505h23m36s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1505h33m29s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1505h33m31s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1505h33m33s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1505h33m37s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1505h55m20s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1506h5m19s
2026-07-15 21:55:15 +08:00
XiaoXia Bot 6ca9f18a58 chore(ci): 重新触发ci-build全链路验证
CI/CD Pipeline / Unit Tests (push) Successful in 2m0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m53s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 3m22s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1505h33m39s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1505h33m40s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1505h38m41s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1505h38m42s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1505h38m43s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1505h38m45s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1505h38m45s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
2026-07-15 21:44:55 +08:00
xiaoxia 39316b7f22 Merge pull request 'fix(ci): 临时禁用registry cache-to,ACR推送缓存太慢导致构建超时' (#378) from fix/ci-disable-registry-cache-to into develop
CI Build & Deploy Pipeline / Build Staging API Image (push) Blocked by required conditions
CI Build & Deploy Pipeline / Build Staging Web Image (push) Blocked by required conditions
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Blocked by required conditions
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI Build & Deploy Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI Build & Deploy Pipeline / Build Production API Image (push) Blocked by required conditions
CI Build & Deploy Pipeline / Build Production Web Image (push) Blocked by required conditions
CI Build & Deploy Pipeline / Build Production Worker Image (push) Blocked by required conditions
CI Build & Deploy Pipeline / Deploy Production (push) Blocked by required conditions
CI Build & Deploy Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Successful in 26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m45s
CI/CD Pipeline / Unit Tests (push) Successful in 1m50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m18s
2026-07-15 21:25:08 +08:00
XiaoXia Bot f04038f955 fix(ci): 临时禁用registry cache-to,ACR推送缓存太慢导致构建超时\n\nStep 2的registry cache sync在首次全量推送时极慢(API 11min+ / Worker 13min+)\n先注释掉确保全链路跑通,缓存优化后续再调\n\n保留cache-from registry(读缓存),本地缓存读写正常
CI/CD Pipeline / Frontend Lint (push) Successful in 48s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m31s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m47s
CI/CD Pipeline / Unit Tests (push) Successful in 1m59s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 43s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m27s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m24s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1506h3m48s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1506h3m53s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1506h3m55s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1506h5m28s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1506h3m57s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1506h5m29s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1506h5m30s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1506h5m30s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1506h5m32s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1506h5m34s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1506h5m34s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1506h5m39s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1506h5m39s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1506h5m40s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1506h5m42s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1506h5m44s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1506h5m44s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1506h37m12s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1506h35m35s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1506h37m22s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1506h37m24s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1506h37m28s
2026-07-15 21:24:22 +08:00
xiaoxia 79d6addcef Merge pull request 'fix: 修复生成按钮 PUT 状态限制 + 进度弹窗错误显示' (#377) from fix/generation-put-status-restriction into develop
CI/CD Pipeline / Frontend Lint (push) Successful in 53s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m55s
CI/CD Pipeline / Unit Tests (push) Successful in 2m4s
CI/CD Pipeline / Integration Tests (push) Successful in 1m17s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-15 21:20:36 +08:00
CI Bot 989a8221f2 fix: 修复生成按钮 PUT 状态限制 + 进度弹窗错误显示
CI/CD Pipeline / Frontend Lint (push) Successful in 1m58s
CI/CD Pipeline / Unit Tests (push) Successful in 1m59s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 28s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m4s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m30s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m54s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m30s
CI/CD Pipeline / Integration Tests (push) Successful in 1m27s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1506h9m13s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1506h9m16s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1506h9m16s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1506h11m14s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1506h11m22s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1506h11m15s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1506h11m24s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1506h11m36s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1506h11m40s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1506h11m26s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1506h11m42s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1506h11m43s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1506h11m44s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1506h11m46s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1506h11m46s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1506h11m47s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1506h41m2s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1506h43m1s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1506h40m58s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1506h43m22s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1506h43m27s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1506h43m29s
- 移除 updateEditPlan 中的 status: 'editing' 参数,避免非 draft 状态被后端拒绝
- 用 try/catch 包裹 updateEditPlan 调用,失败时 warn 并继续执行 generateEditPlan
- 进度弹窗 Modal 的 open 条件增加 !!genError,确保错误信息可见

修复问题:点击'生成视频'按钮无反应(根因:failed 状态计划 PUT 返回 400)
2026-07-15 21:18:06 +08:00
XiaoXia Bot 2526f18890 test(ci): 测试runs-on字符串格式
CI/CD Pipeline / Frontend Lint (push) Successful in 55s
CI/CD Pipeline / Unit Tests (push) Successful in 1m44s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m3s
CI/CD Pipeline / Integration Tests (push) Successful in 1m23s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1505h34m39s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1505h34m39s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1506h34m42s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1506h34m48s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1506h34m44s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1506h34m50s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m22s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1507h6m30s
2026-07-15 20:43:00 +08:00
XiaoXia Bot e24636d2dd fix(ci): 将build-farm标签改为build-only以匹配新Runner
CI/CD Pipeline / Frontend Lint (push) Successful in 48s
CI/CD Pipeline / Unit Tests (push) Successful in 1m43s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m6s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
2026-07-15 20:39:35 +08:00
XiaoXia Bot ad37a1420f fix(ci): 修复ci-build.yml中checkout脚本的YAML解析问题
CI/CD Pipeline / Frontend Lint (push) Successful in 50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m48s
CI/CD Pipeline / Unit Tests (push) Successful in 1m52s
CI/CD Pipeline / Integration Tests (push) Successful in 1m20s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-15 20:29:47 +08:00
XiaoXia Bot 86663150ae fix(ci): 修复ci-build.yml中checkout脚本的YAML解析问题
CI/CD Pipeline / Frontend Lint (push) Successful in 58s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m58s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m50s
CI/CD Pipeline / Unit Tests (push) Successful in 2m5s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m5s
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m29s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1507h4m9s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1507h4m10s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1507h4m10s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1507h4m10s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1507h4m10s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1507h4m11s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1507h4m12s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1507h4m12s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1507h4m22s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1507h4m26s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1507h4m23s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1507h4m26s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1507h4m24s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1507h4m24s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1507h4m24s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1507h4m26s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1507h4m26s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1507h4m26s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1507h35m54s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1507h35m56s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1507h35m54s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1507h36m9s
将checkout步骤的run字段从单行双引号字符串改为多行块标量,
避免act_runner对长字符串的解析bug导致Python语法错误
2026-07-15 20:25:40 +08:00
XiaoXia Bot ecf457ecdb Merge PR #374: 修复ci-build.yml checkout步骤Python语法错误
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m46s
CI/CD Pipeline / Unit Tests (push) Successful in 1m54s
CI/CD Pipeline / Frontend Lint (push) Successful in 51s
CI/CD Pipeline / Integration Tests (push) Successful in 1m39s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 30m1s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1506h34m49s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1506h34m49s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1506h34m50s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1507h8m54s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1507h8m55s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1507h8m56s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1507h8m56s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1507h40m40s
2026-07-15 20:16:21 +08:00
XiaoXia Bot e5a96db948 fix(ci): 修复ci-build.yml checkout步骤Python语法错误
CI/CD Pipeline / Frontend Lint (push) Successful in 47s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 49s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m17s
CI/CD Pipeline / Unit Tests (push) Successful in 2m24s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m38s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m34s
CI/CD Pipeline / Integration Tests (push) Successful in 1m34s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1507h13m49s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1507h13m50s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1507h13m50s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1507h13m50s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1507h13m51s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1507h13m51s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1507h13m52s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1507h13m52s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1507h13m55s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1507h13m56s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1507h13m56s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1507h13m56s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1507h13m56s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1507h13m56s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1507h13m58s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1507h13m58s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1507h45m34s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1507h45m34s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1507h45m35s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1507h45m40s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1507h45m40s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1507h45m42s
2026-07-15 20:16:08 +08:00
XiaoXia Bot f499f4a0e7 Merge PR #373: fix(ci): 修复ci-build.yml的YAML语法错误
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 0s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 0s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m47s
CI/CD Pipeline / Unit Tests (push) Successful in 1m50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m14s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1507h27m51s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1507h27m52s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1507h27m52s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1507h27m53s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1507h27m53s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1507h27m54s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1507h59m36s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1507h59m37s
2026-07-15 20:02:10 +08:00
XiaoXia Bot 6978ec66ed fix(ci): 修复ci-build.yml的YAML转义错误(buildx引号+deploy单引号)
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 47s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m50s
CI/CD Pipeline / Unit Tests (push) Successful in 2m4s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m45s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m9s
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m20s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1507h28m22s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1507h28m23s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1507h28m23s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1507h28m24s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1507h28m23s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1507h28m25s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1507h28m23s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1507h28m25s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1507h28m23s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1507h29m2s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1507h29m4s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1507h29m4s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1507h29m6s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1507h29m6s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1507h29m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1507h29m6s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1507h29m7s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1508h0m7s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1508h0m8s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1508h0m48s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1508h0m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1508h0m50s
2026-07-15 20:01:00 +08:00
CI Bot 9c1bcd93d2 fix: 生成失败时进度弹窗不显示 — Modal open 条件补上 genError
CI/CD Pipeline / Frontend Lint (push) Successful in 38s
CI/CD Pipeline / Unit Tests (push) Successful in 1m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m9s
CI/CD Pipeline / Integration Tests (push) Successful in 1m15s
根因:Modal open={generating || generated},失败时两者都 false,
弹窗关闭导致 genError 错误信息不可见。
修复:open={generating || generated || !!genError},
title 同步区分失败/完成/进行中三种状态。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:57:55 +08:00
XiaoXia Bot 7748604e76 Merge PR #372: feat(ci): 拆分构建workflow + buildx动态命名
CI/CD Pipeline / Frontend Lint (push) Successful in 44s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m33s
CI/CD Pipeline / Unit Tests (push) Successful in 1m39s
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
2026-07-15 19:48:46 +08:00
XiaoXia Bot 5471ed473e feat(ci): 拆分构建workflow + buildx动态命名(解决cancel-in-progress踩踏)
CI/CD Pipeline / Frontend Lint (push) Successful in 46s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 44s
CI/CD Pipeline / Unit Tests (push) Successful in 1m55s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m53s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m12s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m24s
CI/CD Pipeline / Integration Tests (push) Successful in 1m17s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m17s
- ci-cd.yml:仅保留4个门禁(validate/unit/integration/frontend-lint),cancel-in-progress: true
- ci-build.yml:构建/部署/E2E,cancel-in-progress: false,避免频繁推送取消构建
- buildx builder动态命名:ci-builder--,避免并发job冲突
- 已同步阿里云ACR配置
2026-07-15 19:40:52 +08:00
CI Bot 488928f6eb ci: 触发 staging 部署(Docker registry 已修复)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 51s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1507h55m1s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1508h31m15s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1507h55m2s
CI/CD Pipeline / Deploy Production (push) Failing after 1508h31m18s
CI/CD Pipeline / Build Production API Image (push) Failing after 1508h31m18s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1508h31m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Failing after 1509h3m1s
2026-07-15 18:58:07 +08:00
CI Bot 81779c4e1f feat(edit-plans): 列表页新增 result_count 视频数列
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1508h45m4s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1508h45m5s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1508h45m5s
CI/CD Pipeline / Build Production API Image (push) Failing after 1508h45m5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1509h16m47s
- EditPlan interface 新增 result_count: number 字段
- EditPlans.tsx 表格在时长列后新增「视频数」列
- 居中展示,有值显示数字,无值显示 —

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 18:44:21 +08:00
CI Bot 833ea8e9d8 style: prettier 格式化 GenerationHistoryModal
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1508h44m41s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1508h44m43s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1508h44m43s
CI/CD Pipeline / Build Production API Image (push) Failing after 1508h44m44s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1509h16m25s
2026-07-15 18:37:22 +08:00
CI Bot 018358cbb1 fix: EditPlanResponse暴露result_count字段(列表/详情接口)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-15 18:36:40 +08:00
CI Bot ba288f2e8b fix: EditPlanGeneration 类型对齐后端 + 生成历史弹窗字段修复
CI/CD Pipeline / Frontend Lint (push) Failing after 31s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1508h54m8s
CI/CD Pipeline / Deploy Production (push) Failing after 1508h54m10s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1508h54m12s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1508h54m12s
CI/CD Pipeline / Build Production API Image (push) Failing after 1508h54m14s
- EditPlanGeneration interface 改为匹配实际 API 响应(id 即 task_id)
- 补充 result_count/progress/error_message/logs 等字段
- GenerationHistoryModal 改用 gen.id 替代不存在的 gen.generation_task_id
- 彻底修复 P0 崩溃:generation_task_id undefined → slice 报错

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 18:35:19 +08:00
CI Bot 86a868acfb fix: 修复生成历史弹窗 generation_task_id 为 null 时页面崩溃
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1s
CI/CD Pipeline / Build Staging API Image (push) Failing after 9s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 11s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1509h4m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1509h4m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1509h4m58s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1509h6m4s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1509h6m2s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1509h6m4s
CI/CD Pipeline / Build Production API Image (push) Failing after 1509h6m4s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1509h37m46s
- GenerationHistoryModal 中 gen.generation_task_id.slice() 未做空值保护
- 当后端返回的 generation_task_id 为 undefined 时直接崩溃
- 添加空值检查,为 null 时显示 '—'

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 18:23:24 +08:00
XiaoXia Bot c18146287e feat(ci): 构建切换到阿里云ACR,解决registry并发瓶颈
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1s
CI/CD Pipeline / Build Staging API Image (push) Failing after 9s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 9s
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1509h7m11s
CI/CD Pipeline / Deploy Production (push) Failing after 1509h8m18s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1509h7m11s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1509h8m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1509h7m12s
CI/CD Pipeline / Build Production API Image (push) Failing after 1509h8m20s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Failing after 1509h40m0s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1509h40m2s
2026-07-15 18:21:05 +08:00
XiaoXia Bot e1b6ccaf0a fix(ci): 修复check_migration_safety.py的import排序(isort I001)
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1509h13m19s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1509h13m20s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1509h13m21s
CI/CD Pipeline / Build Production API Image (push) Failing after 1509h13m23s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1509h45m3s
2026-07-15 18:16:06 +08:00
xiaoxia ab5d3bd251 chore(ci): 恢复cancel-in-progress=true
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 43s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1510h0m14s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1510h0m18s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1510h0m18s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1510h0m15s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1510h0m21s
CI/CD Pipeline / Deploy Production (push) Failing after 1510h0m22s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1510h0m23s
CI/CD Pipeline / Build Production API Image (push) Failing after 1510h0m23s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1510h31m59s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1510h32m2s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1510h32m6s
2026-07-15 17:28:58 +08:00
CI Bot 13883511f2 chore(ci): 删除误提交的bak文件
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m16s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1510h26m38s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1510h26m41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1510h26m38s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1510h26m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1510h26m39s
CI/CD Pipeline / Deploy Production (push) Failing after 1510h27m51s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1510h27m53s
CI/CD Pipeline / Build Production API Image (push) Failing after 1510h27m53s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1510h58m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1510h59m32s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1510h59m36s
2026-07-15 16:59:37 +08:00
CI Bot 8ebe970615 fix(ci): 重构migration增量检查 — 改用Gitea API替代git diff,彻底解决CI卡住问题
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
- 移除 Prepare git for migration diff step(git init+fetch+diff方案在host runner上不稳定)
- check_migration_safety.py 改用 Gitea Contents API 对比 develop 分支迁移文件列表
- 轻量可靠:1-2秒完成,不依赖git,不会卡住
- API失败时自动降级为全量检查
2026-07-15 16:59:00 +08:00
CI Bot cebb33c2e2 fix(ci): 彻底修复migration diff step卡住问题 — 用后台进程+轮询替代timeout命令
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1510h31m21s
CI/CD Pipeline / Deploy Production (push) Failing after 1510h31m22s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1510h31m22s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1510h31m22s
CI/CD Pipeline / Build Production API Image (push) Failing after 1510h31m23s
- Gitea Actions v0.2.6不支持step级continue-on-error和timeout-minutes
- timeout命令在host runner环境下行为不可靠(可能不生效)
- 改用后台进程+30秒轮询超时,确保无论fetch成功/失败/卡住都在30秒内返回
- set +e + exit 0确保step永远不会失败,真正做到准备工作不阻断主流程
2026-07-15 16:21:27 +08:00
xiaoxia 8ffc22f348 chore(ci): 临时关闭cancel-in-progress,跑一轮完整CI验证构建修复
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-15 16:17:46 +08:00
xiaoxia c99f3f75ad fix: 硬切场景改用concat filter替代xfade,提升多素材性能与稳定性 (#350)
CI/CD Pipeline / Build Staging API Image (push) Failing after 30m1s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 30m1s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 40m1s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1510h31m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1510h31m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1510h31m23s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1511h12m25s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1511h12m27s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1511h12m27s
CI/CD Pipeline / Build Production API Image (push) Failing after 1511h12m27s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1511h44m9s
2026-07-15 16:16:54 +08:00
CI Bot 0382c4e697 fix(ci): migration diff step失败不阻断Validate — 加continue-on-error + 2分钟超时
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1511h18m27s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1511h18m29s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1511h18m29s
CI/CD Pipeline / Build Production API Image (push) Failing after 1511h18m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1511h50m11s
- Prepare git for migration diff只是准备工作,失败应降级为全量检查
- 加timeout-minutes:2防止git fetch卡住拖慢CI
- continue-on-error:true确保后续migration safety检查正常运行
2026-07-15 16:10:49 +08:00
CI Bot 45e7cfe7c9 fix(ci): 修复migration diff步骤 - 分支名从main改为develop + 加30s超时
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1511h47m30s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1511h47m31s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1511h47m31s
CI/CD Pipeline / Build Production API Image (push) Failing after 1511h47m31s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1512h19m14s
- Prepare git for migration diff 步骤写死了 fetch main 分支
- 项目实际开发分支是 develop,导致 fetch 卡住(之前因 URL 拼错立即失败未暴露)
- 改为 fetch develop + 30秒超时保护
- Check migration safety 也同步改 origin/develop
2026-07-15 15:41:47 +08:00
XiaoXia Bot 370c3923ae fix: migration diff步骤真正修复 — 传入GITHUB_SERVER_URL
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1512h9m11s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1512h9m13s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1512h9m13s
CI/CD Pipeline / Build Production API Image (push) Failing after 1512h9m13s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1512h40m54s
- 根因:Gitea Actions不提供GITHUB_SERVER_URL环境变量
- PR #361只加了set +e && exit 0绕过,未解决根因
- 现在通过env传入github.server_url,恢复set -eu错误处理
2026-07-15 15:20:20 +08:00
XiaoXia Bot 771c5ba579 fix: 修复matrix构建参数错位 — 去掉行续反斜杠\n问题
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1512h10m20s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1512h10m22s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1512h10m22s
CI/CD Pipeline / Build Production API Image (push) Failing after 1512h10m22s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1512h42m2s
- \\n在YAML->shell多层转义后变成字面字符n,导致参数错位
- dockerfile路径被当作image tag,API/Worker 3秒即挂
- 去掉行续反斜杠,所有参数写在一行,空格分隔
2026-07-15 15:19:07 +08:00
XiaoXia Bot 2e59d4a275 fix: 精确修复matrix构建Build and push步骤换行问题
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 3s
CI/CD Pipeline / Build Staging API Image (push) Failing after 3s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m29s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1512h12m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1512h12m48s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1512h15m57s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1512h15m59s
CI/CD Pipeline / Deploy Production (push) Failing after 1512h15m58s
CI/CD Pipeline / Build Production API Image (push) Failing after 1512h15m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1512h44m30s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1512h47m41s
- 仅修改build-staging和build-production的Build and push步骤
- 将多行双引号字符串改为
转义单行格式
- 之前的修复误改了checkout等其他步骤,导致全链路CI崩溃
2026-07-15 15:13:33 +08:00
XiaoXia Bot f905c3ef8d fix: 构建前清理本地旧镜像,解决 buildx --load image already exists 错误
CI/CD Pipeline / Unit Tests (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 2s
CI/CD Pipeline / Integration Tests (push) Failing after 0s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1512h18m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1512h18m35s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1512h18m36s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1512h18m36s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1512h18m36s
CI/CD Pipeline / Build Production API Image (push) Failing after 1512h18m38s
CI/CD Pipeline / Deploy Production (push) Failing after 1512h18m37s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1512h18m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1512h50m15s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1512h50m18s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1512h50m20s
2026-07-15 15:11:31 +08:00
XiaoXia Bot 46c7c351af Merge remote-tracking branch 'origin/develop' into develop
CI/CD Pipeline / Unit Tests (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 3s
CI/CD Pipeline / Integration Tests (push) Failing after 0s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1512h39m41s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1512h39m41s
CI/CD Pipeline / Deploy Production (push) Failing after 1512h39m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1512h39m42s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1512h39m42s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1512h39m42s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1512h39m42s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1512h39m42s
CI/CD Pipeline / Build Production API Image (push) Failing after 1512h39m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1513h11m23s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1513h11m24s
2026-07-15 14:50:23 +08:00
XiaoXia Bot f411dd0e1f fix: 修复matrix构建run命令换行符丢失问题
- matrix重构时Build and push步骤用了YAML多行双引号字符串
- act_runner解析时实际换行被替换为空格,导致变量未定义(set -eu)
- 改为\n转义格式,与其他step保持一致
- 修复build-staging和build-production两处matrix job
2026-07-15 14:50:08 +08:00
CI Bot 09a4dbe04c fix: formatDuration 不取整导致显示浮点数秒(如 9.542993秒)
CI/CD Pipeline / Build Staging API Image (push) Failing after 2s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m58s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1512h46m5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1512h46m6s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1512h49m31s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1512h49m32s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1512h49m32s
CI/CD Pipeline / Build Production API Image (push) Failing after 1512h49m32s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1513h17m47s
CI/CD Pipeline / Deploy Production (push) Failing after 1513h21m13s
全局搜索发现 4 处 formatDuration 函数中 seconds % 60 未做取整,
当后端返回浮点数 total_duration 时直接拼接显示。

修复:先 Math.round(seconds) 再取模,影响页面:
- 剪辑计划列表页(EditPlans.tsx)
- 模板库页面(TemplateLibrary.tsx)
- 查重详情页(DuplicationDetail.tsx)
- 查重结果页(DuplicationResults.tsx)
2026-07-15 14:39:55 +08:00
XiaoXia Bot 533a64e215 feat(ci): M-3 三镜像matrix并行构建重构 (#365)
CI/CD Pipeline / Build Staging API Image (push) Failing after 2s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m19s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1513h4m13s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1513h6m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1513h4m13s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1513h6m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1513h4m15s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1513h6m55s
CI/CD Pipeline / Build Production API Image (push) Failing after 1513h6m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1513h38m38s
- 6个独立构建job → 2个matrix job(build-staging + build-production)
- workflow从1186行→937行(-21%)
- 三镜像从串行约25min → 并行约12min(预计)
- 变量覆盖service/service_display/dockerfile/image_name/cache_name/timeout
- 条件步骤正确:npm build仅web、Cleanup old images仅production web
2026-07-15 14:22:34 +08:00
CI Bot 90f834e03a fix: refresh schema-metadata-snapshot.json — add missing result_count column to edit_plans
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m36s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1513h18m25s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1513h18m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1513h18m27s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1513h20m3s
CI/CD Pipeline / Deploy Production (push) Failing after 1513h20m3s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1513h20m5s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1513h20m5s
CI/CD Pipeline / Build Production API Image (push) Failing after 1513h20m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
The check_schema_metadata.py CI step was failing because the
edit_plans table gained a result_count column (INTEGER) in the
SQLAlchemy model but the snapshot was never updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:08:03 +08:00
XiaoXia Bot 33f08b465b fix: 补充edit_plans.result_count字段的Alembic迁移 (#366)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-15 14:07:57 +08:00
CI Bot 388341b522 Merge branch 'feature/product-center-ui-upgrade' into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m43s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1513h26m0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1513h26m1s
CI/CD Pipeline / Deploy Production (push) Failing after 1513h26m1s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1513h26m3s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1513h26m3s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1513h26m3s
CI/CD Pipeline / Build Production API Image (push) Failing after 1513h26m3s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1513h26m3s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1513h57m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1513h57m42s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1513h57m44s
2026-07-15 14:00:47 +08:00
CI Bot 56c7d9c95a fix: Prettier formatting for TemplateLibrary.tsx
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1513h25m50s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1513h25m51s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1513h25m51s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1513h25m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1513h25m52s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1513h25m53s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1513h25m53s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1513h25m53s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1513h28m25s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1513h28m27s
CI/CD Pipeline / Deploy Production (push) Failing after 1513h28m28s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1513h28m29s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1513h28m30s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1513h28m30s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1513h28m30s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1513h28m30s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1514h0m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1514h0m10s
CI/CD Pipeline / Build Production API Image (push) Failing after 1514h0m11s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1513h57m32s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1513h57m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1513h57m34s
修复 CI/CD frontend-lint Prettier check 失败

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:00:39 +08:00
CI Bot 05730e6c56 Merge branch 'feature/product-center-ui-upgrade' into develop
CI/CD Pipeline / Frontend Lint (push) Failing after 36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m54s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1513h37m29s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1513h37m30s
CI/CD Pipeline / Deploy Production (push) Failing after 1513h37m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1513h37m31s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1513h37m33s
CI/CD Pipeline / Build Production API Image (push) Failing after 1513h37m33s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1513h37m33s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1513h37m33s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1514h9m12s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1514h9m15s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1514h9m15s
2026-07-15 13:50:37 +08:00
CI Bot d2ea8ba579 fix: TemplateItem 类型 estimated_duration 字段对齐后端 API
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47s
CI/CD Pipeline / Frontend Lint (push) Failing after 58s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 58s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1513h42m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1513h42m38s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1513h42m39s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1513h42m39s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1513h42m40s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1513h42m40s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1513h42m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1513h42m40s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1513h43m42s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1513h43m45s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1513h43m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1513h43m42s
CI/CD Pipeline / Build Production API Image (push) Failing after 1513h43m46s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1513h43m46s
CI/CD Pipeline / Deploy Production (push) Failing after 1513h43m44s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1513h43m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1513h43m44s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1514h14m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1514h14m21s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1514h14m22s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Failing after 1514h15m27s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1514h15m28s
- TemplateItem 新增 estimated_duration 字段,匹配后端实际返回字段名
- target_duration 标记为 deprecated,保留向后兼容
- TemplateLibrary 两处引用改为 estimated_duration ?? target_duration 回退
- 新增 mode/user_id 可选字段,description/clip_count/is_active 改 optional

修复模板卡片时长显示 NaN 问题(根因:前端读 target_duration 但后端返回 estimated_duration)
2026-07-15 13:45:22 +08:00
CI Bot 1486c7028a fix: 模板库两个bug修复 — handleUse改为导航到剪辑编辑器 + formatDuration NaN防护
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m3s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1513h48m39s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1513h48m39s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1513h48m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1513h48m42s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1513h48m43s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1513h48m43s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1513h48m43s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1513h48m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1513h48m43s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1513h49m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1513h49m55s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1513h49m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1513h49m54s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1513h49m57s
CI/CD Pipeline / Build Production API Image (push) Failing after 1513h49m57s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1513h49m57s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1513h49m57s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1513h49m57s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1514h20m22s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1514h20m25s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1514h21m36s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1514h21m39s
1. handleUse: 从调用不存在的 POST /templates/{id}/generate 改为导航到 /app/editing-planner?templateId=xxx
2. formatDuration: 参数类型扩展为 number|undefined|null,防止 target_duration 为空时显示 NaN
3. 清理未使用的 generateMutation 和 generateFromTemplate import

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:38:58 +08:00
CI Bot 73826d2f81 fix: EditPlan增加result_count字段,渲染成功后回写=1
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 2m9s
CI/CD Pipeline / Deploy Production (push) Failing after 14m38s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1513h44m41s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1513h59m19s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1513h59m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1513h59m20s
CI/CD Pipeline / Build Production API Image (push) Failing after 1513h59m21s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1513h59m21s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1513h59m21s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1513h59m21s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1513h59m21s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1514h31m3s
- domain层EditPlan新增result_count字段(int,默认0)
- ORM模型EditPlanModel新增result_count列(Integer,默认0)
- repository层create/update/_model_to_entity三处同步
- 渲染成功回调与total_duration一起回写result_count=1
2026-07-15 13:28:25 +08:00
CI Bot 64833c8225 feat(ci): B 规则正式加入 ruff 阻断级 - P0-5 Step 2 完成
CI/CD Pipeline / Build Staging API Image (push) Failing after 1s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m13s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1514h2m31s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1514h2m32s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1514h3m46s
CI/CD Pipeline / Deploy Production (push) Failing after 1514h3m48s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1514h3m50s
CI/CD Pipeline / Build Production API Image (push) Failing after 1514h3m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1514h34m16s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1514h35m32s
- ruff select 加入 B(flake8-bugbear)
- B008 加入 ignore(FastAPI 依赖注入模式,335 处,非 bug)
- B904: 71 处已修复(raise from 补全)
- B007: 12 处已修复(未使用循环变量改下划线)
- B011: 6 处已修复(assert False 改 raise AssertionError)

P0-5 Step 2 全部完成:ruff 门禁从 E/F/W 升级为 E/F/W/B
下一步 Step 3:UP + SIM 等扩展规则集
2026-07-15 13:24:25 +08:00
CI Bot 7ff7ae6c7d fix/b007-unused-loop-var 合并
CI/CD Pipeline / Build Staging API Image (push) Failing after 2s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m11s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1514h6m5s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1514h7m17s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1514h6m6s
CI/CD Pipeline / Deploy Production (push) Failing after 1514h7m18s
CI/CD Pipeline / Build Production API Image (push) Failing after 1514h7m19s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1514h7m19s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1514h37m49s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1514h39m1s
2026-07-15 13:20:58 +08:00
CI Bot b04345c7ff fix/b011-assert-false 合并 2026-07-15 13:20:55 +08:00
XiaoXia Bot a330ce50c0 feat(ci): M-2 local cache为主 + registry cache兜底 - 解决缓存导入慢247s问题 (#362)
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m43s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1514h8m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1514h8m55s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1514h10m38s
CI/CD Pipeline / Deploy Production (push) Failing after 1514h10m39s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1514h10m40s
CI/CD Pipeline / Build Production API Image (push) Failing after 1514h10m40s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1514h40m36s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1514h42m22s
2026-07-15 13:17:48 +08:00
xiaoxia 5e2800d360 chore(ci): 同步 develop 最新 ci-cd.yml - 解决冲突
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1514h12m40s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1514h12m40s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1514h12m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1514h12m42s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1514h12m44s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1514h12m44s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1514h12m44s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1514h12m44s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1514h13m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1514h13m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1514h13m58s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1514h14m1s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1514h14m1s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1514h14m1s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1514h14m1s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1514h14m1s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1514h44m22s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1514h44m26s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1514h44m26s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1514h45m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1514h45m40s
CI/CD Pipeline / Build Production API Image (push) Failing after 1514h45m43s
2026-07-15 13:14:27 +08:00
CI Bot 04d48d624a fix: 移除render_metrics依赖(该模块在develop上不存在),保持PR独立可合并
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1514h25m34s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1514h25m34s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1514h25m36s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1514h25m34s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1514h25m36s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1514h25m35s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1514h25m36s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1514h25m36s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1514h26m56s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1514h25m36s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1514h26m57s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1514h26m56s
CI/CD Pipeline / Build Production API Image (push) Failing after 1514h26m57s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1514h26m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1514h26m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1514h26m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1514h26m56s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1514h26m57s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1514h57m16s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1514h57m17s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Failing after 1514h58m38s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1514h58m38s
PR #248 核心是flag调试日志 + fallback统一,埋点应由专门的灰度观测指标PR负责。
移除compose_video中对render_metrics的引用,确保PR可独立合并、不引入运行时错误。
2026-07-15 13:00:43 +08:00
CI Bot c8ed027e98 fix: 渲染引擎flag解析增加调试日志 + 统一fallback行为
- 三个入口(compose_video/edit_plan/generate_video)都增加flag配置详情日志
  (enabled/percentage/whitelist/default_engine),灰度期间便于排查
- 统一fallback行为:异常时全部fallback到legacy(保守策略)
  之前generate_video异常时fallback到unified,与其他入口不一致
2026-07-15 13:00:40 +08:00
CI Bot 83a3d072fc fix(ci): 修复 migration diff step 失败 - 增加容错避免阻断 CI
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m11s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1514h25m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1514h25m30s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1514h27m38s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1514h27m41s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1514h27m41s
CI/CD Pipeline / Build Production API Image (push) Failing after 1514h27m42s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1514h57m9s
CI/CD Pipeline / Deploy Production (push) Failing after 1514h59m20s
- Prepare git for migration diff step 改为 set +e + exit 0
- 该 step 失败不影响后续检查(Check migration safety 有全量检查 fallback)
- 解决 tarball checkout 环境下 git init/commit 失败导致 Validate 全挂的问题
2026-07-15 12:58:59 +08:00
CI Bot 7f8193fbc6 fix(ci): 增强 Frontend Lint 缓存验证 - 检查 tsc/prettier/vitest 完整性
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
- 缓存有效性检查增加 tsc/prettier/vitest 二进制文件存在性校验
- npm ci 安装后增加完整性检查,关键工具缺失则自动清理重装
- 解决 node_modules 缓存脏数据导致 typescript/prettier/vitest 随机失败的问题
2026-07-15 12:58:10 +08:00
xiaoxia d3ed0bb4ba Merge pull request 'fix(ci): 修复registry不稳定导致镜像构建失败 - 解耦缓存推送' (#357) from ci/registry-cache-stability-fix into develop
CI/CD Pipeline / Unit Tests (push) Successful in 2m14s
CI/CD Pipeline / Frontend Lint (push) Successful in 59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m43s
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
Merge PR #357: fix(ci): 修复registry不稳定导致构建失败 - 构建与缓存推送解耦
2026-07-15 12:55:13 +08:00
xiaoxia 8107b2a255 chore(ci): 同步 develop 最新 ci-cd.yml - 增量扫描修复
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m15s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1514h46m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1514h46m51s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1514h46m49s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1514h46m51s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1514h46m49s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1514h46m51s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1514h46m51s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1514h46m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1514h48m49s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1514h46m52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1514h48m49s
CI/CD Pipeline / Deploy Production (push) Failing after 1514h48m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1514h48m50s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1514h48m51s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1514h48m51s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1514h48m51s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1514h48m51s
CI/CD Pipeline / Build Production API Image (push) Failing after 1514h48m51s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1515h18m33s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1515h18m33s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1515h20m31s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1515h20m33s
2026-07-15 12:40:00 +08:00
xiaoxia 2977e27ede fix(ci): 修复增量扫描误检 - 改用 Gitea API 获取 PR 改动文件
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1514h46m46s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1514h46m47s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1514h46m47s
CI/CD Pipeline / Build Production API Image (push) Failing after 1514h46m47s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1515h18m29s
2026-07-15 12:39:33 +08:00
XiaoXia Bot 99fa146a5c fix(ci): 修复registry不稳定导致构建失败 - 构建与缓存推送解耦
CI/CD Pipeline / Production Browser E2E (push) Failing after 1514h54m10s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1514h54m12s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1514h54m12s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1514h54m13s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1514h54m13s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1514h54m13s
CI/CD Pipeline / Build Production API Image (push) Failing after 1514h54m13s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1514h54m14s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1514h54m13s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1514h54m16s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1514h54m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1514h54m16s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1514h54m18s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1514h54m18s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1514h54m18s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1514h54m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1515h25m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1515h25m55s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1515h25m55s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1515h25m57s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1515h26m0s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1515h26m0s
- 新增docker_build_push.sh通用脚本
- 构建镜像(只读缓存)与推送缓存分离
- 缓存推送失败重试3次,不影响主构建结果
- 使用zstd压缩缓存层
2026-07-15 12:33:02 +08:00
CI Bot d99cda8bc2 fix: 剪辑计划P0+P1修复 - asset_ids传递 + result_count修正 + total_duration回写 + logs埋点
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1515h7m36s
CI/CD Pipeline / Deploy Production (push) Failing after 1515h7m37s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1515h7m38s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1515h7m38s
CI/CD Pipeline / Build Production API Image (push) Failing after 1515h7m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
P0修复:
- 触发生成时将plan.config.asset_ids传递到GenerationTask
- 同时补全project_id字段(之前为空字符串)

P1修复:
- result_count: 剪辑计划多片段合成1个成片,改为1(之前误存为clip数)
- total_duration: 渲染成功后回写实际输出时长到EditPlan.total_duration
- logs: 新增5个关键节点日志埋点(render_start/download_done/download_failed/render_complete/render_failed)
- 下载完成后进度推进到30%,便于前端展示素材下载中状态
2026-07-15 12:19:42 +08:00
xiaoxia 4b2f42f0be fix(worker): 修复unified_render_service.py的black格式化问题 (#359)
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1515h29m38s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1515h29m40s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1515h29m41s
CI/CD Pipeline / Build Production API Image (push) Failing after 1515h29m41s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1516h1m21s
2026-07-15 11:52:51 +08:00
xiaoxia 531aacb57e fix(code-quality): 第二批 - B904 raise-without-from 批量修复 (71个) (#353)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-15 11:51:45 +08:00
xiaoxia 497ea8ca25 chore: 恢复 ci-cd.yml 到 develop 版本(移除调试代码)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 53s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 43s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1515h58m25s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1515h58m25s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1515h58m26s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1515h58m26s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1515h58m26s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1515h58m26s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1515h58m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1515h58m27s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1515h59m30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1515h59m30s
CI/CD Pipeline / Deploy Production (push) Failing after 1515h59m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1515h59m30s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1515h59m32s
CI/CD Pipeline / Build Production API Image (push) Failing after 1515h59m32s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1515h59m32s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1515h59m32s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1516h30m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1516h30m7s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1516h30m7s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1516h31m11s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1516h31m13s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1516h31m13s
2026-07-15 11:27:22 +08:00
xiaoxia 3ded5eb962 style: Prettier 格式化 EditingPlanner.tsx(修复 Frontend Lint 失败)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 54s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 47s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1516h0m3s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1516h0m3s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1516h0m4s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1516h0m5s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1516h0m5s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1516h0m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1516h0m5s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1516h0m5s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1516h0m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1516h0m36s
CI/CD Pipeline / Deploy Production (push) Failing after 1516h0m38s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1516h0m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1516h0m38s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1516h0m40s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1516h0m40s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1516h0m40s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1516h0m40s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1516h32m17s
CI/CD Pipeline / Build Production API Image (push) Failing after 1516h32m21s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1516h31m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1516h31m45s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1516h31m46s
2026-07-15 11:26:56 +08:00
xiaoxia 7c67468115 debug: 加 node_modules 调试 step
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 58s
CI/CD Pipeline / Frontend Lint (push) Failing after 44s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 49s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 46s
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1516h4m39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1516h4m39s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1516h4m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1516h4m40s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1516h4m41s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1516h4m42s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1516h4m42s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1516h4m42s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1516h5m25s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1516h5m25s
CI/CD Pipeline / Deploy Production (push) Failing after 1516h5m27s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1516h5m29s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1516h5m29s
CI/CD Pipeline / Build Production API Image (push) Failing after 1516h5m29s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1516h5m29s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1516h5m29s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1516h5m29s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1516h36m20s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1516h36m22s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1516h36m22s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1516h37m5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1516h37m7s
2026-07-15 11:21:31 +08:00
xiaoxia 7c8748ece6 perf(ci): 扩展CI触发分支模式 + 增加手动触发入口 (#349)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m11s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1516h8m49s
CI/CD Pipeline / Deploy Production (push) Failing after 1516h8m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1516h8m49s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1516h8m51s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1516h8m51s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1516h8m51s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1516h8m51s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1516h8m52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1516h40m30s
CI/CD Pipeline / Build Production API Image (push) Failing after 1516h40m32s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1516h8m49s
2026-07-15 11:20:08 +08:00
CI Bot f0a5f4fe42 fix: 硬切场景改用concat filter替代xfade,提升多素材场景性能与稳定性
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 46s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1516h10m27s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1516h10m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1516h10m28s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1516h10m29s
CI/CD Pipeline / Build Production API Image (push) Failing after 1516h10m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1516h10m29s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1516h10m29s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1516h10m29s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Failing after 1516h42m10s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1516h42m10s
CI/CD Pipeline / Deploy Production (push) Failing after 1516h42m9s
- 全硬切(no transition)场景下使用concat filter,性能远优于xfade
- 有转场效果时继续走xfade路径,保持兼容
- 修复硬切默认走xfade(fade fallback)的设计问题
- 新增单元测试覆盖concat和xfade两种路径
- 87个unified_render测试全绿,全套2147测试零回归
2026-07-15 11:18:28 +08:00
xiaoxia eba96fc864 feat(api): 剪辑计划生成状态API增强 - 新增progress/video_url/error_message等字段 (#351)
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1516h11m57s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1516h11m59s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1516h11m59s
CI/CD Pipeline / Build Production API Image (push) Failing after 1516h11m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1516h43m39s
2026-07-15 11:15:55 +08:00
xiaoxia 5f17a00318 chore: 同步 package-lock.json 到 develop(补充 prettier 等依赖)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m0s
CI/CD Pipeline / Frontend Lint (push) Failing after 1m34s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m33s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1516h14m10s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1516h14m10s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1516h14m11s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1516h14m12s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1516h14m12s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1516h14m12s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1516h14m12s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1516h14m12s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1516h15m50s
CI/CD Pipeline / Build Production API Image (push) Failing after 1516h15m54s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1516h15m50s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1516h15m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1516h15m50s
CI/CD Pipeline / Deploy Production (push) Failing after 1516h15m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1516h15m51s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1516h15m54s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1516h15m54s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1516h15m54s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1516h45m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1516h45m52s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1516h45m53s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1516h47m35s
2026-07-15 11:12:43 +08:00
xiaoxia 2a4aa46881 chore: 同步 package.json 到 develop(补充 prettier 等依赖)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-15 11:12:42 +08:00
xiaoxia 999ea2856a chore: 同步 ci-cd.yml 到 develop 最新版本(解决 merge 冲突 + 修复 Frontend Lint)
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m3s
CI/CD Pipeline / Frontend Lint (push) Failing after 1m4s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m13s
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1516h18m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1516h18m38s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1516h18m39s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1516h18m40s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1516h18m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1516h18m40s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1516h19m48s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1516h19m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1516h18m39s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1516h19m51s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1516h18m40s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1516h19m51s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1516h19m51s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1516h19m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1516h19m48s
CI/CD Pipeline / Deploy Production (push) Failing after 1516h19m49s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1516h19m51s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1516h50m19s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1516h50m21s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1516h50m21s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1516h51m31s
CI/CD Pipeline / Build Production API Image (push) Failing after 1516h51m32s
2026-07-15 11:09:07 +08:00
xiaoxia 68fe8aabd5 perf(ci): P1级Web构建缓存优化 - tsc增量 + Vite构建缓存 (#348)
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1515h7m58s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1515h8m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1515h23m17s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1515h35m47s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1515h35m51s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1515h35m51s
CI/CD Pipeline / Build Production API Image (push) Failing after 1515h35m51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1516h7m30s
2026-07-15 10:52:39 +08:00
xiaoxia d9e00992a6 fix(ci): 修复 Frontend Lint 拉错 eslint 版本的问题 (#347)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-15 10:52:26 +08:00
xiaoxia 2293adfbcf perf(ci): P0-4 Phase 1 - PR模式下lint增量扫描,只扫改动文件
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1516h43m11s
CI/CD Pipeline / Deploy Production (push) Failing after 1516h43m11s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1516h43m14s
CI/CD Pipeline / Build Production API Image (push) Failing after 1516h43m14s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Failing after 1517h14m54s
2026-07-15 10:44:20 +08:00
xiaoxia 4397c670d8 perf(ci): 用 ruff 替换 flake8 - 规则完全对齐,速度提升10x+
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1517h15m23s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1517h15m25s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1517h15m25s
CI/CD Pipeline / Build Production API Image (push) Failing after 1517h15m25s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1517h47m5s
2026-07-15 10:12:26 +08:00
xiaoxia 9c155d0561 fix(ci): 修复Frontend Lint缓存脏数据导致npm ci失败 (#345)
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1517h23m8s
CI/CD Pipeline / Deploy Production (push) Failing after 1517h23m9s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1517h23m9s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1517h23m9s
CI/CD Pipeline / Build Production API Image (push) Failing after 1517h23m9s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
2026-07-15 10:04:55 +08:00
CI Bot 7da09bfcfd feat: 打通剪辑计划生成链路 — EditingPlanner 生成按钮对接真实 API
CI/CD Pipeline / Frontend Lint (push) Failing after 50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m7s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 2m19s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1517h38m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1517h38m22s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1517h38m22s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1517h38m20s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1517h38m22s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1517h38m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1517h38m21s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1517h38m24s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1517h38m26s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1517h38m26s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1517h38m22s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1517h38m26s
CI/CD Pipeline / Build Production API Image (push) Failing after 1517h38m22s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1517h38m26s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1517h38m25s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1517h38m25s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1517h38m25s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1517h38m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1518h10m1s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1518h10m2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1518h10m6s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1518h10m6s
- 生成按钮调用 generateEditPlan(planId) 而非跳转一键生成页面
- 进度弹窗 + 轮询 getGenerationStatus(每2秒)+ 视频结果展示
- 列表页编辑按钮正确加载计划数据到编辑器(planId URL参数)
- 生成历史弹窗正常工作
- 列表页状态自动同步(已有5秒 refetch)
- 修复所有 TypeScript 编译错误(tsc 零错误)
2026-07-15 09:49:24 +08:00
xiaoxia 535e525a0e fix(worker): 修复瘦身后shared模块无法导入的PYTHONPATH问题
CI/CD Pipeline / Frontend Lint (push) Failing after 16s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1518h9m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1518h9m11s
CI/CD Pipeline / Deploy Production (push) Failing after 1518h9m11s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1518h9m12s
CI/CD Pipeline / Build Production API Image (push) Failing after 1518h9m12s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1518h9m12s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1518h40m50s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1518h40m52s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1518h40m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1518h40m52s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1518h40m52s
PR #343: 修复Worker镜像瘦身后shared模块无法导入的PYTHONPATH问题

将ENV PYTHONPATH=/app改为ENV PYTHONPATH=/app:/app/packages,解决ModuleNotFoundError: No module named "shared"问题。
2026-07-15 09:16:35 +08:00
xiaoxia e705cbdd8a perf(ci): 修复Frontend Lint的npm缓存假命中问题 (#341)
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1518h24m11s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1518h24m14s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1518h24m14s
CI/CD Pipeline / Build Production API Image (push) Failing after 1518h24m14s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1518h55m52s
2026-07-15 09:04:03 +08:00
CI Bot 1af8c7ae9e fix(ci): use npx for prettier since it is not a project dependency
CI/CD Pipeline / Production Browser E2E (push) Failing after 1518h43m59s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1518h44m0s
CI/CD Pipeline / Deploy Production (push) Failing after 1518h44m0s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1518h44m1s
CI/CD Pipeline / Build Production API Image (push) Failing after 1518h44m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1518h44m0s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1518h44m1s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1518h44m1s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1518h44m7s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1518h44m7s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1518h44m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1518h44m9s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1518h44m10s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1518h44m10s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1518h44m10s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1518h44m10s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1519h15m50s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1519h15m47s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1519h15m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1519h15m40s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1519h15m41s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1519h15m41s
Prettier is not listed in apps/web/package.json devDependencies,
so ./node_modules/.bin/prettier does not exist after npm ci.
Use npx --yes prettier@3 to download and run on-the-fly.
2026-07-15 08:43:30 +08:00
CI Bot 27681c785a fix(ci): 使用本地 ESLint/Prettier/TypeScript 二进制,绕过损坏的 npm cache 卷
CI/CD Pipeline / Frontend Lint (push) Failing after 1m10s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m16s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1518h51m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1518h51m39s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1518h51m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1518h51m38s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1518h51m39s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1518h51m39s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1518h51m39s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1518h51m40s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1518h51m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1518h51m44s
CI/CD Pipeline / Deploy Production (push) Failing after 1518h51m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1518h51m45s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1518h51m46s
CI/CD Pipeline / Build Production API Image (push) Failing after 1518h51m46s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1518h51m46s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1518h51m46s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1519h23m19s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1519h23m19s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1519h23m19s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1519h23m26s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1519h23m24s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1519h23m25s
- Install 步骤: rm -rf node_modules (非 /*) + --cache /tmp/npm-cache 绕过 Docker 卷缓存
- ESLint: npx eslint → ./node_modules/.bin/eslint (避免 npx 下载 eslint@10 不兼容 flat config)
- Prettier: npx prettier → ./node_modules/.bin/prettier
- TypeScript: npx tsc → ./node_modules/.bin/tsc
2026-07-15 08:36:16 +08:00
CI Bot 639f73b16d Merge remote-tracking branch 'origin/develop' into feature/product-center-ui-upgrade
CI/CD Pipeline / Frontend Lint (push) Failing after 53s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1519h2m48s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1519h2m48s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1519h2m48s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1519h2m50s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1519h2m48s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1519h2m50s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1519h2m50s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1519h2m50s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1519h2m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1519h2m55s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1519h2m51s
CI/CD Pipeline / Deploy Production (push) Failing after 1519h2m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1519h2m56s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1519h2m57s
CI/CD Pipeline / Build Production API Image (push) Failing after 1519h2m57s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1519h2m57s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1519h2m57s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1519h2m57s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1519h34m30s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1519h34m28s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1519h34m34s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1519h34m36s
# Conflicts:
#	apps/web/src/api/products.ts
#	apps/web/src/pages/products/ProductLibrary.tsx
2026-07-15 08:24:44 +08:00
CI Bot a7b2cbfc8c feat(products): 成片中心 UI 4 大功能升级
CI/CD Pipeline / Frontend Lint (push) Failing after 1m10s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m23s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1541h39m39s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1541h39m41s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1541h39m41s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1541h39m43s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1541h39m43s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1541h44m23s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 1541h44m28s
CI/CD Pipeline / Deploy Production (push) Failing after 1541h44m25s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1541h44m25s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1541h44m25s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1542h11m17s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1542h16m4s
1. 封面预览 — 卡片已支持 thumbnailUrl 展示
2. 复核状态标签 — 右上角显示待复核(灰)/已通过(绿)/需修改(红),点击循环切换
3. 批量操作 — 批量下载改用 batch-download API + job_id 轮询
4. 筛选栏 — 新增按项目、按复核状态筛选 Select

API 层新增:
- updateReviewStatus: PATCH /products/{id}/review
- batchDownload: POST /products/batch-download
- getBatchDownloadStatus: GET /products/batch-download/{jobId}

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 09:42:08 +08:00
Ops Bot f901705050 docs: 新增 runner-planning.md
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 47s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 37s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1558h59m1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1558h59m4s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1558h59m6s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1558h59m4s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1558h59m6s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1559h30m38s
2026-07-13 16:27:17 +08:00
Ops Bot 8d826d73c0 docs: 新增 ci-failure-diagnosis-20260713.md 2026-07-13 16:27:16 +08:00
388 changed files with 40568 additions and 14094 deletions
-14
View File
@@ -1,14 +0,0 @@
[flake8]
max-line-length = 120
exclude =
.git,
.cache,
__pycache__,
.venv,
venv,
node_modules,
alembic
per-file-ignores =
tests/integration/*:F821
tests/unit/*:F821
+174
View File
@@ -0,0 +1,174 @@
name: Auto Approve CI PRs
on:
pull_request:
types: [synchronize, opened, ready_for_review]
jobs:
auto-approve:
name: Auto Approve on CI Green
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 20
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: Auto approve when CI passes
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
# 检查是否纯前端改动
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
SKIP_BACKEND=true
echo "✅ 纯前端改动,只检查Frontend Lint"
else
SKIP_BACKEND=false
echo "🔧 包含后端/公共变更,检查全部CI"
fi
# 定义需要检查的context
if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Unit Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
fi
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项"
for ctx in "${CONTEXTS[@]}"; do
echo " - $ctx"
done
echo
# 初始等待30秒,给CI启动写status的时间,避免checkout太快导致全找不到context误判
echo "等待30秒让CI启动..."
sleep 30
# 轮询等待,最多20分钟(120次x10秒)
for attempt in $(seq 1 120); do
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 调用辅助脚本检查每个context状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " $ctx: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
ANY_PENDING=true
fi
done
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
# 检查是否已有审批(任何用户的APPROVED都算,避免重复审批)
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
if [ "$EXISTING" = "yes" ]; then
echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过"
exit 0
fi
# 第一步:创建PENDING review(Gitea API需要先创建再提交)
echo "创建review..."
REVIEW_CREATE=$(curl -s -X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
if [ -z "$REVIEW_ID" ]; then
echo "❌ 创建review失败"
echo "$REVIEW_CREATE"
exit 1
fi
# 如果已经是APPROVED就不用再submit了(兼容不同Gitea版本)
if [ "$REVIEW_STATE" = "APPROVED" ]; then
echo "✅ 自动审批成功(直接创建为APPROVED)"
exit 0
fi
# 第二步:submit review为APPROVED
echo "提交review审批..."
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
echo "提交API HTTP状态: $SUBMIT_CODE"
cat /tmp/submit_resp.json 2>/dev/null || true
echo
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
exit 0
else
echo "❌ 提交审批失败"
exit 1
fi
fi
# 还有CI在跑(pending状态)→ 继续等
if [ "$ANY_PENDING" = "true" ]; then
echo "⏳ CI仍在运行中,继续等待(第${attempt}/120次轮询)..."
sleep 10
continue
fi
# 所有CI都跑完了但有失败 → 退出
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI检查有失败项,不自动审批"
exit 0
fi
# 其他情况继续等
sleep 10
done
echo
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
exit 0
+174
View File
@@ -0,0 +1,174 @@
name: Auto Merge CI PRs
on:
pull_request:
types: [synchronize, opened, ready_for_review, review_requested]
jobs:
auto-merge:
name: Auto Merge on CI Green + Approved
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
timeout-minutes: 30
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: Auto merge when CI passes and approved
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
echo
# 只合develop分支
if [ "$BASE_REF" != "develop" ]; then
echo "Skip: 目标分支不是develop"
exit 0
fi
# 判断是否纯前端改动
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
echo "纯前端改动,只检查Frontend Lint"
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Unit Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
"CI/CD Pipeline / Integration Tests (pull_request)"
)
echo "检查全部四门禁"
fi
echo
# 初始等待30秒,给CI启动写status的时间,避免checkout太快导致全找不到context误判
echo "等待30秒让CI启动..."
sleep 30
# 405连续计数器:连续多次合并返回405才放弃
MERGE_405_COUNT=0
MAX_405_RETRIES=10
# 轮询等待,最多30分钟(180次x10秒)
for attempt in $(seq 1 180); do
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 检查CI状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " CI: ${ctx##*/}: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ]; then
ANY_PENDING=true
fi
done
# 检查审批状态
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
echo " 审批: $APPROVAL_RESULT"
HAS_APPROVAL=false
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
HAS_APPROVAL=true
fi
# 全部满足 → 合并
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
echo
echo "CI全绿 + 审批通过,执行自动合并"
echo "等待60秒冷却,给Gitea内部状态同步时间..."
sleep 60
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行squash merge
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
cat /tmp/merge_resp.json 2>/dev/null || true
echo
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 0
fi
sleep 30
continue
else
echo "自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
else
# 本轮不满足合并条件,重置405计数器
MERGE_405_COUNT=0
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "CI有失败项,不自动合并"
exit 0
fi
sleep 10
done
echo
echo "等待超时(30分钟)"
exit 0
File diff suppressed because one or more lines are too long
Regular → Executable
+261 -780
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
name: CI Trigger Monitor
on:
schedule:
- cron: '*/5 * * * *' # 每5分钟检查一次
workflow_dispatch:
inputs:
stale_threshold:
description: 'CI未触发告警阈值(分钟)'
required: false
default: '5'
permissions:
contents: read
jobs:
monitor:
name: Monitor CI Trigger Reliability
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Check CI trigger status for all open PRs
env:
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
GITEA_URL: https://git.xiaoxiajianji.com
GITEA_REPO: xiaoxia/xiaoxia-saas
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
STALE_THRESHOLD_MIN: ${{ inputs.stale_threshold || 5 }}
run: |
set +e
python3 scripts/ci_trigger_monitor.py
# 监控脚本永远不fail,避免告警风暴
exit 0
+53
View File
@@ -0,0 +1,53 @@
name: AI Code Review
on:
pull_request:
types:
- opened
- synchronize
- reopened
# 同一个 PR 只跑一个 review,新的取消旧的
concurrency:
group: code-review-${{ gitea.repository }}-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
code-review:
name: AI Code Review
runs-on: ubuntu-latest
# 跳过草稿 PR
if: ${{ !gitea.event.pull_request.draft }}
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install dependencies
run: |
python3 -m pip install --upgrade pip
python3 -m pip install requests
- name: Run AI Code Review
env:
# Gitea 配置(自动从运行环境获取)
GITEA_API_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
REPO_NAME: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
LLM_PROVIDER: "coze"
# 扣子模式配置(默认国内站 api.coze.cn)
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
COZE_BOT_ID: ${{ secrets.COZE_BOT_ID }}
LLM_MODEL: ${{ secrets.LLM_MODEL }}
# 可选参数
MAX_DIFF_CHARS: "30000"
LLM_TIMEOUT: "120"
run: |
python3 scripts/ci_code_review.py
# 审查脚本异常不影响 CI 通过
continue-on-error: true
+195
View File
@@ -0,0 +1,195 @@
name: Preview Cleanup
on:
pull_request:
types:
- closed
branches:
- main
- develop
permissions:
contents: read
pull-requests: write
jobs:
cleanup-preview:
name: Cleanup Preview Environment
runs-on: runtime-builder
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: Extract PR number
shell: sh
run: |
set -eu
# 优先从event payload中读取(兼容所有PR事件类型)
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
fi
# fallback: 从GITHUB_REF中提取
if [ -z "${PR_NUMBER:-}" ]; then
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
fi
# 再fallback: 兼容纯数字ref
if [ -z "${PR_NUMBER:-}" ] || ! echo "$PR_NUMBER" | grep -qE '^[0-9]+$'; then
echo "WARNING: Could not extract PR number cleanly, using raw ref suffix"
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
fi
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "PR number: $PR_NUMBER"
echo "Preview dir: /var/www/preview/pr-${PR_NUMBER}"
- name: Install SSH client
shell: sh
run: |
set -eu
# 先检查是否已存在ssh
if command -v ssh >/dev/null 2>&1 && command -v ssh-keyscan >/dev/null 2>&1; then
echo "SSH client already available: $(ssh -V 2>&1)"
exit 0
fi
# 尝试多种包管理器安装
if command -v apk >/dev/null 2>&1; then
apk add --no-cache openssh-client >/dev/null 2>&1
echo "openssh-client installed via apk"
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
echo "openssh-client installed via apt-get"
elif command -v yum >/dev/null 2>&1; then
yum install -y openssh-clients >/dev/null 2>&1
echo "openssh-client installed via yum"
elif command -v dnf >/dev/null 2>&1; then
dnf install -y openssh-clients >/dev/null 2>&1
echo "openssh-client installed via dnf"
else
echo "ERROR: No package manager found and ssh not pre-installed"
which ssh 2>/dev/null || echo " ssh: not found"
which ssh-keyscan 2>/dev/null || echo " ssh-keyscan: not found"
exit 1
fi
- name: Remove preview directory from server
shell: sh
env:
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
preview_user="${PREVIEW_SSH_USER:-deploy}"
preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
mkdir -p ~/.ssh
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key)
key_path=""
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using key from PREVIEW_SSH_KEY secret"
elif [ -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)"
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 "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH keyscan done"
# 测试SSH连接
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
echo "SSH connection verified"
# 检查目录是否存在
DIR_EXISTS=$(ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"if [ -d '${preview_dir}' ]; then echo 'yes'; else echo 'no'; fi")
if [ "$DIR_EXISTS" = "yes" ]; then
echo "Removing preview directory: ${preview_dir}"
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"rm -rf ${preview_dir} && echo 'Preview directory removed successfully'"
echo "Cleanup completed: ${preview_dir}"
else
echo "Preview directory does not exist: ${preview_dir}, nothing to clean up"
fi
- name: Comment cleanup notice on PR
if: success()
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
# 从event payload读取PR号(最可靠)
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
else
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
fi
export PR_NUMBER
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py cleanup)
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
curl -s -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"$API_URL" \
> /dev/null
echo "Cleanup comment posted"
+274
View File
@@ -0,0 +1,274 @@
name: Preview Deploy
on:
pull_request:
types:
- opened
- synchronize
- reopened
branches:
- main
- develop
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "手动触发 - 预览环境补跑"
permissions:
contents: read
pull-requests: write
concurrency:
group: preview-deploy-${{ gitea.ref }}
cancel-in-progress: true
jobs:
deploy-preview:
name: Deploy Preview Environment
runs-on: runtime-builder
timeout-minutes: 20
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: Record job start time
shell: sh
run: |
set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
- name: Extract PR number
shell: sh
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "PR number: $PR_NUMBER"
echo "PREVIEW_URL=https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com" >> $GITHUB_ENV
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
- name: Build frontend
shell: sh
run: |
set -eu
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
echo "Created npm cache volume: $NPM_CACHE_VOLUME"
fi
docker run --rm \
-v "$PWD:/workspace" \
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
-w /workspace/apps/web \
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
docker.m.daocloud.io/library/node:20 \
sh -lc '
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
CACHE_HASH_FILE="node_modules/.package-lock-hash"
CACHE_VALID=false
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
CACHE_VALID=true
echo "Cache hit: dependencies valid, skipping npm ci"
fi
if [ "$CACHE_VALID" = "false" ]; then
echo "Cache miss or invalid: running npm ci..."
if ! npm ci --include=dev; then
echo "npm ci failed, cleaning node_modules and retrying..."
rm -rf node_modules
mkdir -p node_modules
npm ci --include=dev
fi
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
echo "Dependencies installed, cache updated"
fi
echo "Running TypeScript check..."
npx --no-install tsc
echo "Running Vite build..."
npx --no-install vite build
echo "Build completed successfully"
ls -la dist/
'
- name: Install SSH client and rsync
shell: sh
run: |
set -eu
if command -v apk >/dev/null 2>&1; then
apk add --no-cache openssh-client rsync >/dev/null 2>&1
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq openssh-client rsync >/dev/null 2>&1
elif command -v yum >/dev/null 2>&1; then
yum install -y openssh-clients rsync >/dev/null 2>&1
else
echo "ERROR: No package manager found"
exit 1
fi
echo "openssh-client and rsync installed"
- name: Deploy preview to server
shell: sh
env:
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
preview_user="${PREVIEW_SSH_USER:-deploy}"
preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
mkdir -p ~/.ssh
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key)
key_path=""
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using key from PREVIEW_SSH_KEY secret"
elif [ -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)"
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 "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH keyscan done"
# 测试SSH连接
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
echo "SSH connection verified"
# 创建预览目录并上传文件
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"mkdir -p ${preview_dir} && echo 'Preview directory created: ${preview_dir}'"
# 使用rsync上传dist目录内容
rsync -avz --delete -e "ssh -p ${preview_port} -i ${key_path} -o StrictHostKeyChecking=no" \
apps/web/dist/ \
"${preview_user}@${preview_host}:${preview_dir}/"
echo "Preview deployed to: ${preview_dir}"
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
- name: Comment preview link on PR
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
PREVIEW_URL="https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
export PR_NUMBER PREVIEW_URL
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py deploy)
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
EXISTING_COMMENT_ID=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "
import sys, json
try:
for c in json.load(sys.stdin):
if '预览环境已部署' in c.get('body', ''):
print(c['id'])
break
except Exception:
pass
")
if [ -n "$EXISTING_COMMENT_ID" ]; then
curl -s -X PATCH \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_COMMENT_ID}" \
> /dev/null
echo "Comment updated"
else
curl -s -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"$API_URL" \
> /dev/null
echo "Comment posted"
fi
- name: Job duration summary
if: always()
shell: sh
run: |
set +eu
if [ -n "$JOB_START_TIME" ]; then
END_TIME=$(date +%s)
DURATION=$((END_TIME - JOB_START_TIME))
MINS=$((DURATION / 60))
SECS=$((DURATION % 60))
echo "JOB_DURATION_SECONDS=$DURATION" >> $GITHUB_ENV
echo "=== Job Duration: ${MINS}m${SECS}s ==="
else
echo "JOB_DURATION_SECONDS=0" >> $GITHUB_ENV
echo "=== Job Duration: unknown ==="
fi
- 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 Preview Environment" python3 scripts/ci_notify.py
+4
View File
@@ -49,3 +49,7 @@ build/
tracker_tasks.json tracker_tasks.json
frontend-v21-ui-prototype-final.html frontend-v21-ui-prototype-final.html
!.vscode/
!.vscode/settings.json
.vscode/extensions.json
+29
View File
@@ -0,0 +1,29 @@
repos:
- repo: https://github.com/psf/black
rev: 26.5.1
hooks:
- id: black
language_version: python3.12
- repo: https://github.com/pycqa/isort
rev: 8.0.1
hooks:
- id: isort
args: ["--profile", "black"]
language_version: python3.12
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0
hooks:
- id: ruff
args: [--fix]
language_version: python3.12
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.11
hooks:
- id: prettier
name: prettier (frontend)
files: ^apps/web/.*\.(ts|tsx|js|jsx|css|scss|less|json|html|md|yaml|yml)$
additional_dependencies:
- prettier@3.4.2
Vendored Executable
+49
View File
@@ -0,0 +1,49 @@
{
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"prettier.requireConfig": true,
"isort.args": ["--profile", "black"],
"python.linting.ruffEnabled": true,
"python.analysis.typeCheckingMode": "basic"
}
@@ -0,0 +1,29 @@
"""add result_count to edit_plans
Revision ID: 041_result_count
Revises: 040_playback_speed
Create Date: 2026-07-15 14:05:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "041_result_count"
down_revision = "040_playback_speed"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plans",
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("edit_plans", "result_count")
+29
View File
@@ -0,0 +1,29 @@
"""add storage_key to assets
Revision ID: 042_storage_key
Revises: 041_result_count
Create Date: 2026-07-17 18:10:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "042_storage_key"
down_revision = "041_result_count"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"assets",
sa.Column("storage_key", sa.String(500), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("assets", "storage_key")
+33
View File
@@ -0,0 +1,33 @@
"""add updated_at to generation_tasks
Revision ID: 043_updated_at_generation_tasks
Revises: 042_storage_key
Create Date: 2026-07-18 19:30:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "043_updated_at_generation_tasks"
down_revision = "042_storage_key"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column(
"updated_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "updated_at")
View File
+93
View File
@@ -1,5 +1,6 @@
"""路由层共享辅助函数 — 消除跨文件重复定义。""" """路由层共享辅助函数 — 消除跨文件重复定义。"""
from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -46,3 +47,95 @@ def require_project_and_library(
libraries = asset_library_repository.find_by_project(project_id) libraries = asset_library_repository.find_by_project(project_id)
if not any(item.id == library_id for item in libraries): if not any(item.id == library_id for item in libraries):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
def auto_select_video_assets(
*,
project_id: str,
asset_library_repo: Any,
asset_repo: Any,
logger=None,
) -> list[str]:
"""从项目视频素材库自动选取 ready 状态的视频素材。
Args:
project_id: 项目 ID
asset_library_repo: 素材库仓储
asset_repo: 素材仓储
logger: 可选的 logger 实例,用于记录警告
Returns:
选中的素材 ID 列表,无可用素材时返回空列表
"""
if not project_id:
return []
# 找到项目的视频素材库
libs = asset_library_repo.find_by_project(project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if not video_lib:
if logger:
logger.warning("自动选素材: 项目 %s 无视频素材库", project_id)
return []
# 从素材库中选取 ready 状态的视频素材
assets = asset_repo.find_by_library(video_lib.id)
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
# 过滤横屏素材(只保留竖屏/正方形)
# 移动端短视频场景默认竖屏,横屏素材裁剪后画面不可用
# 注意:这只是选素材优化,渲染引擎本身支持任何分辨率的素材
filtered_videos = []
skipped_landscape = 0
for a in ready_videos:
width = a.width if hasattr(a, "width") and a.width else 0
height = a.height if hasattr(a, "height") and a.height else 0
if not width or not height:
# 从 metadata 兜底
if a.metadata and isinstance(a.metadata, dict):
width = int(a.metadata.get("width", 0) or 0)
height = int(a.metadata.get("height", 0) or 0)
if width and height and width > height:
skipped_landscape += 1
continue
filtered_videos.append(a)
if skipped_landscape and logger:
logger.warning("自动选素材: 跳过 %d 个横屏素材", skipped_landscape)
if not filtered_videos:
if logger:
logger.warning("自动选素材: 素材库 %s 无可用视频素材", video_lib.name)
return []
# 按创建时间降序(新素材在前)
filtered_videos.sort(key=lambda a: a.created_at, reverse=True)
return [a.id for a in filtered_videos]
def format_utc_datetime(dt: datetime | None) -> str:
"""将数据库读出的 UTC naive datetime 格式化为带时区的 ISO 8601 字符串。
数据库 DateTime 列不带时区信息,但存的是 UTC 时间。
直接 .isoformat() 输出无时区标识,前端会按本地时间解析,导致差 8 小时。
输出带 Z 后缀,前端 new Date() 自动转本地时间。
"""
if dt is None:
return ""
if isinstance(dt, str):
return dt
if dt.tzinfo is None:
return dt.isoformat() + "Z"
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+46 -15
View File
@@ -1,7 +1,7 @@
import logging import logging
from typing import Any, Optional from typing import Any, Optional
from app.api.routes._helpers import check_project_access from app.api.routes._helpers import check_project_access, format_utc_datetime
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service from app.core.storage import get_storage_service
from app.dependencies import ( from app.dependencies import (
@@ -71,6 +71,7 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
status=item.status.value, status=item.status.value,
classification_status=item.classification_status.value, classification_status=item.classification_status.value,
quality_score=item.quality_score, quality_score=item.quality_score,
created_at=format_utc_datetime(item.created_at),
uploaded_by_user_id=item.uploaded_by_user_id, uploaded_by_user_id=item.uploaded_by_user_id,
tag_ids=getattr(item, "tag_ids", []), tag_ids=getattr(item, "tag_ids", []),
) )
@@ -94,6 +95,12 @@ def list_assets(
None, None,
description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他", description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他",
), ),
status: Optional[str] = Query(
"default",
description="按状态筛选,逗号分隔多值;默认返回除deleted外的所有状态;传deleted查看回收站;传all返回所有状态",
),
page: Optional[int] = Query(None, ge=1, description="页码,从1开始;与 page_size 配对使用,优先于 skip/limit"),
page_size: Optional[int] = Query(None, ge=1, le=500, description="每页数量;与 page 配对使用"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -103,6 +110,26 @@ def list_assets(
) -> ListAssetsResponse: ) -> ListAssetsResponse:
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
# ── 分页:page/page_size 优先于 skip/limit
if page is not None and page_size is not None:
skip = (page - 1) * page_size
limit = page_size
# ── 解析 status 过滤
status_list: list[str] | None
if status and status.lower() == "all":
status_list = None # None = 不过滤,返回所有状态
elif status and status.lower() == "deleted":
status_list = ["deleted"] # 仅查回收站
elif status and status.lower() == "default":
status_list = ["ready", "uploading", "processing", "error"] # 默认排除deleted
elif status:
status_list = [s.strip() for s in status.split(",") if s.strip()]
if not status_list:
status_list = ["ready", "uploading", "processing", "error"]
else:
status_list = ["ready", "uploading", "processing", "error"]
# kind → file_type 映射(voice 对应 audio) # kind → file_type 映射(voice 对应 audio)
kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"} kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"}
@@ -171,11 +198,13 @@ def list_assets(
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found") raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
check_project_access(library.project_id, user_id, project_repository) check_project_access(library.project_id, user_id, project_repository)
if ft: if ft:
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit) items = asset_repository.find_by_library_and_file_type(
total = asset_repository.count_by_project(library.project_id) if not kind else len(items) library_id, ft, skip=skip, limit=limit, status=status_list
)
total = len(items)
else: else:
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit) items = asset_repository.find_by_library(library_id, skip=skip, limit=limit, status=status_list)
total = asset_repository.count_by_project(library.project_id) total = asset_repository.count_by_project(library.project_id, status=status_list)
return ListAssetsResponse( return ListAssetsResponse(
items=[_to_asset_response(item) for item in items], items=[_to_asset_response(item) for item in items],
total=total, total=total,
@@ -188,13 +217,13 @@ def list_assets(
check_project_access(project_id, user_id, project_repository) check_project_access(project_id, user_id, project_repository)
if ft: if ft:
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好) # 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
all_items = asset_repository.find_by_project(project_id) all_items = asset_repository.find_by_project(project_id, status=status_list)
items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft)] items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft)]
total = len(items) total = len(items)
paged = items[skip : skip + limit] paged = items[skip : skip + limit]
else: else:
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit) items = asset_repository.find_by_project(project_id, skip=skip, limit=limit, status=status_list)
total = asset_repository.count_by_project(project_id) total = asset_repository.count_by_project(project_id, status=status_list)
paged = items paged = items
return ListAssetsResponse( return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged], items=[_to_asset_response(item) for item in paged],
@@ -214,17 +243,17 @@ def list_assets(
if not project_ids: if not project_ids:
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit) return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
total = asset_repository.count_by_project_ids(project_ids) total = asset_repository.count_by_project_ids(project_ids, status=status_list)
# 跨项目分页:逐项目累积直到凑够一页 # 跨项目分页:逐项目累积直到凑够一页
paged_items: list = [] paged_items: list = []
offset = skip offset = skip
remaining = limit remaining = limit
for pid in project_ids: for pid in project_ids:
proj_total = asset_repository.count_by_project(pid) proj_total = asset_repository.count_by_project(pid, status=status_list)
if offset >= proj_total: if offset >= proj_total:
offset -= proj_total offset -= proj_total
continue continue
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining) proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining, status=status_list)
paged_items.extend(proj_items) paged_items.extend(proj_items)
remaining -= len(proj_items) remaining -= len(proj_items)
offset = 0 offset = 0
@@ -245,12 +274,14 @@ def list_assets(
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found") raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
check_project_access(library.project_id, user_id, project_repository) check_project_access(library.project_id, user_id, project_repository)
if kind: if kind:
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind]) all_items = asset_repository.find_by_library_and_file_type(
library_id, kind_to_file_type[kind], status=status_list
)
else: else:
all_items = asset_repository.find_by_library(library_id) all_items = asset_repository.find_by_library(library_id, status=status_list)
elif project_id: elif project_id:
check_project_access(project_id, user_id, project_repository) check_project_access(project_id, user_id, project_repository)
all_items = asset_repository.find_by_project(project_id) all_items = asset_repository.find_by_project(project_id, status=status_list)
else: else:
try: try:
projects = project_repository.find_accessible_projects(user_id) projects = project_repository.find_accessible_projects(user_id)
@@ -259,7 +290,7 @@ def list_assets(
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit) return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
all_items = [] all_items = []
for proj in projects: for proj in projects:
all_items.extend(asset_repository.find_by_project(proj.id)) all_items.extend(asset_repository.find_by_project(proj.id, status=status_list))
# 应用 kind 过滤(如果有)+ keyword/gender/style # 应用 kind 过滤(如果有)+ keyword/gender/style
if kind: if kind:
+4 -2
View File
@@ -239,7 +239,9 @@ def get_duplication_detail(
return _to_detail_response(record) return _to_detail_response(record)
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) @router.delete(
"/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_duplication_record( def delete_duplication_record(
record_id: str, record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -287,7 +289,7 @@ def retry_duplication(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e), detail=str(e),
) ) from e
if updated is None: if updated is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
+689 -20
View File
@@ -22,9 +22,15 @@ from datetime import datetime
from typing import Any, List, Optional from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
get_project_repository,
)
from app.schemas.generation_task import GenerationTaskResponse from app.schemas.generation_task import GenerationTaskResponse
from app.services import EditPlanService from app.services import EditPlanService, EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -32,7 +38,7 @@ from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus from packages.domain.edit_plan import EditPlan, EditPlanStatus
from ._helpers import check_project_access from ._helpers import auto_select_video_assets, check_project_access
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -50,6 +56,7 @@ class EditPlanCreateRequest(BaseModel):
config: dict[str, Any] = Field(default_factory=dict, description="计划配置 (JSON)") config: dict[str, Any] = Field(default_factory=dict, description="计划配置 (JSON)")
total_duration: float = Field(default=0.0, ge=0.0, description="总时长 (秒)") total_duration: float = Field(default=0.0, ge=0.0, description="总时长 (秒)")
project_id: str = Field(default="", description="所属项目 ID") project_id: str = Field(default="", description="所属项目 ID")
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表,创建时自动分配给片段")
class EditPlanUpdateRequest(BaseModel): class EditPlanUpdateRequest(BaseModel):
@@ -64,6 +71,15 @@ class EditPlanUpdateRequest(BaseModel):
) )
class CopyPlanRequest(BaseModel):
"""复制剪辑计划请求体"""
name: Optional[str] = Field(
default=None, min_length=1, max_length=200, description="新计划名称,不传则为「原名 - 副本」"
)
project_id: Optional[str] = Field(default=None, description="目标项目 ID,不传则复用源计划的项目")
class EditPlanResponse(BaseModel): class EditPlanResponse(BaseModel):
"""剪辑计划响应体""" """剪辑计划响应体"""
@@ -72,6 +88,7 @@ class EditPlanResponse(BaseModel):
name: str name: str
status: str status: str
total_duration: float total_duration: float
result_count: int = 0
project_id: str = "" project_id: str = ""
created_by_user_id: str = "" created_by_user_id: str = ""
config: dict[str, Any] config: dict[str, Any]
@@ -108,6 +125,10 @@ class EditPlanGenerationStatusResponse(BaseModel):
plan_id: str plan_id: str
plan_status: str plan_status: str
generation_task_id: Optional[str] = None generation_task_id: Optional[str] = None
generation_task_status: Optional[str] = None
progress: float = 0.0
video_url: str = ""
error_message: str = ""
clips: List[ClipStatusItem] clips: List[ClipStatusItem]
@@ -231,20 +252,45 @@ class GenerateFromTemplateResponse(BaseModel):
def _to_response(p: EditPlan) -> EditPlanResponse: def _to_response(p: EditPlan) -> EditPlanResponse:
# 对 config 中的 rendered_url 做签名转换(私有bucket裸URL会403)
config = dict(p.config) if p.config else {}
raw_rendered_url = config.get("rendered_url", "")
if raw_rendered_url:
try:
storage = get_storage_service()
config["rendered_url"] = storage.get_download_url(raw_rendered_url, expires_seconds=86400)
except Exception as e:
logger.warning("剪辑计划rendered_url签名失败,返回原始URL: plan_id=%s error=%s", p.id, e)
return EditPlanResponse( return EditPlanResponse(
id=p.id, id=p.id,
template_id=p.template_id, template_id=p.template_id,
name=p.name, name=p.name,
status=p.status.value if hasattr(p.status, "value") else p.status, status=p.status.value if hasattr(p.status, "value") else p.status,
total_duration=p.total_duration, total_duration=p.total_duration,
result_count=getattr(p, "result_count", 0),
project_id=p.project_id or "", project_id=p.project_id or "",
created_by_user_id=p.created_by_user_id or "", created_by_user_id=p.created_by_user_id or "",
config=p.config, config=config,
created_at=p.created_at, created_at=p.created_at,
updated_at=p.updated_at, updated_at=p.updated_at,
) )
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
# 注意:含静态路径的子路由需放在 CRUD 路由之前,避免被 /{plan_id} 抢先匹配
from .edit_plans_adjustments import router as adjustments_router
from .edit_plans_export import router as export_router
from .edit_plans_filter import router as filter_router
from .edit_plans_transitions import router as transitions_router
router.include_router(export_router)
router.include_router(adjustments_router)
router.include_router(filter_router)
router.include_router(transitions_router)
# ── CRUD Routes ─────────────────────────────────────────────────────────────── # ── CRUD Routes ───────────────────────────────────────────────────────────────
@@ -275,11 +321,11 @@ def list_plans(
if status_filter: if status_filter:
try: try:
status_enum = EditPlanStatus(status_filter) status_enum = EditPlanStatus(status_filter)
except ValueError: except ValueError as _e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的筛选条件,请选择正确的状态", detail="无效的筛选条件,请选择正确的状态",
) ) from _e
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问 # 项目鉴权:如果指定了 project_id,校验用户是否有权访问
if project_id: if project_id:
@@ -322,7 +368,7 @@ def get_plan(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc), detail=str(exc),
) ) from exc
# 项目鉴权 # 项目鉴权
if plan.project_id: if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository) check_project_access(plan.project_id, current_user.user.id, project_repository)
@@ -335,37 +381,121 @@ def create_plan(
db: Session = Depends(get_db_session), db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository), project_repository: Any = Depends(get_project_repository),
asset_repository: Any = Depends(get_asset_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
) -> EditPlanResponse: ) -> EditPlanResponse:
"""创建剪辑计划""" """创建剪辑计划
基于模板自动生成片段结构:
- 模板存在时:从模板的 clip_configs 生成初始 clips
- 模板不存在时:降级为空计划(保持向后兼容)
- 用户传入的 config 与模板 config 合并(用户配置优先级更高)
- total_duration 自动根据 clips 总时长计算
"""
from app.services import PlanGeneratorService
# 空串 project_id 统一为 "" # 空串 project_id 统一为 ""
project_id = (body.project_id or "").strip() project_id = (body.project_id or "").strip()
# 项目鉴权 # 项目鉴权
if project_id: if project_id:
check_project_access(project_id, current_user.user.id, project_repository) check_project_access(project_id, current_user.user.id, project_repository)
# 标准化用户传入的 config
normalized_config = normalize_plan_config(body.config or {})
template_svc = EditTemplateService(db)
svc = EditPlanService(db) svc = EditPlanService(db)
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
normalized_config = normalize_plan_config(body.config) # 尝试从模板生成(模板不存在时降级为空计划)
template = None
clips = []
try: try:
created = svc.create_plan( template = template_svc.get_template_or_raise(body.template_id)
except ValueError:
# 模板不存在,降级为普通空计划
logger.info("模板不存在,创建空计划: template_id=%s", body.template_id)
plan = svc.create_plan(
template_id=body.template_id, template_id=body.template_id,
name=body.name, name=body.name,
config=normalized_config, config=normalized_config,
total_duration=body.total_duration,
project_id=project_id, project_id=project_id,
created_by_user_id=current_user.user.id, created_by_user_id=current_user.user.id,
total_duration=body.total_duration if body.total_duration > 0 else 0.0,
)
logger.info(
"创建空剪辑计划: id=%s name=%s by user=%s",
plan.id,
plan.name,
current_user.user.id,
)
return _to_response(plan)
# 模板存在,从模板生成计划+片段
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
# 自动选素材:未传 asset_ids 但有 project_id 时,从项目视频素材库选 ready 的视频素材
resolved_asset_ids = list(body.asset_ids)
if not resolved_asset_ids and project_id:
auto_assets = auto_select_video_assets(
project_id=project_id,
asset_library_repo=asset_library_repository,
asset_repo=asset_repository,
logger=logger,
)
if auto_assets:
resolved_asset_ids = auto_assets
logger.info(
"自动选素材: project_id=%s count=%d",
project_id,
len(auto_assets),
)
generator = PlanGeneratorService(db)
try:
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=resolved_asset_ids,
project_id=project_id,
created_by_user_id=current_user.user.id,
name=body.name,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc), detail=str(exc),
) from exc
plan = result["plan"]
clips = result["clips"]
# 如果用户传入了自定义 config,合并覆盖模板配置
if body.config:
base_config = template.config or {}
merged_config = {**base_config, **normalized_config}
# 重新标准化确保默认值填充正确
merged_config = normalize_plan_config(merged_config)
plan = svc.update_plan(
plan.id,
config=merged_config,
total_duration=body.total_duration if body.total_duration > 0 else None,
) )
# 把 asset_ids 写入 plan.config,供生成时兜底分配使用
if resolved_asset_ids:
current_config = plan.config or {}
if current_config.get("asset_ids") != resolved_asset_ids:
current_config["asset_ids"] = resolved_asset_ids
plan = svc.update_plan(plan.id, config=normalize_plan_config(current_config))
logger.info( logger.info(
"创建剪辑计划: id=%s name=%s by user=%s", "创建剪辑计划: id=%s name=%s clips=%d by user=%s",
created.id, plan.id,
created.name, plan.name,
len(clips),
current_user.user.id, current_user.user.id,
) )
return _to_response(created) return _to_response(plan)
@router.put("/{plan_id}", response_model=EditPlanResponse) @router.put("/{plan_id}", response_model=EditPlanResponse)
@@ -401,11 +531,11 @@ def update_plan(
if body.status is not None: if body.status is not None:
try: try:
target_status = EditPlanStatus(body.status) target_status = EditPlanStatus(body.status)
except ValueError: except ValueError as _e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的状态值,请选择正确的状态", detail="无效的状态值,请选择正确的状态",
) ) from _e
svc.transition_status(plan_id, target_status) svc.transition_status(plan_id, target_status)
except ValueError as exc: except ValueError as exc:
err_msg = str(exc) err_msg = str(exc)
@@ -413,11 +543,11 @@ def update_plan(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=err_msg, detail=err_msg,
) ) from exc
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=err_msg, detail=err_msg,
) ) from exc
# 返回最新状态 # 返回最新状态
result = svc.get_plan_or_raise(plan_id) result = svc.get_plan_or_raise(plan_id)
@@ -451,12 +581,551 @@ def delete_plan(
) )
@router.post("/{plan_id}/copy", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED)
def copy_plan(
plan_id: str,
body: CopyPlanRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanResponse:
"""复制剪辑计划(含所有片段配置)
新计划状态为 editing,不含生成任务和结果记录。
"""
svc = EditPlanService(db)
# 源计划鉴权
existing = svc.get_plan(plan_id)
if existing is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if existing.project_id:
check_project_access(existing.project_id, current_user.user.id, project_repository)
# 目标项目鉴权(如果指定了不同的项目)
target_project_id = body.project_id if body.project_id is not None else existing.project_id
if target_project_id and target_project_id != existing.project_id:
check_project_access(target_project_id, current_user.user.id, project_repository)
try:
new_plan = svc.copy_plan(
plan_id,
new_name=body.name,
project_id=target_project_id,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info(
"复制剪辑计划: source=%s target=%s by user=%s",
plan_id,
new_plan.id,
current_user.user.id,
)
return _to_response(new_plan)
# ── 字幕管理 ────────────────────────────────────────────────────────────────
class SubtitleCreateRequest(BaseModel):
"""添加字幕请求体"""
start: float = Field(..., ge=0, description="开始时间(秒)")
end: float = Field(..., gt=0, description="结束时间(秒)")
text: str = Field(..., min_length=1, max_length=500, description="字幕文本")
style: Optional[dict[str, Any]] = Field(default=None, description="字幕样式")
class SubtitleUpdateRequest(BaseModel):
"""更新字幕请求体"""
start: Optional[float] = Field(default=None, ge=0, description="开始时间(秒)")
end: Optional[float] = Field(default=None, gt=0, description="结束时间(秒)")
text: Optional[str] = Field(default=None, min_length=1, max_length=500, description="字幕文本")
style: Optional[dict[str, Any]] = Field(default=None, description="字幕样式")
class SubtitleBatchUpdateRequest(BaseModel):
"""批量更新字幕请求体"""
subtitles: list[dict[str, Any]] = Field(
default_factory=list,
description="字幕列表(全量替换),每条包含 start/end/text,可选 id/style",
)
@router.get(
"/clips/{clip_id}/subtitles",
response_model=list[dict[str, Any]],
summary="获取片段的所有字幕",
)
def list_subtitles(
clip_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> list[dict[str, Any]]:
"""获取指定片段的所有字幕,按时间排序。"""
service = EditPlanService(db)
clip = service.get_clip(clip_id)
if clip is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = service.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
return service.list_subtitles(clip_id)
@router.post(
"/clips/{clip_id}/subtitles",
response_model=dict[str, Any],
summary="添加一条字幕",
status_code=status.HTTP_201_CREATED,
)
def add_subtitle(
clip_id: str,
body: SubtitleCreateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> dict[str, Any]:
"""给片段添加一条字幕。"""
service = EditPlanService(db)
clip = service.get_clip(clip_id)
if clip is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = service.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
try:
subtitle = service.add_subtitle(
clip_id,
start=body.start,
end=body.end,
text=body.text,
style=body.style,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
logger.info("添加字幕: clip_id=%s by user=%s", clip_id, current_user.user.id)
return subtitle
@router.put(
"/clips/{clip_id}/subtitles/{subtitle_id}",
response_model=dict[str, Any],
summary="更新一条字幕",
)
def update_subtitle(
clip_id: str,
subtitle_id: str,
body: SubtitleUpdateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> dict[str, Any]:
"""更新一条字幕的时间、文本或样式。"""
service = EditPlanService(db)
clip = service.get_clip(clip_id)
if clip is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = service.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
try:
subtitle = service.update_subtitle(
clip_id,
subtitle_id,
start=body.start,
end=body.end,
text=body.text,
style=body.style,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
logger.info(
"更新字幕: clip_id=%s subtitle_id=%s by user=%s",
clip_id,
subtitle_id,
current_user.user.id,
)
return subtitle
@router.delete(
"/clips/{clip_id}/subtitles/{subtitle_id}",
summary="删除一条字幕",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
response_class=Response,
)
def delete_subtitle(
clip_id: str,
subtitle_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> None:
"""删除一条字幕。"""
service = EditPlanService(db)
clip = service.get_clip(clip_id)
if clip is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = service.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
deleted = service.delete_subtitle(clip_id, subtitle_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"字幕不存在: {subtitle_id}",
)
logger.info(
"删除字幕: clip_id=%s subtitle_id=%s by user=%s",
clip_id,
subtitle_id,
current_user.user.id,
)
@router.put(
"/clips/{clip_id}/subtitles",
response_model=list[dict[str, Any]],
summary="批量更新字幕(全量替换)",
)
def batch_update_subtitles(
clip_id: str,
body: SubtitleBatchUpdateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> list[dict[str, Any]]:
"""批量更新片段的所有字幕(全量替换)。
用于批量编辑、SRT导入、ASR结果导入等场景。
每条字幕包含 start/end/text,已有 id 则保留,否则生成新 id。
"""
service = EditPlanService(db)
clip = service.get_clip(clip_id)
if clip is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = service.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
try:
subtitles = service.batch_update_subtitles(clip_id, body.subtitles)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
logger.info(
"批量更新字幕: clip_id=%s count=%d by user=%s",
clip_id,
len(subtitles),
current_user.user.id,
)
return subtitles
# ── BGM 背景音乐 ───────────────────────────────────────────────────────────
class BGMConfigUpdateRequest(BaseModel):
"""更新BGM配置请求体"""
enabled: Optional[bool] = Field(default=None, description="是否启用 BGM")
source: Optional[str] = Field(default=None, description="BGM 来源: library/upload/ai_recommend")
asset_id: Optional[str] = Field(default=None, max_length=64, description="BGM 素材 ID")
preset_id: Optional[str] = Field(default=None, max_length=64, description="预设 BGM ID")
audio_url: Optional[str] = Field(default=None, max_length=500, description="BGM 音频 URL")
volume: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="音量 (0.0 ~ 1.0)")
fade_in: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡入时长(秒)")
fade_out: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡出时长(秒)")
loop_enabled: Optional[bool] = Field(default=None, description="是否循环播放")
sidechain_enabled: Optional[bool] = Field(default=None, description="是否启用人声闪避")
sidechain_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="闪避音量降低比例")
@router.get(
"/{plan_id}/bgm",
response_model=dict[str, Any],
summary="获取剪辑计划的 BGM 配置",
)
def get_plan_bgm(
plan_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> dict[str, Any]:
"""获取指定剪辑计划的 BGM 配置。"""
service = EditPlanService(db)
plan = service.get_plan(plan_id)
if plan is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
config = plan.config or {}
bgm_config = config.get("bgm", {})
return {
"plan_id": plan.id,
"bgm": bgm_config,
}
@router.put(
"/{plan_id}/bgm",
response_model=dict[str, Any],
summary="更新剪辑计划的 BGM 配置",
)
def update_plan_bgm(
plan_id: str,
body: BGMConfigUpdateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> dict[str, Any]:
"""更新剪辑计划的 BGM 配置。
支持部分更新,只传需要修改的字段即可。
启用 BGM 后需要指定来源(asset_id / preset_id / audio_url 三选一)。
"""
service = EditPlanService(db)
plan = service.get_plan(plan_id)
if plan is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
# 读取当前 BGM 配置,合并更新
config = dict(plan.config) if plan.config else {}
current_bgm = dict(config.get("bgm", {}))
update_data = body.model_dump(exclude_none=True)
current_bgm.update(update_data)
# 校验:启用 BGM 时至少有一个有效来源
if current_bgm.get("enabled"):
has_source = any(current_bgm.get(key) for key in ("asset_id", "preset_id", "audio_url") if current_bgm.get(key))
if not has_source:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="启用 BGM 时需要指定素材来源(asset_id / preset_id / audio_url)",
)
# 保存到 plan.config.bgm
config["bgm"] = current_bgm
updated_plan = service.update_plan_config(plan_id, config)
logger.info(
"更新BGM配置: plan_id=%s enabled=%s by user=%s",
plan_id,
current_bgm.get("enabled", False),
current_user.user.id,
)
return {
"plan_id": updated_plan.id,
"bgm": current_bgm,
}
# ── BGM 预设库 ────────────────────────────────────────────────────────────────
@router.get(
"/bgm/presets",
response_model=dict[str, Any],
summary="获取预设 BGM 列表",
)
def list_bgm_presets(
style: Optional[str] = Query(default=None, description="按风格筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
skip: int = Query(default=0, ge=0, description="分页偏移"),
limit: int = Query(default=50, ge=1, le=200, description="每页数量"),
) -> dict[str, Any]:
"""获取预设 BGM 列表,支持按风格筛选和关键词搜索。
风格可选: upbeat(轻快)、relax(治愈)、tech(科技)、commerce(电商)、
emotional(情感)、cinematic(电影)
"""
from packages.domain.preset_bgm import (
BGM_STYLES,
PRESET_BGM_LIBRARY,
list_preset_bgm_by_style,
search_preset_bgm,
)
bgm_list = PRESET_BGM_LIBRARY
if keyword:
bgm_list = search_preset_bgm(keyword)
elif style:
bgm_list = list_preset_bgm_by_style(style)
total = len(bgm_list)
paged = bgm_list[skip : skip + limit]
return {
"total": total,
"skip": skip,
"limit": limit,
"styles": BGM_STYLES,
"items": [
{
"id": bgm.id,
"name": bgm.name,
"style": bgm.style,
"style_label": BGM_STYLES.get(bgm.style, bgm.style),
"duration": bgm.duration,
"artist": bgm.artist,
"description": bgm.description,
"tags": bgm.tags,
"audio_url": bgm.audio_url,
}
for bgm in paged
],
}
# ── 保存为模板 ────────────────────────────────────────────────────────────────
class SaveAsTemplateRequest(BaseModel):
"""保存为模板请求体"""
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
description: str = Field(default="", max_length=500, description="模板描述")
template_type: str = Field(default="custom", max_length=50, description="模板类型")
preview_url: str = Field(default="", max_length=500, description="预览图 URL")
@router.post(
"/{plan_id}/save-as-template",
response_model=dict[str, Any],
summary="将剪辑计划保存为模板",
status_code=status.HTTP_201_CREATED,
)
def save_plan_as_template(
plan_id: str,
body: SaveAsTemplateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> dict[str, Any]:
"""将指定剪辑计划的配置和片段结构保存为一个新模板。
新模板会复制计划的所有片段配置(类型、时长、转场、文案等),
但不绑定具体素材,可重复用于创建新的剪辑计划。
"""
# 校验计划存在性和项目权限
plan_service = EditPlanService(db)
plan = plan_service.get_plan(plan_id)
if plan is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
template_service = EditTemplateService(db)
try:
result = template_service.save_plan_as_template(
plan_id=plan_id,
name=body.name,
description=body.description,
template_type=body.template_type,
preview_url=body.preview_url,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
template = result["template"]
clip_configs = result["clip_configs"]
logger.info(
"保存计划为模板: plan_id=%s template_id=%s name=%s by user=%s",
plan_id,
template.id,
body.name,
current_user.user.id,
)
return {
"id": template.id,
"name": template.name,
"description": template.description,
"template_type": template.template_type,
"editing_mode": template.editing_mode,
"preview_url": template.preview_url,
"status": template.status.value,
"clip_count": len(clip_configs),
"created_at": template.created_at.isoformat(),
}
# ── Include sub-routers (拆分模块) ──────────────────────────────────────────── # ── Include sub-routers (拆分模块) ────────────────────────────────────────────
from .edit_plans_ai import router as ai_router from .edit_plans_ai import router as ai_router
from .edit_plans_clips import router as clips_router
from .edit_plans_clips_batch import router as clips_batch_router
from .edit_plans_cover import router as cover_router
from .edit_plans_generation import router as generation_router from .edit_plans_generation import router as generation_router
from .edit_plans_timeline import router as timeline_router from .edit_plans_timeline import router as timeline_router
router.include_router(generation_router) router.include_router(generation_router)
router.include_router(ai_router) router.include_router(ai_router)
router.include_router(timeline_router) router.include_router(timeline_router)
router.include_router(clips_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
router.include_router(clips_batch_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
router.include_router(cover_router)
+311
View File
@@ -0,0 +1,311 @@
"""片段调整 API.
- PUT /clips/{clip_id}/speed 调速
- PUT /clips/{clip_id}/volume 音量调节
- PUT /clips/{clip_id}/trim 裁剪(trim in/out)
- PUT /clips/{clip_id}/adjustments 统一调整(speed+volume+trim)
- POST /{plan_id}/clips/batch-speed 批量调速
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class SpeedAdjustRequest(BaseModel):
"""调速请求"""
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0")
class VolumeAdjustRequest(BaseModel):
"""音量调节请求"""
volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.0(1.0=原音量)")
class TrimAdjustRequest(BaseModel):
"""裁剪请求"""
trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数")
trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数")
class ClipAdjustmentsRequest(BaseModel):
"""统一调整请求"""
speed: Optional[float] = Field(default=None, ge=0.25, le=4.0)
volume: Optional[float] = Field(default=None, ge=0.0, le=2.0)
trim_start: Optional[float] = Field(default=None, ge=0.0)
trim_end: Optional[float] = Field(default=None, ge=0.0)
class BatchSpeedRequest(BaseModel):
"""批量调速请求"""
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度")
class ClipAdjustResponse(BaseModel):
"""片段调整响应"""
clip_id: str
speed: float
volume: float
trim_start: float
trim_end: float
duration: float
class BatchSpeedResponse(BaseModel):
"""批量调速响应"""
updated_count: int
plan_id: str
# ── Helpers ──────────────────────────────────────────────────────────────────
def _get_clip_config(clip) -> dict:
config = getattr(clip, "config", {}) or {}
if not isinstance(config, dict):
config = {}
return config
def _get_volume(clip) -> float:
config = _get_clip_config(clip)
return float(config.get("volume", 1.0))
def _get_trim(clip) -> tuple[float, float]:
config = _get_clip_config(clip)
trim_start = float(config.get("trim_start", 0.0))
trim_end = float(config.get("trim_end", 0.0))
return trim_start, trim_end
def _build_response(clip) -> ClipAdjustResponse:
trim_start, trim_end = _get_trim(clip)
return ClipAdjustResponse(
clip_id=clip.id,
speed=clip.playback_speed,
volume=_get_volume(clip),
trim_start=trim_start,
trim_end=trim_end,
duration=clip.duration,
)
def _validate_trim(trim_start: float, trim_end: float, total_duration: float) -> None:
"""验证裁剪时长不超过总时长"""
if trim_start + trim_end >= total_duration:
raise ValueError(f"裁剪总时长({trim_start + trim_end:.2f}s)不能大于等于片段总时长({total_duration:.2f}s)")
# ── Routes ───────────────────────────────────────────────────────────────────
def _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository):
svc = EditPlanService(db)
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = svc.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
return svc, plan, clip
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse)
def adjust_speed(
clip_id: str,
body: SpeedAdjustRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""调整片段播放速度"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
updated = svc.update_clip(clip_id, playback_speed=body.speed)
logger.info(
"调整片段速度: clip_id=%s speed=%.2f by user=%s",
clip_id,
body.speed,
current_user.user.id,
)
return _build_response(updated)
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse)
def adjust_volume(
clip_id: str,
body: VolumeAdjustRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""调整片段音量"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
# 更新 config.volume
config = dict(_get_clip_config(clip))
config["volume"] = body.volume
updated = svc.update_clip(clip_id, config=config)
logger.info(
"调整片段音量: clip_id=%s volume=%.2f by user=%s",
clip_id,
body.volume,
current_user.user.id,
)
return _build_response(updated)
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse)
def adjust_trim(
clip_id: str,
body: TrimAdjustRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""裁剪片段(trim in/out)"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
# 验证裁剪时长
try:
_validate_trim(body.trim_start, body.trim_end, clip.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 更新 config
config = dict(_get_clip_config(clip))
config["trim_start"] = body.trim_start
config["trim_end"] = body.trim_end
updated = svc.update_clip(clip_id, config=config)
logger.info(
"裁剪片段: clip_id=%s trim_start=%.2f trim_end=%.2f by user=%s",
clip_id,
body.trim_start,
body.trim_end,
current_user.user.id,
)
return _build_response(updated)
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse)
def adjust_all(
clip_id: str,
body: ClipAdjustmentsRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""统一调整片段的 speed / volume / trim"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
update_kwargs = {}
config_updates = {}
if body.speed is not None:
update_kwargs["playback_speed"] = body.speed
if body.volume is not None:
config_updates["volume"] = body.volume
if body.trim_start is not None:
config_updates["trim_start"] = body.trim_start
if body.trim_end is not None:
config_updates["trim_end"] = body.trim_end
# 验证 trim
current_trim_start, current_trim_end = _get_trim(clip)
new_trim_start = body.trim_start if body.trim_start is not None else current_trim_start
new_trim_end = body.trim_end if body.trim_end is not None else current_trim_end
if body.trim_start is not None or body.trim_end is not None:
try:
_validate_trim(new_trim_start, new_trim_end, clip.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
if config_updates:
config = dict(_get_clip_config(clip))
config.update(config_updates)
update_kwargs["config"] = config
if not update_kwargs:
return _build_response(clip)
updated = svc.update_clip(clip_id, **update_kwargs)
logger.info(
"统一调整片段: clip_id=%s speed=%s volume=%s by user=%s",
clip_id,
body.speed,
body.volume,
current_user.user.id,
)
return _build_response(updated)
@router.post("/{plan_id}/clips/batch-speed", response_model=BatchSpeedResponse)
def batch_adjust_speed(
plan_id: str,
body: BatchSpeedRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> BatchSpeedResponse:
"""批量调整计划内所有片段的播放速度"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id, limit=500, skip=0)
count = 0
for clip in clips:
svc.update_clip(clip.id, playback_speed=body.speed)
count += 1
logger.info(
"批量调速: plan_id=%s count=%d speed=%.2f by user=%s",
plan_id,
count,
body.speed,
current_user.user.id,
)
return BatchSpeedResponse(updated_count=count, plan_id=plan_id)
+4 -4
View File
@@ -58,7 +58,7 @@ def ai_recommend_clips(
try: try:
plan = svc.get_plan_or_raise(plan_id) plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if plan.project_id: if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository) check_project_access(plan.project_id, current_user.user.id, project_repository)
@@ -103,7 +103,7 @@ def ai_recommend_clips(
config=normalized_config, config=normalized_config,
total_duration=result["total_duration"], total_duration=result["total_duration"],
) )
except Exception: except Exception as _e:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id) logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
try: try:
db.rollback() db.rollback()
@@ -116,7 +116,7 @@ def ai_recommend_clips(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试", detail="AI推荐结果保存失败,请稍后重试",
) ) from _e
logger.info( logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s", "AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
@@ -167,7 +167,7 @@ def generate_cover(
try: try:
plan = svc.get_plan_or_raise(plan_id) plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if plan.project_id: if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository) check_project_access(plan.project_id, current_user.user.id, project_repository)
+415
View File
@@ -0,0 +1,415 @@
"""剪辑计划片段(Clip)CRUD 路由。"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.domain.edit_plan_clip import EditPlanClipStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
from pydantic import BaseModel, Field
class EditPlanClipResponse(BaseModel):
"""剪辑片段响应体"""
id: str
plan_id: str
clip_type: str
order: int
asset_id: str = ""
text_content: str = ""
start_time: float = 0.0
duration: float = 0.0
transition_effect: str = "cut"
transition_duration: float = 0.0
playback_speed: float = 1.0
status: str
config: dict[str, Any] = Field(default_factory=dict)
created_at: Optional[str] = None
updated_at: Optional[str] = None
class EditPlanClipListResponse(BaseModel):
"""剪辑片段列表响应体"""
items: List[EditPlanClipResponse]
total: int
class EditPlanClipCreateRequest(BaseModel):
"""创建剪辑片段请求体"""
clip_type: str = Field(
..., min_length=1, max_length=50, description="片段类型: main/intro/outro/overlay/background/b_roll 等"
)
order: int = Field(..., ge=0, description="排序序号")
asset_id: str = Field(default="", max_length=64, description="关联素材 ID")
text_content: str = Field(default="", max_length=5000, description="文本内容(字幕/配音等)")
start_time: float = Field(default=0.0, ge=0.0, description="起始时间 (秒)")
duration: float = Field(default=0.0, ge=0.0, description="时长 (秒)")
transition_effect: str = Field(default="cut", max_length=50, description="转场效果")
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长 (秒)")
playback_speed: float = Field(default=1.0, gt=0.0, le=10.0, description="播放速度倍率")
config: dict[str, Any] = Field(default_factory=dict, description="扩展配置 (JSON)")
class EditPlanClipUpdateRequest(BaseModel):
"""更新剪辑片段请求体"""
clip_type: Optional[str] = Field(default=None, min_length=1, max_length=50, description="片段类型")
order: Optional[int] = Field(default=None, ge=0, description="排序序号")
asset_id: Optional[str] = Field(default=None, max_length=64, description="关联素材 ID")
text_content: Optional[str] = Field(default=None, max_length=5000, description="文本内容")
start_time: Optional[float] = Field(default=None, ge=0.0, description="起始时间 (秒)")
duration: Optional[float] = Field(default=None, ge=0.0, description="时长 (秒)")
transition_effect: Optional[str] = Field(default=None, max_length=50, description="转场效果")
transition_duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长 (秒)")
playback_speed: Optional[float] = Field(default=None, gt=0.0, le=10.0, description="播放速度倍率")
config: Optional[dict[str, Any]] = Field(default=None, description="扩展配置 (JSON)")
# ── Helpers ──────────────────────────────────────────────────────────────────
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
"""验证用户是否有权限访问该剪辑计划(通过项目关联)。
返回 plan 对象供后续使用,避免重复查询。
"""
from app.services.edit_plan_service import EditPlanService
from ._helpers import check_project_access
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if plan is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan.project_id:
check_project_access(plan.project_id, user_id, project_repository)
return plan
def _clip_to_response(clip) -> EditPlanClipResponse:
"""将领域对象转换为响应体"""
return EditPlanClipResponse(
id=clip.id,
plan_id=clip.plan_id,
clip_type=clip.clip_type,
order=clip.order,
asset_id=clip.asset_id or "",
text_content=clip.text_content or "",
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=clip.transition_duration or 0.0,
playback_speed=clip.playback_speed or 1.0,
status=clip.status.value if hasattr(clip.status, "value") else str(clip.status),
config=clip.config or {},
created_at=clip.created_at.isoformat() if clip.created_at else None,
updated_at=clip.updated_at.isoformat() if clip.updated_at else None,
)
def _get_svc(db: Session):
"""获取 EditPlanService 实例"""
from app.services.edit_plan_service import EditPlanService
return EditPlanService(db)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanClipListResponse)
def list_clips(
plan_id: str,
status_filter: Optional[str] = Query(None, alias="status", description="按状态过滤"),
skip: int = Query(0, ge=0, description="分页偏移"),
limit: int = Query(100, ge=1, le=500, description="每页数量"),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipListResponse:
"""获取剪辑计划的片段列表"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
status_enum = EditPlanClipStatus(status_filter) if status_filter else None
clips = svc.list_clips(plan_id, status=status_enum, skip=skip, limit=limit)
total = svc.count_clips(plan_id, status=status_enum)
return EditPlanClipListResponse(
items=[_clip_to_response(c) for c in clips],
total=total,
)
@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED)
def create_clip(
plan_id: str,
body: EditPlanClipCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""创建剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
try:
clip = svc.create_clip(
plan_id=plan_id,
clip_type=body.clip_type,
order=body.order,
asset_id=body.asset_id,
text_content=body.text_content,
start_time=body.start_time,
duration=body.duration,
transition_effect=body.transition_effect,
transition_duration=body.transition_duration,
playback_speed=body.playback_speed,
config=body.config,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info("创建剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip.id, current_user.user.id)
return _clip_to_response(clip)
@router.get("/{clip_id}", response_model=EditPlanClipResponse)
def get_clip(
plan_id: str,
clip_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""获取剪辑片段详情"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
return _clip_to_response(clip)
@router.put("/{clip_id}", response_model=EditPlanClipResponse)
def update_clip(
plan_id: str,
clip_id: str,
body: EditPlanClipUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""更新剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证 clip 属于该 plan
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
try:
updated = svc.update_clip(
clip_id,
clip_type=body.clip_type,
order=body.order,
asset_id=body.asset_id,
text_content=body.text_content,
start_time=body.start_time,
duration=body.duration,
transition_effect=body.transition_effect,
transition_duration=body.transition_duration,
playback_speed=body.playback_speed,
config=body.config,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info("更新剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return _clip_to_response(updated)
@router.delete("/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_clip(
plan_id: str,
clip_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> None:
"""删除剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证 clip 属于该 plan
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
deleted = svc.delete_clip(clip_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return None
# ── 片段分割与合并 ──────────────────────────────────────────────────────────
class SplitClipRequest(BaseModel):
"""分割片段请求体"""
split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)")
class MergeClipsRequest(BaseModel):
"""合并片段请求体"""
clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表")
@router.post(
"/{clip_id}/split",
response_model=dict[str, Any],
summary="分割片段",
status_code=status.HTTP_200_OK,
)
def split_clip(
plan_id: str,
clip_id: str,
body: SplitClipRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repository: Any = Depends(get_project_repository),
) -> dict[str, Any]:
"""将一个片段从指定时间点分割为两个片段。
分割后原片段变为左半部分,新增右半部分片段,后续片段顺序自动后移。
若片段有关联素材,会自动设置 trim_start/trim_end 标记裁剪范围。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
clip = svc.get_clip(clip_id)
if clip is None or clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
try:
result = svc.split_clip(clip_id, body.split_time)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
left = result["left_clip"]
right = result["right_clip"]
logger.info("分割片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return {
"left_clip": {
"id": left.id,
"plan_id": left.plan_id,
"clip_type": left.clip_type,
"order": left.order,
"duration": left.duration,
"start_time": left.start_time,
},
"right_clip": {
"id": right.id,
"plan_id": right.plan_id,
"clip_type": right.clip_type,
"order": right.order,
"duration": right.duration,
"start_time": right.start_time,
},
}
@router.post(
"/merge",
response_model=dict[str, Any],
summary="合并多个连续片段",
status_code=status.HTTP_200_OK,
)
def merge_clips(
plan_id: str,
body: MergeClipsRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repository: Any = Depends(get_project_repository),
) -> dict[str, Any]:
"""将多个连续的同类型片段合并为一个片段。
合并要求:
- 至少 2 个片段
- 属于同一剪辑计划
- order 连续
- 类型相同
合并后保留第一个片段,其余删除,后续片段顺序自动前移。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 校验所有片段都属于该 plan
for cid in body.clip_ids:
clip = svc.get_clip(cid)
if clip is None or clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {cid}",
)
try:
merged = svc.merge_clips(body.clip_ids)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
logger.info(
"合并片段: plan_id=%s clip_count=%d by user=%s",
plan_id,
len(body.clip_ids),
current_user.user.id,
)
return {
"id": merged.id,
"plan_id": merged.plan_id,
"clip_type": merged.clip_type,
"order": merged.order,
"duration": merged.duration,
"text_content": merged.text_content,
}
+241
View File
@@ -0,0 +1,241 @@
"""剪辑计划片段批量操作 API。"""
from __future__ import annotations
import logging
from typing import Any, List
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class ClipReorderItem(BaseModel):
"""重排序条目"""
clip_id: str
new_order: int = Field(..., ge=0, description="新的排序序号")
class ClipReorderRequest(BaseModel):
"""片段重排序请求"""
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
class ClipReorderResponse(BaseModel):
"""片段重排序响应"""
success: bool
updated_count: int
message: str = ""
class ClipBatchDeleteRequest(BaseModel):
"""批量删除片段请求"""
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
class ClipBatchDeleteResponse(BaseModel):
"""批量删除片段响应"""
success: bool
deleted_count: int
message: str = ""
class ClipsFromAssetsRequest(BaseModel):
"""从素材批量创建片段请求"""
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
clip_type: str = Field(default="main", description="片段类型,默认 main")
class ClipsFromAssetsResponse(BaseModel):
"""从素材批量创建片段响应"""
success: bool
created_count: int
message: str = ""
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
# ── Helpers ──────────────────────────────────────────────────────────────────
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
"""验证用户是否有权限访问该剪辑计划,返回 plan 对象。"""
from app.services.edit_plan_service import EditPlanService
from ._helpers import check_project_access
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if plan is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan.project_id:
check_project_access(plan.project_id, user_id, project_repository)
return plan
def _get_svc(db: Session):
"""获取 EditPlanService 实例"""
from app.services.edit_plan_service import EditPlanService
return EditPlanService(db)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.post("/reorder", response_model=ClipReorderResponse)
def reorder_clips(
plan_id: str,
body: ClipReorderRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipReorderResponse:
"""批量重排序片段
前端拖拽调整顺序后,一次性提交所有变更的 order。
自动触发编辑状态回退(从 completed/failed 切回 editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证所有 clip 都属于该 plan
clip_ids = [item.clip_id for item in body.items]
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
existing_ids = {c.id for c in existing_clips}
invalid_ids = [cid for cid in clip_ids if cid not in existing_ids]
if invalid_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"以下片段不属于该计划: {', '.join(invalid_ids[:5])}",
)
# 执行重排序
updated_count = 0
for item in body.items:
try:
svc.update_clip(item.clip_id, order=item.new_order)
updated_count += 1
except ValueError as e:
logger.warning("重排序片段失败: clip_id=%s error=%s", item.clip_id, e)
logger.info(
"批量重排序片段: plan_id=%s count=%d by user=%s",
plan_id,
updated_count,
current_user.user.id,
)
return ClipReorderResponse(
success=True,
updated_count=updated_count,
message=f"成功更新 {updated_count} 个片段的顺序",
)
@router.post("/batch-delete", response_model=ClipBatchDeleteResponse)
def batch_delete_clips(
plan_id: str,
body: ClipBatchDeleteRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipBatchDeleteResponse:
"""批量删除片段
自动触发编辑状态回退(从 completed/failed 切回 editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证所有 clip 都属于该 plan
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
existing_ids = {c.id for c in existing_clips}
valid_ids = [cid for cid in body.clip_ids if cid in existing_ids]
skipped = len(body.clip_ids) - len(valid_ids)
# 执行删除
deleted_count = 0
for clip_id in valid_ids:
if svc.delete_clip(clip_id):
deleted_count += 1
message = f"成功删除 {deleted_count} 个片段"
if skipped > 0:
message += f",跳过 {skipped} 个不存在的片段"
logger.info(
"批量删除片段: plan_id=%s deleted=%d skipped=%d by user=%s",
plan_id,
deleted_count,
skipped,
current_user.user.id,
)
return ClipBatchDeleteResponse(
success=True,
deleted_count=deleted_count,
message=message,
)
@router.post("/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets(
plan_id: str,
body: ClipsFromAssetsRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipsFromAssetsResponse:
"""从素材批量创建片段(追加到时间线末尾)
一次性将多个素材作为片段添加到剪辑计划,自动读取素材时长。
自动触发编辑状态回退(completed/failed → editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
try:
clips = svc.create_clips_from_assets(
plan_id=plan_id,
asset_ids=body.asset_ids,
clip_type=body.clip_type,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
clip_ids = [c.id for c in clips]
logger.info(
"从素材批量创建片段: plan_id=%s count=%d by user=%s",
plan_id,
len(clips),
current_user.user.id,
)
return ClipsFromAssetsResponse(
success=True,
created_count=len(clips),
message=f"成功创建 {len(clips)} 个片段",
clip_ids=clip_ids,
)
+315
View File
@@ -0,0 +1,315 @@
"""封面管理 API.
- GET /{plan_id}/cover 获取封面配置
- PUT /{plan_id}/cover 更新封面配置
- POST /{plan_id}/cover/extract 从指定片段抽帧生成封面
- POST /{plan_id}/cover/smart 智能选帧生成封面
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_repository,
get_db_session,
get_project_repository,
)
from app.services import EditPlanService
from app.services.cover_service import CoverService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class CoverConfigResponse(BaseModel):
"""封面配置响应"""
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
image_url: str = Field(default="", description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
class CoverUpdateRequest(BaseModel):
"""更新封面配置请求"""
type: Optional[str] = Field(default=None, description="封面类型")
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
class CoverExtractRequest(BaseModel):
"""从片段抽帧生成封面请求"""
clip_id: str = Field(..., description="片段 ID")
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
class CoverSmartRequest(BaseModel):
"""智能选帧请求"""
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
class CoverGenerateResponse(BaseModel):
"""封面生成响应"""
type: str = Field(..., description="封面类型")
image_url: str = Field(..., description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/{plan_id}/cover", response_model=CoverConfigResponse)
def get_cover(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> CoverConfigResponse:
"""获取封面配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
cover = CoverService.get_cover_config(plan.config or {})
return CoverConfigResponse(**cover)
@router.put("/{plan_id}/cover", response_model=CoverConfigResponse)
def update_cover(
plan_id: str,
body: CoverUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> CoverConfigResponse:
"""更新封面配置
用于:设置上传的封面图片 URL、切换封面类型、调整时间点等。
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 合并更新
current_cover = CoverService.get_cover_config(plan.config or {})
updates = body.model_dump(exclude_none=True)
new_cover = {**current_cover, **updates}
# 验证 type 值
valid_types = {"ai_frame", "manual", "upload", "ai_regenerate"}
if "type" in updates and updates["type"] not in valid_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的封面类型: {updates['type']},有效值: {valid_types}",
)
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["cover"] = new_cover
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
result = CoverService.get_cover_config(updated_plan.config or {})
logger.info("更新封面配置: plan_id=%s type=%s by user=%s", plan_id, result["type"], current_user.user.id)
return CoverConfigResponse(**result)
@router.post("/{plan_id}/cover/extract", response_model=CoverGenerateResponse)
def extract_cover(
plan_id: str,
body: CoverExtractRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
storage_service: Any = Depends(get_storage_service),
asset_repository: Any = Depends(get_asset_repository),
) -> CoverGenerateResponse:
"""从指定片段的指定时间点抽帧生成封面"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 获取片段对应的素材
clip = svc.get_clip(body.clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {body.clip_id}",
)
if clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段不属于该剪辑计划",
)
if not clip.asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段没有关联素材,无法抽帧",
)
# 抽帧生成封面
cover_svc = CoverService(storage_service, asset_repository)
try:
cover_data = cover_svc.extract_cover_from_clip(
plan_id=plan_id,
asset_id=clip.asset_id,
frame_time=body.frame_time,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
except RuntimeError as e:
logger.error("封面抽帧失败: plan_id=%s clip_id=%s error=%s", plan_id, body.clip_id, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"封面抽帧失败: {e}",
) from e
# 更新到 plan.config.cover
current_config = dict(plan.config or {})
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"封面抽帧完成: plan_id=%s clip_id=%s time=%.2fs by user=%s",
plan_id,
body.clip_id,
body.frame_time,
current_user.user.id,
)
return CoverGenerateResponse(**cover_data)
@router.post("/{plan_id}/cover/smart", response_model=CoverGenerateResponse)
def smart_cover(
plan_id: str,
body: CoverSmartRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
storage_service: Any = Depends(get_storage_service),
asset_repository: Any = Depends(get_asset_repository),
) -> CoverGenerateResponse:
"""智能选帧生成封面
从指定片段(或第一个视频片段)中智能选取一帧作为封面。
当前实现:取片段第3秒帧(后续可优化为多帧选最清晰)。
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 确定使用哪个片段
clip_id = body.clip_id
asset_id = ""
if clip_id:
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
if clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段不属于该剪辑计划",
)
if not clip.asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段没有关联素材",
)
asset_id = clip.asset_id
else:
# 找第一个有素材的视频片段
clips = svc.list_clips(plan_id, limit=50, skip=0)
for c in clips:
if c.asset_id and c.clip_type == "video":
asset_id = c.asset_id
clip_id = c.id
break
if not asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="没有找到可用的视频片段",
)
# 智能选帧
cover_svc = CoverService(storage_service, asset_repository)
try:
cover_data = cover_svc.generate_smart_cover(
plan_id=plan_id,
asset_id=asset_id,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
except RuntimeError as e:
logger.error("智能封面生成失败: plan_id=%s error=%s", plan_id, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"智能封面生成失败: {e}",
) from e
# 更新到 plan.config.cover
current_config = dict(plan.config or {})
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"智能封面生成完成: plan_id=%s clip_id=%s by user=%s",
plan_id,
clip_id,
current_user.user.id,
)
return CoverGenerateResponse(**cover_data)
+274
View File
@@ -0,0 +1,274 @@
"""导出设置 API.
- GET /{plan_id}/export 获取导出配置
- PUT /{plan_id}/export 更新导出配置
- GET /export-presets 导出预设列表
"""
from __future__ import annotations
import logging
import re
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, validator
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── 导出预设 ──────────────────────────────────────────────────────────────────
EXPORT_PRESETS = [
{
"id": "export_1080p_30",
"name": "1080P 高清",
"resolution": "1080x1920",
"fps": 30,
"video_bitrate": 8000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "balanced",
"description": "竖屏高清,适合短视频平台",
"size_hint": "约 10MB/分钟",
},
{
"id": "export_1080p_60",
"name": "1080P 高帧率",
"resolution": "1080x1920",
"fps": 60,
"video_bitrate": 12000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "high",
"description": "60帧高帧率,流畅运动画面",
"size_hint": "约 18MB/分钟",
},
{
"id": "export_720p_30",
"name": "720P 流畅",
"resolution": "720x1280",
"fps": 30,
"video_bitrate": 4000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "fast",
"description": "快速导出,文件较小",
"size_hint": "约 5MB/分钟",
},
{
"id": "export_4k_30",
"name": "4K 超清",
"resolution": "2160x3840",
"fps": 30,
"video_bitrate": 20000,
"audio_bitrate": 192,
"format": "mp4",
"quality_preset": "best",
"description": "4K超清画质,专业品质",
"size_hint": "约 30MB/分钟",
},
{
"id": "export_1080p_30_mov",
"name": "1080P ProRes",
"resolution": "1080x1920",
"fps": 30,
"video_bitrate": 15000,
"audio_bitrate": 256,
"format": "mov",
"quality_preset": "high",
"description": "MOV格式,适合后期剪辑",
"size_hint": "约 25MB/分钟",
},
]
VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
VALID_FORMATS = {"mp4", "mov"}
RESOLUTION_PATTERN = re.compile(r"^\d+x\d+$")
# ── Schemas ──────────────────────────────────────────────────────────────────
class ExportConfigResponse(BaseModel):
"""导出配置响应"""
resolution: str
fps: int
video_bitrate: int
audio_bitrate: int
format: str
quality_preset: str
watermark_enabled: bool
watermark_text: str
class ExportUpdateRequest(BaseModel):
"""更新导出配置请求"""
resolution: Optional[str] = None
fps: Optional[int] = Field(default=None, ge=15, le=60)
video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000)
audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320)
format: Optional[str] = None
quality_preset: Optional[str] = None
watermark_enabled: Optional[bool] = None
watermark_text: Optional[str] = None
@validator("resolution")
def validate_resolution(cls, v):
if v is None:
return v
if not RESOLUTION_PATTERN.match(v):
raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920")
w, h = v.split("x")
if int(w) < 100 or int(h) < 100:
raise ValueError("分辨率数值过小")
if int(w) > 4096 or int(h) > 4096:
raise ValueError("分辨率数值过大,最大 4096x4096")
return v
@validator("format")
def validate_format(cls, v):
if v is None:
return v
if v not in VALID_FORMATS:
raise ValueError(f"无效格式: {v},支持: {VALID_FORMATS}")
return v
@validator("quality_preset")
def validate_quality_preset(cls, v):
if v is None:
return v
if v not in VALID_QUALITY_PRESETS:
raise ValueError(f"无效质量预设: {v},支持: {VALID_QUALITY_PRESETS}")
return v
class ExportPresetItem(BaseModel):
"""导出预设条目"""
id: str
name: str
resolution: str
fps: int
video_bitrate: int
audio_bitrate: int
format: str
quality_preset: str
description: str
size_hint: str
class ExportPresetListResponse(BaseModel):
"""导出预设列表响应"""
items: List[ExportPresetItem]
total: int
# ── Helpers ──────────────────────────────────────────────────────────────────
def _get_export_config(plan_config: dict) -> dict:
e = plan_config.get("export", {})
if not isinstance(e, dict):
e = {}
return {
"resolution": e.get("resolution", "1080x1920"),
"fps": e.get("fps", 30),
"video_bitrate": e.get("video_bitrate", 8000),
"audio_bitrate": e.get("audio_bitrate", 128),
"format": e.get("format", "mp4"),
"quality_preset": e.get("quality_preset", "balanced"),
"watermark_enabled": e.get("watermark_enabled", False),
"watermark_text": e.get("watermark_text", ""),
}
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/export-presets", response_model=ExportPresetListResponse)
def list_export_presets(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> ExportPresetListResponse:
"""获取导出预设列表"""
items = [ExportPresetItem(**p) for p in EXPORT_PRESETS]
return ExportPresetListResponse(items=items, total=len(items))
@router.get("/{plan_id}/export", response_model=ExportConfigResponse)
def get_export_config(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ExportConfigResponse:
"""获取导出配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
config = _get_export_config(plan.config or {})
return ExportConfigResponse(**config)
@router.put("/{plan_id}/export", response_model=ExportConfigResponse)
def update_export_config(
plan_id: str,
body: ExportUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ExportConfigResponse:
"""更新导出配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 合并更新
current = _get_export_config(plan.config or {})
updates = body.model_dump(exclude_none=True)
new_export = {**current, **updates}
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["export"] = new_export
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"export": normalized["export"]})
result = _get_export_config(updated_plan.config or {})
logger.info(
"更新导出配置: plan_id=%s resolution=%s fps=%d by user=%s",
plan_id,
result["resolution"],
result["fps"],
current_user.user.id,
)
return ExportConfigResponse(**result)
+197
View File
@@ -0,0 +1,197 @@
"""滤镜调色 API.
- GET /filter-presets 滤镜预设列表
- GET /{plan_id}/filter 获取全局滤镜配置
- PUT /{plan_id}/filter 更新全局滤镜配置
"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.filter_presets import (
FilterPreset,
get_filter_preset,
list_filter_presets,
)
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class FilterPresetResponse(BaseModel):
"""滤镜预设响应"""
id: str
name: str
category: str
description: str
tags: List[str] = Field(default_factory=list)
class FilterConfigResponse(BaseModel):
"""滤镜配置响应"""
enabled: bool
preset_id: str
intensity: int
brightness: float
contrast: float
saturation: float
warmth: float
class FilterUpdateRequest(BaseModel):
"""更新滤镜配置请求"""
enabled: Optional[bool] = None
preset_id: Optional[str] = None
intensity: Optional[int] = Field(default=None, ge=0, le=100)
brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0)
saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0)
warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
class FilterPresetListResponse(BaseModel):
"""滤镜预设列表响应"""
items: List[FilterPresetResponse]
total: int
# ── Helpers ──────────────────────────────────────────────────────────────────
def _preset_to_response(p: FilterPreset) -> FilterPresetResponse:
return FilterPresetResponse(
id=p.id,
name=p.name,
category=p.category,
description=p.description,
tags=list(p.tags),
)
def _get_filter_config(plan_config: dict) -> dict:
"""从 plan.config 中提取滤镜配置"""
f = plan_config.get("filter", {})
if not isinstance(f, dict):
f = {}
return {
"enabled": f.get("enabled", False),
"preset_id": f.get("preset_id", "filter_none"),
"intensity": f.get("intensity", 100),
"brightness": f.get("brightness", 0.0),
"contrast": f.get("contrast", 1.0),
"saturation": f.get("saturation", 1.0),
"warmth": f.get("warmth", 0.0),
}
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/filter-presets", response_model=FilterPresetListResponse)
def list_presets(
category: Optional[str] = Query(default=None, description="按分类筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> FilterPresetListResponse:
"""获取滤镜预设列表"""
presets = list_filter_presets(category=category, keyword=keyword)
items = [_preset_to_response(p) for p in presets]
return FilterPresetListResponse(items=items, total=len(items))
@router.get("/{plan_id}/filter", response_model=FilterConfigResponse)
def get_filter(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> FilterConfigResponse:
"""获取剪辑计划的全局滤镜配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
config = _get_filter_config(plan.config or {})
return FilterConfigResponse(**config)
@router.put("/{plan_id}/filter", response_model=FilterConfigResponse)
def update_filter(
plan_id: str,
body: FilterUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> FilterConfigResponse:
"""更新全局滤镜配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证 preset_id
updates = body.model_dump(exclude_none=True)
if "preset_id" in updates:
preset = get_filter_preset(updates["preset_id"])
if preset is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的滤镜预设: {updates['preset_id']}",
)
# 合并更新
current = _get_filter_config(plan.config or {})
new_filter = {**current, **updates}
# 如果设为原图 preset,自动关闭
if new_filter["preset_id"] == "filter_none":
new_filter["enabled"] = False
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["filter"] = new_filter
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"filter": normalized["filter"]})
result = _get_filter_config(updated_plan.config or {})
logger.info(
"更新滤镜配置: plan_id=%s preset=%s intensity=%d by user=%s",
plan_id,
result["preset_id"],
result["intensity"],
current_user.user.id,
)
return FilterConfigResponse(**result)
+42 -13
View File
@@ -20,6 +20,7 @@ from app.api.routes.edit_plans import (
) )
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.services import EditPlanService from app.services import EditPlanService
@@ -131,17 +132,21 @@ def _auto_fallback_auto_material_mode(
asset_library_repo: Any, asset_library_repo: Any,
asset_repo: Any, asset_repo: Any,
) -> None: ) -> None:
"""自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取""" """自动兜底 4: 项目有视频素材库时,自动选取 ready 视频素材分配给无素材片段
注:原先需要 material_mode=="auto" 才触发,但全代码库没有任何地方设置为 auto,
导致这道兜底防线永远不生效。现改为:只要有 project_id 且存在无素材片段,
就自动从项目视频素材库选取素材兜底,确保一键生成等场景能正常出片。
"""
if not clips_without_asset: if not clips_without_asset:
return return
material_mode = (plan_check.config or {}).get("material_mode", "manual") if not plan_check.project_id:
if material_mode != "auto" or not plan_check.project_id:
return return
import random import random
logger.info( logger.info(
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)", "自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段",
plan_id, plan_id,
len(clips_without_asset), len(clips_without_asset),
) )
@@ -183,9 +188,7 @@ def _auto_fallback_auto_material_mode(
def _check_queue_limits(gen_task_repo, user_id: str) -> None: def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查""" """队列限流预检查"""
try: try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr( has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(gen_task_repo, "count_pending_total")
gen_task_repo, "count_pending_total"
)
if has_count: if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id) user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total() global_pending = gen_task_repo.count_pending_total()
@@ -241,7 +244,7 @@ def generate_plan(
try: try:
can_gen, reason = svc.can_generate(plan_id) can_gen, reason = svc.can_generate(plan_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if not can_gen: if not can_gen:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
@@ -255,12 +258,15 @@ def generate_plan(
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo) gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id) plan = svc.get_plan_or_raise(plan_id)
# 从 plan.config 中读取 asset_ids 并传递给 GenerationTask
config_asset_ids = (plan.config or {}).get("asset_ids", [])
gen_task = gen_task_use_case.execute( gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand( CreateGenerationTaskCommand(
project_id="", project_id=plan.project_id or "",
template_id=plan.template_id, template_id=plan.template_id,
created_by_user_id=current_user.user.id, created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id, source_edit_plan_id=plan_id,
asset_ids=list(config_asset_ids) if config_asset_ids else [],
) )
) )
@@ -286,7 +292,7 @@ def generate_plan(
) )
except HTTPException: except HTTPException:
raise raise
except Exception: except Exception as _e:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id) logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
try: try:
svc.transition_status(plan_id, EditPlanStatus.FAILED) svc.transition_status(plan_id, EditPlanStatus.FAILED)
@@ -295,7 +301,7 @@ def generate_plan(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试", detail="生成失败,请稍后重试",
) ) from _e
@router.get( @router.get(
@@ -307,13 +313,14 @@ def get_generation_status(
db: Session = Depends(get_db_session), db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository), project_repository: Any = Depends(get_project_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> EditPlanGenerationStatusResponse: ) -> EditPlanGenerationStatusResponse:
"""查询剪辑计划生成进度""" """查询剪辑计划生成进度"""
svc = EditPlanService(db) svc = EditPlanService(db)
try: try:
gen_status = svc.get_generation_status(plan_id) gen_status = svc.get_generation_status(plan_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
plan = gen_status["plan"] plan = gen_status["plan"]
if plan.project_id: if plan.project_id:
@@ -333,10 +340,32 @@ def get_generation_status(
for c in clips for c in clips
] ]
# 从 plan.config 中取渲染结果 URL,转换为签名 URL
raw_video_url = (plan.config or {}).get("rendered_url", "")
video_url = ""
if raw_video_url:
try:
video_url = storage_service.get_download_url(raw_video_url, expires_seconds=86400)
except Exception as e:
logger.warning("生成视频签名URL失败,返回原始URL: plan_id=%s error=%s", plan_id, e)
video_url = raw_video_url
# 从 gen_status 中取进度、错误信息、任务状态
progress = gen_status.get("progress", 0.0)
error_message = gen_status.get("error_message", "")
gen_task_status = gen_status.get("generation_task_status")
# 如果计划已完成但进度还是0,补100
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status_val == "completed" and progress < 100:
progress = 100.0
return EditPlanGenerationStatusResponse( return EditPlanGenerationStatusResponse(
plan_id=plan_id, plan_id=plan_id,
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status, plan_status=plan_status_val,
generation_task_id=gen_status["generation_task_id"], generation_task_id=gen_status["generation_task_id"],
generation_task_status=gen_task_status,
progress=progress,
video_url=video_url,
error_message=error_message,
clips=clip_items, clips=clip_items,
) )
+42 -6
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging import logging
from typing import Any, List from typing import Any, List
from app.api.routes._helpers import check_project_access from app.api.routes._helpers import auto_select_video_assets, check_project_access
from app.api.routes.edit_plans import ( from app.api.routes.edit_plans import (
GenerateFromTemplateRequest, GenerateFromTemplateRequest,
GenerateFromTemplateResponse, GenerateFromTemplateResponse,
@@ -18,9 +18,14 @@ from app.api.routes.edit_plans import (
_to_response, _to_response,
) )
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
get_project_repository,
)
from app.services import EditPlanService, PlanGeneratorService from app.services import EditPlanService, PlanGeneratorService
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -161,6 +166,8 @@ def generate_from_template(
db: Session = Depends(get_db_session), db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository), project_repository: Any = Depends(get_project_repository),
asset_repository: Any = Depends(get_asset_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
) -> GenerateFromTemplateResponse: ) -> GenerateFromTemplateResponse:
"""基于模板 + 素材自动生成剪辑计划""" """基于模板 + 素材自动生成剪辑计划"""
from app.services import EditTemplateService from app.services import EditTemplateService
@@ -173,15 +180,32 @@ def generate_from_template(
try: try:
template = template_svc.get_template_or_raise(body.template_id) template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200) clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
# 自动选素材:未传 asset_ids 但有 project_id 时,从项目视频素材库选 ready 的视频素材
resolved_asset_ids = list(body.asset_ids)
if not resolved_asset_ids and body.project_id:
auto_assets = auto_select_video_assets(
project_id=body.project_id,
asset_library_repo=asset_library_repository,
asset_repo=asset_repository,
logger=logger,
)
if auto_assets:
resolved_asset_ids = auto_assets
logger.info(
"generate-from-template 自动选素材: project_id=%s count=%d",
body.project_id,
len(auto_assets),
)
generator = PlanGeneratorService(db) generator = PlanGeneratorService(db)
result = generator.generate_from_template( result = generator.generate_from_template(
template=template, template=template,
clip_configs=clip_configs, clip_configs=clip_configs,
asset_ids=body.asset_ids, asset_ids=resolved_asset_ids,
project_id=body.project_id, project_id=body.project_id,
created_by_user_id=current_user.user.id, created_by_user_id=current_user.user.id,
name=body.name, name=body.name,
@@ -190,8 +214,20 @@ def generate_from_template(
plan = result["plan"] plan = result["plan"]
clips = result["clips"] clips = result["clips"]
# 把 asset_ids 写入 plan.config,供生成时兜底分配使用
if resolved_asset_ids:
from app.services import EditPlanService
from packages.domain.config_schemas import normalize_plan_config
svc = EditPlanService(db)
current_config = plan.config or {}
if current_config.get("asset_ids") != resolved_asset_ids:
current_config["asset_ids"] = resolved_asset_ids
plan = svc.update_plan(plan.id, config=normalize_plan_config(current_config))
logger.info( logger.info(
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s", "基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%d",
plan.id, plan.id,
body.template_id, body.template_id,
len(clips), len(clips),
+272
View File
@@ -0,0 +1,272 @@
"""转场特效 API.
- GET /transition-presets 转场预设列表
- PUT /clips/{clip_id}/transition 设置单个片段转场
- POST /{plan_id}/transitions/batch 批量设置转场(所有片段)
"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.transition_presets import (
TransitionPreset,
get_transition_preset,
list_transition_presets,
)
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class TransitionPresetResponse(BaseModel):
"""转场预设响应"""
id: str
name: str
category: str
description: str
tags: List[str] = Field(default_factory=list)
default_duration: float
min_duration: float
max_duration: float
class TransitionUpdateRequest(BaseModel):
"""更新转场请求"""
effect: str = Field(..., description="转场效果 ID")
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
class BatchTransitionRequest(BaseModel):
"""批量设置转场请求"""
effect: str = Field(..., description="转场效果 ID")
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
apply_to: str = Field(
default="all",
description="应用范围: all=所有片段, except_first=除第一个外, except_last=除最后一个, middle=中间片段",
)
class ClipTransitionResponse(BaseModel):
"""片段转场信息响应"""
clip_id: str
effect: str
duration: float
class BatchTransitionResponse(BaseModel):
"""批量转场响应"""
updated_count: int
plan_id: str
class TransitionPresetListResponse(BaseModel):
"""转场预设列表响应"""
items: List[TransitionPresetResponse]
total: int
# ── Helpers ──────────────────────────────────────────────────────────────────
def _preset_to_response(p: TransitionPreset) -> TransitionPresetResponse:
return TransitionPresetResponse(
id=p.id,
name=p.name,
category=p.category,
description=p.description,
tags=list(p.tags),
default_duration=p.default_duration,
min_duration=p.min_duration,
max_duration=p.max_duration,
)
def _validate_transition(effect: str, duration: Optional[float] = None) -> tuple[str, float]:
"""验证转场效果和时长,返回 (effect, duration)"""
preset = get_transition_preset(effect)
if preset is None:
raise ValueError(f"无效的转场效果: {effect}")
# 硬切特殊处理,时长强制为0
if effect == "transition_none" or preset.transition == "none":
return "cut", 0.0
final_duration = duration if duration is not None else preset.default_duration
if final_duration < preset.min_duration:
final_duration = preset.min_duration
if final_duration > preset.max_duration:
final_duration = preset.max_duration
return preset.transition, round(final_duration, 3)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/transition-presets", response_model=TransitionPresetListResponse)
def list_presets(
category: Optional[str] = Query(default=None, description="按分类筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> TransitionPresetListResponse:
"""获取转场预设列表"""
presets = list_transition_presets(category=category, keyword=keyword)
items = [_preset_to_response(p) for p in presets]
return TransitionPresetListResponse(items=items, total=len(items))
@router.put("/clips/{clip_id}/transition", response_model=ClipTransitionResponse)
def update_clip_transition(
clip_id: str,
body: TransitionUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipTransitionResponse:
"""设置单个片段的转场效果"""
svc = EditPlanService(db)
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = svc.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证转场参数
try:
effect, duration = _validate_transition(body.effect, body.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 更新片段
updated_clip = svc.update_clip(
clip_id,
transition_effect=effect,
transition_duration=duration,
)
logger.info(
"更新片段转场: clip_id=%s effect=%s duration=%.3f by user=%s",
clip_id,
effect,
duration,
current_user.user.id,
)
return ClipTransitionResponse(
clip_id=clip_id,
effect=updated_clip.transition_effect,
duration=updated_clip.transition_duration,
)
@router.post("/{plan_id}/transitions/batch", response_model=BatchTransitionResponse)
def batch_update_transitions(
plan_id: str,
body: BatchTransitionRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> BatchTransitionResponse:
"""批量设置计划内所有片段的转场效果
apply_to 说明:
- all: 所有片段
- except_first: 除第一个片段外(第一个片段不需要前转场)
- except_last: 除最后一个片段外
- middle: 只设置中间片段(除首尾)
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证转场参数
try:
effect, duration = _validate_transition(body.effect, body.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 获取所有片段
clips = svc.list_clips(plan_id, limit=500, skip=0)
if not clips:
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
# 确定应用范围
total = len(clips)
if total <= 1:
# 只有一个片段时,只有 all 模式才应用
if body.apply_to != "all":
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
# 按 order 排序
clips_sorted = sorted(clips, key=lambda c: c.order)
indices_to_update = []
if body.apply_to == "all":
indices_to_update = list(range(total))
elif body.apply_to == "except_first":
indices_to_update = list(range(1, total))
elif body.apply_to == "except_last":
indices_to_update = list(range(total - 1))
elif body.apply_to == "middle":
if total <= 2:
indices_to_update = []
else:
indices_to_update = list(range(1, total - 1))
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的 apply_to: {body.apply_to}",
)
# 批量更新
count = 0
for idx in indices_to_update:
clip = clips_sorted[idx]
svc.update_clip(
clip.id,
transition_effect=effect,
transition_duration=duration,
)
count += 1
logger.info(
"批量更新转场: plan_id=%s count=%d effect=%s apply_to=%s by user=%s",
plan_id,
count,
effect,
body.apply_to,
current_user.user.id,
)
return BatchTransitionResponse(updated_count=count, plan_id=plan_id)
+6 -6
View File
@@ -105,7 +105,7 @@ async def list_feature_flags(
return sorted(result, key=lambda x: x.name) return sorted(result, key=lambda x: x.name)
except Exception as exc: except Exception as exc:
logger.error("Failed to list feature flags: %s", exc) logger.error("Failed to list feature flags: %s", exc)
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}") raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}") from exc
@router.get("/{name}", response_model=FeatureFlagResponse) @router.get("/{name}", response_model=FeatureFlagResponse)
@@ -120,7 +120,7 @@ async def get_feature_flag(
return FeatureFlagResponse.from_config(config) return FeatureFlagResponse.from_config(config)
except Exception as exc: except Exception as exc:
logger.error("Failed to get feature flag %s: %s", name, exc) logger.error("Failed to get feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}") raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}") from exc
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse) @router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
@@ -136,7 +136,7 @@ async def check_feature_flag(
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier) return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
except Exception as exc: except Exception as exc:
logger.error("Failed to check feature flag %s: %s", name, exc) logger.error("Failed to check feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}") raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}") from exc
@router.put("/{name}", response_model=FeatureFlagResponse) @router.put("/{name}", response_model=FeatureFlagResponse)
@@ -170,7 +170,7 @@ async def update_feature_flag(
return FeatureFlagResponse.from_config(config) return FeatureFlagResponse.from_config(config)
except Exception as exc: except Exception as exc:
logger.error("Failed to update feature flag %s: %s", name, exc) logger.error("Failed to update feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}") raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}") from exc
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) @router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@@ -178,7 +178,7 @@ async def delete_feature_flag(
name: str, name: str,
_: bool = Depends(_verify_internal_api_key), _: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store), store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) : ):
"""删除 Feature Flag。 """删除 Feature Flag。
只允许删除 ALLOWED_FLAGS 列表中的 flag。 只允许删除 ALLOWED_FLAGS 列表中的 flag。
@@ -191,4 +191,4 @@ async def delete_feature_flag(
pass pass
except Exception as exc: except Exception as exc:
logger.error("Failed to delete feature flag %s: %s", name, exc) logger.error("Failed to delete feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") from exc
+56 -5
View File
@@ -43,6 +43,7 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _to_generation_task_response(task) -> GenerationTaskResponse: def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse( return GenerationTaskResponse(
id=task.id, id=task.id,
@@ -282,28 +283,28 @@ def create_generation_task(
created_tasks.append(task) created_tasks.append(task)
else: else:
failed_tasks.append(task) failed_tasks.append(task)
except UserPendingLimitExceeded: except UserPendingLimitExceeded as _e:
# 兜底:如果预检查后又并发提交了,在这里也拦住 # 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task) failed_tasks.append(task)
if not created_tasks: if not created_tasks:
raise HTTPException( raise HTTPException(
status_code=429, status_code=429,
detail="您的待处理任务过多,请等待完成后再提交", detail="您的待处理任务过多,请等待完成后再提交",
) ) from _e
break break
except GlobalQueueFull: except GlobalQueueFull as _e:
failed_tasks.append(task) failed_tasks.append(task)
if not created_tasks: if not created_tasks:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail="系统繁忙,请稍后再试", detail="系统繁忙,请稍后再试",
) ) from _e
break break
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("[生成任务] 创建失败: %s", e, exc_info=True) logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志") raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志") from e
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks] items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items)) return BatchGenerationTaskResponse(items=items, total=len(items))
@@ -426,3 +427,53 @@ def retry_generation_task(
detail="系统繁忙,请稍后再试", detail="系统繁忙,请稍后再试",
) from None ) from None
return _to_generation_task_response(retried) return _to_generation_task_response(retried)
@router.post("/tasks/{task_id}/cancel", response_model=GenerationTaskResponse)
def cancel_generation_task(
task_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> GenerationTaskResponse:
"""取消生成任务。
仅 pending / running 状态的任务可取消;取消后状态变为 cancelled。
对于已在运行的 Celery 任务,标记为 cancelled 后,worker 在下次检查点会中止执行。
"""
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
# 权限校验
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this task")
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
# 终态不可取消
if status_val in ("completed", "failed", "cancelled"):
raise HTTPException(
status_code=409,
detail=f"Cannot cancel task in {status_val} status",
)
# 执行取消
try:
task.mark_cancelled()
task.append_log(
stage="cancelled",
message="用户主动取消任务",
level="INFO",
cancelled_by=authenticated_user.user.id,
)
generation_task_repository.update(task)
logger.info(
"生成任务已取消: task_id=%s user_id=%s previous_status=%s",
task_id,
authenticated_user.user.id,
status_val,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return _to_generation_task_response(task)
+3 -3
View File
@@ -81,11 +81,11 @@ def delete_project(
use_case = DeleteProjectUseCase(project_repository) use_case = DeleteProjectUseCase(project_repository)
try: try:
deleted = use_case.execute(project_id, authenticated_user.user.id) deleted = use_case.execute(project_id, authenticated_user.user.id)
except PermissionError: except PermissionError as _e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only the project owner can delete this project", detail="Only the project owner can delete this project",
) ) from _e
if not deleted: if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return return # type: ignore[return-value]
+6 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import replace from dataclasses import replace
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List from typing import List
@@ -20,6 +21,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
from packages.ports.user_repository import UserRepository from packages.ports.user_repository import UserRepository
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -254,7 +257,9 @@ async def payment_callback(
return {"success": True, "message": "支付成功", "record_id": record_id} return {"success": True, "message": "支付成功", "record_id": record_id}
except Exception as e: except Exception as e:
session.rollback() session.rollback()
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}") logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}")
# 不返回原始异常信息,避免泄漏内部实现细节
raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e
finally: finally:
session.close() session.close()
+11 -12
View File
@@ -1,6 +1,7 @@
import logging import logging
from typing import Any from typing import Any
from app.api.routes._helpers import format_utc_datetime
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.core.task_enqueue import ( from app.core.task_enqueue import (
@@ -24,8 +25,6 @@ from app.schemas.task_center import (
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import ( from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
RetryGenerationTaskUseCase, RetryGenerationTaskUseCase,
SubmitIngestJobCommand, SubmitIngestJobCommand,
SubmitIngestJobUseCase, SubmitIngestJobUseCase,
@@ -101,8 +100,8 @@ def _generation_task_to_user_response(task) -> UserTaskResponse:
retryable=_status_value(task.status) == "failed", retryable=_status_value(task.status) == "failed",
retry_count=task.retry_count or 0, retry_count=task.retry_count or 0,
source_id=task.id, source_id=task.id,
created_at=task.created_at, created_at=format_utc_datetime(task.created_at),
updated_at=task.completed_at or task.started_at or task.created_at, updated_at=format_utc_datetime(task.completed_at or task.started_at or task.created_at),
) )
@@ -121,8 +120,8 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse:
retry_count=task.retry_count or 0, retry_count=task.retry_count or 0,
source_id=task.id, source_id=task.id,
template_id=task.template_id, template_id=task.template_id,
created_at=task.created_at, created_at=format_utc_datetime(task.created_at),
updated_at=task.completed_at or task.started_at or task.created_at, updated_at=format_utc_datetime(task.completed_at or task.started_at or task.created_at),
) )
@@ -286,8 +285,8 @@ def list_project_tasks(
user_message=_humanize_task_error(job.error_message), user_message=_humanize_task_error(job.error_message),
retryable=_status_value(job.status) == "failed", retryable=_status_value(job.status) == "failed",
source_id=job.id, source_id=job.id,
created_at=job.created_at, created_at=format_utc_datetime(job.created_at),
updated_at=job.updated_at, updated_at=format_utc_datetime(job.updated_at),
) )
) )
@@ -368,9 +367,9 @@ def retry_project_task(
raise HTTPException(status_code=404, detail="Ingest job not found") raise HTTPException(status_code=404, detail="Ingest job not found")
if _status_value(job.status) != "failed": if _status_value(job.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried") raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = SubmitIngestJobUseCase(ingest_job_repository) use_case = SubmitIngestJobUseCase(ingest_job_repository) # type: ignore[assignment]
retried = use_case.execute( retried = use_case.execute(
SubmitIngestJobCommand( SubmitIngestJobCommand( # type: ignore[arg-type]
project_id=job.project_id, project_id=job.project_id,
library_id=job.library_id, library_id=job.library_id,
storage_key=job.storage_key, storage_key=job.storage_key,
@@ -385,7 +384,7 @@ def retry_project_task(
progress=0, progress=0,
current_step=_ingest_step(retried), current_step=_ingest_step(retried),
source_id=retried.id, source_id=retried.id,
created_at=retried.created_at, created_at=format_utc_datetime(retried.created_at),
updated_at=retried.updated_at, updated_at=format_utc_datetime(retried.updated_at), # type: ignore[attr-defined]
) )
raise HTTPException(status_code=400, detail="Unsupported task type") raise HTTPException(status_code=400, detail="Unsupported task type")
+17 -15
View File
@@ -147,9 +147,9 @@ def get_template(
use_case = GetTemplateUseCase(template_repository) use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id) template = use_case.execute(template_id, user_id)
usage = template_repository.get_usage_count(template_id) usage = template_repository.get_usage_count(template_id)
except Exception: except Exception as _e:
logger.exception("get_template 查询失败: template_id=%s", template_id) logger.exception("get_template 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") from _e
if template is None: if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template, usage_count=usage) return _to_response(template, usage_count=usage)
@@ -186,7 +186,7 @@ def create_template(
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return _to_response(template) return _to_response(template)
@@ -226,10 +226,10 @@ def update_template(
use_case = UpdateTemplateUseCase(template_repository) use_case = UpdateTemplateUseCase(template_repository)
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except NotFoundError: except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return _to_response(template) return _to_response(template)
@@ -264,10 +264,10 @@ def copy_template(
use_case = CopyTemplateUseCase(template_repository) use_case = CopyTemplateUseCase(template_repository)
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except NotFoundError: except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return _to_response(template) return _to_response(template)
@@ -299,9 +299,9 @@ def toggle_favorite(
use_case = GetTemplateUseCase(template_repository) use_case = GetTemplateUseCase(template_repository)
try: try:
template = use_case.execute(template_id, user_id) template = use_case.execute(template_id, user_id)
except Exception: except Exception as _e:
logger.exception("toggle_favorite 查询失败: template_id=%s", template_id) logger.exception("toggle_favorite 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
if template is None: if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return ToggleFavoriteResponse(id=template_id, is_favorite=False) return ToggleFavoriteResponse(id=template_id, is_favorite=False)
@@ -326,10 +326,10 @@ def validate_template(
use_case = ValidateTemplateUseCase(template_repository) use_case = ValidateTemplateUseCase(template_repository)
try: try:
result = use_case.execute(command) result = use_case.execute(command)
except NotFoundError: except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return ValidateTemplateResponse( return ValidateTemplateResponse(
template=_to_response(result.template), template=_to_response(result.template),
@@ -375,7 +375,9 @@ def create_category(
) )
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) @router.delete(
"/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_category( def delete_category(
category_id: str, category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
+3 -3
View File
@@ -148,7 +148,7 @@ def create_title(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
) ) from exc
return _to_response(item) return _to_response(item)
@@ -172,8 +172,8 @@ def update_title(
use_case = UpdateTitleLibraryUseCase(title_repository) use_case = UpdateTitleLibraryUseCase(title_repository)
try: try:
item = use_case.execute(command) item = use_case.execute(command)
except NotFoundError: except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e
return _to_response(item) return _to_response(item)
+7 -7
View File
@@ -236,8 +236,8 @@ def get_tts_job(
use_case = GetTTSJobUseCase(repository) use_case = GetTTSJobUseCase(repository)
try: try:
job = use_case.execute(job_id, user_id) job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError: except TTSJobNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
return _to_response(job, sign_url) return _to_response(job, sign_url)
@@ -253,8 +253,8 @@ def get_tts_job_status(
use_case = GetTTSJobStatusUseCase(repository) use_case = GetTTSJobStatusUseCase(repository)
try: try:
job = use_case.execute(job_id, user_id) job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError: except TTSJobNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
output_url = job.output_audio_url output_url = job.output_audio_url
if output_url: if output_url:
output_url = sign_url(output_url) output_url = sign_url(output_url)
@@ -309,8 +309,8 @@ def save_tts_job_to_library(
get_use_case = GetTTSJobUseCase(tts_repository) get_use_case = GetTTSJobUseCase(tts_repository)
try: try:
job = get_use_case.execute(job_id, user_id) job = get_use_case.execute(job_id, user_id)
except TTSJobNotFoundError: except TTSJobNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
# 校验已完成 # 校验已完成
if not job.is_completed: if not job.is_completed:
@@ -363,7 +363,7 @@ def save_tts_job_to_library(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) ) from exc
return SaveToLibraryResponse( return SaveToLibraryResponse(
id=item.id, id=item.id,
+65 -7
View File
@@ -1,19 +1,20 @@
import logging import logging
import uuid
from app.api.routes._helpers import check_project_access from app.api.routes._helpers import format_utc_datetime
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_generated_video_repository from app.dependencies import get_generated_video_repository
from app.schemas.video_center import ( from app.schemas.video_center import (
BatchDeleteRequest,
BatchDownloadRequest, BatchDownloadRequest,
BatchDownloadResponse, BatchDownloadResponse,
BatchOperationResponse,
ListVideosResponse, ListVideosResponse,
UpdateVideoReviewRequest, UpdateVideoReviewRequest,
VideoItemResponse, VideoItemResponse,
) )
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Response
from packages.application import ( from packages.application import (
GetGeneratedVideoUseCase, GetGeneratedVideoUseCase,
@@ -50,7 +51,7 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
review_status=item.review_status, review_status=item.review_status,
generation_params=item.generation_params, generation_params=item.generation_params,
download_url=download_url, download_url=download_url,
generated_at=item.generated_at.isoformat() if hasattr(item, "generated_at") and item.generated_at else "", generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "",
) )
@@ -110,10 +111,67 @@ def update_video_review_status(
item = use_case.execute(video_id, request.review_status) item = use_case.execute(video_id, request.review_status)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="Video not found") 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) 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) return _to_video_response(item, storage)
@router.delete("/videos/{video_id}", status_code=204, response_class=Response)
def delete_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")
# 尝试删除 OSS 文件,失败不影响数据库删除
if item.file_url:
try:
key = storage._normalize_storage_key(item.file_url)
storage.delete_file(key)
except Exception:
logger.warning("删除 OSS 视频文件失败,跳过: video_id=%s", video_id)
repo.delete(video_id)
return Response(status_code=204)
@router.post("/videos/batch-delete", response_model=BatchOperationResponse)
def batch_delete_videos(
request: BatchDeleteRequest,
repo=Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""批量删除成片。"""
videos = repo.get_by_ids(request.video_ids)
existing_ids = {v.id for v in videos}
failed_ids = [vid for vid in request.video_ids if vid not in existing_ids]
failed_details = {vid: "Video not found" for vid in failed_ids}
# 尝试删除 OSS 文件
for video in videos:
if video.file_url:
try:
key = storage._normalize_storage_key(video.file_url)
storage.delete_file(key)
except Exception:
logger.warning("批量删除 OSS 视频文件失败,跳过: video_id=%s", video.id)
success_count = repo.batch_delete(list(existing_ids))
return BatchOperationResponse(
success_count=success_count,
failed_ids=failed_ids,
failed_details=failed_details,
)
@router.post("/videos/batch-download", response_model=BatchDownloadResponse) @router.post("/videos/batch-download", response_model=BatchDownloadResponse)
def batch_download_videos( def batch_download_videos(
request: BatchDownloadRequest, request: BatchDownloadRequest,
@@ -121,7 +179,7 @@ def batch_download_videos(
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
): ):
"""批量下载成片,异步打包 zip。 """批量下载成片,异步打包 zip。
传入 video_ids 列表,创建一个批量下载任务,任务完成后返回 zip 下载链接。 传入 video_ids 列表,创建一个批量下载任务,任务完成后返回 zip 下载链接。
""" """
if not request.video_ids: if not request.video_ids:
@@ -154,7 +212,7 @@ def get_batch_download_status(
from celery.result import AsyncResult from celery.result import AsyncResult
task = AsyncResult(job_id, app=celery_app) task = AsyncResult(job_id, app=celery_app)
status_map = { status_map = {
"PENDING": "pending", "PENDING": "pending",
"STARTED": "running", "STARTED": "running",
+8 -8
View File
@@ -141,8 +141,8 @@ def get_voice_clone(
use_case = GetVoiceCloneUseCase(repository) use_case = GetVoiceCloneUseCase(repository)
try: try:
profile = use_case.execute(clone_id, user_id) profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError: except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
return _to_response(profile) return _to_response(profile)
@@ -157,8 +157,8 @@ def get_voice_clone_status(
use_case = GetVoiceCloneStatusUseCase(repository) use_case = GetVoiceCloneStatusUseCase(repository)
try: try:
profile = use_case.execute(clone_id, user_id) profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError: except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
return VoiceCloneStatusResponse( return VoiceCloneStatusResponse(
id=profile.id, id=profile.id,
status=profile.status, status=profile.status,
@@ -201,13 +201,13 @@ def retry_voice_clone(
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
try: try:
profile = workflow.retry_clone(clone_id, user_id) profile = workflow.retry_clone(clone_id, user_id)
except VoiceCloneNotFoundError: except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
except VoiceCloneNotRetryableError: except VoiceCloneNotRetryableError as _e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Voice clone is not retryable (only failed clones can be retried)", detail="Voice clone is not retryable (only failed clones can be retried)",
) ) from _e
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询 # 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "") task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
+3 -3
View File
@@ -287,7 +287,7 @@ def create_voice(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) ) from exc
return _to_response(item, sign_url) return _to_response(item, sign_url)
@@ -317,8 +317,8 @@ def update_voice(
use_case = UpdateVoiceLibraryUseCase(voice_repository) use_case = UpdateVoiceLibraryUseCase(voice_repository)
try: try:
item = use_case.execute(command) item = use_case.execute(command)
except NotFoundError: except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") from _e
return _to_response(item, sign_url) return _to_response(item, sign_url)
+2 -2
View File
@@ -141,7 +141,7 @@ def safe_enqueue_generation_task(
global_pending_limit, global_pending_limit,
user_id or "unknown", user_id or "unknown",
) )
exc = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit) exc: Exception = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc)) _mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc raise exc
@@ -194,7 +194,7 @@ def safe_enqueue_generation_task(
if global_over or user_over: if global_over or user_over:
if global_over: if global_over:
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}" reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
exc: Exception = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit) exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
else: else:
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}" reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit) exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
+1 -1
View File
@@ -132,7 +132,7 @@ def get_tag_repository(
session: Session = Depends(get_db_session), session: Session = Depends(get_db_session),
) -> TagRepository: ) -> TagRepository:
"""Provide the SQLAlchemy tag repository implementation.""" """Provide the SQLAlchemy tag repository implementation."""
return SQLAlchemyTagRepository(session) return SQLAlchemyTagRepository(session) # type: ignore[return-value]
def get_user_repository( def get_user_repository(
+1 -1
View File
@@ -105,7 +105,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self.max_requests = max_requests self.max_requests = max_requests
self.window_seconds = window_seconds self.window_seconds = window_seconds
self.paths = set(paths) if paths else None self.paths = set(paths) if paths else None
self.requests = {} # {ip: [timestamps]} self.requests: dict[str, list[float]] = {}
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
# 如果配置了路径过滤,只对指定路径限流 # 如果配置了路径过滤,只对指定路径限流
+1
View File
@@ -50,6 +50,7 @@ class AssetResponse(BaseModel):
status: str status: str
classification_status: str classification_status: str
quality_score: float | None = None quality_score: float | None = None
created_at: str
uploaded_by_user_id: str uploaded_by_user_id: str
tag_ids: list[str] = Field(default_factory=list) tag_ids: list[str] = Field(default_factory=list)
+4 -6
View File
@@ -1,5 +1,3 @@
from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -17,8 +15,8 @@ class ProjectTaskResponse(BaseModel):
retry_count: int = 0 retry_count: int = 0
source_id: str = "" source_id: str = ""
template_id: str = "" template_id: str = ""
created_at: datetime | None = None created_at: str = ""
updated_at: datetime | None = None updated_at: str = ""
class ListProjectTasksResponse(BaseModel): class ListProjectTasksResponse(BaseModel):
@@ -42,8 +40,8 @@ class UserTaskResponse(BaseModel):
retryable: bool = False retryable: bool = False
retry_count: int = 0 retry_count: int = 0
source_id: str = "" source_id: str = ""
created_at: datetime | None = None created_at: str = ""
updated_at: datetime | None = None updated_at: str = ""
class ListTasksResponse(BaseModel): class ListTasksResponse(BaseModel):
+13
View File
@@ -35,6 +35,19 @@ class UpdateVideoReviewRequest(BaseModel):
review_status: VideoReviewStatus review_status: VideoReviewStatus
MAX_BATCH_DELETE_SIZE = 200
class BatchDeleteRequest(BaseModel):
video_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_DELETE_SIZE, description="要删除的成片ID列表")
class BatchOperationResponse(BaseModel):
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="失败详情")
class BatchDownloadRequest(BaseModel): class BatchDownloadRequest(BaseModel):
video_ids: list[str] video_ids: list[str]
+2 -1
View File
@@ -12,6 +12,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -155,7 +156,7 @@ class AutoClipService:
self, self,
clip: EditPlanClip, clip: EditPlanClip,
project_id: str, project_id: str,
config_map: dict[str, object], config_map: Mapping[str, object],
) -> ClipAssignDetail: ) -> ClipAssignDetail:
"""为单个片段分配素材。""" """为单个片段分配素材。"""
config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None
+276
View File
@@ -0,0 +1,276 @@
"""封面管理服务.
提供封面配置管理和从视频抽帧生成封面的能力。
抽帧使用 FFmpeg,上传使用共享存储服务。
"""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from typing import Any, Dict
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_COVER_WIDTH = 1080
DEFAULT_COVER_HEIGHT = 1920
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
COVER_STORAGE_PREFIX = "covers"
class CoverService:
"""封面管理服务."""
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
self._storage = storage_service
self._asset_repo = asset_repository
# ── 配置读写 ──────────────────────────────────────────────────────────
@staticmethod
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
"""从 plan.config 中提取封面配置.
Args:
plan_config: 剪辑计划的 config 字段
Returns:
封面配置 dict
"""
cover = plan_config.get("cover", {})
if not isinstance(cover, dict):
cover = {}
# 确保默认字段存在
return {
"type": cover.get("type", "ai_frame"),
"image_url": cover.get("image_url", ""),
"frame_time": cover.get("frame_time"),
}
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
def extract_cover_from_clip(
self,
plan_id: str,
asset_id: str,
frame_time: float = 1.0,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""从指定素材的指定时间点抽取一帧作为封面.
Args:
plan_id: 剪辑计划 ID(用于生成存储路径)
asset_id: 素材 ID
frame_time: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict,包含 type / image_url / frame_time
Raises:
ValueError: 素材不存在或不是视频
RuntimeError: 抽帧或上传失败
"""
# 1. 获取素材
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
if not asset:
raise ValueError(f"素材不存在: {asset_id}")
storage_key = getattr(asset, "storage_key", "")
if not storage_key:
raise ValueError(f"素材没有文件: {asset_id}")
mime_type = getattr(asset, "mime_type", "")
if mime_type and not mime_type.startswith("video"):
raise ValueError(f"素材不是视频类型: {mime_type}")
# 2. 下载视频到临时目录
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
tmp_path = Path(tmp_dir)
video_path = tmp_path / f"source_{asset_id[:8]}"
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
try:
self._storage.download_file(storage_key, str(video_path))
except Exception as e:
raise RuntimeError(f"下载素材失败: {e}") from e
if not video_path.exists() or video_path.stat().st_size == 0:
raise RuntimeError("下载的素材文件为空")
# 3. FFmpeg 抽帧
output_path = tmp_path / "cover.jpg"
self._extract_frame(
video_path=video_path,
output_path=output_path,
time_sec=frame_time,
width=width,
height=height,
quality=quality,
)
if not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError("封面抽帧失败")
# 4. 上传到 OSS
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
logger.info("上传封面到存储: key=%s", cover_key)
try:
self._storage.upload_file(
file_or_path=str(output_path),
storage_key=cover_key,
content_type="image/jpeg",
)
except Exception as e:
raise RuntimeError(f"上传封面失败: {e}") from e
# 5. 获取访问 URL
try:
image_url = self._storage.get_url(cover_key)
except Exception:
image_url = cover_key # 降级为 storage_key
logger.info(
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
plan_id,
asset_id,
frame_time,
output_path.stat().st_size if output_path.exists() else 0,
)
return {
"type": "manual",
"image_url": image_url,
"frame_time": frame_time,
}
def generate_smart_cover(
self,
plan_id: str,
asset_id: str,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
Args:
plan_id: 剪辑计划 ID
asset_id: 素材 ID
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict
"""
# 简单实现:取视频 1/3 处的帧作为智能封面
# 更复杂的多帧选清晰帧可以后续优化
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
result = self.extract_cover_from_clip(
plan_id=plan_id,
asset_id=asset_id,
frame_time=frame_time,
width=width,
height=height,
quality=quality,
)
result["type"] = "ai_frame"
return result
# ── 内部方法 ──────────────────────────────────────────────────────────
@staticmethod
def _extract_frame(
video_path: Path,
output_path: Path,
*,
time_sec: float,
width: int,
height: int,
quality: int,
) -> None:
"""使用 FFmpeg 从视频中抽取一帧.
Args:
video_path: 视频文件路径
output_path: 输出图片路径
time_sec: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
"""
import subprocess
# scale + crop 实现 cover 裁剪
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
# 尝试不使用 scale+crop 的简化命令
simple_command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
result2 = subprocess.run(
simple_command,
capture_output=True,
text=True,
timeout=60,
)
if result2.returncode != 0:
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
except subprocess.TimeoutExpired as e:
raise RuntimeError("FFmpeg 抽帧超时") from e
except FileNotFoundError as e:
raise RuntimeError("FFmpeg 不可用") from e
+590 -1
View File
@@ -141,6 +141,19 @@ class EditPlanService:
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name) logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
return created return created
def _auto_resume_editing(self, plan_id: str) -> None:
"""如果计划处于 completed/failed 状态,自动切回 editing(编辑操作前置)"""
plan = self._plan_repo.get(plan_id)
if plan is None:
return
if plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
try:
plan.resume_editing()
self._plan_repo.update(plan)
logger.info("自动重新编辑: plan_id=%s", plan_id)
except ValueError:
pass
def update_plan( def update_plan(
self, self,
plan_id: str, plan_id: str,
@@ -156,6 +169,10 @@ class EditPlanService:
""" """
existing = self.get_plan_or_raise(plan_id) existing = self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
existing = self.get_plan_or_raise(plan_id)
updated = EditPlan( updated = EditPlan(
id=existing.id, id=existing.id,
template_id=existing.template_id, template_id=existing.template_id,
@@ -212,8 +229,24 @@ class EditPlanService:
return plan return plan
# 根据目标状态调用对应的状态机方法 # 根据目标状态调用对应的状态机方法
# EDITING 支持从 draft / completed / failed 进入
if target_status == EditPlanStatus.EDITING:
if plan.status == EditPlanStatus.DRAFT:
plan.start_editing()
elif plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
plan.resume_editing()
else:
raise ValueError(f"无法从 {plan.status} 切换到 {target_status}")
result = self._plan_repo.update(plan)
logger.info(
"状态流转: plan_id=%s %s → %s",
plan_id,
plan.status,
target_status,
)
return result
transition_map = { transition_map = {
EditPlanStatus.EDITING: plan.start_editing,
EditPlanStatus.RENDERING: plan.start_rendering, EditPlanStatus.RENDERING: plan.start_rendering,
EditPlanStatus.COMPLETED: plan.mark_completed, EditPlanStatus.COMPLETED: plan.mark_completed,
EditPlanStatus.FAILED: plan.mark_failed, EditPlanStatus.FAILED: plan.mark_failed,
@@ -292,6 +325,8 @@ class EditPlanService:
""" """
# 确保计划存在 # 确保计划存在
self.get_plan_or_raise(plan_id) self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
clip = EditPlanClip.create( clip = EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -339,6 +374,9 @@ class EditPlanService:
""" """
existing = self.get_clip_or_raise(clip_id) existing = self.get_clip_or_raise(clip_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(existing.plan_id)
# 速度边界钳制 # 速度边界钳制
if playback_speed is not None: if playback_speed is not None:
if playback_speed <= 0: if playback_speed <= 0:
@@ -381,6 +419,8 @@ class EditPlanService:
ValueError: 片段不存在或 asset_id 为空 ValueError: 片段不存在或 asset_id 为空
""" """
clip = self.get_clip_or_raise(clip_id) clip = self.get_clip_or_raise(clip_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(clip.plan_id)
clip.assign_asset(asset_id) clip.assign_asset(asset_id)
result = self._clip_repo.update(clip) result = self._clip_repo.update(clip)
logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id) logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id)
@@ -407,7 +447,463 @@ class EditPlanService:
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count) logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
return count return count
def create_clips_from_assets(
self,
plan_id: str,
asset_ids: list[str],
*,
clip_type: str = "main",
) -> list[EditPlanClip]:
"""从素材批量创建片段(追加到时间线末尾)。
Args:
plan_id: 计划 ID
asset_ids: 素材 ID 列表(按顺序追加)
clip_type: 片段类型
Returns:
list[EditPlanClip]: 创建的片段列表
"""
if not asset_ids:
return []
# 确保计划存在 + 自动回退状态
self.get_plan_or_raise(plan_id)
self._auto_resume_editing(plan_id)
# 查询素材信息(取 duration)
from packages.adapters.sqlalchemy_impl.models import AssetModel
session = self._clip_repo.session # type: ignore[attr-defined]
assets = session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
asset_map = {a.id: a for a in assets}
# 从现有片段数量开始追加
existing_count = self._clip_repo.count(plan_id=plan_id)
# 批量创建片段
created: list[EditPlanClip] = []
for i, asset_id in enumerate(asset_ids):
asset = asset_map.get(asset_id)
duration = asset.duration if asset and asset.duration else 0.0
clip = self.create_clip(
plan_id=plan_id,
clip_type=clip_type,
order=existing_count + i,
asset_id=asset_id,
duration=duration,
)
created.append(clip)
logger.info(
"从素材批量创建片段: plan_id=%s count=%d",
plan_id,
len(created),
)
return created
# ── 渲染生成流程 ──────────────────────────────────────────────────────── # ── 渲染生成流程 ────────────────────────────────────────────────────────
# ── 片段分割与合并 ──────────────────────────────────────────────────────
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
"""将一个片段从指定位置分割为两个片段
Args:
clip_id: 要分割的片段 ID
split_time: 分割点(相对于片段起始的秒数),必须在 (0, duration) 范围内
Returns:
dict: {"left_clip": EditPlanClip, "right_clip": EditPlanClip}
Raises:
ValueError: 片段不存在、分割时间越界
"""
clip = self.get_clip_or_raise(clip_id)
plan_id = clip.plan_id
if split_time <= 0 or split_time >= clip.duration:
raise ValueError(f"分割时间必须在 (0, {clip.duration:.3f}) 范围内,当前: {split_time}")
self._auto_resume_editing(plan_id)
original_duration = clip.duration
left_duration = round(split_time, 3)
right_duration = round(original_duration - split_time, 3)
original_order = clip.order
# 更新左半部分(原片段)
clip.duration = left_duration
left_clip = self._clip_repo.update(clip)
# 后面片段的 order 全部 +1(给右半部分腾位置)
all_clips = self._clip_repo.list_by_plan(plan_id)
for c in all_clips:
if c.order > original_order and c.id != clip_id:
c.order += 1
self._clip_repo.update(c)
# 创建右半部分新片段(继承原片段的大部分属性)
right_config = dict(clip.config) if clip.config else {}
# 素材裁剪信息
if clip.asset_id:
# 右半部分从 split_time 开始播放
right_config["trim_start"] = left_duration
# 左半部分在 split_time 处结束
left_config = dict(left_clip.config) if left_clip.config else {}
left_config["trim_end"] = right_duration
left_clip.config = left_config
left_clip = self._clip_repo.update(left_clip)
right_clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip.clip_type,
order=original_order + 1,
template_clip_config_id=clip.template_clip_config_id,
asset_id=clip.asset_id,
text_content=clip.text_content,
start_time=clip.start_time + left_duration,
duration=right_duration,
transition_effect=clip.transition_effect,
transition_duration=clip.transition_duration,
playback_speed=clip.playback_speed,
config=right_config,
)
created_right = self._clip_repo.create(right_clip)
logger.info(
"分割片段: clip_id=%s plan_id=%s split_time=%.3fs left_dur=%.3fs right_dur=%.3fs",
clip_id,
plan_id,
split_time,
left_duration,
right_duration,
)
return {
"left_clip": left_clip,
"right_clip": created_right,
}
def merge_clips(self, clip_ids: List[str]) -> EditPlanClip:
"""合并多个连续片段为一个片段
Args:
clip_ids: 要合并的片段 ID 列表(至少2个),必须属于同一个计划且 order 连续
Returns:
EditPlanClip: 合并后的新片段
Raises:
ValueError: 数量不足、不属于同一计划、不连续、类型不一致
"""
if len(clip_ids) < 2:
raise ValueError("至少需要 2 个片段才能合并")
# 读取所有片段
clips = []
for cid in clip_ids:
clip = self.get_clip_or_raise(cid)
clips.append(clip)
# 校验:同一计划
plan_id = clips[0].plan_id
for c in clips[1:]:
if c.plan_id != plan_id:
raise ValueError("只能合并同一计划下的片段")
# 按 order 排序
clips.sort(key=lambda c: c.order)
# 校验:order 连续
for i in range(1, len(clips)):
if clips[i].order != clips[i - 1].order + 1:
raise ValueError(f"片段不连续:order {clips[i-1].order} → {clips[i].order}")
# 校验:类型一致
clip_type = clips[0].clip_type
for c in clips[1:]:
if c.clip_type != clip_type:
raise ValueError("只能合并相同类型的片段")
self._auto_resume_editing(plan_id)
# 计算合并后的属性
first_clip = clips[0]
total_duration = round(sum(c.duration for c in clips), 3)
first_order = first_clip.order
# 合并文案(用换行连接)
merged_text = "\n".join(c.text_content for c in clips if c.text_content.strip())
# 合并 config(后面的覆盖前面的)
merged_config: Dict[str, Any] = {}
for c in clips:
if c.config:
merged_config.update(c.config)
# 清理 trim 相关字段(合并后就是完整片段了)
merged_config.pop("trim_start", None)
merged_config.pop("trim_end", None)
# 更新第一个片段(保留它作为合并结果)
first_clip.duration = total_duration
first_clip.text_content = merged_text
first_clip.config = merged_config
# 转场保留第一个的(合并后的入点转场)
# playback_speed 取第一个的
merged_clip = self._clip_repo.update(first_clip)
# 删除其余片段
for c in clips[1:]:
self._clip_repo.delete(c.id)
# 后面的片段 order 前移 (len - 1) 位
shift = len(clips) - 1
all_clips = self._clip_repo.list_by_plan(plan_id)
for c in all_clips:
if c.order > first_order and c.id != merged_clip.id:
c.order -= shift
self._clip_repo.update(c)
logger.info(
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
plan_id,
len(clips),
total_duration,
)
return merged_clip
# ── 字幕管理 ──────────────────────────────────────────────────────────
def list_subtitles(self, clip_id: str) -> List[Dict[str, Any]]:
"""获取片段的所有字幕
Returns:
List[dict]: 字幕列表,按 start 时间排序
"""
clip = self.get_clip_or_raise(clip_id)
config = clip.config or {}
subtitles = config.get("subtitles", [])
# 按开始时间排序
subtitles.sort(key=lambda s: s.get("start", 0))
return subtitles
def get_subtitle(self, clip_id: str, subtitle_id: str) -> Optional[Dict[str, Any]]:
"""获取单条字幕"""
subtitles = self.list_subtitles(clip_id)
for s in subtitles:
if s.get("id") == subtitle_id:
return s
return None
def add_subtitle(
self,
clip_id: str,
start: float,
end: float,
text: str,
*,
style: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""添加一条字幕
Args:
clip_id: 片段 ID
start: 开始时间(秒,相对于片段)
end: 结束时间(秒)
text: 字幕文本
style: 样式配置(字体、大小、颜色、位置等)
Returns:
dict: 新增的字幕条目
Raises:
ValueError: 时间非法或文本为空
"""
clip = self.get_clip_or_raise(clip_id)
if start < 0 or end <= start:
raise ValueError(f"字幕时间非法: start={start}, end={end}")
if not text.strip():
raise ValueError("字幕文本不能为空")
if end > clip.duration + 0.001:
raise ValueError(f"字幕结束时间不能超过片段时长: end={end:.3f}, duration={clip.duration:.3f}")
self._auto_resume_editing(clip.plan_id)
from uuid import uuid4
config = dict(clip.config) if clip.config else {}
subtitles = list(config.get("subtitles", []))
subtitle = {
"id": uuid4().hex,
"start": round(start, 3),
"end": round(end, 3),
"text": text.strip(),
"style": style or {},
}
subtitles.append(subtitle)
subtitles.sort(key=lambda s: s.get("start", 0))
config["subtitles"] = subtitles
clip.config = config
self._clip_repo.update(clip)
logger.info(
"添加字幕: clip_id=%s subtitle_id=%s start=%.3fs end=%.3fs",
clip_id,
subtitle["id"],
start,
end,
)
return subtitle
def update_subtitle(
self,
clip_id: str,
subtitle_id: str,
*,
start: Optional[float] = None,
end: Optional[float] = None,
text: Optional[str] = None,
style: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""更新一条字幕
Returns:
dict: 更新后的字幕条目
Raises:
ValueError: 字幕不存在或参数非法
"""
clip = self.get_clip_or_raise(clip_id)
config = dict(clip.config) if clip.config else {}
subtitles = list(config.get("subtitles", []))
found = False
for i, s in enumerate(subtitles):
if s.get("id") == subtitle_id:
# 更新字段
updated_s = dict(s)
if start is not None:
updated_s["start"] = round(start, 3)
if end is not None:
updated_s["end"] = round(end, 3)
if text is not None:
if not text.strip():
raise ValueError("字幕文本不能为空")
updated_s["text"] = text.strip()
if style is not None:
updated_s["style"] = style
# 校验时间
if updated_s["start"] < 0 or updated_s["end"] <= updated_s["start"]:
raise ValueError(f"字幕时间非法: start={updated_s['start']}, end={updated_s['end']}")
if updated_s["end"] > clip.duration + 0.001:
raise ValueError("字幕结束时间不能超过片段时长")
subtitles[i] = updated_s
found = True
break
if not found:
raise ValueError(f"字幕不存在: {subtitle_id}")
self._auto_resume_editing(clip.plan_id)
subtitles.sort(key=lambda s: s.get("start", 0))
config["subtitles"] = subtitles
clip.config = config
self._clip_repo.update(clip)
logger.info("更新字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
return subtitles[next(i for i, s in enumerate(subtitles) if s["id"] == subtitle_id)]
def delete_subtitle(self, clip_id: str, subtitle_id: str) -> bool:
"""删除一条字幕
Returns:
bool: 是否删除成功
"""
clip = self.get_clip_or_raise(clip_id)
config = dict(clip.config) if clip.config else {}
subtitles = list(config.get("subtitles", []))
new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id]
if len(new_subtitles) == len(subtitles):
return False
self._auto_resume_editing(clip.plan_id)
config["subtitles"] = new_subtitles
clip.config = config
self._clip_repo.update(clip)
logger.info("删除字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
return True
def batch_update_subtitles(
self,
clip_id: str,
subtitles: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""批量更新字幕(全量替换,用于批量编辑或导入)
Args:
clip_id: 片段 ID
subtitles: 字幕列表,每条需包含 start/end/text,已有 id 则保留
Returns:
List[dict]: 更新后的字幕列表
"""
clip = self.get_clip_or_raise(clip_id)
from uuid import uuid4
validated = []
for s in subtitles:
start = float(s.get("start", 0))
end = float(s.get("end", 0))
text = str(s.get("text", ""))
if start < 0 or end <= start:
raise ValueError(f"字幕时间非法: start={start}, end={end}")
if not text.strip():
continue # 跳过空字幕
if end > clip.duration + 0.001:
raise ValueError(f"字幕结束时间不能超过片段时长: end={end}")
subtitle_id = s.get("id") or uuid4().hex
validated.append(
{
"id": subtitle_id,
"start": round(start, 3),
"end": round(end, 3),
"text": text.strip(),
"style": s.get("style", {}),
}
)
validated.sort(key=lambda s: s["start"])
self._auto_resume_editing(clip.plan_id)
config = dict(clip.config) if clip.config else {}
config["subtitles"] = validated
clip.config = config
self._clip_repo.update(clip)
logger.info(
"批量更新字幕: clip_id=%s count=%d",
clip_id,
len(validated),
)
return validated
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]: def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
"""获取计划及其所有片段 """获取计划及其所有片段
@@ -431,6 +927,8 @@ class EditPlanService:
"clips": List[EditPlanClip], "clips": List[EditPlanClip],
"generation_task_id": Optional[str], "generation_task_id": Optional[str],
"generation_task_status": Optional[str], "generation_task_status": Optional[str],
"progress": float,
"error_message": str,
} }
Raises: Raises:
@@ -442,17 +940,23 @@ class EditPlanService:
# 从 plan.config 中获取 generation_task_id # 从 plan.config 中获取 generation_task_id
generation_task_id = plan.config.get("generation_task_id") generation_task_id = plan.config.get("generation_task_id")
generation_task_status = None generation_task_status = None
progress = 0.0
error_message = ""
if generation_task_id: if generation_task_id:
task = self._generation_task_repo.get(generation_task_id) task = self._generation_task_repo.get(generation_task_id)
if task: if task:
generation_task_status = task.status.value if hasattr(task.status, "value") else task.status generation_task_status = task.status.value if hasattr(task.status, "value") else task.status
progress = getattr(task, "progress", 0.0) or 0.0
error_message = getattr(task, "error_message", "") or ""
return { return {
"plan": plan, "plan": plan,
"clips": clips, "clips": clips,
"generation_task_id": generation_task_id, "generation_task_id": generation_task_id,
"generation_task_status": generation_task_status, "generation_task_status": generation_task_status,
"progress": progress,
"error_message": error_message,
} }
def can_generate(self, plan_id: str) -> tuple[bool, str]: def can_generate(self, plan_id: str) -> tuple[bool, str]:
@@ -503,6 +1007,9 @@ class EditPlanService:
更新后的计划 更新后的计划
""" """
plan = self.get_plan_or_raise(plan_id) plan = self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
plan = self.get_plan_or_raise(plan_id)
new_config = {**plan.config, **config_updates} new_config = {**plan.config, **config_updates}
updated = EditPlan( updated = EditPlan(
@@ -519,3 +1026,85 @@ class EditPlanService:
updated_at=plan.updated_at, updated_at=plan.updated_at,
) )
return self._plan_repo.update(updated) return self._plan_repo.update(updated)
# ── 复制计划 ────────────────────────────────────────────────────────────
def copy_plan(
self,
plan_id: str,
*,
new_name: Optional[str] = None,
project_id: Optional[str] = None,
) -> EditPlan:
"""复制一个剪辑计划(含所有片段配置)。
新计划状态为 editing,不含生成任务和结果记录。
Args:
plan_id: 源计划 ID
new_name: 新计划名称,不传则为「原名 - 副本」
project_id: 新计划的项目 ID,不传则复用源计划
Returns:
EditPlan: 新创建的计划
Raises:
ValueError: 源计划不存在
"""
source = self.get_plan_or_raise(plan_id)
source_clips = self._clip_repo.list_by_plan(plan_id)
# 新计划名称
name = new_name or f"{source.name} - 副本"
new_project_id = project_id if project_id is not None else source.project_id
# 复制 plan 配置(去除渲染结果相关字段)
new_config = dict(source.config)
new_config.pop("rendered_url", None)
new_config.pop("rendered_storage_key", None)
new_config.pop("generation_task_id", None)
# 创建新计划
new_plan = EditPlan.create(
template_id=source.template_id,
name=name,
config=new_config,
total_duration=source.total_duration,
project_id=new_project_id,
created_by_user_id=source.created_by_user_id,
source_edit_plan_id=plan_id,
)
# 强制切到 editing 状态
if new_plan.status != EditPlanStatus.EDITING:
try:
new_plan.start_editing()
except ValueError:
pass
created_plan = self._plan_repo.create(new_plan)
logger.info(
"复制剪辑计划: source=%s target=%s name=%s clips=%d",
plan_id,
created_plan.id,
name,
len(source_clips),
)
# 复制所有片段
for clip in source_clips:
new_clip = self.create_clip(
plan_id=created_plan.id,
clip_type=clip.clip_type,
order=clip.order,
asset_id=clip.asset_id or "",
text_content=clip.text_content or "",
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=clip.transition_duration or 0.0,
playback_speed=clip.playback_speed or 1.0,
config=dict(clip.config) if clip.config else None,
)
logger.debug("复制片段: source=%s target=%s order=%d", clip.id, new_clip.id, clip.order)
return self.get_plan_or_raise(created_plan.id)
+139
View File
@@ -12,6 +12,8 @@ from typing import Any, List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import ( from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanClipRepository,
SQLAlchemyEditPlanRepository,
SQLAlchemyEditTemplateRepository, SQLAlchemyEditTemplateRepository,
SQLAlchemyTemplateClipConfigRepository, SQLAlchemyTemplateClipConfigRepository,
) )
@@ -37,6 +39,9 @@ class EditTemplateService:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self._template_repo = SQLAlchemyEditTemplateRepository(db) self._template_repo = SQLAlchemyEditTemplateRepository(db)
self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db) self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._plan_clip_repo = SQLAlchemyEditPlanClipRepository(db)
self._db = db
# ── 模板 CRUD ────────────────────────────────────────────────────────── # ── 模板 CRUD ──────────────────────────────────────────────────────────
@@ -394,3 +399,137 @@ class EditTemplateService:
"template": template, "template": template,
"clip_configs": clip_configs, "clip_configs": clip_configs,
} }
# ── 从剪辑计划保存为模板 ──────────────────────────────────────────────
def save_plan_as_template(
self,
plan_id: str,
name: str,
*,
description: str = "",
template_type: str = "custom",
preview_url: str = "",
) -> dict[str, Any]:
"""将剪辑计划保存为模板
将指定剪辑计划的配置和片段结构另存为一个新模板,
方便后续基于该模板快速创建新的剪辑计划。
转换规则:
- 计划名称 → 模板名称(调用方传入,支持自定义)
- 计划 config → 模板 config(整体迁移)
- 计划 editing_mode 从 config 中提取,默认 one_take
- 每个片段转换为模板片段配置:
- clip_type 直接映射
- order 保持不变
- duration → min_duration = max_duration = duration(固定时长)
- text_content → text_template
- transition_effect 直接映射
- playback_speed 等播放参数存入 config
- 不保留 asset_id(模板不绑定具体素材)
Args:
plan_id: 源剪辑计划 ID
name: 新模板名称
description: 模板描述
template_type: 模板类型,默认 custom(用户自定义)
preview_url: 预览图 URL
Returns:
dict: {"template": EditTemplate, "clip_configs": List[TemplateClipConfig]}
Raises:
ValueError: 计划不存在或名称为空/重复
"""
# 1. 读取源计划
plan = self._plan_repo.get(plan_id)
if plan is None:
raise ValueError(f"剪辑计划不存在: {plan_id}")
# 2. 读取所有片段(按 order 排序)
clips = self._plan_clip_repo.list_by_plan(plan_id)
clips.sort(key=lambda c: c.order)
# 3. 提取 editing_mode
editing_mode = plan.config.get("editing_mode", "one_take") if plan.config else "one_take"
# 4. 创建模板(复用 create_template 的校验逻辑,但手动构建避免重复查询)
clean_name = name.strip()
if not clean_name:
raise ValueError("模板名称不能为空")
# 名称重复检查
existing = self._template_repo.list_all(skip=0, limit=1000)
for t in existing:
if t.name == clean_name and t.status == EditTemplateStatus.ACTIVE:
raise ValueError(f"模板名称已存在: {clean_name}")
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
plan_config = plan.config or {}
template_config: dict[str, Any] = {}
for key, value in plan_config.items():
# 跳过明显的运行时/实例字段,保留风格/模式类配置
if key not in {"asset_ids", "source_edit_plan_id", "generation_task_id"}:
template_config[key] = value
template = EditTemplate.create(
name=clean_name,
description=description,
template_type=template_type,
editing_mode=editing_mode,
config=template_config,
preview_url=preview_url,
)
created_template = self._template_repo.create(template)
logger.info(
"从剪辑计划创建模板: plan_id=%s template_id=%s name=%s clip_count=%d",
plan_id,
created_template.id,
clean_name,
len(clips),
)
# 5. 转换每个片段为模板片段配置
created_configs: List[TemplateClipConfig] = []
for clip in clips:
clip_config: dict[str, Any] = {}
# 播放速度存入 config
if clip.playback_speed and clip.playback_speed != 1.0:
clip_config["playback_speed"] = clip.playback_speed
# 片段自有 config 合并(优先级:clip.config 覆盖上面的)
if clip.config:
clip_config.update(clip.config)
# 去掉素材相关字段
clip_config.pop("asset_info", None)
clip_config.pop("source_asset_id", None)
# 转场效果兼容校验
try:
transition = TransitionEffect(clip.transition_effect)
except ValueError:
transition = TransitionEffect.CUT
# 片段类型兼容校验
try:
clip_type = ClipType(clip.clip_type)
except ValueError:
clip_type = ClipType.MAIN
clip_config_obj = TemplateClipConfig.create(
template_id=created_template.id,
clip_type=clip_type,
order=clip.order,
min_duration=clip.duration,
max_duration=clip.duration,
text_template=clip.text_content or "",
transition_effect=transition,
config=clip_config,
)
created = self._clip_config_repo.create(clip_config_obj)
created_configs.append(created)
return {
"template": created_template,
"clip_configs": created_configs,
}
+52 -6
View File
@@ -99,6 +99,11 @@ class PlanGeneratorService:
# 3. 生成片段列表 # 3. 生成片段列表
if clip_configs: if clip_configs:
clips = self._create_clips_from_configs(plan.id, clip_configs) clips = self._create_clips_from_configs(plan.id, clip_configs)
# 模板 clip_config 的 clip_type 是 ClipType 枚举(main/intro/outro 等),
# 但 PIP / VOICE_PIP 模式需要特定的 clip_type(overlay/background/corner_voice/b_roll)
# 才能让素材分配和渲染分层正确工作。
# 这里将 MAIN 类型的片段按顺序映射为对应模式的角色类型。
self._map_clip_types_for_mode(clips, editing_mode)
else: else:
clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids)) clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids))
@@ -146,8 +151,8 @@ class PlanGeneratorService:
plan_config: dict[str, Any] = { plan_config: dict[str, Any] = {
"editing_mode": editing_mode, "editing_mode": editing_mode,
} }
# 继承模板的 cover/title/subtitle/bgm 配置 # 继承模板的 cover/title/subtitle/bgm/export/filter 配置
for key in ("cover", "title", "subtitle", "bgm"): for key in ("cover", "title", "subtitle", "bgm", "export", "filter"):
if key in template_config: if key in template_config:
plan_config[key] = template_config[key] plan_config[key] = template_config[key]
@@ -182,6 +187,10 @@ class PlanGeneratorService:
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
) )
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
clip_cfg = cfg.config or {}
playback_speed = clip_cfg.get("playback_speed", clip_cfg.get("speed_ratio", 1.0)) or 1.0
clip = EditPlanClip.create( clip = EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
clip_type=clip_type, clip_type=clip_type,
@@ -190,11 +199,48 @@ class PlanGeneratorService:
text_content=getattr(cfg, "text_template", "") or "", text_content=getattr(cfg, "text_template", "") or "",
duration=duration, duration=duration,
transition_effect=transition or "cut", transition_effect=transition or "cut",
playback_speed=playback_speed,
config=clip_cfg,
) )
clips.append(clip) clips.append(clip)
return clips return clips
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
"""将模板 clip_config 生成的 MAIN 类型片段,按 editing_mode 映射为对应角色类型。
模板的 clip_config 使用 ClipType 枚举(main/intro/outro 等),
但 PIP / VOICE_PIP 模式的素材分配和渲染分层依赖特定的 clip_type 命名
(overlay / background / corner_voice / b_roll)。
映射规则(仅修改 MAIN 类型片段,非 MAIN 片段保持原类型):
- PIP: 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画)
- VOICE_PIP: 第1个 → background,第2个 → corner_voice,第3+个 → b_roll
- ONE_TAKE / VOICE_OVER: 保持 main 不变
"""
from packages.domain.template_clip_config import ClipType
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if not main_clips:
return
if editing_mode == EditingMode.PIP.value:
# 第1个 main 保持(背景层),其余改为 overlay(画中画层)
for i, clip in enumerate(main_clips):
if i > 0:
clip.clip_type = "overlay"
elif editing_mode == EditingMode.VOICE_PIP.value:
for i, clip in enumerate(main_clips):
if i == 0:
clip.clip_type = "background"
elif i == 1:
clip.clip_type = "corner_voice"
else:
clip.clip_type = "b_roll"
# ONE_TAKE / VOICE_OVER: 保持 main 不变,无需处理
def _generate_default_clips( def _generate_default_clips(
self, self,
plan_id: str, plan_id: str,
@@ -224,7 +270,7 @@ class PlanGeneratorService:
) )
order += 1 order += 1
# 剩余为 overlay # 剩余为 overlay
for i in range(1, n): for _ in range(1, n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -237,7 +283,7 @@ class PlanGeneratorService:
elif editing_mode == EditingMode.VOICE_OVER.value: elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clips(B-roll) # N 个 main clips(B-roll)
for i in range(n): for _ in range(n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -271,7 +317,7 @@ class PlanGeneratorService:
) )
order += 1 order += 1
# 剩余为 b_roll # 剩余为 b_roll
for i in range(2, n): for _ in range(2, n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -284,7 +330,7 @@ class PlanGeneratorService:
else: else:
# ONE_TAKE: N 个 main clips # ONE_TAKE: N 个 main clips
for i in range(n): for _ in range(n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
+43 -18
View File
@@ -219,7 +219,7 @@ class VideoComposeService:
根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。 根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。
滤镜链逻辑: 滤镜链逻辑:
- 每个片段:scale → crop → setpts → trim → atrim - 每个片段:scale → crop → fps → setpts → trim → atrim
- 多片段之间:concat 滤镜 或 xfade 转场 - 多片段之间:concat 滤镜 或 xfade 转场
- 最终输出:-map '[outv]' -map '[outa]'(如有音频) - 最终输出:-map '[outv]' -map '[outa]'(如有音频)
""" """
@@ -406,20 +406,30 @@ class VideoComposeService:
滤镜顺序: 滤镜顺序:
1. scale — 等比缩放到目标分辨率(保证覆盖) 1. scale — 等比缩放到目标分辨率(保证覆盖)
2. crop — 居中裁剪到目标分辨率 2. crop — 居中裁剪到目标分辨率
3. setpts — 重置时间戳 + 偏移 3. fps — 统一输出帧率(concat 要求所有输入帧率一致)
4. trim — 视频时长裁剪 4. setpts — 重置时间戳 + 偏移
5. atrim — 音频时长裁剪(如有音频流) 5. trim — 视频时长裁剪
6. atrim — 音频时长裁剪(如有音频流)
""" """
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒 duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
start = clip.start_time start = clip.start_time
filters: list[str] = [] filters: list[str] = []
# 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop) # 1. scale: 等比缩放(保持比例,不裁剪)
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=increase") filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease")
# 2. crop: 居中裁剪 # 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容)
filters.append(f"crop={output_width}:{output_height}") filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black")
# 3. format: 统一像素格式为 yuv420p(H.264 标准格式,concat 要求所有输入像素格式一致)
# 不同素材可能是 yuv420p / yuv422p / yuv444p / nv12 等,必须统一
filters.append("format=yuv420p")
# 4. fps: 统一帧率(concat 要求所有输入帧率一致)
# 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一
if fps and fps > 0:
filters.append(f"fps={fps}")
# 3. setpts: 重置时间戳 # 3. setpts: 重置时间戳
if start > 0: if start > 0:
@@ -532,11 +542,17 @@ def _build_concat_filter(
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]" concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
parts.append(concat_filter) parts.append(concat_filter)
# 音频 concat(如果有) # 音频 concat(如果有)— 先统一音频格式再拼接,否则不同采样率/声道会导致concat失败
audio_parts: list[str] = [] audio_parts: list[str] = []
for idx, chain in enumerate(clip_chains): for idx, chain in enumerate(clip_chains):
if chain.audio_label: if chain.audio_label:
audio_parts.append(f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]") # aformat: 统一采样率48000Hz + 双声道stereo + fltp采样格式(AAC标准格式)
audio_filters = [
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
f"atrim=0:{chain.duration}",
"asetpts=PTS-STARTPTS",
]
audio_parts.append(f"[{idx}:a]{','.join(audio_filters)}[{chain.audio_label}]")
if audio_parts: if audio_parts:
parts.extend(audio_parts) parts.extend(audio_parts)
@@ -611,13 +627,22 @@ def _build_xfade_filter(
# 总时长需要减去转场重叠部分 # 总时长需要减去转场重叠部分
total_duration -= transition_duration * (n - 1) total_duration -= transition_duration * (n - 1)
# 音频 crossfade(简化处理:使用 adelay + amix) # 音频:先 aformat 归一化再 concat(不同采样率/声道/采样格式会导致concat失败)
audio_labels = [c.audio_label for c in clip_chains if c.audio_label] audio_chains_with_label = [(c, c.audio_label) for c in clip_chains if c.audio_label]
if len(audio_labels) >= 2: if len(audio_chains_with_label) >= 2:
# 简单拼接音频(不做 crossfade) normalized_audio_labels: list[str] = []
audio_inputs = "".join(f"[{label}]" for label in audio_labels) for chain, _ in audio_chains_with_label:
parts.append(f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]") norm_label = f"anorm_{chain.video_label}"
elif len(audio_labels) == 1: audio_filters = [
parts.append(f"[{audio_labels[0]}]acopy[outa]") "aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
f"atrim=0:{chain.duration}",
"asetpts=PTS-STARTPTS",
]
parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]")
normalized_audio_labels.append(norm_label)
audio_inputs = "".join(f"[{label}]" for label in normalized_audio_labels)
parts.append(f"{audio_inputs}concat=n={len(normalized_audio_labels)}:v=0:a=1[outa]")
elif len(audio_chains_with_label) == 1:
parts.append(f"[{audio_chains_with_label[0][0].audio_label}]acopy[outa]")
return ";".join(parts), max(0.0, total_duration) return ";".join(parts), max(0.0, total_duration)
Regular → Executable
+4 -6
View File
@@ -8,16 +8,14 @@ module.exports = {
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended",
"prettier", // 关掉与 Prettier 冲突的 ESLint 规则
], ],
ignorePatterns: ["dist", ".eslintrc.cjs"], ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
plugins: ["react-refresh"], plugins: ["react-refresh"],
rules: { rules: {
"react-refresh/only-export-components": [ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
}, },
}; }
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
dist-ssr
*.local
.vscode/*
!.vscode/extensions.json
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+12
View File
@@ -0,0 +1,12 @@
{
"semi": false,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf",
"bracketSpacing": true,
"bracketSameLine": false
}
+4 -4
View File
@@ -91,17 +91,17 @@ VITE_API_URL=http://localhost:8000
使用 Zustand 创建 Store: 使用 Zustand 创建 Store:
```typescript ```typescript
import { create } from "zustand"; import { create } from "zustand"
interface MyStore { interface MyStore {
data: any; data: any
setData: (data: any) => void; setData: (data: any) => void
} }
export const useMyStore = create<MyStore>((set) => ({ export const useMyStore = create<MyStore>((set) => ({
data: null, data: null,
setData: (data) => set({ data }), setData: (data) => set({ data }),
})); }))
``` ```
## 部署 ## 部署
+240 -399
View File
@@ -5,33 +5,29 @@
* 删除素材、批量删除、空状态 * 删除素材、批量删除、空状态
* 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互 * 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test"
const PASSWORD = "SmokePass123!"; const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async ( const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
page: import("@playwright/test").Page, if (!apiOrigin) return
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -44,30 +40,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json(); const regData = await reg.json()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -75,7 +71,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
}; }
} }
/** 创建项目 */ /** 创建项目 */
@@ -90,10 +86,10 @@ async function createProject(
name: `Assets Test Proj ${suffix}`, name: `Assets Test Proj ${suffix}`,
description: "E2E assets test", description: "E2E assets test",
}, },
}); })
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy(); expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy()
const data = await resp.json(); const data = await resp.json()
return data.id; return data.id
} }
/** 创建素材库 */ /** 创建素材库 */
@@ -107,10 +103,10 @@ async function createLibrary(
const resp = await request.post(`${apiBase}/asset-libraries`, { const resp = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
data: { project_id: projectId, name, kind }, data: { project_id: projectId, name, kind },
}); })
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy(); expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy()
const data = await resp.json(); const data = await resp.json()
return data.id; return data.id
} }
/** 创建素材记录 */ /** 创建素材记录 */
@@ -136,10 +132,10 @@ async function createAsset(
uploaded_by_user_id: userId, uploaded_by_user_id: userId,
metadata: { duration: 15.5, resolution: "1080p" }, metadata: { duration: 15.5, resolution: "1080p" },
}, },
}); })
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy(); expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy()
const data = await resp.json(); const data = await resp.json()
return data.id; return data.id
} }
/** 在浏览器中设置登录态 */ /** 在浏览器中设置登录态 */
@@ -150,14 +146,14 @@ async function setupAuthInBrowser(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token, token,
@@ -171,551 +167,400 @@ async function setupAuthInBrowser(
email_verified: true, email_verified: true,
}, },
}, },
); )
} }
test.describe("素材库页面 - 完整交互测试", () => { test.describe("素材库页面 - 完整交互测试", () => {
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 })
// ─── 页面加载 ────────────────────────────────────── // ─── 页面加载 ──────────────────────────────────────
test("素材库列表页面加载", async ({ page, request }) => { test("素材库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { headers, userId, accessToken, email, username } = const { headers, userId, accessToken, email, username } = await createAuthedUser(
await createAuthedUser(request, "assets-load");
const projectId = await createProject(
request, request,
headers, "assets-load",
Date.now().toString(), )
); const projectId = await createProject(request, headers, Date.now().toString())
await createLibrary(request, headers, projectId, "默认视频库", "video"); await createLibrary(request, headers, projectId, "默认视频库", "video")
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/assets"); await page.goto("/app/assets")
// 页面布局容器 // 页面布局容器
await expect(page.locator(".xx-assets-page")).toBeVisible({ await expect(page.locator(".xx-assets-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 左侧素材库列表 // 左侧素材库列表
await expect(page.locator(".xx-asset-library-list")).toBeVisible(); await expect(page.locator(".xx-asset-library-list")).toBeVisible()
// 右侧内容区(上传区 + 筛选 + 素材网格) // 右侧内容区(上传区 + 筛选 + 素材网格)
await expect(page.locator(".xx-assets-content")).toBeVisible(); await expect(page.locator(".xx-assets-content")).toBeVisible()
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible(); await expect(page.locator(".xx-asset-upload-zone")).toBeVisible()
await expect(page.locator(".xx-assets-filters")).toBeVisible(); await expect(page.locator(".xx-assets-filters")).toBeVisible()
// 无错误提示 // 无错误提示
await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, { await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, {
timeout: 5_000, timeout: 5_000,
}); })
}); })
// ─── 创建素材库 ──────────────────────────────────── // ─── 创建素材库 ────────────────────────────────────
test("创建新素材库 - 通过 UI", async ({ page, request }) => { test("创建新素材库 - 通过 UI", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { headers, userId, accessToken, email, username } = const { headers, userId, accessToken, email, username } = await createAuthedUser(
await createAuthedUser(request, "assets-create");
const projectId = await createProject(
request, request,
headers, "assets-create",
Date.now().toString(), )
); const projectId = await createProject(request, headers, Date.now().toString())
await createLibrary(request, headers, projectId, "初始库", "video"); await createLibrary(request, headers, projectId, "初始库", "video")
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/assets"); await page.goto("/app/assets")
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 点击新建素材库 // 点击新建素材库
await page.locator(".xx-asset-library-add").click(); await page.locator(".xx-asset-library-add").click()
// 弹窗出现 // 弹窗出现
const modal = page const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" })
.locator(".ant-modal-content") await expect(modal).toBeVisible()
.filter({ hasText: "新建素材库" });
await expect(modal).toBeVisible();
// 填写表单 // 填写表单
const newLibName = `E2E 新建库 ${Date.now()}`; const newLibName = `E2E 新建库 ${Date.now()}`
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName); await modal.getByPlaceholder("请输入素材库名称").fill(newLibName)
// 类型选择默认是 video,保持即可 // 类型选择默认是 video,保持即可
// 监听创建请求 // 监听创建请求
const createPromise = page.waitForResponse( const createPromise = page.waitForResponse(
(resp) => (resp) => resp.url().includes("/asset-libraries") && resp.request().method() === "POST",
resp.url().includes("/asset-libraries") &&
resp.request().method() === "POST",
{ timeout: 10_000 }, { timeout: 10_000 },
); )
// 点击创建 // 点击创建
await modal.getByRole("button", { name: "创建" }).click(); await modal.getByRole("button", { name: "创建" }).click()
const resp = await createPromise; const resp = await createPromise
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy(); expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy()
// 新素材库应出现在列表中 // 新素材库应出现在列表中
await expect( await expect(
page.locator(".xx-asset-library-item").filter({ hasText: newLibName }), page.locator(".xx-asset-library-item").filter({ hasText: newLibName }),
).toBeVisible({ timeout: 10_000 }); ).toBeVisible({ timeout: 10_000 })
}); })
// ─── 切换素材库 ──────────────────────────────────── // ─── 切换素材库 ────────────────────────────────────
test("切换不同素材库", async ({ page, request }) => { test("切换不同素材库", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { headers, userId, accessToken, email, username } = const { headers, userId, accessToken, email, username } = await createAuthedUser(
await createAuthedUser(request, "assets-switch");
const projectId = await createProject(
request, request,
headers, "assets-switch",
Date.now().toString(), )
); const projectId = await createProject(request, headers, Date.now().toString())
const videoLibName = "视频素材库 A"; const videoLibName = "视频素材库 A"
const imageLibName = "图片素材库 B"; const imageLibName = "图片素材库 B"
const videoLibId = await createLibrary( const videoLibId = await createLibrary(request, headers, projectId, videoLibName, "video")
request,
headers,
projectId,
videoLibName,
"video",
);
const imageLibId = await createLibrary(
request,
headers,
projectId,
imageLibName,
"image",
);
// 在视频库里创建一个素材 // 在视频库里创建一个素材
await createAsset( await createAsset(request, headers, projectId, videoLibId, userId, "demo_video.mp4")
request,
headers,
projectId,
videoLibId,
userId,
"demo_video.mp4",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/assets"); await page.goto("/app/assets")
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 点击视频库,应显示素材 // 点击视频库,应显示素材
const videoLibItem = page const videoLibItem = page.locator(".xx-asset-library-item").filter({ hasText: videoLibName })
.locator(".xx-asset-library-item") await videoLibItem.click({ force: true })
.filter({ hasText: videoLibName }); await expect(videoLibItem).toHaveClass(/active/)
await videoLibItem.click({ force: true });
await expect(videoLibItem).toHaveClass(/active/);
// 验证视频素材出现 // 验证视频素材出现
await expect(page.getByText("demo_video.mp4")).toBeVisible({ await expect(page.getByText("demo_video.mp4")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}); })
// 点击图片库,应切换且不显示视频 // 点击图片库,应切换且不显示视频
const imageLibItem = page const imageLibItem = page.locator(".xx-asset-library-item").filter({ hasText: imageLibName })
.locator(".xx-asset-library-item") await imageLibItem.click({ force: true })
.filter({ hasText: imageLibName }); await expect(imageLibItem).toHaveClass(/active/)
await imageLibItem.click({ force: true });
await expect(imageLibItem).toHaveClass(/active/);
// 空状态或图片库内容 // 空状态或图片库内容
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
timeout: 5_000, timeout: 5_000,
}); })
}); })
// ─── 素材搜索 ────────────────────────────────────── // ─── 素材搜索 ──────────────────────────────────────
test("素材搜索功能", async ({ page, request }) => { test("素材搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { headers, userId, accessToken, email, username } = const { headers, userId, accessToken, email, username } = await createAuthedUser(
await createAuthedUser(request, "assets-search");
const projectId = await createProject(
request, request,
headers, "assets-search",
Date.now().toString(), )
); const projectId = await createProject(request, headers, Date.now().toString())
const libraryId = await createLibrary( const libraryId = await createLibrary(request, headers, projectId, "搜索测试库", "video")
request,
headers,
projectId,
"搜索测试库",
"video",
);
// 创建两个不同名称的素材 // 创建两个不同名称的素材
await createAsset( await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4")
request, await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4")
headers,
projectId,
libraryId,
userId,
"apple_clip.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"banana_clip.mp4",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/assets"); await page.goto("/app/assets")
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 确保在测试库中 // 确保在测试库中
const libItem = page const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "搜索测试库" })
.locator(".xx-asset-library-item") await libItem.click({ force: true })
.filter({ hasText: "搜索测试库" });
await libItem.click({ force: true });
// 两个素材都应可见 // 两个素材都应可见
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ await expect(page.getByText("apple_clip.mp4")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}); })
await expect(page.getByText("banana_clip.mp4")).toBeVisible(); await expect(page.getByText("banana_clip.mp4")).toBeVisible()
// 搜索 apple,只显示 apple // 搜索 apple,只显示 apple
await page.getByPlaceholder("搜索素材名称...").fill("apple"); await page.getByPlaceholder("搜索素材名称...").fill("apple")
await expect(page.getByText("apple_clip.mp4")).toBeVisible(); await expect(page.getByText("apple_clip.mp4")).toBeVisible()
await expect(page.getByText("banana_clip.mp4")).toHaveCount(0); await expect(page.getByText("banana_clip.mp4")).toHaveCount(0)
// 清空搜索,两个都显示 // 清空搜索,两个都显示
await page.getByPlaceholder("搜索素材名称...").fill(""); await page.getByPlaceholder("搜索素材名称...").fill("")
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ await expect(page.getByText("apple_clip.mp4")).toBeVisible({
timeout: 5_000, timeout: 5_000,
}); })
await expect(page.getByText("banana_clip.mp4")).toBeVisible(); await expect(page.getByText("banana_clip.mp4")).toBeVisible()
}); })
// ─── 筛选类型 ────────────────────────────────────── // ─── 筛选类型 ──────────────────────────────────────
test("素材类型筛选", async ({ page, request }) => { test("素材类型筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { headers, userId, accessToken, email, username } = const { headers, userId, accessToken, email, username } = await createAuthedUser(
await createAuthedUser(request, "assets-filter");
const projectId = await createProject(
request, request,
headers, "assets-filter",
Date.now().toString(), )
); const projectId = await createProject(request, headers, Date.now().toString())
const libraryId = await createLibrary( const libraryId = await createLibrary(request, headers, projectId, "筛选测试库", "video")
request,
headers,
projectId,
"筛选测试库",
"video",
);
// 创建视频素材 // 创建视频素材
await createAsset( await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4")
request,
headers,
projectId,
libraryId,
userId,
"video_clip.mp4",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/assets"); await page.goto("/app/assets")
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const libItem = page const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "筛选测试库" })
.locator(".xx-asset-library-item") await libItem.click({ force: true })
.filter({ hasText: "筛选测试库" });
await libItem.click({ force: true });
// 素材应可见 // 素材应可见
await expect(page.getByText("video_clip.mp4")).toBeVisible({ await expect(page.getByText("video_clip.mp4")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}); })
// 筛选类型下拉存在 // 筛选类型下拉存在
const filterSelect = page.locator(".xx-assets-filters-left select").first(); const filterSelect = page.locator(".xx-assets-filters-left select").first()
await expect(filterSelect).toBeVisible(); await expect(filterSelect).toBeVisible()
}); })
// ─── 素材详情/播放 ──────────────────────────────── // ─── 素材详情/播放 ────────────────────────────────
test("素材详情查看 - 播放弹窗", async ({ page, request }) => { test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { headers, userId, accessToken, email, username } = const { headers, userId, accessToken, email, username } = await createAuthedUser(
await createAuthedUser(request, "assets-detail");
const projectId = await createProject(
request, request,
headers, "assets-detail",
Date.now().toString(), )
); const projectId = await createProject(request, headers, Date.now().toString())
const libraryId = await createLibrary( const libraryId = await createLibrary(request, headers, projectId, "详情测试库", "video")
request, await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4")
headers,
projectId,
"详情测试库",
"video",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"play_test.mp4",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/assets"); await page.goto("/app/assets")
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const libItem = page const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "详情测试库" })
.locator(".xx-asset-library-item") await libItem.click({ force: true })
.filter({ hasText: "详情测试库" });
await libItem.click({ force: true });
// 找到素材卡片并点击播放按钮 // 找到素材卡片并点击播放按钮
const assetCard = page const assetCard = page.locator(".xx-asset-card").filter({ hasText: "play_test.mp4" })
.locator(".xx-asset-card") await expect(assetCard).toBeVisible({ timeout: 10_000 })
.filter({ hasText: "play_test.mp4" });
await expect(assetCard).toBeVisible({ timeout: 10_000 });
// 点击播放按钮 // 点击播放按钮
await assetCard.locator(".xx-asset-play").click({ force: true }); await assetCard.locator(".xx-asset-play").click({ force: true })
// 播放弹窗出现 // 播放弹窗出现
const modal = page const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" })
.locator(".ant-modal-content") await expect(modal).toBeVisible()
.filter({ hasText: "播放" });
await expect(modal).toBeVisible();
// 关闭弹窗 // 关闭弹窗
await modal.locator(".ant-modal-close").click(); await modal.locator(".ant-modal-close").click()
await expect(modal).not.toBeVisible({ timeout: 5_000 }); await expect(modal).not.toBeVisible({ timeout: 5_000 })
}); })
// ─── 删除素材 ────────────────────────────────────── // ─── 删除素材 ──────────────────────────────────────
test("删除素材 - 带确认对话框", async ({ page, request }) => { test("删除素材 - 带确认对话框", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { headers, userId, accessToken, email, username } = const { headers, userId, accessToken, email, username } = await createAuthedUser(
await createAuthedUser(request, "assets-delete");
const projectId = await createProject(
request, request,
headers, "assets-delete",
Date.now().toString(), )
); const projectId = await createProject(request, headers, Date.now().toString())
const libraryId = await createLibrary( const libraryId = await createLibrary(request, headers, projectId, "删除测试库", "video")
request, await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4")
headers,
projectId,
"删除测试库",
"video",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"to_delete.mp4",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/assets"); await page.goto("/app/assets")
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const libItem = page const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "删除测试库" })
.locator(".xx-asset-library-item") await libItem.click({ force: true })
.filter({ hasText: "删除测试库" });
await libItem.click({ force: true });
const assetCard = page const assetCard = page.locator(".xx-asset-card").filter({ hasText: "to_delete.mp4" })
.locator(".xx-asset-card") await expect(assetCard).toBeVisible({ timeout: 10_000 })
.filter({ hasText: "to_delete.mp4" });
await expect(assetCard).toBeVisible({ timeout: 10_000 });
// 悬停显示删除按钮 // 悬停显示删除按钮
await assetCard.hover(); await assetCard.hover()
// 点击删除 // 点击删除
const deleteBtn = assetCard.locator(".xx-asset-delete"); const deleteBtn = assetCard.locator(".xx-asset-delete")
await expect(deleteBtn).toBeVisible(); await expect(deleteBtn).toBeVisible()
await deleteBtn.click({ force: true }); await deleteBtn.click({ force: true })
// 确认对话框出现 // 确认对话框出现
const confirmModal = page const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" })
.locator(".ant-popover") await expect(confirmModal).toBeVisible()
.filter({ hasText: "确认删除" });
await expect(confirmModal).toBeVisible();
// 监听删除请求 // 监听删除请求
const deletePromise = page.waitForResponse( const deletePromise = page.waitForResponse(
(resp) => (resp) => resp.url().includes("/assets/") && resp.request().method() === "DELETE",
resp.url().includes("/assets/") && resp.request().method() === "DELETE",
{ timeout: 10_000 }, { timeout: 10_000 },
); )
// 点击确认删除 // 点击确认删除
await confirmModal.getByRole("button", { name: "删除" }).click(); await confirmModal.getByRole("button", { name: "删除" }).click()
const resp = await deletePromise; const resp = await deletePromise
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy(); expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy()
// 素材应从列表中消失 // 素材应从列表中消失
await expect(page.getByText("to_delete.mp4")).toHaveCount(0, { await expect(page.getByText("to_delete.mp4")).toHaveCount(0, {
timeout: 10_000, timeout: 10_000,
}); })
}); })
// ─── 批量删除素材 ────────────────────────────────── // ─── 批量删除素材 ──────────────────────────────────
test("批量删除素材", async ({ page, request }) => { test("批量删除素材", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { headers, userId, accessToken, email, username } = const { headers, userId, accessToken, email, username } = await createAuthedUser(
await createAuthedUser(request, "assets-batch");
const projectId = await createProject(
request, request,
headers, "assets-batch",
Date.now().toString(), )
); const projectId = await createProject(request, headers, Date.now().toString())
const libraryId = await createLibrary( const libraryId = await createLibrary(request, headers, projectId, "批量删除库", "video")
request,
headers,
projectId,
"批量删除库",
"video",
);
// 创建多个素材 // 创建多个素材
await createAsset( await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4")
request, await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4")
headers, await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4")
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, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/assets"); await page.goto("/app/assets")
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const libItem = page const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "批量删除库" })
.locator(".xx-asset-library-item") await libItem.click({ force: true })
.filter({ hasText: "批量删除库" });
await libItem.click({ force: true });
// 所有素材应可见 // 所有素材应可见
await expect(page.getByText("batch_1.mp4")).toBeVisible({ await expect(page.getByText("batch_1.mp4")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}); })
await expect(page.getByText("batch_2.mp4")).toBeVisible(); await expect(page.getByText("batch_2.mp4")).toBeVisible()
await expect(page.getByText("batch_3.mp4")).toBeVisible(); await expect(page.getByText("batch_3.mp4")).toBeVisible()
// 点击全选 // 点击全选
const selectAllBtn = page.getByRole("button", { name: "全选" }); const selectAllBtn = page.getByRole("button", { name: "全选" })
await expect(selectAllBtn).toBeVisible(); await expect(selectAllBtn).toBeVisible()
await selectAllBtn.click(); await selectAllBtn.click()
// 批量操作栏出现 // 批量操作栏出现
const batchBar = page.locator(".xx-assets-batch-bar"); const batchBar = page.locator(".xx-assets-batch-bar")
await expect(batchBar).toBeVisible(); await expect(batchBar).toBeVisible()
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible(); await expect(batchBar.getByText(/已选 3 项/)).toBeVisible()
// 点击批量删除 // 点击批量删除
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" }); const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" })
await expect(batchDeleteBtn).toBeVisible(); await expect(batchDeleteBtn).toBeVisible()
await batchDeleteBtn.click(); await batchDeleteBtn.click()
// 确认对话框 // 确认对话框
const confirmPop = page const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" })
.locator(".ant-popover") await expect(confirmPop).toBeVisible()
.filter({ hasText: "确定删除" });
await expect(confirmPop).toBeVisible();
// 确认删除 // 确认删除
await confirmPop.getByRole("button", { name: "删除" }).click(); await confirmPop.getByRole("button", { name: "删除" }).click()
// 验证素材已删除(通过 API 确认) // 验证素材已删除(通过 API 确认)
await expect await expect
@@ -724,57 +569,53 @@ test.describe("素材库页面 - 完整交互测试", () => {
const resp = await request.get(`${apiBase}/assets`, { const resp = await request.get(`${apiBase}/assets`, {
headers, headers,
params: { library_id: libraryId }, params: { library_id: libraryId },
}); })
if (!resp.ok()) return "error"; if (!resp.ok()) return "error"
const data = await resp.json(); const data = await resp.json()
const items = data.items || []; const items = data.items || []
return items.length; return items.length
}, },
{ timeout: 15_000, intervals: [1_000, 2_000, 3_000] }, { timeout: 15_000, intervals: [1_000, 2_000, 3_000] },
) )
.toBe(0); .toBe(0)
}); })
// ─── 空状态 ──────────────────────────────────────── // ─── 空状态 ────────────────────────────────────────
test("空素材库展示空状态", async ({ page, request }) => { test("空素材库展示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { headers, userId, accessToken, email, username } = const { headers, userId, accessToken, email, username } = await createAuthedUser(
await createAuthedUser(request, "assets-empty");
const projectId = await createProject(
request, request,
headers, "assets-empty",
Date.now().toString(), )
); const projectId = await createProject(request, headers, Date.now().toString())
await createLibrary(request, headers, projectId, "空素材库", "video"); await createLibrary(request, headers, projectId, "空素材库", "video")
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/assets"); await page.goto("/app/assets")
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const libItem = page const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "空素材库" })
.locator(".xx-asset-library-item") await libItem.click({ force: true })
.filter({ hasText: "空素材库" });
await libItem.click({ force: true });
// 空状态应显示 // 空状态应显示
await expect(page.locator(".xx-assets-empty")).toBeVisible({ await expect(page.locator(".xx-assets-empty")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}); })
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible(); await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible()
}); })
// ─── 未登录访问 ──────────────────────────────────── // ─── 未登录访问 ────────────────────────────────────
test("未登录访问素材库 - 重定向到登录页", async ({ page }) => { test("未登录访问素材库 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/assets"); await page.goto("/app/assets")
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/)
}); })
}); })
+5 -5
View File
@@ -1,8 +1,8 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test"
test.describe("App route guard", () => { test.describe("App route guard", () => {
test("redirects anonymous users to login", async ({ page }) => { test("redirects anonymous users to login", async ({ page }) => {
await page.goto("/app/dashboard"); await page.goto("/app/dashboard")
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/)
}); })
}); })
+7 -7
View File
@@ -1,10 +1,10 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test"
test.describe("Authentication page", () => { test.describe("Authentication page", () => {
test("renders login form", async ({ page }) => { test("renders login form", async ({ page }) => {
await page.goto("/login"); await page.goto("/login")
await expect(page.getByLabel("邮箱")).toBeVisible(); await expect(page.getByLabel("邮箱")).toBeVisible()
await expect(page.getByLabel("密码")).toBeVisible(); await expect(page.getByLabel("密码")).toBeVisible()
await expect(page.getByRole("button", { name: "登录" })).toBeVisible(); await expect(page.getByRole("button", { name: "登录" })).toBeVisible()
}); })
}); })
+111 -122
View File
@@ -1,23 +1,19 @@
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test"
const PASSWORD = "SmokePass123!"; const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async ( const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
page: import("@playwright/test").Page, if (!apiOrigin) return
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
async function loginWithRetry( async function loginWithRetry(
request: APIRequestContext, request: APIRequestContext,
@@ -28,73 +24,70 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
type ProjectResponse = { id: string }; type ProjectResponse = { id: string }
type LibraryResponse = { id: string }; type LibraryResponse = { id: string }
type TemplateResponse = { id: string }; type TemplateResponse = { id: string }
type AssetListResponse = { type AssetListResponse = {
items: Array<{ items: Array<{
id: string; id: string
name: string; name: string
status: string; status: string
}>; }>
}; }
test.describe("Core generation flow", () => { test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 })
test("walks through 5-step wizard and starts generation", async ({ test("walks through 5-step wizard and starts generation", async ({ page, request }) => {
page, test.setTimeout(180_000)
request,
}) => {
test.setTimeout(180_000);
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
const email = `e2e-gen-${suffix}@example.com`; const email = `e2e-gen-${suffix}@example.com`
const username = `e2e_gen_${suffix}`; const username = `e2e_gen_${suffix}`
const libraryName = `E2E Gen Lib ${suffix}`; const libraryName = `E2E Gen Lib ${suffix}`
// Register // Register
const register = await request.post(`${apiBase}/auth/register`, { const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username }, data: { email, username, password: PASSWORD, display_name: username },
}); })
expect(register.status()).toBe(201); expect(register.status()).toBe(201)
const registerData = (await register.json()) as { user_id: string }; const registerData = (await register.json()) as { user_id: string }
// Login // Login
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status()).toBe(200); expect(login.status()).toBe(200)
const loginData = (await login.json()) as { access_token: string }; const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }; const headers = { Authorization: `Bearer ${loginData.access_token}` }
// Create project // Create project
const project = await request.post(`${apiBase}/projects`, { const project = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `E2E Gen Proj ${suffix}` }, data: { name: `E2E Gen Proj ${suffix}` },
}); })
expect(project.status()).toBe(200); expect(project.status()).toBe(200)
const projectData = (await project.json()) as ProjectResponse; const projectData = (await project.json()) as ProjectResponse
// Create asset library // Create asset library
const library = await request.post(`${apiBase}/asset-libraries`, { const library = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
data: { project_id: projectData.id, name: libraryName, kind: "video" }, data: { project_id: projectData.id, name: libraryName, kind: "video" },
}); })
expect(library.status()).toBe(200); expect(library.status()).toBe(200)
const libraryData = (await library.json()) as LibraryResponse; const libraryData = (await library.json()) as LibraryResponse
// Upload source video // Upload source video
const sourceFileName = "e2e-gen-source.mp4"; const sourceFileName = "e2e-gen-source.mp4"
const upload = await request.post(`${apiBase}/upload`, { const upload = await request.post(`${apiBase}/upload`, {
headers, headers,
multipart: { multipart: {
@@ -106,8 +99,8 @@ test.describe("Core generation flow", () => {
buffer: Buffer.from("e2e source data"), buffer: Buffer.from("e2e source data"),
}, },
}, },
}); })
expect(upload.status()).toBe(200); expect(upload.status()).toBe(200)
// Wait for asset to be ready // Wait for asset to be ready
await expect await expect
@@ -116,16 +109,16 @@ test.describe("Core generation flow", () => {
const assets = await request.get(`${apiBase}/assets`, { const assets = await request.get(`${apiBase}/assets`, {
headers, headers,
params: { library_id: libraryData.id }, params: { library_id: libraryData.id },
}); })
if (!assets.ok()) return `http_${assets.status()}`; if (!assets.ok()) return `http_${assets.status()}`
const data = (await assets.json()) as AssetListResponse; const data = (await assets.json()) as AssetListResponse
const asset = data.items.find((a) => a.name === sourceFileName); const asset = data.items.find((a) => a.name === sourceFileName)
if (!asset) return "missing"; if (!asset) return "missing"
return asset.status; return asset.status
}, },
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
) )
.toBe("ready"); .toBe("ready")
// Create an editing template so the generate page has at least one template // Create an editing template so the generate page has at least one template
// (templates are now loaded from API; new users have none by default) // (templates are now loaded from API; new users have none by default)
@@ -145,22 +138,22 @@ test.describe("Core generation flow", () => {
], ],
tags: ["e2e"], tags: ["e2e"],
}, },
}); })
expect(template.status(), await template.text()).toBe(201); expect(template.status(), await template.text()).toBe(201)
const templateData = (await template.json()) as TemplateResponse; const templateData = (await template.json()) as TemplateResponse
expect(templateData.id).toBeTruthy(); expect(templateData.id).toBeTruthy()
// Set auth in localStorage // Set auth in localStorage
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token: loginData.access_token, token: loginData.access_token,
@@ -174,43 +167,43 @@ test.describe("Core generation flow", () => {
email_verified: true, email_verified: true,
}, },
}, },
); )
// Navigate to generate page // Navigate to generate page
await page.goto("/app/generate"); await page.goto("/app/generate")
await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({ await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// Step 1: template - default selected, click next // Step 1: template - default selected, click next
await expect(page.locator(".xx-choice-item.selected")).toBeVisible(); await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
await page.getByRole("button", { name: "下一步" }).click(); await page.getByRole("button", { name: "下一步" }).click()
// Step 2: select material // Step 2: select material
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible(); await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
const librarySelect = page.locator("select").first(); const librarySelect = page.locator("select").first()
await librarySelect.selectOption({ label: libraryName }); await librarySelect.selectOption({ label: libraryName })
const materialLabel = page.getByText(sourceFileName).locator(".."); const materialLabel = page.getByText(sourceFileName).locator("..")
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({ await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}); })
await materialLabel.locator("input[type='checkbox']").check(); await materialLabel.locator("input[type='checkbox']").check()
await page.getByRole("button", { name: "下一步" }).click(); await page.getByRole("button", { name: "下一步" }).click()
// Step 3: title // Step 3: title
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible(); await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible()
const titleText = `E2E Test ${suffix}`; const titleText = `E2E Test ${suffix}`
await page.getByPlaceholder("输入自定义标题…").fill(titleText); await page.getByPlaceholder("输入自定义标题…").fill(titleText)
await page.getByRole("button", { name: "下一步" }).click(); await page.getByRole("button", { name: "下一步" }).click()
// Step 4: voice // Step 4: voice
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible(); await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible()
const firstVoiceCard = page.locator(".xx-voice-choice-item").first(); const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
await firstVoiceCard.click(); await firstVoiceCard.click()
await page.getByRole("button", { name: "下一步" }).click(); await page.getByRole("button", { name: "下一步" }).click()
// Step 5: confirm and generate // Step 5: confirm and generate
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible(); await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
// Wait for plan creation API to be called // Wait for plan creation API to be called
const createPlanPromise = page.waitForResponse( const createPlanPromise = page.waitForResponse(
@@ -219,68 +212,64 @@ test.describe("Core generation flow", () => {
response.request().method() === "POST" && response.request().method() === "POST" &&
!response.url().includes("/generate"), !response.url().includes("/generate"),
{ timeout: 30_000 }, { timeout: 30_000 },
); )
// Click generate button // Click generate button
await page await page.locator(".xx-btn-primary").filter({ hasText: "确认生成" }).first().click()
.locator(".xx-btn-primary")
.filter({ hasText: "确认生成" })
.first()
.click();
// Verify plan was created successfully // Verify plan was created successfully
const planResp = await createPlanPromise; const planResp = await createPlanPromise
expect(planResp.ok()).toBeTruthy(); expect(planResp.ok()).toBeTruthy()
const planData = (await planResp.json()) as { id: string }; const planData = (await planResp.json()) as { id: string }
expect(planData.id).toBeTruthy(); expect(planData.id).toBeTruthy()
// Generation may fail in test env (no worker), that's OK // Generation may fail in test env (no worker), that's OK
// Just verify the flow started - check page shows generation-related UI // Just verify the flow started - check page shows generation-related UI
await page await page
.getByText(/生成中|生成完成|生成失败/) .getByText(/生成中|生成完成|生成失败/)
.isVisible({ timeout: 15_000 }) .isVisible({ timeout: 15_000 })
.catch(() => false); .catch(() => false)
// If we see progress or result, great; if not, flow still reached the end // If we see progress or result, great; if not, flow still reached the end
// which is sufficient for an E2E smoke test // which is sufficient for an E2E smoke test
// Verify product library page loads (smoke: just verify page renders) // Verify product library page loads (smoke: just verify page renders)
await page.goto("/app/products"); await page.goto("/app/products")
await expect(page).toHaveURL(/\/app\/products/); await expect(page).toHaveURL(/\/app\/products/)
// Verify page container exists = page rendered correctly // Verify page container exists = page rendered correctly
// (works in all states: loading/error/success - more reliable than checking search input) // (works in all states: loading/error/success - more reliable than checking search input)
await expect(page.locator(".xx-products-page")).toBeVisible({ await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 15_000, timeout: 15_000,
}); })
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错 // 清理所有路由,避免页面关闭时飞地API请求导致测试报错
await page.unrouteAll({ behavior: "ignoreErrors" }); await page.unrouteAll({ behavior: "ignoreErrors" })
}); })
test("generation task API creates and lists tasks", async ({ request }) => { test("generation task API creates and lists tasks", async ({ request }) => {
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
const email = `e2e-gen-api-${suffix}@example.com`; const email = `e2e-gen-api-${suffix}@example.com`
const username = `e2e_gen_api_${suffix}`; const username = `e2e_gen_api_${suffix}`
const register = await request.post(`${apiBase}/auth/register`, { const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username }, data: { email, username, password: PASSWORD, display_name: username },
}); })
expect(register.status()).toBe(201); expect(register.status()).toBe(201)
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status()).toBe(200); expect(login.status()).toBe(200)
const loginData = (await login.json()) as { access_token: string }; const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }; const headers = { Authorization: `Bearer ${loginData.access_token}` }
const project = await request.post(`${apiBase}/projects`, { const project = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `E2E API Proj ${suffix}` }, data: { name: `E2E API Proj ${suffix}` },
}); })
expect(project.status()).toBe(200); expect(project.status()).toBe(200)
// List generation tasks via task center API // List generation tasks via task center API
const tasks = await request.get(`${apiBase}/tasks`, { headers }); const tasks = await request.get(`${apiBase}/tasks`, { headers })
expect(tasks.status()).toBe(200); expect(tasks.status()).toBe(200)
const tasksData = await tasks.json(); const tasksData = await tasks.json()
expect(Array.isArray(tasksData.items)).toBe(true); expect(Array.isArray(tasksData.items)).toBe(true)
}); })
}); })
+66 -76
View File
@@ -1,23 +1,19 @@
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test"
const PASSWORD = "SmokePass123!"; const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async ( const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
page: import("@playwright/test").Page, if (!apiOrigin) return
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry( async function loginWithRetry(
@@ -29,38 +25,35 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
test.describe("Title library flow", () => { test.describe("Title library flow", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("loads title library page and displays titles", async ({ test("loads title library page and displays titles", async ({ page, request }) => {
page, await routeBrowserApiToTestApi(page)
request, const suffix = Date.now().toString(36)
}) => { const email = `e2e-title-${suffix}@example.com`
await routeBrowserApiToTestApi(page); const username = `e2e_title_${suffix}`
const suffix = Date.now().toString(36);
const email = `e2e-title-${suffix}@example.com`;
const username = `e2e_title_${suffix}`;
const register = await request.post(`${apiBase}/auth/register`, { const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username }, data: { email, username, password: PASSWORD, display_name: username },
}); })
expect(register.status(), await register.text()).toBe(201); expect(register.status(), await register.text()).toBe(201)
const registerData = (await register.json()) as { user_id: string }; const registerData = (await register.json()) as { user_id: string }
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status(), await login.text()).toBe(200); expect(login.status(), await login.text()).toBe(200)
const loginData = (await login.json()) as { access_token: string }; const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }; const headers = { Authorization: `Bearer ${loginData.access_token}` }
// Create a title so the titles page has at least one title card to display // Create a title so the titles page has at least one title card to display
// (titles are loaded from API; new users have none by default) // (titles are loaded from API; new users have none by default)
@@ -71,19 +64,19 @@ test.describe("Title library flow", () => {
text: `E2E 测试标题内容 ${suffix}`, text: `E2E 测试标题内容 ${suffix}`,
category: "default", category: "default",
}, },
}); })
expect(createTitle.status(), await createTitle.text()).toBe(201); expect(createTitle.status(), await createTitle.text()).toBe(201)
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token: loginData.access_token, token: loginData.access_token,
@@ -97,45 +90,42 @@ test.describe("Title library flow", () => {
email_verified: true, email_verified: true,
}, },
}, },
); )
await page.goto("/app/titles"); await page.goto("/app/titles")
await expect(page.locator(".xx-titles-page")).toBeVisible({ await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
await expect(page.locator(".xx-title-card").first()).toBeVisible({ await expect(page.locator(".xx-title-card").first()).toBeVisible({
timeout: 10_000, timeout: 10_000,
}); })
const firstTitleText = await page const firstTitleText = await page.locator(".xx-title-card-text").first().textContent()
.locator(".xx-title-card-text")
.first()
.textContent();
if (firstTitleText) { if (firstTitleText) {
await page.getByPlaceholder("搜索标题关键词...").fill(firstTitleText); await page.getByPlaceholder("搜索标题关键词...").fill(firstTitleText)
await expect(page.getByText(firstTitleText)).toBeVisible(); await expect(page.getByText(firstTitleText)).toBeVisible()
} }
await expect(page.locator(".xx-title-card-stat").first()).toBeVisible(); await expect(page.locator(".xx-title-card-stat").first()).toBeVisible()
}); })
test("titles API creates and lists titles", async ({ request }) => { test("titles API creates and lists titles", async ({ request }) => {
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
const email = `e2e-title-api-${suffix}@example.com`; const email = `e2e-title-api-${suffix}@example.com`
const username = `e2e_title_api_${suffix}`; const username = `e2e_title_api_${suffix}`
const register = await request.post(`${apiBase}/auth/register`, { const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username }, data: { email, username, password: PASSWORD, display_name: username },
}); })
expect(register.status()).toBe(201); expect(register.status()).toBe(201)
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status()).toBe(200); expect(login.status()).toBe(200)
const loginData = (await login.json()) as { access_token: string }; const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }; const headers = { Authorization: `Bearer ${loginData.access_token}` }
const titleText = `E2E Test Title ${suffix}`; const titleText = `E2E Test Title ${suffix}`
const createResp = await request.post(`${apiBase}/titles`, { const createResp = await request.post(`${apiBase}/titles`, {
headers, headers,
data: { data: {
@@ -143,20 +133,20 @@ test.describe("Title library flow", () => {
text: titleText, text: titleText,
category: "default", category: "default",
}, },
}); })
expect(createResp.status(), await createResp.text()).toBe(201); expect(createResp.status(), await createResp.text()).toBe(201)
const created = (await createResp.json()) as { const created = (await createResp.json()) as {
id: string; id: string
text: string; text: string
}; }
expect(created.id).toBeTruthy(); expect(created.id).toBeTruthy()
const listResp = await request.get(`${apiBase}/titles`, { headers }); const listResp = await request.get(`${apiBase}/titles`, { headers })
expect(listResp.status()).toBe(200); expect(listResp.status()).toBe(200)
const listData = (await listResp.json()) as { const listData = (await listResp.json()) as {
items: Array<{ id: string; text: string }>; items: Array<{ id: string; text: string }>
}; }
const found = listData.items.find((t) => t.id === created.id); const found = listData.items.find((t) => t.id === created.id)
expect(found).toBeTruthy(); expect(found).toBeTruthy()
}); })
}); })
+71 -86
View File
@@ -1,23 +1,19 @@
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test"
const PASSWORD = "SmokePass123!"; const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async ( const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
page: import("@playwright/test").Page, if (!apiOrigin) return
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
async function loginWithRetry( async function loginWithRetry(
@@ -29,31 +25,28 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
type ProjectResponse = { id: string }; type ProjectResponse = { id: string }
type LibraryResponse = { id: string }; type LibraryResponse = { id: string }
test.describe("Core media upload flow", () => { test.describe("Core media upload flow", () => {
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 })
test("uploads a video asset and shows it in the asset library", async ({ test("uploads a video asset and shows it in the asset library", async ({ page, request }) => {
page, test.setTimeout(120_000)
request,
}) => {
test.setTimeout(120_000);
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
const email = `e2e-mov-${suffix}@example.com`; const email = `e2e-mov-${suffix}@example.com`
const username = `e2e_mov_${suffix}`; const username = `e2e_mov_${suffix}`
const register = await request.post(`${apiBase}/auth/register`, { const register = await request.post(`${apiBase}/auth/register`, {
data: { data: {
@@ -62,15 +55,15 @@ test.describe("Core media upload flow", () => {
password: PASSWORD, password: PASSWORD,
display_name: username, display_name: username,
}, },
}); })
expect(register.status(), await register.text()).toBe(201); expect(register.status(), await register.text()).toBe(201)
const registerData = (await register.json()) as { user_id: string }; const registerData = (await register.json()) as { user_id: string }
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status(), await login.text()).toBe(200); expect(login.status(), await login.text()).toBe(200)
const loginData = (await login.json()) as { access_token: string }; const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }; const headers = { Authorization: `Bearer ${loginData.access_token}` }
const project = await request.post(`${apiBase}/projects`, { const project = await request.post(`${apiBase}/projects`, {
headers, headers,
@@ -78,9 +71,9 @@ test.describe("Core media upload flow", () => {
name: `E2E Project ${suffix}`, name: `E2E Project ${suffix}`,
description: "Playwright upload smoke", description: "Playwright upload smoke",
}, },
}); })
expect(project.status(), await project.text()).toBe(200); expect(project.status(), await project.text()).toBe(200)
const projectData = (await project.json()) as ProjectResponse; const projectData = (await project.json()) as ProjectResponse
const library = await request.post(`${apiBase}/asset-libraries`, { const library = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
@@ -89,20 +82,20 @@ test.describe("Core media upload flow", () => {
name: `E2E Video Library ${suffix}`, name: `E2E Video Library ${suffix}`,
kind: "video", kind: "video",
}, },
}); })
expect(library.status(), await library.text()).toBe(200); expect(library.status(), await library.text()).toBe(200)
const libraryData = (await library.json()) as LibraryResponse; const libraryData = (await library.json()) as LibraryResponse
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token: loginData.access_token, token: loginData.access_token,
@@ -116,12 +109,12 @@ test.describe("Core media upload flow", () => {
email_verified: true, email_verified: true,
}, },
}, },
); )
await page.goto("/app/assets"); await page.goto("/app/assets")
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const upload = await request.post(`${apiBase}/upload`, { const upload = await request.post(`${apiBase}/upload`, {
headers, headers,
@@ -134,12 +127,12 @@ test.describe("Core media upload flow", () => {
buffer: Buffer.from("playwright mov upload smoke"), buffer: Buffer.from("playwright mov upload smoke"),
}, },
}, },
}); })
expect(upload.status(), await upload.text()).toBe(200); expect(upload.status(), await upload.text()).toBe(200)
await expect( await expect(page.getByText(/上传失败|素材列表加载失败|素材库加载失败/)).toHaveCount(0, {
page.getByText(/上传失败|素材列表加载失败|素材库加载失败/), timeout: 5_000,
).toHaveCount(0, { timeout: 5_000 }); })
await expect await expect
.poll( .poll(
@@ -147,52 +140,44 @@ test.describe("Core media upload flow", () => {
const assets = await request.get(`${apiBase}/assets`, { const assets = await request.get(`${apiBase}/assets`, {
headers, headers,
params: { library_id: libraryData.id }, params: { library_id: libraryData.id },
}); })
if (!assets.ok()) { if (!assets.ok()) {
return `http_${assets.status()}`; return `http_${assets.status()}`
} }
const data = (await assets.json()) as { const data = (await assets.json()) as {
items: Array<{ items: Array<{
name: string; name: string
status: string; status: string
file_type?: string; file_type?: string
mime_type?: string; mime_type?: string
}>; }>
}; }
const asset = data.items.find( const asset = data.items.find((item) => item.name === "e2e-sample.MOV")
(item) => item.name === "e2e-sample.MOV", return asset ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` : "missing"
);
return asset
? `${asset.mime_type || asset.file_type || ""}:${asset.status}`
: "missing";
}, },
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
) )
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/); .toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/)
// Select the test library from sidebar // Select the test library from sidebar
await page await page
.locator(".xx-asset-library-item") .locator(".xx-asset-library-item")
.filter({ hasText: `E2E Video Library ${suffix}` }) .filter({ hasText: `E2E Video Library ${suffix}` })
.click({ force: true }); .click({ force: true })
await page.reload(); await page.reload()
await expect(page.locator(".xx-assets-content")).toBeVisible({ await expect(page.locator(".xx-assets-content")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible( await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
{ timeout: 20_000,
timeout: 20_000, })
},
);
// Verify asset card shows status // Verify asset card shows status
const assetCard = page const assetCard = page.locator(".xx-asset-card").filter({ hasText: "e2e-sample.MOV" })
.locator(".xx-asset-card") await expect(assetCard).toBeVisible()
.filter({ hasText: "e2e-sample.MOV" }); await expect(assetCard.locator(".xx-asset-diagnose-btn")).toBeVisible()
await expect(assetCard).toBeVisible();
await expect(assetCard.locator(".xx-asset-diagnose-btn")).toBeVisible();
await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0); await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0)
}); })
}); })
+198 -239
View File
@@ -4,33 +4,29 @@
* 覆盖:去重上传页面、上传区域、去重记录列表、去重详情、 * 覆盖:去重上传页面、上传区域、去重记录列表、去重详情、
* 删除记录、重试去重 * 删除记录、重试去重
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test"
const PASSWORD = "SmokePass123!"; const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async ( const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
page: import("@playwright/test").Page, if (!apiOrigin) return
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -43,30 +39,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json(); const regData = await reg.json()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -74,7 +70,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
}; }
} }
/** 在浏览器中设置登录态 */ /** 在浏览器中设置登录态 */
@@ -85,14 +81,14 @@ async function setupAuthInBrowser(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token, token,
@@ -106,236 +102,216 @@ async function setupAuthInBrowser(
email_verified: true, email_verified: true,
}, },
}, },
); )
} }
test.describe("去重流程", () => { test.describe("去重流程", () => {
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 })
// ─── 上传页面加载 ────────────────────────────────── // ─── 上传页面加载 ──────────────────────────────────
test("去重上传页面加载", async ({ page, request }) => { test("去重上传页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-load")
request,
"dup-load",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/duplication"); await page.goto("/app/duplication")
// 页面容器 // 页面容器
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
// 页面标题 // 页面标题
await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible(); await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible()
// 描述 // 描述
await expect( await expect(page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段")).toBeVisible()
page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段"), })
).toBeVisible();
});
// ─── 上传区域展示 ────────────────────────────────── // ─── 上传区域展示 ──────────────────────────────────
test("上传区域展示", async ({ page, request }) => { test("上传区域展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"dup-upload-zone", "dup-upload-zone",
); )
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/duplication"); await page.goto("/app/duplication")
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
// 拖拽上传区域 // 拖拽上传区域
const uploadZone = page.locator(".dup-upload-zone"); const uploadZone = page.locator(".dup-upload-zone")
await expect(uploadZone).toBeVisible(); await expect(uploadZone).toBeVisible()
// 上传图标和文字 // 上传图标和文字
await expect( await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible()
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(); await expect(page.locator(".dup-upload-formats")).toBeVisible()
// 选择文件按钮 // 选择文件按钮
const selectBtn = page.getByRole("button", { name: "选择文件" }); const selectBtn = page.getByRole("button", { name: "选择文件" })
await expect(selectBtn).toBeVisible(); await expect(selectBtn).toBeVisible()
// 隐藏的文件 input // 隐藏的文件 input
const fileInput = page.locator('input[type="file"]'); const fileInput = page.locator('input[type="file"]')
await expect(fileInput).toHaveCount(1); await expect(fileInput).toHaveCount(1)
}); })
// ─── 格式说明区 ──────────────────────────────────── // ─── 格式说明区 ────────────────────────────────────
test("格式说明和提示区域展示", async ({ page, request }) => { test("格式说明和提示区域展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-info")
request,
"dup-info",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/duplication"); await page.goto("/app/duplication")
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
// 右侧说明区 // 右侧说明区
const infoCard = page.locator(".dup-info-card"); const infoCard = page.locator(".dup-info-card")
await expect(infoCard).toBeVisible(); await expect(infoCard).toBeVisible()
// 查重说明 // 查重说明
await expect(infoCard.getByText("查重说明")).toBeVisible(); await expect(infoCard.getByText("查重说明")).toBeVisible()
// 支持格式 // 支持格式
await expect(infoCard.getByText("支持格式")).toBeVisible(); await expect(infoCard.getByText("支持格式")).toBeVisible()
// 温馨提示 // 温馨提示
await expect(infoCard.getByText("温馨提示")).toBeVisible(); await expect(infoCard.getByText("温馨提示")).toBeVisible()
// 格式标签 // 格式标签
await expect(page.locator(".dup-format-tags")).toBeVisible(); await expect(page.locator(".dup-format-tags")).toBeVisible()
}); })
// ─── 去重记录列表页面 ────────────────────────────── // ─── 去重记录列表页面 ──────────────────────────────
test("去重记录列表页面加载", async ({ page, request }) => { test("去重记录列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-list")
request,
"dup-list",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/duplication/results"); await page.goto("/app/duplication/results")
// 页面容器 // 页面容器
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
// 页面标题 // 页面标题
await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible(); await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible()
// 筛选按钮 // 筛选按钮
await expect(page.locator(".dup-filter")).toBeVisible(); await expect(page.locator(".dup-filter")).toBeVisible()
// 上传查重按钮 // 上传查重按钮
await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible(); await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible()
}); })
test("去重记录列表 - 空状态", async ({ page, request }) => { test("去重记录列表 - 空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"dup-list-empty", "dup-list-empty",
); )
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/duplication/results"); await page.goto("/app/duplication/results")
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
// 空状态(新用户没有记录) // 空状态(新用户没有记录)
const emptyState = page.locator(".dup-results-empty"); const emptyState = page.locator(".dup-results-empty")
await expect(emptyState).toBeVisible({ timeout: 10_000 }); await expect(emptyState).toBeVisible({ timeout: 10_000 })
await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible(); await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible()
}); })
test("去重记录列表 - 风险等级筛选", async ({ page, request }) => { test("去重记录列表 - 风险等级筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-filter")
request,
"dup-filter",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/duplication/results"); await page.goto("/app/duplication/results")
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
// 筛选按钮存在 // 筛选按钮存在
const filterBtns = page.locator(".dup-filter-btn"); const filterBtns = page.locator(".dup-filter-btn")
await expect(filterBtns).toHaveCount(4); // 全部、低风险、中风险、高风险 await expect(filterBtns).toHaveCount(4) // 全部、低风险、中风险、高风险
// 验证按钮文本 // 验证按钮文本
await expect(filterBtns.nth(0)).toHaveText("全部"); await expect(filterBtns.nth(0)).toHaveText("全部")
await expect(filterBtns.nth(1)).toHaveText("低风险"); await expect(filterBtns.nth(1)).toHaveText("低风险")
await expect(filterBtns.nth(2)).toHaveText("中风险"); await expect(filterBtns.nth(2)).toHaveText("中风险")
await expect(filterBtns.nth(3)).toHaveText("高风险"); await expect(filterBtns.nth(3)).toHaveText("高风险")
// 默认选中"全部" // 默认选中"全部"
await expect(filterBtns.nth(0)).toHaveClass(/active/); await expect(filterBtns.nth(0)).toHaveClass(/active/)
// 点击低风险 // 点击低风险
await filterBtns.nth(1).click(); await filterBtns.nth(1).click()
await expect(filterBtns.nth(1)).toHaveClass(/active/); await expect(filterBtns.nth(1)).toHaveClass(/active/)
}); })
test("去重记录列表 - 上传查重按钮跳转", async ({ page, request }) => { test("去重记录列表 - 上传查重按钮跳转", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-nav")
request,
"dup-nav",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/duplication/results"); await page.goto("/app/duplication/results")
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
// 点击上传查重按钮 // 点击上传查重按钮
await page.getByRole("button", { name: "上传查重" }).click(); await page.getByRole("button", { name: "上传查重" }).click()
await expect(page).toHaveURL(/\/app\/duplication$/); await expect(page).toHaveURL(/\/app\/duplication$/)
await expect(page.locator(".dup-upload-zone")).toBeVisible(); await expect(page.locator(".dup-upload-zone")).toBeVisible()
}); })
// ─── 去重详情页 ──────────────────────────────────── // ─── 去重详情页 ────────────────────────────────────
test("去重详情页 - 通过 API 创建测试数据后访问", async ({ test("去重详情页 - 通过 API 创建测试数据后访问", async ({ page, request }) => {
page, await routeBrowserApiToTestApi(page)
request, const { headers, userId, accessToken, email, username } = await createAuthedUser(
}) => { request,
await routeBrowserApiToTestApi(page); "dup-detail",
const { headers, userId, accessToken, email, username } = )
await createAuthedUser(request, "dup-detail");
// 先上传一个文件进行查重,获取 record id // 先上传一个文件进行查重,获取 record id
const uploadResp = await request.post(`${apiBase}/duplication/upload`, { const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -347,44 +323,41 @@ test.describe("去重流程", () => {
buffer: Buffer.from("e2e duplication test data"), buffer: Buffer.from("e2e duplication test data"),
}, },
}, },
}); })
// 如果查重 API 不可用,跳过详情页测试 // 如果查重 API 不可用,跳过详情页测试
if (!uploadResp.ok()) { if (!uploadResp.ok()) {
console.log( console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`)
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`, return
);
return;
} }
const uploadData = await uploadResp.json(); const uploadData = await uploadResp.json()
const recordId = uploadData.id; const recordId = uploadData.id
expect(recordId, "应返回查重记录 ID").toBeTruthy(); expect(recordId, "应返回查重记录 ID").toBeTruthy()
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
// 访问详情页 // 访问详情页
await page.goto(`/app/duplication/${recordId}`); await page.goto(`/app/duplication/${recordId}`)
// 页面应正常渲染 // 页面应正常渲染
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
// 验证无错误 // 验证无错误
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, { await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
timeout: 5_000, timeout: 5_000,
}); })
}); })
// ─── 删除记录 ────────────────────────────────────── // ─── 删除记录 ──────────────────────────────────────
test("去重记录删除 - API 验证", async ({ page, request }) => { test("去重记录删除 - API 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { headers, userId, accessToken, email, username } = const { headers } = await createAuthedUser(request, "dup-delete")
await createAuthedUser(request, "dup-delete");
// 创建查重记录 // 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, { const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -396,59 +369,53 @@ test.describe("去重流程", () => {
buffer: Buffer.from("e2e duplication delete test"), buffer: Buffer.from("e2e duplication delete test"),
}, },
}, },
}); })
if (!uploadResp.ok()) { if (!uploadResp.ok()) {
console.log( console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`)
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`, return
);
return;
} }
const uploadData = await uploadResp.json(); const uploadData = await uploadResp.json()
const recordId = uploadData.id; const recordId = uploadData.id
// 验证记录存在 // 验证记录存在
const listResp = await request.get(`${apiBase}/duplication/records`, { const listResp = await request.get(`${apiBase}/duplication/records`, {
headers, headers,
}); })
if (listResp.ok()) { if (listResp.ok()) {
const records = await listResp.json(); const records = await listResp.json()
const recordExists = Array.isArray(records) const recordExists = Array.isArray(records)
? records.some((r: { id: string }) => r.id === recordId) ? records.some((r: { id: string }) => r.id === recordId)
: (records.items || []).some((r: { id: string }) => r.id === recordId); : (records.items || []).some((r: { id: string }) => r.id === recordId)
expect(recordExists, "记录应存在于列表中").toBeTruthy(); expect(recordExists, "记录应存在于列表中").toBeTruthy()
} }
// 删除记录 // 删除记录
const deleteResp = await request.delete( const deleteResp = await request.delete(`${apiBase}/duplication/records/${recordId}`, {
`${apiBase}/duplication/records/${recordId}`, headers,
{ headers }, })
); expect(deleteResp.ok(), `删除查重记录应成功: ${deleteResp.status()}`).toBeTruthy()
expect(
deleteResp.ok(),
`删除查重记录应成功: ${deleteResp.status()}`,
).toBeTruthy();
// 验证记录已删除 // 验证记录已删除
const listAfterResp = await request.get(`${apiBase}/duplication/records`, { const listAfterResp = await request.get(`${apiBase}/duplication/records`, {
headers, headers,
}); })
if (listAfterResp.ok()) { if (listAfterResp.ok()) {
const recordsAfter = await listAfterResp.json(); const recordsAfter = await listAfterResp.json()
const recordStillExists = Array.isArray(recordsAfter) const recordStillExists = Array.isArray(recordsAfter)
? recordsAfter.some((r: { id: string }) => r.id === recordId) ? recordsAfter.some((r: { id: string }) => r.id === recordId)
: (recordsAfter.items || []).some( : (recordsAfter.items || []).some((r: { id: string }) => r.id === recordId)
(r: { id: string }) => r.id === recordId, expect(recordStillExists, "记录应已被删除").toBeFalsy()
);
expect(recordStillExists, "记录应已被删除").toBeFalsy();
} }
}); })
test("去重记录删除 - UI 验证", async ({ page, request }) => { test("去重记录删除 - UI 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { headers, userId, accessToken, email, username } = const { headers, userId, accessToken, email, username } = await createAuthedUser(
await createAuthedUser(request, "dup-delete-ui"); request,
"dup-delete-ui",
)
// 创建查重记录 // 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, { const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -460,69 +427,66 @@ test.describe("去重流程", () => {
buffer: Buffer.from("e2e duplication ui delete test"), buffer: Buffer.from("e2e duplication ui delete test"),
}, },
}, },
}); })
if (!uploadResp.ok()) { if (!uploadResp.ok()) {
console.log( console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`)
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`, return
);
return;
} }
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/duplication/results"); await page.goto("/app/duplication/results")
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
// 记录卡片应存在 // 记录卡片应存在
const resultCard = page.locator(".dup-result-card").first(); const resultCard = page.locator(".dup-result-card").first()
const cardVisible = await resultCard const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false)
.isVisible({ timeout: 10_000 })
.catch(() => false);
if (cardVisible) { if (cardVisible) {
// 删除按钮存在 // 删除按钮存在
const deleteBtn = resultCard.getByRole("button").filter({ const deleteBtn = resultCard.getByRole("button").filter({
hasText: "🗑️", hasText: "🗑️",
}); })
await expect(deleteBtn).toBeVisible(); await expect(deleteBtn).toBeVisible()
// 删除按钮点击 - 会触发 confirm 对话框 // 删除按钮点击 - 会触发 confirm 对话框
// 这里我们通过监听 confirm 来确认删除 // 这里我们通过监听 confirm 来确认删除
page.once("dialog", async (dialog) => { page.once("dialog", async (dialog) => {
expect(dialog.message()).toContain("确定删除"); expect(dialog.message()).toContain("确定删除")
await dialog.accept(); await dialog.accept()
}); })
// 监听删除请求 // 监听删除请求
const deletePromise = page const deletePromise = page
.waitForResponse( .waitForResponse(
(resp) => (resp) =>
resp.url().includes("/duplication/records/") && resp.url().includes("/duplication/records/") && resp.request().method() === "DELETE",
resp.request().method() === "DELETE",
{ timeout: 10_000 }, { timeout: 10_000 },
) )
.catch(() => null); .catch(() => null)
await deleteBtn.click(); await deleteBtn.click()
const deleteResp = await deletePromise; const deleteResp = await deletePromise
if (deleteResp) { if (deleteResp) {
expect(deleteResp.ok(), "删除请求应成功").toBeTruthy(); expect(deleteResp.ok(), "删除请求应成功").toBeTruthy()
} }
} }
}); })
// ─── 重试去重 ────────────────────────────────────── // ─── 重试去重 ──────────────────────────────────────
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => { test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { headers, userId, accessToken, email, username } = const { headers, userId, accessToken, email, username } = await createAuthedUser(
await createAuthedUser(request, "dup-retry"); request,
"dup-retry",
)
// 创建查重记录 // 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, { const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -534,59 +498,54 @@ test.describe("去重流程", () => {
buffer: Buffer.from("e2e duplication retry test"), buffer: Buffer.from("e2e duplication retry test"),
}, },
}, },
}); })
if (!uploadResp.ok()) { if (!uploadResp.ok()) {
console.log( console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`)
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`, return
);
return;
} }
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/duplication/results"); await page.goto("/app/duplication/results")
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
// 记录列表中至少有一条记录 // 记录列表中至少有一条记录
const resultCard = page.locator(".dup-result-card").first(); const resultCard = page.locator(".dup-result-card").first()
const cardVisible = await resultCard const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false)
.isVisible({ timeout: 10_000 })
.catch(() => false);
if (cardVisible) { if (cardVisible) {
// 验证记录卡片基本结构 // 验证记录卡片基本结构
await expect(resultCard.locator(".dup-result-card-body")).toBeVisible(); await expect(resultCard.locator(".dup-result-card-body")).toBeVisible()
await expect(resultCard.locator(".dup-result-card-score")).toBeVisible(); await expect(resultCard.locator(".dup-result-card-score")).toBeVisible()
// 检查是否有重试按钮(失败状态才显示) // 检查是否有重试按钮(失败状态才显示)
// 新上传的记录可能是处理中或完成状态,不一定显示重试按钮 // 新上传的记录可能是处理中或完成状态,不一定显示重试按钮
// 这里只验证 API 重试接口可用 // 这里只验证 API 重试接口可用
const uploadData = await uploadResp.json(); const uploadData = await uploadResp.json()
const recordId = uploadData.id; const recordId = uploadData.id
const retryResp = await request.post( const retryResp = await request.post(`${apiBase}/duplication/records/${recordId}/retry`, {
`${apiBase}/duplication/records/${recordId}/retry`, headers,
{ headers }, })
);
// 重试接口应返回 2xx 或明确的状态码 // 重试接口应返回 2xx 或明确的状态码
expect(retryResp.status()).toBeLessThan(500); expect(retryResp.status()).toBeLessThan(500)
} }
}); })
// ─── 未登录访问 ──────────────────────────────────── // ─── 未登录访问 ────────────────────────────────────
test("未登录访问去重上传页 - 重定向到登录页", async ({ page }) => { test("未登录访问去重上传页 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/duplication"); await page.goto("/app/duplication")
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/)
}); })
test("未登录访问去重记录页 - 重定向到登录页", async ({ page }) => { test("未登录访问去重记录页 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/duplication/results"); await page.goto("/app/duplication/results")
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/)
}); })
}); })
+150 -191
View File
@@ -6,36 +6,29 @@
* *
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!"; const PASSWORD = "Test123456!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return; if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -48,30 +41,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json(); const regData = await reg.json()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
}; }
} }
/** 设置页面认证状态(localStorage) */ /** 设置页面认证状态(localStorage) */
@@ -90,14 +83,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token, token,
@@ -111,7 +104,7 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
); )
} }
/** 创建一个编辑模板并返回 id */ /** 创建一个编辑模板并返回 id */
@@ -146,80 +139,74 @@ async function createEditingTemplate(
tags: ["e2e", "test"], tags: ["e2e", "test"],
category: "default", category: "default",
}, },
}); })
expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy(); expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy()
const data = await resp.json(); const data = await resp.json()
return data.id; return data.id
} }
test.describe("剪辑策划页面 - 未登录重定向", () => { test.describe("剪辑策划页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => { test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/editing-planner"); await page.goto("/app/editing-planner")
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/)
}); })
}); })
test.describe("剪辑策划页面 - 页面加载", () => { test.describe("剪辑策划页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("剪辑策划页面加载成功", async ({ page, request }) => { test("剪辑策划页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-load")
request,
"ep-load",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E ep-load", display_name: "E2E ep-load",
}); })
await page.goto("/app/editing-planner"); await page.goto("/app/editing-planner")
await expect(page.locator(".ep-v8-root")).toBeVisible({ await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证顶栏存在 // 验证顶栏存在
await expect(page.locator(".ep-top-bar")).toBeVisible(); await expect(page.locator(".ep-top-bar")).toBeVisible()
// 验证模式栏存在 // 验证模式栏存在
await expect(page.locator(".ep-mode-bar")).toBeVisible(); await expect(page.locator(".ep-mode-bar")).toBeVisible()
// 验证主体区域存在 // 验证主体区域存在
await expect(page.locator(".ep-main-body")).toBeVisible(); await expect(page.locator(".ep-main-body")).toBeVisible()
}); })
test("剪辑模式切换正常显示", async ({ page, request }) => { test("剪辑模式切换正常显示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-mode")
request,
"ep-mode",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E ep-mode", display_name: "E2E ep-mode",
}); })
await page.goto("/app/editing-planner"); await page.goto("/app/editing-planner")
await expect(page.locator(".ep-v8-root")).toBeVisible({ await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证模式按钮存在(画中画、人物口播等) // 验证模式按钮存在(画中画、人物口播等)
const modeBtns = page.locator(".ep-mode-btn"); const modeBtns = page.locator(".ep-mode-btn")
await expect(modeBtns.first()).toBeVisible(); await expect(modeBtns.first()).toBeVisible()
const modeCount = await modeBtns.count(); const modeCount = await modeBtns.count()
expect(modeCount).toBeGreaterThanOrEqual(2); expect(modeCount).toBeGreaterThanOrEqual(2)
}); })
}); })
test.describe("剪辑计划 - API 操作", () => { test.describe("剪辑计划 - API 操作", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("创建剪辑计划 - 正向", async ({ request }) => { test("创建剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-create"); const { headers } = await createAuthedUser(request, "ep-create")
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
const templateName = `E2E 创建测试 ${suffix}`; const templateName = `E2E 创建测试 ${suffix}`
const response = await request.post(`${apiBase}/templates`, { const response = await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -238,22 +225,22 @@ test.describe("剪辑计划 - API 操作", () => {
], ],
tags: ["e2e"], tags: ["e2e"],
}, },
}); })
expect( expect(
response.ok(), response.ok(),
`创建剪辑计划应返回 2xx,实际: ${response.status()} ${await response.text()}`, `创建剪辑计划应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
expect(data.id, "应返回模板 ID").toBeTruthy(); expect(data.id, "应返回模板 ID").toBeTruthy()
expect(data.name).toBe(templateName); expect(data.name).toBe(templateName)
expect(data.mode).toBe("pip"); expect(data.mode).toBe("pip")
}); })
test("列出剪辑计划 - 正向", async ({ request }) => { test("列出剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-list"); const { headers } = await createAuthedUser(request, "ep-list")
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
// 创建 2 个模板 // 创建 2 个模板
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
@@ -271,7 +258,7 @@ test.describe("剪辑计划 - API 操作", () => {
}, },
], ],
}, },
}); })
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
headers, headers,
data: { data: {
@@ -287,102 +274,81 @@ test.describe("剪辑计划 - API 操作", () => {
}, },
], ],
}, },
}); })
const response = await request.get(`${apiBase}/templates`, { headers }); const response = await request.get(`${apiBase}/templates`, { headers })
expect( expect(response.ok(), `列出模板应返回 2xx,实际: ${response.status()}`).toBeTruthy()
response.ok(),
`列出模板应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json(); const data = await response.json()
const items = data.items || data.templates || []; const items = data.items || data.templates || []
expect(Array.isArray(items), "返回应为数组").toBeTruthy(); expect(Array.isArray(items), "返回应为数组").toBeTruthy()
expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2); expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2)
}); })
test("获取剪辑计划详情 - 正向", async ({ request }) => { test("获取剪辑计划详情 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-detail"); const { headers } = await createAuthedUser(request, "ep-detail")
const templateId = await createEditingTemplate( const templateId = await createEditingTemplate(request, headers, Date.now().toString(36))
request,
headers,
Date.now().toString(36),
);
const response = await request.get(`${apiBase}/templates/${templateId}`, { const response = await request.get(`${apiBase}/templates/${templateId}`, {
headers, headers,
}); })
expect( expect(response.ok(), `获取详情应返回 2xx,实际: ${response.status()}`).toBeTruthy()
response.ok(),
`获取详情应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json(); const data = await response.json()
expect(data.id).toBe(templateId); expect(data.id).toBe(templateId)
expect(data.name).toBeTruthy(); expect(data.name).toBeTruthy()
expect(data.mode).toBeTruthy(); expect(data.mode).toBeTruthy()
}); })
test("编辑剪辑计划 - 正向", async ({ request }) => { test("编辑剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-update"); const { headers } = await createAuthedUser(request, "ep-update")
const templateId = await createEditingTemplate( const templateId = await createEditingTemplate(request, headers, Date.now().toString(36))
request,
headers,
Date.now().toString(36),
);
const newName = `更新后的剪辑计划 ${Date.now()}`; const newName = `更新后的剪辑计划 ${Date.now()}`
const response = await request.patch(`${apiBase}/templates/${templateId}`, { const response = await request.patch(`${apiBase}/templates/${templateId}`, {
headers, headers,
data: { data: {
name: newName, name: newName,
description: "更新后的描述", description: "更新后的描述",
}, },
}); })
expect( expect(
response.ok(), response.ok(),
`更新模板应返回 2xx,实际: ${response.status()} ${await response.text()}`, `更新模板应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
expect(data.name).toBe(newName); expect(data.name).toBe(newName)
// 验证更新后的数据 // 验证更新后的数据
const verify = await request.get(`${apiBase}/templates/${templateId}`, { const verify = await request.get(`${apiBase}/templates/${templateId}`, {
headers, headers,
}); })
const verifyData = await verify.json(); const verifyData = await verify.json()
expect(verifyData.name).toBe(newName); expect(verifyData.name).toBe(newName)
}); })
test("删除剪辑计划 - 正向", async ({ request }) => { test("删除剪辑计划 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-delete"); const { headers } = await createAuthedUser(request, "ep-delete")
const templateId = await createEditingTemplate( const templateId = await createEditingTemplate(request, headers, Date.now().toString(36))
request,
headers,
Date.now().toString(36),
);
// 删除 // 删除
const deleteResp = await request.delete( const deleteResp = await request.delete(`${apiBase}/templates/${templateId}`, { headers })
`${apiBase}/templates/${templateId}`,
{ headers },
);
expect( expect(
[200, 204].includes(deleteResp.status()), [200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`, `删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy(); ).toBeTruthy()
// 验证已删除 // 验证已删除
const getResp = await request.get(`${apiBase}/templates/${templateId}`, { const getResp = await request.get(`${apiBase}/templates/${templateId}`, {
headers, headers,
}); })
expect([404, 410]).toContain(getResp.status()); expect([404, 410]).toContain(getResp.status())
}); })
test("创建剪辑计划 - 无效 mode 反向", async ({ request }) => { test("创建剪辑计划 - 无效 mode 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-badmode"); const { headers } = await createAuthedUser(request, "ep-badmode")
const response = await request.post(`${apiBase}/templates`, { const response = await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -392,20 +358,17 @@ test.describe("剪辑计划 - API 操作", () => {
estimated_duration: 30, estimated_duration: 30,
segments: [], segments: [],
}, },
}); })
expect([400, 422]).toContain(response.status()); expect([400, 422]).toContain(response.status())
}); })
test("获取不存在的剪辑计划 - 反向", async ({ request }) => { test("获取不存在的剪辑计划 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "ep-404"); const { headers } = await createAuthedUser(request, "ep-404")
const response = await request.get( const response = await request.get(`${apiBase}/templates/nonexistent-template-999`, { headers })
`${apiBase}/templates/nonexistent-template-999`, expect(response.status(), "不存在的模板应返回 404").toBe(404)
{ headers }, })
);
expect(response.status(), "不存在的模板应返回 404").toBe(404);
});
test("未登录创建剪辑计划 - 反向", async ({ request }) => { test("未登录创建剪辑计划 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/templates`, { const response = await request.post(`${apiBase}/templates`, {
@@ -415,81 +378,77 @@ test.describe("剪辑计划 - API 操作", () => {
estimated_duration: 30, estimated_duration: 30,
segments: [], segments: [],
}, },
}); })
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
}); })
test.describe("剪辑策划页面 - 已模板数据加载", () => { test.describe("剪辑策划页面 - 已模板数据加载", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("已创建的模板在页面中显示", async ({ page, request }) => { test("已创建的模板在页面中显示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username, headers } = const { accessToken, userId, email, username, headers } = await createAuthedUser(
await createAuthedUser(request, "ep-data"); request,
const suffix = Date.now().toString(36); "ep-data",
)
const suffix = Date.now().toString(36)
await createEditingTemplate(request, headers, suffix); await createEditingTemplate(request, headers, suffix)
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E ep-data", display_name: "E2E ep-data",
}); })
await page.goto("/app/editing-planner"); await page.goto("/app/editing-planner")
await expect(page.locator(".ep-v8-root")).toBeVisible({ await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证状态栏存在 // 验证状态栏存在
await expect(page.locator(".ep-status-bar")).toBeVisible(); await expect(page.locator(".ep-status-bar")).toBeVisible()
}); })
test("撤销/重做按钮存在", async ({ page, request }) => { test("撤销/重做按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-undo")
request,
"ep-undo",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E ep-undo", display_name: "E2E ep-undo",
}); })
await page.goto("/app/editing-planner"); await page.goto("/app/editing-planner")
await expect(page.locator(".ep-v8-root")).toBeVisible({ await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证顶栏按钮存在(撤销、重做、保存、生成等) // 验证顶栏按钮存在(撤销、重做、保存、生成等)
const topBarBtns = page.locator(".ep-top-bar-right .ep-btn"); const topBarBtns = page.locator(".ep-top-bar-right .ep-btn")
await expect(topBarBtns.first()).toBeVisible(); await expect(topBarBtns.first()).toBeVisible()
const btnCount = await topBarBtns.count(); const btnCount = await topBarBtns.count()
expect(btnCount).toBeGreaterThanOrEqual(2); expect(btnCount).toBeGreaterThanOrEqual(2)
}); })
test("生成按钮存在", async ({ page, request }) => { test("生成按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-gen")
request,
"ep-gen",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E ep-gen", display_name: "E2E ep-gen",
}); })
await page.goto("/app/editing-planner"); await page.goto("/app/editing-planner")
await expect(page.locator(".ep-v8-root")).toBeVisible({ await expect(page.locator(".ep-v8-root")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证主操作按钮存在 // 验证主操作按钮存在
await expect(page.locator(".ep-btn-primary")).toBeVisible(); await expect(page.locator(".ep-btn-primary")).toBeVisible()
}); })
}); })
+240 -277
View File
@@ -7,33 +7,29 @@
* 说明:产品创建依赖生成流程,测试通过 Mock API 返回产品数据来验证 UI 行为。 * 说明:产品创建依赖生成流程,测试通过 Mock API 返回产品数据来验证 UI 行为。
* 真实的生成流程测试见 core-generation.spec.ts。 * 真实的生成流程测试见 core-generation.spec.ts。
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test"
const PASSWORD = "SmokePass123!"; const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async ( const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
page: import("@playwright/test").Page, if (!apiOrigin) return
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -46,30 +42,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json(); const regData = await reg.json()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -77,14 +73,14 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
}; }
} }
/** Mock 产品数据 */ /** Mock 产品数据 */
function mockProducts(count: number, statuses: string[] = ["completed"]) { function mockProducts(count: number, statuses: string[] = ["completed"]) {
const products = []; const products = []
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const status = statuses[i % statuses.length]; const status = statuses[i % statuses.length]
products.push({ products.push({
id: `mock-prod-${Date.now()}-${i}`, id: `mock-prod-${Date.now()}-${i}`,
title: `测试作品 ${i + 1}`, title: `测试作品 ${i + 1}`,
@@ -93,14 +89,13 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) {
resolution: "1080x1920", resolution: "1080x1920",
file_size: (5 + i) * 1024 * 1024, file_size: (5 + i) * 1024 * 1024,
duplicate_rate: i * 5, duplicate_rate: i * 5,
video_url: video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
status === "completed" ? "https://example.com/video.mp4" : undefined,
thumbnail_url: undefined, thumbnail_url: undefined,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}); })
} }
return products; return products
} }
/** 在浏览器中设置登录态 */ /** 在浏览器中设置登录态 */
@@ -111,14 +106,14 @@ async function setupAuthInBrowser(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token, token,
@@ -132,17 +127,14 @@ async function setupAuthInBrowser(
email_verified: true, email_verified: true,
}, },
}, },
); )
} }
/** Mock 产品列表 API */ /** Mock 产品列表 API */
async function mockProductsApi( async function mockProductsApi(page: import("@playwright/test").Page, products: unknown[]) {
page: import("@playwright/test").Page,
products: unknown[],
) {
await page.route("**/api/v1/products", (route) => { await page.route("**/api/v1/products", (route) => {
const method = route.request().method(); const method = route.request().method()
const url = route.request().url(); const url = route.request().url()
if (method === "GET" && url.match(/\/api\/v1\/products$/)) { if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
// 列表 // 列表
@@ -150,31 +142,29 @@ async function mockProductsApi(
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ items: products, total: products.length }), body: JSON.stringify({ items: products, total: products.length }),
}); })
return; return
} }
// 单个产品详情 // 单个产品详情
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/); const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/)
if (method === "GET" && detailMatch) { if (method === "GET" && detailMatch) {
const productId = detailMatch[1]; const productId = detailMatch[1]
const product = (products as Array<{ id: string }>).find( const product = (products as Array<{ id: string }>).find((p) => p.id === productId)
(p) => p.id === productId,
);
if (product) { if (product) {
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(product), body: JSON.stringify(product),
}); })
} else { } else {
route.fulfill({ route.fulfill({
status: 404, status: 404,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ detail: "Not found" }), body: JSON.stringify({ detail: "Not found" }),
}); })
} }
return; return
} }
// 删除 // 删除
@@ -183,8 +173,8 @@ async function mockProductsApi(
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ message: "deleted" }), body: JSON.stringify({ message: "deleted" }),
}); })
return; return
} }
// 下载链接 // 下载链接
@@ -196,67 +186,67 @@ async function mockProductsApi(
url: "https://example.com/download.mp4", url: "https://example.com/download.mp4",
expires_at: new Date().toISOString(), expires_at: new Date().toISOString(),
}), }),
}); })
return; return
} }
route.continue(); route.continue()
}); })
} }
test.describe("作品库页面", () => { test.describe("作品库页面", () => {
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 })
// ─── 页面加载 ────────────────────────────────────── // ─── 页面加载 ──────────────────────────────────────
test("作品库列表页面加载", async ({ page, request }) => { test("作品库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-load", "products-load",
); )
const products = mockProducts(3, ["completed", "processing", "failed"]); const products = mockProducts(3, ["completed", "processing", "failed"])
await mockProductsApi(page, products); await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/products"); await page.goto("/app/products")
// 页面容器 // 页面容器
await expect(page.locator(".xx-products-page")).toBeVisible({ await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 页面标题 // 页面标题
await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible(); await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible()
// 筛选栏 // 筛选栏
await expect(page.locator(".xx-products-filters")).toBeVisible(); await expect(page.locator(".xx-products-filters")).toBeVisible()
// 卡片网格 // 卡片网格
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}); })
// 作品卡片存在 // 作品卡片存在
await expect(page.locator(".xx-product-card")).toHaveCount(3, { await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000, timeout: 10_000,
}); })
}); })
// ─── 状态展示 ────────────────────────────────────── // ─── 状态展示 ──────────────────────────────────────
test("作品状态展示 - 已完成/处理中/失败", async ({ page, request }) => { test("作品状态展示 - 已完成/处理中/失败", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-status", "products-status",
); )
const products = [ const products = [
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" }, { ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
@@ -270,334 +260,311 @@ test.describe("作品库页面", () => {
title: "失败作品", title: "失败作品",
id: `mock-prod-${Date.now()}-f`, id: `mock-prod-${Date.now()}-f`,
}, },
]; ]
await mockProductsApi(page, products); await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/products"); await page.goto("/app/products")
await expect(page.locator(".xx-products-page")).toBeVisible({ await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 等待卡片加载 // 等待卡片加载
await expect(page.locator(".xx-product-card")).toHaveCount(3, { await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000, timeout: 10_000,
}); })
// 验证各状态标签存在 // 验证各状态标签存在
const completedCard = page const completedCard = page.locator(".xx-product-card").filter({ hasText: "已完成作品" })
.locator(".xx-product-card") await expect(completedCard.locator(".xx-product-status.completed")).toHaveText("已完成")
.filter({ hasText: "已完成作品" });
await expect(
completedCard.locator(".xx-product-status.completed"),
).toHaveText("已完成");
const processingCard = page const processingCard = page.locator(".xx-product-card").filter({ hasText: "处理中作品" })
.locator(".xx-product-card") await expect(processingCard.locator(".xx-product-status.processing")).toHaveText("处理中")
.filter({ hasText: "处理中作品" });
await expect(
processingCard.locator(".xx-product-status.processing"),
).toHaveText("处理中");
const failedCard = page const failedCard = page.locator(".xx-product-card").filter({ hasText: "失败作品" })
.locator(".xx-product-card") await expect(failedCard.locator(".xx-product-status.failed")).toHaveText("失败")
.filter({ hasText: "失败作品" }); })
await expect(failedCard.locator(".xx-product-status.failed")).toHaveText(
"失败",
);
});
// ─── 作品详情页 ──────────────────────────────────── // ─── 作品详情页 ────────────────────────────────────
test("作品详情页打开", async ({ page, request }) => { test("作品详情页打开", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-detail", "products-detail",
); )
const products = mockProducts(1, ["completed"]); const products = mockProducts(1, ["completed"])
products[0].title = "详情页测试作品"; products[0].title = "详情页测试作品"
const productId = products[0].id; const productId = products[0].id
await mockProductsApi(page, products); await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
// 直接访问详情页 // 直接访问详情页
await page.goto(`/app/products/${productId}`); await page.goto(`/app/products/${productId}`)
// 验证 URL // 验证 URL
await expect(page).toHaveURL(/\/app\/products\//); await expect(page).toHaveURL(/\/app\/products\//)
// 页面应正常渲染(无错误) // 页面应正常渲染(无错误)
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, { await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
timeout: 5_000, timeout: 5_000,
}); })
}); })
// ─── 视频播放 ────────────────────────────────────── // ─── 视频播放 ──────────────────────────────────────
test("视频播放器存在(播放弹窗)", async ({ page, request }) => { test("视频播放器存在(播放弹窗)", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-play", "products-play",
); )
const products = mockProducts(1, ["completed"]); const products = mockProducts(1, ["completed"])
products[0].title = "播放测试作品"; products[0].title = "播放测试作品"
products[0].video_url = "https://example.com/test-video.mp4"; products[0].video_url = "https://example.com/test-video.mp4"
await mockProductsApi(page, products); await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/products"); await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 点击作品卡片打开播放 // 点击作品卡片打开播放
const productCard = page const productCard = page.locator(".xx-product-card").filter({ hasText: "播放测试作品" })
.locator(".xx-product-card") await expect(productCard).toBeVisible()
.filter({ hasText: "播放测试作品" });
await expect(productCard).toBeVisible();
// 点击播放按钮 // 点击播放按钮
await productCard.locator(".xx-product-play").click({ force: true }); await productCard.locator(".xx-product-play").click({ force: true })
// 播放弹窗出现 - 验证有视频元素或播放器容器 // 播放弹窗出现 - 验证有视频元素或播放器容器
// (通过 Mock 的 video_url,video 元素应能渲染) // (通过 Mock 的 video_url,video 元素应能渲染)
const videoEl = page.locator("video"); const videoEl = page.locator("video")
const videoVisible = await videoEl const videoVisible = await videoEl
.first() .first()
.isVisible({ timeout: 5000 }) .isVisible({ timeout: 5000 })
.catch(() => false); .catch(() => false)
// 或弹窗容器可见 // 或弹窗容器可见
const modalVisible = await page const modalVisible = await page
.locator(".ant-modal-content") .locator(".ant-modal-content")
.filter({ hasText: "播放测试作品" }) .filter({ hasText: "播放测试作品" })
.isVisible() .isVisible()
.catch(() => false); .catch(() => false)
expect(videoVisible || modalVisible).toBeTruthy(); expect(videoVisible || modalVisible).toBeTruthy()
}); })
// ─── 下载按钮 ────────────────────────────────────── // ─── 下载按钮 ──────────────────────────────────────
test("下载按钮存在", async ({ page, request }) => { test("下载按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-download", "products-download",
); )
const products = mockProducts(1, ["completed"]); const products = mockProducts(1, ["completed"])
products[0].title = "下载测试作品"; products[0].title = "下载测试作品"
await mockProductsApi(page, products); await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/products"); await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const productCard = page const productCard = page.locator(".xx-product-card").filter({ hasText: "下载测试作品" })
.locator(".xx-product-card") await expect(productCard).toBeVisible()
.filter({ hasText: "下载测试作品" });
await expect(productCard).toBeVisible();
// 下载按钮存在且可用(已完成状态) // 下载按钮存在且可用(已完成状态)
const downloadBtn = productCard.getByRole("button", { name: "下载" }); const downloadBtn = productCard.getByRole("button", { name: "下载" })
await expect(downloadBtn).toBeVisible(); await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).not.toBeDisabled(); await expect(downloadBtn).not.toBeDisabled()
}); })
test("处理中作品下载按钮禁用", async ({ page, request }) => { test("处理中作品下载按钮禁用", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-disabled", "products-disabled",
); )
const products = mockProducts(1, ["processing"]); const products = mockProducts(1, ["processing"])
products[0].title = "处理中下载测试"; products[0].title = "处理中下载测试"
await mockProductsApi(page, products); await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/products"); await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const productCard = page const productCard = page.locator(".xx-product-card").filter({ hasText: "处理中下载测试" })
.locator(".xx-product-card") await expect(productCard).toBeVisible()
.filter({ hasText: "处理中下载测试" });
await expect(productCard).toBeVisible();
// 处理中的作品下载按钮应禁用 // 处理中的作品下载按钮应禁用
const downloadBtn = productCard.getByRole("button", { name: "下载" }); const downloadBtn = productCard.getByRole("button", { name: "下载" })
await expect(downloadBtn).toBeVisible(); await expect(downloadBtn).toBeVisible()
const isDisabled = await downloadBtn.isDisabled(); const isDisabled = await downloadBtn.isDisabled()
const hasDisabled = await downloadBtn.evaluate( const hasDisabled = await downloadBtn.evaluate(
(el) => el.hasAttribute("disabled") || el.classList.contains("disabled"), (el) => el.hasAttribute("disabled") || el.classList.contains("disabled"),
); )
expect(isDisabled || hasDisabled).toBeTruthy(); expect(isDisabled || hasDisabled).toBeTruthy()
}); })
// ─── 删除作品 ────────────────────────────────────── // ─── 删除作品 ──────────────────────────────────────
test("删除作品 - API 调用正确", async ({ page, request }) => { test("删除作品 - API 调用正确", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-delete", "products-delete",
); )
const products = mockProducts(1, ["completed"]); const products = mockProducts(1, ["completed"])
products[0].title = "待删除作品"; products[0].title = "待删除作品"
let deleteCalled = false; let deleteCalled = false
let deletedId = ""; let deletedId = ""
await page.route("**/api/v1/products", (route) => { await page.route("**/api/v1/products", (route) => {
const method = route.request().method(); const method = route.request().method()
const url = route.request().url(); const url = route.request().url()
if (method === "GET" && url.match(/\/api\/v1\/products$/)) { if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ items: products, total: products.length }), body: JSON.stringify({ items: products, total: products.length }),
}); })
return; return
} }
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/); const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/)
if (method === "DELETE" && detailMatch) { if (method === "DELETE" && detailMatch) {
deleteCalled = true; deleteCalled = true
deletedId = detailMatch[1]; deletedId = detailMatch[1]
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ message: "deleted" }), body: JSON.stringify({ message: "deleted" }),
}); })
return; return
} }
if (method === "GET" && detailMatch) { if (method === "GET" && detailMatch) {
const productId = detailMatch[1]; const productId = detailMatch[1]
const product = products.find((p) => p.id === productId); const product = products.find((p) => p.id === productId)
route.fulfill({ route.fulfill({
status: product ? 200 : 404, status: product ? 200 : 404,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(product || { detail: "Not found" }), body: JSON.stringify(product || { detail: "Not found" }),
}); })
return; return
} }
route.continue(); route.continue()
}); })
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/products"); await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const productCard = page const productCard = page.locator(".xx-product-card").filter({ hasText: "待删除作品" })
.locator(".xx-product-card") await expect(productCard).toBeVisible()
.filter({ hasText: "待删除作品" });
await expect(productCard).toBeVisible();
// 验证 DELETE API 存在于 products API 中 // 验证 DELETE API 存在于 products API 中
// 我们通过检查实际 API 来确认删除功能可用 // 我们通过检查实际 API 来确认删除功能可用
// (mock 只是为了测试 UI 行为) // (mock 只是为了测试 UI 行为)
expect(deleteCalled).toBe(false); // 初始状态未调用 expect(deleteCalled).toBe(false) // 初始状态未调用
expect(deletedId).toBe(""); expect(deletedId).toBe("")
}); })
test("删除作品 API 端点存在", async ({ request }) => { test("删除作品 API 端点存在", async ({ request }) => {
const { headers } = await createAuthedUser(request, "products-del-api"); const { headers } = await createAuthedUser(request, "products-del-api")
// 测试删除不存在的产品,验证 API 端点存在 // 测试删除不存在的产品,验证 API 端点存在
const resp = await request.delete( const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
`${apiBase}/products/nonexistent-test-id`, headers,
{ })
headers,
},
);
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在) // 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
// 404 表示资源不存在但端点存在 // 404 表示资源不存在但端点存在
expect(resp.status(), "删除 API 端点应存在").not.toBe(405); expect(resp.status(), "删除 API 端点应存在").not.toBe(405)
expect([200, 204, 403, 404]).toContain(resp.status()); expect([200, 204, 403, 404]).toContain(resp.status())
}); })
// ─── 空状态 ──────────────────────────────────────── // ─── 空状态 ────────────────────────────────────────
test("空状态展示", async ({ page, request }) => { test("空状态展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-empty", "products-empty",
); )
// Mock 空列表 // Mock 空列表
await mockProductsApi(page, []); await mockProductsApi(page, [])
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/products"); await page.goto("/app/products")
await expect(page.locator(".xx-products-page")).toBeVisible({ await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 空状态应显示 // 空状态应显示
await expect(page.locator(".xx-products-empty")).toBeVisible({ await expect(page.locator(".xx-products-empty")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}); })
await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible(); await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible()
}); })
// ─── 搜索筛选 ────────────────────────────────────── // ─── 搜索筛选 ──────────────────────────────────────
test("作品搜索功能", async ({ page, request }) => { test("作品搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-search", "products-search",
); )
const products = [ const products = [
{ {
@@ -610,44 +577,44 @@ test.describe("作品库页面", () => {
title: "香蕉推广视频", title: "香蕉推广视频",
id: `mock-prod-${Date.now()}-banana`, id: `mock-prod-${Date.now()}-banana`,
}, },
]; ]
await mockProductsApi(page, products); await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/products"); await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 两个作品都可见 // 两个作品都可见
await expect(page.getByText("苹果宣传视频")).toBeVisible({ await expect(page.getByText("苹果宣传视频")).toBeVisible({
timeout: 5_000, timeout: 5_000,
}); })
await expect(page.getByText("香蕉推广视频")).toBeVisible(); await expect(page.getByText("香蕉推广视频")).toBeVisible()
// 搜索"苹果" // 搜索"苹果"
await page.getByPlaceholder("搜索成片名称...").fill("苹果"); await page.getByPlaceholder("搜索成片名称...").fill("苹果")
await expect(page.getByText("苹果宣传视频")).toBeVisible(); await expect(page.getByText("苹果宣传视频")).toBeVisible()
await expect(page.getByText("香蕉推广视频")).toHaveCount(0); await expect(page.getByText("香蕉推广视频")).toHaveCount(0)
// 清空搜索 // 清空搜索
await page.getByPlaceholder("搜索成片名称...").fill(""); await page.getByPlaceholder("搜索成片名称...").fill("")
await expect(page.getByText("香蕉推广视频")).toBeVisible({ await expect(page.getByText("香蕉推广视频")).toBeVisible({
timeout: 5_000, timeout: 5_000,
}); })
}); })
test("作品状态筛选", async ({ page, request }) => { test("作品状态筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-filter-status", "products-filter-status",
); )
const products = [ const products = [
{ {
@@ -660,94 +627,90 @@ test.describe("作品库页面", () => {
title: "处理中筛选", title: "处理中筛选",
id: `mock-prod-${Date.now()}-proc`, id: `mock-prod-${Date.now()}-proc`,
}, },
]; ]
await mockProductsApi(page, products); await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/products"); await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 两个都可见 // 两个都可见
await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 }); await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 })
await expect(page.getByText("处理中筛选")).toBeVisible(); await expect(page.getByText("处理中筛选")).toBeVisible()
// 状态筛选下拉存在 // 状态筛选下拉存在
const selects = page.locator(".xx-products-filters-left select"); const selects = page.locator(".xx-products-filters-left select")
const count = await selects.count(); const count = await selects.count()
if (count >= 2) { if (count >= 2) {
// 第2个 select 是状态筛选 // 第2个 select 是状态筛选
await selects.nth(1).selectOption({ label: "已完成" }); await selects.nth(1).selectOption({ label: "已完成" })
await expect(page.getByText("已完成筛选")).toBeVisible(); await expect(page.getByText("已完成筛选")).toBeVisible()
await expect(page.getByText("处理中筛选")).toHaveCount(0); await expect(page.getByText("处理中筛选")).toHaveCount(0)
} }
}); })
// ─── 批量操作 ────────────────────────────────────── // ─── 批量操作 ──────────────────────────────────────
test("批量选择和批量操作栏", async ({ page, request }) => { test("批量选择和批量操作栏", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser( const { userId, accessToken, email, username } = await createAuthedUser(
request, request,
"products-batch", "products-batch",
); )
const products = mockProducts(3, ["completed"]); const products = mockProducts(3, ["completed"])
products[0].title = "批量测试 1"; products[0].title = "批量测试 1"
products[1].title = "批量测试 2"; products[1].title = "批量测试 2"
products[2].title = "批量测试 3"; products[2].title = "批量测试 3"
await mockProductsApi(page, products); await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}); })
await page.goto("/app/products"); await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({ await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 三张卡片 // 三张卡片
await expect(page.locator(".xx-product-card")).toHaveCount(3, { await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000, timeout: 10_000,
}); })
// 点击第一张卡片的复选框 // 点击第一张卡片的复选框
const firstCard = page.locator(".xx-product-card").first(); const firstCard = page.locator(".xx-product-card").first()
const checkbox = firstCard.locator(".xx-product-card-checkbox"); const checkbox = firstCard.locator(".xx-product-card-checkbox")
await expect(checkbox).toBeVisible(); await expect(checkbox).toBeVisible()
await checkbox.click(); await checkbox.click()
// 批量操作栏应出现 // 批量操作栏应出现
const batchBar = page.locator(".xx-products-batch-bar"); const batchBar = page.locator(".xx-products-batch-bar")
await expect(batchBar).toBeVisible({ timeout: 5_000 }); await expect(batchBar).toBeVisible({ timeout: 5_000 })
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible(); await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible()
// 批量按钮存在 // 批量按钮存在
await expect( await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible()
batchBar.getByRole("button", { name: "批量下载" }), await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible()
).toBeVisible();
await expect(
batchBar.getByRole("button", { name: "批量删除" }),
).toBeVisible();
// 取消选择 // 取消选择
await batchBar.getByRole("button", { name: "取消选择" }).click(); await batchBar.getByRole("button", { name: "取消选择" }).click()
await expect(batchBar).not.toBeVisible({ timeout: 3_000 }); await expect(batchBar).not.toBeVisible({ timeout: 3_000 })
}); })
// ─── 未登录访问 ──────────────────────────────────── // ─── 未登录访问 ────────────────────────────────────
test("未登录访问作品库 - 重定向到登录页", async ({ page }) => { test("未登录访问作品库 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/products"); await page.goto("/app/products")
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/)
}); })
}); })
+157 -185
View File
@@ -6,36 +6,29 @@
* *
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!"; const PASSWORD = "Test123456!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return; if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -48,30 +41,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json(); const regData = await reg.json()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
}; }
} }
/** 设置页面认证状态(localStorage) */ /** 设置页面认证状态(localStorage) */
@@ -90,14 +83,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token, token,
@@ -111,210 +104,204 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
); )
} }
test.describe("个人设置页面 - 未登录重定向", () => { test.describe("个人设置页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => { test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/profile"); await page.goto("/app/profile")
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/)
}); })
}); })
test.describe("个人设置页面 - 页面加载", () => { test.describe("个人设置页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("设置页面加载成功", async ({ page, request }) => { test("设置页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-load")
request,
"profile-load",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-load", display_name: "E2E profile-load",
}); })
await page.goto("/app/profile"); await page.goto("/app/profile")
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
}); })
test("页面标题存在", async ({ page, request }) => { test("页面标题存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"profile-title", "profile-title",
); )
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-title", display_name: "E2E profile-title",
}); })
await page.goto("/app/profile"); await page.goto("/app/profile")
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证页面包含"个人设置"标题 // 验证页面包含"个人设置"标题
const heading = page.getByRole("heading", { name: /个人设置/ }); const heading = page.getByRole("heading", { name: /个人设置/ })
await expect(heading.first()).toBeVisible({ timeout: 10_000 }); await expect(heading.first()).toBeVisible({ timeout: 10_000 })
}); })
}); })
test.describe("个人设置 - 个人信息展示", () => { test.describe("个人设置 - 个人信息展示", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("个人信息卡片展示", async ({ page, request }) => { test("个人信息卡片展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-info")
request,
"profile-info",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-info", display_name: "E2E profile-info",
}); })
await page.goto("/app/profile"); await page.goto("/app/profile")
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证设置卡片存在 // 验证设置卡片存在
await expect(page.locator(".xx-settings-card")).toBeVisible(); await expect(page.locator(".xx-settings-card")).toBeVisible()
}); })
test("用户名、邮箱字段展示", async ({ page, request }) => { test("用户名、邮箱字段展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"profile-fields", "profile-fields",
); )
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-fields", display_name: "E2E profile-fields",
}); })
await page.goto("/app/profile"); await page.goto("/app/profile")
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证表单字段存在 // 验证表单字段存在
const fields = page.locator(".xx-settings-field"); const fields = page.locator(".xx-settings-field")
await expect(fields.first()).toBeVisible(); await expect(fields.first()).toBeVisible()
const fieldCount = await fields.count(); const fieldCount = await fields.count()
expect(fieldCount).toBeGreaterThanOrEqual(2); expect(fieldCount).toBeGreaterThanOrEqual(2)
}); })
test("用户名标签和输入框存在", async ({ page, request }) => { test("用户名标签和输入框存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"profile-username", "profile-username",
); )
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-username", display_name: "E2E profile-username",
}); })
await page.goto("/app/profile"); await page.goto("/app/profile")
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证用户名标签 // 验证用户名标签
const usernameLabel = page.locator(".xx-settings-label").filter({ const usernameLabel = page.locator(".xx-settings-label").filter({
hasText: "用户名", hasText: "用户名",
}); })
await expect(usernameLabel).toBeVisible(); await expect(usernameLabel).toBeVisible()
// 验证邮箱标签 // 验证邮箱标签
const emailLabel = page.locator(".xx-settings-label").filter({ const emailLabel = page.locator(".xx-settings-label").filter({
hasText: "邮箱", hasText: "邮箱",
}); })
await expect(emailLabel).toBeVisible(); await expect(emailLabel).toBeVisible()
}); })
test("显示名称字段可编辑", async ({ page, request }) => { test("显示名称字段可编辑", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"profile-dispname", "profile-dispname",
); )
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-dispname", display_name: "E2E profile-dispname",
}); })
await page.goto("/app/profile"); await page.goto("/app/profile")
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 查找显示名称输入框 // 查找显示名称输入框
const displayNameField = page.locator(".xx-settings-field").filter({ const displayNameField = page.locator(".xx-settings-field").filter({
has: page.locator(".xx-settings-label", { hasText: "显示名称" }), has: page.locator(".xx-settings-label", { hasText: "显示名称" }),
}); })
if (await displayNameField.isVisible()) { if (await displayNameField.isVisible()) {
const input = displayNameField.locator("input"); const input = displayNameField.locator("input")
if (await input.isVisible()) { if (await input.isVisible()) {
// 验证输入框存在且可输入 // 验证输入框存在且可输入
await expect(input).toBeVisible(); await expect(input).toBeVisible()
const initialValue = await input.inputValue(); const initialValue = await input.inputValue()
await input.fill("新的显示名称"); await input.fill("新的显示名称")
await expect(input).toHaveValue("新的显示名称"); await expect(input).toHaveValue("新的显示名称")
// 恢复原值 // 恢复原值
await input.fill(initialValue); await input.fill(initialValue)
} }
} }
}); })
}); })
test.describe("个人设置 - 修改密码", () => { test.describe("个人设置 - 修改密码", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("修改密码 API - 正向", async ({ request }) => { test("修改密码 API - 正向", async ({ request }) => {
const { headers, email } = await createAuthedUser(request, "profile-chpwd"); const { headers, email } = await createAuthedUser(request, "profile-chpwd")
const newPassword = "NewPass123456!"; const newPassword = "NewPass123456!"
const response = await request.post(`${apiBase}/auth/change-password`, { const response = await request.post(`${apiBase}/auth/change-password`, {
headers, headers,
data: { data: {
old_password: PASSWORD, old_password: PASSWORD,
new_password: newPassword, new_password: newPassword,
}, },
}); })
// 修改密码可能成功或接口不存在 // 修改密码可能成功或接口不存在
expect( expect(
response.status() < 500, response.status() < 500,
`修改密码应返回 2xx 或 4xx,实际: ${response.status()}`, `修改密码应返回 2xx 或 4xx,实际: ${response.status()}`,
).toBeTruthy(); ).toBeTruthy()
// 如果成功,用新密码登录验证 // 如果成功,用新密码登录验证
if (response.ok()) { if (response.ok()) {
const loginResp = await loginWithRetry(request, email, newPassword); const loginResp = await loginWithRetry(request, email, newPassword)
expect(loginResp.ok(), "新密码应能登录").toBeTruthy(); expect(loginResp.ok(), "新密码应能登录").toBeTruthy()
} }
}); })
test("修改密码 - 旧密码错误反向", async ({ request }) => { test("修改密码 - 旧密码错误反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "profile-badpwd"); const { headers } = await createAuthedUser(request, "profile-badpwd")
const response = await request.post(`${apiBase}/auth/change-password`, { const response = await request.post(`${apiBase}/auth/change-password`, {
headers, headers,
@@ -322,17 +309,17 @@ test.describe("个人设置 - 修改密码", () => {
old_password: "WrongOldPass123!", old_password: "WrongOldPass123!",
new_password: "NewPass123456!", new_password: "NewPass123456!",
}, },
}); })
// 如果接口存在,应该返回 400/401 // 如果接口存在,应该返回 400/401
if (response.status() < 500 && response.status() >= 400) { if (response.status() < 500 && response.status() >= 400) {
expect([400, 401]).toContain(response.status()); expect([400, 401]).toContain(response.status())
} }
// 接口不存在(404)也正常 // 接口不存在(404)也正常
}); })
test("修改密码 - 新密码太弱反向", async ({ request }) => { test("修改密码 - 新密码太弱反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "profile-weakpwd"); const { headers } = await createAuthedUser(request, "profile-weakpwd")
const response = await request.post(`${apiBase}/auth/change-password`, { const response = await request.post(`${apiBase}/auth/change-password`, {
headers, headers,
@@ -340,12 +327,12 @@ test.describe("个人设置 - 修改密码", () => {
old_password: PASSWORD, old_password: PASSWORD,
new_password: "123", new_password: "123",
}, },
}); })
if (response.status() < 500 && response.status() >= 400) { if (response.status() < 500 && response.status() >= 400) {
expect([400, 422]).toContain(response.status()); expect([400, 422]).toContain(response.status())
} }
}); })
test("未登录修改密码 - 反向", async ({ request }) => { test("未登录修改密码 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/auth/change-password`, { const response = await request.post(`${apiBase}/auth/change-password`, {
@@ -353,130 +340,115 @@ test.describe("个人设置 - 修改密码", () => {
old_password: "old", old_password: "old",
new_password: "new", new_password: "new",
}, },
}); })
expect([401, 403, 404]).toContain(response.status()); expect([401, 403, 404]).toContain(response.status())
}); })
}); })
test.describe("个人设置 - 账号安全", () => { test.describe("个人设置 - 账号安全", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("获取当前用户信息 - 正向", async ({ request }) => { test("获取当前用户信息 - 正向", async ({ request }) => {
const { headers, email, username } = await createAuthedUser( const { headers, email, username } = await createAuthedUser(request, "profile-me")
request,
"profile-me",
);
const response = await request.get(`${apiBase}/auth/me`, { headers }); const response = await request.get(`${apiBase}/auth/me`, { headers })
expect( expect(response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`).toBeTruthy()
response.ok(),
`获取用户信息应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json(); const data = await response.json()
expect(data.email).toBe(email); expect(data.email).toBe(email)
expect(data.username).toBe(username); expect(data.username).toBe(username)
}); })
test("账号安全区域提示信息存在", async ({ page, request }) => { test("账号安全区域提示信息存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"profile-security", "profile-security",
); )
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-security", display_name: "E2E profile-security",
}); })
await page.goto("/app/profile"); await page.goto("/app/profile")
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证通知区域存在 // 验证通知区域存在
const notice = page.locator(".xx-settings-notice"); const notice = page.locator(".xx-settings-notice")
await expect(notice).toBeVisible(); await expect(notice).toBeVisible()
}); })
}); })
test.describe("个人设置 - 退出登录", () => { test.describe("个人设置 - 退出登录", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("登出 API - 正向", async ({ request }) => { test("登出 API - 正向", async ({ request }) => {
const { headers, email } = await createAuthedUser( const { headers } = await createAuthedUser(request, "profile-logout")
request,
"profile-logout",
);
const response = await request.post(`${apiBase}/auth/logout`, { const response = await request.post(`${apiBase}/auth/logout`, {
headers, headers,
}); })
expect( expect(response.ok(), `登出应返回 2xx,实际: ${response.status()}`).toBeTruthy()
response.ok(),
`登出应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
// 登出后 token 应失效 // 登出后 token 应失效
const meResp = await request.get(`${apiBase}/auth/me`, { headers }); const meResp = await request.get(`${apiBase}/auth/me`, { headers })
expect([401, 403]).toContain(meResp.status()); expect([401, 403]).toContain(meResp.status())
}); })
test("登出后页面跳转登录页", async ({ page, request }) => { test("登出后页面跳转登录页", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"profile-logout-ui", "profile-logout-ui",
); )
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-logout-ui", display_name: "E2E profile-logout-ui",
}); })
await page.goto("/app/profile"); await page.goto("/app/profile")
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 清除 localStorage 模拟登出 // 清除 localStorage 模拟登出
await page.evaluate(() => { await page.evaluate(() => {
localStorage.removeItem("access_token"); localStorage.removeItem("access_token")
localStorage.removeItem("auth-storage"); localStorage.removeItem("auth-storage")
}); })
// 刷新页面应该重定向到登录页 // 刷新页面应该重定向到登录页
await page.reload(); await page.reload()
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 }); await expect(page).toHaveURL(/\/login/, { timeout: 10_000 })
}); })
}); })
test.describe("个人设置 - 保存按钮", () => { test.describe("个人设置 - 保存按钮", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("保存按钮存在", async ({ page, request }) => { test("保存按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-save")
request,
"profile-save",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E profile-save", display_name: "E2E profile-save",
}); })
await page.goto("/app/profile"); await page.goto("/app/profile")
await expect(page.locator(".xx-settings-page")).toBeVisible({ await expect(page.locator(".xx-settings-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证按钮存在 // 验证按钮存在
const button = page.getByRole("button", { name: /保存|暂未开放/ }); const button = page.getByRole("button", { name: /保存|暂未开放/ })
await expect(button.first()).toBeVisible({ timeout: 5_000 }); await expect(button.first()).toBeVisible({ timeout: 5_000 })
}); })
}); })
+122 -159
View File
@@ -4,17 +4,17 @@
* 覆盖:页面渲染、表单验证、成功注册、跳转链接 * 覆盖:页面渲染、表单验证、成功注册、跳转链接
* 每个测试独立,使用随机邮箱避免冲突。 * 每个测试独立,使用随机邮箱避免冲突。
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test"
const PASSWORD = "Test123456!"; const PASSWORD = "Test123456!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */ /** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
@@ -27,181 +27,154 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
test.describe("注册页面", () => { test.describe("注册页面", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
// ─── 页面渲染 ────────────────────────────────────── // ─── 页面渲染 ──────────────────────────────────────
test("页面正常渲染 - 标题、表单元素、提交按钮", async ({ page }) => { test("页面正常渲染 - 标题、表单元素、提交按钮", async ({ page }) => {
await page.goto("/register"); await page.goto("/register")
// 品牌标识 // 品牌标识
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪"); await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪")
// 标题/描述 // 标题/描述
await expect( await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible()
page.getByText("创建账户,开启智能视频创作之旅"),
).toBeVisible();
// 表单字段 // 表单字段
await expect(page.getByLabel("邮箱")).toBeVisible(); await expect(page.getByLabel("邮箱")).toBeVisible()
await expect(page.getByLabel("用户名")).toBeVisible(); await expect(page.getByLabel("用户名")).toBeVisible()
await expect(page.getByLabel("密码")).toBeVisible(); await expect(page.getByLabel("密码")).toBeVisible()
await expect(page.getByLabel("确认密码")).toBeVisible(); await expect(page.getByLabel("确认密码")).toBeVisible()
// 提交按钮 // 提交按钮
await expect( await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible()
page.locator("button[type='submit']").filter({ hasText: "注册" }), })
).toBeVisible();
});
// ─── 表单验证 ────────────────────────────────────── // ─── 表单验证 ──────────────────────────────────────
test("空提交 - 显示必填错误", async ({ page }) => { test("空提交 - 显示必填错误", async ({ page }) => {
await page.goto("/register"); await page.goto("/register")
// 直接点击注册按钮 // 直接点击注册按钮
await page await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示必填错误 // 应显示必填错误
await expect(page.getByText("请输入邮箱")).toBeVisible(); await expect(page.getByText("请输入邮箱")).toBeVisible()
await expect(page.getByText("请输入用户名")).toBeVisible(); await expect(page.getByText("请输入用户名")).toBeVisible()
await expect(page.getByText("请输入密码")).toBeVisible(); await expect(page.getByText("请输入密码")).toBeVisible()
await expect(page.getByText("请确认密码")).toBeVisible(); await expect(page.getByText("请确认密码")).toBeVisible()
}); })
test("无效邮箱格式 - 显示格式错误", async ({ page }) => { test("无效邮箱格式 - 显示格式错误", async ({ page }) => {
await page.goto("/register"); await page.goto("/register")
await page.getByLabel("邮箱").fill("not-an-email"); await page.getByLabel("邮箱").fill("not-an-email")
await page.getByLabel("用户名").fill("testuser"); await page.getByLabel("用户名").fill("testuser")
await page.getByLabel("密码").fill(PASSWORD); await page.getByLabel("密码").fill(PASSWORD)
await page.getByLabel("确认密码").fill(PASSWORD); await page.getByLabel("确认密码").fill(PASSWORD)
await page await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示邮箱格式错误 // 应显示邮箱格式错误
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible(); await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible()
}); })
test("密码太短 - 显示长度错误", async ({ page }) => { test("密码太短 - 显示长度错误", async ({ page }) => {
await page.goto("/register"); await page.goto("/register")
await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd")); await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd"))
await page.getByLabel("用户名").fill("testuser"); await page.getByLabel("用户名").fill("testuser")
await page.getByLabel("密码").fill("123"); await page.getByLabel("密码").fill("123")
await page.getByLabel("确认密码").fill("123"); await page.getByLabel("确认密码").fill("123")
await page await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示密码长度错误 // 应显示密码长度错误
await expect(page.getByText("密码至少 8 个字符")).toBeVisible(); await expect(page.getByText("密码至少 8 个字符")).toBeVisible()
}); })
test("确认密码不一致 - 显示不一致错误", async ({ page }) => { test("确认密码不一致 - 显示不一致错误", async ({ page }) => {
await page.goto("/register"); await page.goto("/register")
await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch")); await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch"))
await page.getByLabel("用户名").fill("testuser"); await page.getByLabel("用户名").fill("testuser")
await page.getByLabel("密码").fill(PASSWORD); await page.getByLabel("密码").fill(PASSWORD)
await page.getByLabel("确认密码").fill("Different123!"); await page.getByLabel("确认密码").fill("Different123!")
await page await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示密码不一致错误 // 应显示密码不一致错误
await expect(page.getByText("两次输入的密码不一致")).toBeVisible(); await expect(page.getByText("两次输入的密码不一致")).toBeVisible()
}); })
test("用户名为空 - 显示必填错误", async ({ page }) => { test("用户名为空 - 显示必填错误", async ({ page }) => {
await page.goto("/register"); await page.goto("/register")
await page.getByLabel("邮箱").fill(uniqueEmail("empty-user")); await page.getByLabel("邮箱").fill(uniqueEmail("empty-user"))
await page.getByLabel("用户名").fill(""); await page.getByLabel("用户名").fill("")
await page.getByLabel("密码").fill(PASSWORD); await page.getByLabel("密码").fill(PASSWORD)
await page.getByLabel("确认密码").fill(PASSWORD); await page.getByLabel("确认密码").fill(PASSWORD)
await page await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
await expect(page.getByText("请输入用户名")).toBeVisible(); await expect(page.getByText("请输入用户名")).toBeVisible()
}); })
// ─── 成功注册 ────────────────────────────────────── // ─── 成功注册 ──────────────────────────────────────
test("成功注册 - 提交有效表单", async ({ page, request }) => { test("成功注册 - 提交有效表单", async ({ page, _request }) => {
const email = uniqueEmail("reg-ui-ok"); const email = uniqueEmail("reg-ui-ok")
const username = uniqueUsername("reguiok"); const username = uniqueUsername("reguiok")
await page.goto("/register"); await page.goto("/register")
await page.getByLabel("邮箱").fill(email); await page.getByLabel("邮箱").fill(email)
await page.getByLabel("用户名").fill(username); await page.getByLabel("用户名").fill(username)
await page.getByLabel("密码").fill(PASSWORD); await page.getByLabel("密码").fill(PASSWORD)
await page.getByLabel("确认密码").fill(PASSWORD); await page.getByLabel("确认密码").fill(PASSWORD)
// 监听注册请求 // 监听注册请求
const registerResponse = page.waitForResponse( const registerResponse = page.waitForResponse(
(resp) => (resp) => resp.url().includes("/auth/register") && resp.request().method() === "POST",
resp.url().includes("/auth/register") &&
resp.request().method() === "POST",
{ timeout: 15_000 }, { timeout: 15_000 },
); )
await page await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
const resp = await registerResponse; const resp = await registerResponse
expect( expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy()
resp.ok(),
`注册请求应返回 2xx,实际: ${resp.status()}`,
).toBeTruthy();
// 注册成功后应跳转到登录页或显示成功消息 // 注册成功后应跳转到登录页或显示成功消息
// 页面应停留在可识别的状态(成功提示或跳转) // 页面应停留在可识别的状态(成功提示或跳转)
await expect await expect
.poll( .poll(
async () => { async () => {
const url = page.url(); const url = page.url()
// 可能跳转到 login,也可能在当前页显示成功消息 // 可能跳转到 login,也可能在当前页显示成功消息
if (url.includes("/login")) return "redirected"; if (url.includes("/login")) return "redirected"
const hasSuccess = await page.getByText(/注册成功/).isVisible(); const hasSuccess = await page.getByText(/注册成功/).isVisible()
return hasSuccess ? "success_msg" : url; return hasSuccess ? "success_msg" : url
}, },
{ timeout: 10_000 }, { timeout: 10_000 },
) )
.toMatch(/redirected|success_msg/); .toMatch(/redirected|success_msg/)
}); })
test("注册已存在邮箱 - UI 显示错误", async ({ page, request }) => { test("注册已存在邮箱 - UI 显示错误", async ({ page, request }) => {
const email = uniqueEmail("reg-ui-dup"); const email = uniqueEmail("reg-ui-dup")
const username1 = uniqueUsername("reguidup1"); const username1 = uniqueUsername("reguidup1")
const username2 = uniqueUsername("reguidup2"); const username2 = uniqueUsername("reguidup2")
// 先通过 API 注册一个账号 // 先通过 API 注册一个账号
const firstReg = await request.post(`${apiBase}/auth/register`, { const firstReg = await request.post(`${apiBase}/auth/register`, {
@@ -211,74 +184,66 @@ test.describe("注册页面", () => {
username: username1, username: username1,
display_name: "User 1", display_name: "User 1",
}, },
}); })
expect(firstReg.ok(), "第一次注册应成功").toBeTruthy(); expect(firstReg.ok(), "第一次注册应成功").toBeTruthy()
// 再在 UI 上用相同邮箱注册 // 再在 UI 上用相同邮箱注册
await page.goto("/register"); await page.goto("/register")
await page.getByLabel("邮箱").fill(email); await page.getByLabel("邮箱").fill(email)
await page.getByLabel("用户名").fill(username2); await page.getByLabel("用户名").fill(username2)
await page.getByLabel("密码").fill(PASSWORD); await page.getByLabel("密码").fill(PASSWORD)
await page.getByLabel("确认密码").fill(PASSWORD); await page.getByLabel("确认密码").fill(PASSWORD)
await page await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示错误提示(通过 antd message 或表单错误) // 应显示错误提示(通过 antd message 或表单错误)
await expect await expect
.poll( .poll(
async () => { async () => {
// 检查是否有错误消息 // 检查是否有错误消息
const hasError = await page const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible()
.getByText(/注册失败|已注册|已存在|exists/) return hasError ? "error_shown" : "waiting"
.isVisible();
return hasError ? "error_shown" : "waiting";
}, },
{ timeout: 10_000 }, { timeout: 10_000 },
) )
.toBe("error_shown"); .toBe("error_shown")
}); })
// ─── 跳转链接 ────────────────────────────────────── // ─── 跳转链接 ──────────────────────────────────────
test("跳转到登录页的链接", async ({ page }) => { test("跳转到登录页的链接", async ({ page }) => {
await page.goto("/register"); await page.goto("/register")
await page.getByRole("link", { name: "立即登录" }).click(); await page.getByRole("link", { name: "立即登录" }).click()
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/)
await expect(page.getByLabel("邮箱")).toBeVisible(); await expect(page.getByLabel("邮箱")).toBeVisible()
}); })
test("登录页有跳转到注册页的链接(反向验证)", async ({ page }) => { test("登录页有跳转到注册页的链接(反向验证)", async ({ page }) => {
await page.goto("/login"); await page.goto("/login")
await page.getByRole("link", { name: "立即注册" }).click(); await page.getByRole("link", { name: "立即注册" }).click()
await expect(page).toHaveURL(/\/register/); await expect(page).toHaveURL(/\/register/)
}); })
test("登录页有忘记密码链接", async ({ page }) => { test("登录页有忘记密码链接", async ({ page }) => {
await page.goto("/login"); await page.goto("/login")
await expect(page.getByRole("link", { name: /忘记密码/ })).toBeVisible(); await expect(page.getByRole("link", { name: /忘记密码/ })).toBeVisible()
await page.getByRole("link", { name: /忘记密码/ }).click(); await page.getByRole("link", { name: /忘记密码/ }).click()
await expect(page).toHaveURL(/\/forgot-password/); await expect(page).toHaveURL(/\/forgot-password/)
}); })
// ─── 路由守卫 - 已登录用户访问注册页 ────────────── // ─── 路由守卫 - 已登录用户访问注册页 ──────────────
test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({ test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({ page, request }) => {
page, const email = uniqueEmail("reg-auth")
request, const username = uniqueUsername("regauth")
}) => {
const email = uniqueEmail("reg-auth");
const username = uniqueUsername("regauth");
// 注册 // 注册
await request.post(`${apiBase}/auth/register`, { await request.post(`${apiBase}/auth/register`, {
@@ -288,24 +253,24 @@ test.describe("注册页面", () => {
username, username,
display_name: "Reg Auth Test", display_name: "Reg Auth Test",
}, },
}); })
// 登录 // 登录
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), "登录应成功").toBeTruthy(); expect(login.ok(), "登录应成功").toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
// 设置登录态 // 设置登录态
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token: loginData.access_token, token: loginData.access_token,
@@ -319,15 +284,13 @@ test.describe("注册页面", () => {
email_verified: true, email_verified: true,
}, },
}, },
); )
await page.goto("/register"); await page.goto("/register")
// 注册页对已登录用户也可访问(注册页是公开页面) // 注册页对已登录用户也可访问(注册页是公开页面)
// 验证页面正常渲染 // 验证页面正常渲染
await expect(page.getByLabel("邮箱")).toBeVisible(); await expect(page.getByLabel("邮箱")).toBeVisible()
await expect( await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible()
page.locator("button[type='submit']").filter({ hasText: "注册" }), })
).toBeVisible(); })
});
});
+212 -264
View File
@@ -9,36 +9,29 @@
* *
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!"; const PASSWORD = "Test123456!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return; if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -51,30 +44,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json(); const regData = await reg.json()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -82,7 +75,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
}; }
} }
/** 设置页面认证状态(localStorage) */ /** 设置页面认证状态(localStorage) */
@@ -93,14 +86,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token, token,
@@ -114,138 +107,129 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
); )
} }
test.describe("订阅套餐页 - 页面加载", () => { test.describe("订阅套餐页 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("订阅套餐页面加载成功", async ({ page, request }) => { test("订阅套餐页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-load")
request,
"sub-load",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-load", display_name: "E2E sub-load",
}); })
await page.goto("/app/subscription"); await page.goto("/app/subscription")
await expect(page.locator(".xx-plans-page")).toBeVisible({ await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
}); })
test("套餐卡片网格展示", async ({ page, request }) => { test("套餐卡片网格展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-cards")
request,
"sub-cards",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-cards", display_name: "E2E sub-cards",
}); })
await page.goto("/app/subscription"); await page.goto("/app/subscription")
await expect(page.locator(".xx-plans-page")).toBeVisible({ await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证套餐卡片存在 // 验证套餐卡片存在
const planCards = page.locator(".xx-plan-card"); const planCards = page.locator(".xx-plan-card")
await expect(planCards.first()).toBeVisible({ timeout: 10_000 }); await expect(planCards.first()).toBeVisible({ timeout: 10_000 })
const cardCount = await planCards.count(); const cardCount = await planCards.count()
expect(cardCount).toBeGreaterThanOrEqual(2); expect(cardCount).toBeGreaterThanOrEqual(2)
}); })
test("套餐卡片包含名称、价格、特性列表", async ({ page, request }) => { test("套餐卡片包含名称、价格、特性列表", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-cardinfo")
request,
"sub-cardinfo",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-cardinfo", display_name: "E2E sub-cardinfo",
}); })
await page.goto("/app/subscription"); await page.goto("/app/subscription")
await expect(page.locator(".xx-plans-page")).toBeVisible({ await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const firstCard = page.locator(".xx-plan-card").first(); const firstCard = page.locator(".xx-plan-card").first()
await expect(firstCard).toBeVisible({ timeout: 10_000 }); await expect(firstCard).toBeVisible({ timeout: 10_000 })
// 验证价格区域存在 // 验证价格区域存在
await expect(firstCard.locator(".xx-plan-price")).toBeVisible(); await expect(firstCard.locator(".xx-plan-price")).toBeVisible()
// 验证特性列表存在 // 验证特性列表存在
await expect(firstCard.locator(".xx-features")).toBeVisible(); await expect(firstCard.locator(".xx-features")).toBeVisible()
// 验证订阅按钮存在 // 验证订阅按钮存在
await expect(firstCard.locator(".xx-subscribe-btn")).toBeVisible(); await expect(firstCard.locator(".xx-subscribe-btn")).toBeVisible()
}); })
test("推荐套餐有特殊标识", async ({ page, request }) => { test("推荐套餐有特殊标识", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"sub-recommended", "sub-recommended",
); )
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-recommended", display_name: "E2E sub-recommended",
}); })
await page.goto("/app/subscription"); await page.goto("/app/subscription")
await expect(page.locator(".xx-plans-page")).toBeVisible({ await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证有推荐标签 // 验证有推荐标签
const featuredCard = page.locator(".xx-plan-card.featured"); const featuredCard = page.locator(".xx-plan-card.featured")
if (await featuredCard.isVisible({ timeout: 5_000 })) { if (await featuredCard.isVisible({ timeout: 5_000 })) {
await expect(featuredCard.locator(".xx-badge")).toBeVisible(); await expect(featuredCard.locator(".xx-badge")).toBeVisible()
} }
}); })
}); })
test.describe("订阅套餐页 - 升级交互", () => { test.describe("订阅套餐页 - 升级交互", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("点击升级套餐按钮跳转升级页", async ({ page, request }) => { test("点击升级套餐按钮跳转升级页", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"sub-upgrade-btn", "sub-upgrade-btn",
); )
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-upgrade-btn", display_name: "E2E sub-upgrade-btn",
}); })
await page.goto("/app/subscription"); await page.goto("/app/subscription")
await expect(page.locator(".xx-plans-page")).toBeVisible({ await expect(page.locator(".xx-plans-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 点击一个订阅按钮 // 点击一个订阅按钮
const subscribeBtn = page.locator(".xx-subscribe-btn").first(); const subscribeBtn = page.locator(".xx-subscribe-btn").first()
if (await subscribeBtn.isVisible({ timeout: 10_000 })) { if (await subscribeBtn.isVisible({ timeout: 10_000 })) {
await subscribeBtn.click(); await subscribeBtn.click()
// 可能跳转到升级页或打开支付弹窗 // 可能跳转到升级页或打开支付弹窗
const url = page.url(); const url = page.url()
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗) // 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
expect( expect(
url.includes("/subscription/upgrade") || url.includes("/subscription/upgrade") ||
@@ -255,201 +239,180 @@ test.describe("订阅套餐页 - 升级交互", () => {
.first() .first()
.isVisible() .isVisible()
.catch(() => false)), .catch(() => false)),
).toBeTruthy(); ).toBeTruthy()
} }
}); })
test("升级套餐升级页面可访问", async ({ page, request }) => { test("升级套餐升级页面可访问", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"sub-upgrade-page", "sub-upgrade-page",
); )
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-upgrade-page", display_name: "E2E sub-upgrade-page",
}); })
await page.goto("/app/subscription/upgrade"); await page.goto("/app/subscription/upgrade")
// 升级页面应该可访问(可能跳转到订阅页或显示升级内容) // 升级页面应该可访问(可能跳转到订阅页或显示升级内容)
await expect(page).toHaveURL(/\/subscription/, { timeout: 10_000 }); await expect(page).toHaveURL(/\/subscription/, { timeout: 10_000 })
}); })
}); })
test.describe("订阅 - 账单列表页", () => { test.describe("订阅 - 账单列表页", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("账单页面加载成功", async ({ page, request }) => { test("账单页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"sub-billing-load", "sub-billing-load",
); )
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-billing-load", display_name: "E2E sub-billing-load",
}); })
await page.goto("/app/subscription/billing"); await page.goto("/app/subscription/billing")
await expect(page.locator(".xx-billing-page")).toBeVisible({ await expect(page.locator(".xx-billing-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
}); })
test("账单概览区域展示", async ({ page, request }) => { test("账单概览区域展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"sub-billing-overview", "sub-billing-overview",
); )
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-billing-overview", display_name: "E2E sub-billing-overview",
}); })
await page.goto("/app/subscription/billing"); await page.goto("/app/subscription/billing")
await expect(page.locator(".xx-billing-page")).toBeVisible({ await expect(page.locator(".xx-billing-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证概览区域存在 // 验证概览区域存在
const overview = page.locator(".xx-billing-overview"); const overview = page.locator(".xx-billing-overview")
if (await overview.isVisible({ timeout: 5_000 })) { if (await overview.isVisible({ timeout: 5_000 })) {
await expect(overview).toBeVisible(); await expect(overview).toBeVisible()
// 验证套餐信息 // 验证套餐信息
await expect(overview.locator(".xx-overview-item").first()).toBeVisible(); await expect(overview.locator(".xx-overview-item").first()).toBeVisible()
} }
}); })
test("自动续费开关存在", async ({ page, request }) => { test("自动续费开关存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(
request, request,
"sub-autorenew-ui", "sub-autorenew-ui",
); )
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E sub-autorenew-ui", display_name: "E2E sub-autorenew-ui",
}); })
await page.goto("/app/subscription/billing"); await page.goto("/app/subscription/billing")
await expect(page.locator(".xx-billing-page")).toBeVisible({ await expect(page.locator(".xx-billing-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证自动续费区域存在 // 验证自动续费区域存在
const autoRenew = page.locator(".xx-billing-auto-renew"); const autoRenew = page.locator(".xx-billing-auto-renew")
if (await autoRenew.isVisible({ timeout: 5_000 })) { if (await autoRenew.isVisible({ timeout: 5_000 })) {
await expect(autoRenew).toBeVisible(); await expect(autoRenew).toBeVisible()
// 验证开关组件存在 // 验证开关组件存在
await expect(autoRenew.locator(".xx-toggle-switch")).toBeVisible(); await expect(autoRenew.locator(".xx-toggle-switch")).toBeVisible()
} }
}); })
test("账单记录 API 返回数据", async ({ request }) => { test("账单记录 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-bills-api"); const { headers } = await createAuthedUser(request, "sub-bills-api")
const response = await request.get( const response = await request.get(`${apiBase}/subscription/billing-records`, { headers })
`${apiBase}/subscription/billing-records`,
{ headers },
);
expect( expect(response.ok(), `获取账单记录应返回 2xx,实际: ${response.status()}`).toBeTruthy()
response.ok(),
`获取账单记录应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json(); const data = await response.json()
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy(); expect(Array.isArray(data), "账单记录应为数组").toBeTruthy()
}); })
}); })
test.describe("订阅 - 自动续费切换", () => { test.describe("订阅 - 自动续费切换", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("切换自动续费 - 正向 API", async ({ request }) => { test("切换自动续费 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-toggle-api"); const { headers } = await createAuthedUser(request, "sub-toggle-api")
// 关闭自动续费 // 关闭自动续费
const disableResp = await request.post( const disableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
`${apiBase}/subscription/toggle-auto-renew`, headers,
{ data: { enabled: false },
headers, })
data: { enabled: false }, expect(disableResp.ok(), `关闭自动续费应成功: ${await disableResp.text()}`).toBeTruthy()
},
);
expect(
disableResp.ok(),
`关闭自动续费应成功: ${await disableResp.text()}`,
).toBeTruthy();
// 重新开启自动续费 // 重新开启自动续费
const enableResp = await request.post( const enableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
`${apiBase}/subscription/toggle-auto-renew`, headers,
{ data: { enabled: true },
headers, })
data: { enabled: true }, expect(enableResp.ok(), `开启自动续费应成功: ${await enableResp.text()}`).toBeTruthy()
}, })
);
expect(
enableResp.ok(),
`开启自动续费应成功: ${await enableResp.text()}`,
).toBeTruthy();
});
test("切换自动续费 - 无效参数反向", async ({ request }) => { test("切换自动续费 - 无效参数反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-toggle-bad"); const { headers } = await createAuthedUser(request, "sub-toggle-bad")
const response = await request.post( const response = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
`${apiBase}/subscription/toggle-auto-renew`, headers,
{ data: {},
headers, })
data: {},
},
);
expect([400, 422]).toContain(response.status()); expect([400, 422]).toContain(response.status())
}); })
}); })
test.describe("订阅 - 取消订阅", () => { test.describe("订阅 - 取消订阅", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("取消订阅 API - 免费用户反向", async ({ request }) => { test("取消订阅 API - 免费用户反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-cancel-api"); const { headers } = await createAuthedUser(request, "sub-cancel-api")
const response = await request.post(`${apiBase}/subscription/cancel`, { const response = await request.post(`${apiBase}/subscription/cancel`, {
headers, headers,
}); })
// 免费用户取消订阅可能返回错误 // 免费用户取消订阅可能返回错误
if (!response.ok()) { if (!response.ok()) {
const data = await response.json(); const data = await response.json()
expect(data.error?.message || data.detail || data.message).toBeTruthy(); expect(data.error?.message || data.detail || data.message).toBeTruthy()
} }
// 如果成功了也没问题(某些实现可能允许) // 如果成功了也没问题(某些实现可能允许)
expect(response.status() < 500).toBeTruthy(); expect(response.status() < 500).toBeTruthy()
}); })
test("未登录取消订阅 - 反向", async ({ request }) => { test("未登录取消订阅 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/subscription/cancel`); const response = await request.post(`${apiBase}/subscription/cancel`)
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
}); })
test.describe("订阅 - 套餐变更", () => { test.describe("订阅 - 套餐变更", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("升级到 Pro 套餐 - 正向 API", async ({ request }) => { test("升级到 Pro 套餐 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-upgrade-api"); const { headers } = await createAuthedUser(request, "sub-upgrade-api")
const response = await request.post(`${apiBase}/subscription/change-plan`, { const response = await request.post(`${apiBase}/subscription/change-plan`, {
headers, headers,
@@ -457,19 +420,16 @@ test.describe("订阅 - 套餐变更", () => {
target_plan_id: "pro", target_plan_id: "pro",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}); })
expect( expect(response.ok(), `升级套餐应成功: ${await response.text()}`).toBeTruthy()
response.ok(),
`升级套餐应成功: ${await response.text()}`,
).toBeTruthy();
const data = await response.json(); const data = await response.json()
expect(data).toBeTruthy(); expect(data).toBeTruthy()
}); })
test("获取当前订阅信息 - 验证升级", async ({ request }) => { test("获取当前订阅信息 - 验证升级", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-current-api"); const { headers } = await createAuthedUser(request, "sub-current-api")
// 先升级 // 先升级
await request.post(`${apiBase}/subscription/change-plan`, { await request.post(`${apiBase}/subscription/change-plan`, {
@@ -478,25 +438,22 @@ test.describe("订阅 - 套餐变更", () => {
target_plan_id: "pro", target_plan_id: "pro",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}); })
// 获取当前订阅 // 获取当前订阅
const response = await request.get(`${apiBase}/subscription/current`, { const response = await request.get(`${apiBase}/subscription/current`, {
headers, headers,
}); })
expect( expect(response.ok(), `获取订阅信息应返回 2xx,实际: ${response.status()}`).toBeTruthy()
response.ok(),
`获取订阅信息应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json(); const data = await response.json()
expect(data.plan_id, "应返回 plan_id").toBeTruthy(); expect(data.plan_id, "应返回 plan_id").toBeTruthy()
expect(data.status, "应返回 status").toBeTruthy(); expect(data.status, "应返回 status").toBeTruthy()
}); })
test("降级到 Standard 套餐 - 正向 API", async ({ request }) => { test("降级到 Standard 套餐 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-downgrade-api"); const { headers } = await createAuthedUser(request, "sub-downgrade-api")
// 先升级到 Pro // 先升级到 Pro
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, { const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
@@ -505,29 +462,26 @@ test.describe("订阅 - 套餐变更", () => {
target_plan_id: "pro", target_plan_id: "pro",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}); })
expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy(); expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy()
// 降级到 Standard // 降级到 Standard
const downgrade = await request.post( const downgrade = await request.post(`${apiBase}/subscription/change-plan`, {
`${apiBase}/subscription/change-plan`, headers,
{ data: {
headers, target_plan_id: "standard",
data: { billing_cycle: "monthly",
target_plan_id: "standard",
billing_cycle: "monthly",
},
}, },
); })
expect( expect(
downgrade.status() < 500, downgrade.status() < 500,
`降级请求应返回 2xx 或 4xx,实际: ${downgrade.status()}`, `降级请求应返回 2xx 或 4xx,实际: ${downgrade.status()}`,
).toBeTruthy(); ).toBeTruthy()
}); })
test("切换到无效套餐 - 反向", async ({ request }) => { test("切换到无效套餐 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-badplan-api"); const { headers } = await createAuthedUser(request, "sub-badplan-api")
const response = await request.post(`${apiBase}/subscription/change-plan`, { const response = await request.post(`${apiBase}/subscription/change-plan`, {
headers, headers,
@@ -535,82 +489,76 @@ test.describe("订阅 - 套餐变更", () => {
target_plan_id: "nonexistent_plan", target_plan_id: "nonexistent_plan",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}); })
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400); expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400)
expect(response.status()).toBeLessThan(500); expect(response.status()).toBeLessThan(500)
}); })
}); })
test.describe("订阅 - 支付流程", () => { test.describe("订阅 - 支付流程", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("创建支付订单 - 正向 API", async ({ request }) => { test("创建支付订单 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-pay-api"); const { headers } = await createAuthedUser(request, "sub-pay-api")
const response = await request.post( const response = await request.post(`${apiBase}/subscription/create-order`, {
`${apiBase}/subscription/create-order`, headers,
{ data: {
headers, plan_id: "pro",
data: { billing_cycle: "monthly",
plan_id: "pro",
billing_cycle: "monthly",
},
}, },
); })
// 创建支付订单可能成功或接口不存在 // 创建支付订单可能成功或接口不存在
expect( expect(
response.status() < 500, response.status() < 500,
`创建订单应返回 2xx 或 4xx,实际: ${response.status()}`, `创建订单应返回 2xx 或 4xx,实际: ${response.status()}`,
).toBeTruthy(); ).toBeTruthy()
if (response.ok()) { if (response.ok()) {
const data = await response.json(); const data = await response.json()
// 应返回订单 ID 或支付链接 // 应返回订单 ID 或支付链接
expect(data.order_id || data.payment_url || data).toBeTruthy(); expect(data.order_id || data.payment_url || data).toBeTruthy()
} }
}); })
test("未登录创建订单 - 反向", async ({ request }) => { test("未登录创建订单 - 反向", async ({ request }) => {
const response = await request.post( const response = await request.post(`${apiBase}/subscription/create-order`, {
`${apiBase}/subscription/create-order`, data: {
{ plan_id: "pro",
data: { billing_cycle: "monthly",
plan_id: "pro",
billing_cycle: "monthly",
},
}, },
); })
expect([401, 403, 404]).toContain(response.status()); expect([401, 403, 404]).toContain(response.status())
}); })
}); })
test.describe("订阅 - 套餐列表 API", () => { test.describe("订阅 - 套餐列表 API", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("获取套餐列表 - 正向", async ({ request }) => { test("获取套餐列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-plans-api"); const { headers } = await createAuthedUser(request, "sub-plans-api")
const response = await request.get(`${apiBase}/subscription/plans`, { const response = await request.get(`${apiBase}/subscription/plans`, {
headers, headers,
}); })
// 套餐列表可能需要登录也可能公开 // 套餐列表可能需要登录也可能公开
if (response.ok()) { if (response.ok()) {
const data = await response.json(); const data = await response.json()
const plans = Array.isArray(data) ? data : data.plans || data.items; const plans = Array.isArray(data) ? data : data.plans || data.items
if (Array.isArray(plans)) { if (Array.isArray(plans)) {
expect(plans.length).toBeGreaterThanOrEqual(2); expect(plans.length).toBeGreaterThanOrEqual(2)
} }
} }
// 如果需要登录也正常 // 如果需要登录也正常
expect(response.status() < 500).toBeTruthy(); expect(response.status() < 500).toBeTruthy()
}); })
test("未登录获取套餐列表", async ({ request }) => { test("未登录获取套餐列表", async ({ request }) => {
const response = await request.get(`${apiBase}/subscription/plans`); const response = await request.get(`${apiBase}/subscription/plans`)
// 套餐列表可能公开也可能需要登录 // 套餐列表可能公开也可能需要登录
expect(response.status() < 500).toBeTruthy(); expect(response.status() < 500).toBeTruthy()
}); })
}); })
+101 -133
View File
@@ -3,17 +3,17 @@
* *
* 覆盖:路由守卫、订阅降级、过期处理、订阅状态检查 * 覆盖:路由守卫、订阅降级、过期处理、订阅状态检查
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test"
const PASSWORD = "Test123456!"; const PASSWORD = "Test123456!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -26,74 +26,74 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
email, email,
username, username,
}; }
} }
test.describe("Subscription route guard", () => { test.describe("Subscription route guard", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("redirects anonymous users to login", async ({ page }) => { test("redirects anonymous users to login", async ({ page }) => {
await page.goto("/app/subscription"); await page.goto("/app/subscription")
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/)
}); })
}); })
test.describe("订阅信息查看", () => { test.describe("订阅信息查看", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("获取当前订阅信息 - 正向", async ({ request }) => { test("获取当前订阅信息 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-info"); const { headers } = await createAuthedUser(request, "sub-info")
const response = await request.get(`${apiBase}/subscription/current`, { const response = await request.get(`${apiBase}/subscription/current`, {
headers, headers,
}); })
expect( expect(
response.ok(), response.ok(),
`获取订阅信息应返回 2xx,实际: ${response.status()} ${await response.text()}`, `获取订阅信息应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
expect(data.plan_id, "应返回 plan_id").toBeTruthy(); expect(data.plan_id, "应返回 plan_id").toBeTruthy()
expect(data.status, "应返回 status").toBeTruthy(); expect(data.status, "应返回 status").toBeTruthy()
}); })
test("未登录获取订阅信息 - 反向", async ({ request }) => { test("未登录获取订阅信息 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/subscription/current`); const response = await request.get(`${apiBase}/subscription/current`)
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
}); })
test.describe("订阅降级", () => { test.describe("订阅降级", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("Pro 用户降级到 Standard - 正向", async ({ request }) => { test("Pro 用户降级到 Standard - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-downgrade"); const { headers } = await createAuthedUser(request, "sub-downgrade")
// 先升级到 Pro // 先升级到 Pro
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, { const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
@@ -102,34 +102,28 @@ test.describe("订阅降级", () => {
target_plan_id: "pro", target_plan_id: "pro",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}); })
expect( expect(upgrade.ok(), `升级到 Pro 应成功: ${await upgrade.text()}`).toBeTruthy()
upgrade.ok(),
`升级到 Pro 应成功: ${await upgrade.text()}`,
).toBeTruthy();
// 降级到 Standard // 降级到 Standard
const downgrade = await request.post( const downgrade = await request.post(`${apiBase}/subscription/change-plan`, {
`${apiBase}/subscription/change-plan`, headers,
{ data: {
headers, target_plan_id: "standard",
data: { billing_cycle: "monthly",
target_plan_id: "standard",
billing_cycle: "monthly",
},
}, },
); })
// 降级应成功或返回提示信息(某些业务可能限制降级) // 降级应成功或返回提示信息(某些业务可能限制降级)
expect(downgrade.status(), "降级请求应返回 2xx 或 4xx").toBeLessThan(500); expect(downgrade.status(), "降级请求应返回 2xx 或 4xx").toBeLessThan(500)
const data = await downgrade.json(); const data = await downgrade.json()
// 成功或失败都应有明确响应 // 成功或失败都应有明确响应
expect(data).toBeTruthy(); expect(data).toBeTruthy()
}); })
test("降级到相同套餐 - 反向", async ({ request }) => { test("降级到相同套餐 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-same"); const { headers } = await createAuthedUser(request, "sub-same")
// 用户默认为 free,再次选择 free // 用户默认为 free,再次选择 free
const response = await request.post(`${apiBase}/subscription/change-plan`, { const response = await request.post(`${apiBase}/subscription/change-plan`, {
@@ -138,19 +132,19 @@ test.describe("订阅降级", () => {
target_plan_id: "free", target_plan_id: "free",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}); })
// 相同套餐应返回 200 + success=false,或者 400 // 相同套餐应返回 200 + success=false,或者 400
if (response.ok()) { if (response.ok()) {
const data = await response.json(); const data = await response.json()
expect(data.success).toBe(false); expect(data.success).toBe(false)
} else { } else {
expect([400, 422]).toContain(response.status()); expect([400, 422]).toContain(response.status())
} }
}); })
test("降级到无效套餐 - 反向", async ({ request }) => { test("降级到无效套餐 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-badplan"); const { headers } = await createAuthedUser(request, "sub-badplan")
const response = await request.post(`${apiBase}/subscription/change-plan`, { const response = await request.post(`${apiBase}/subscription/change-plan`, {
headers, headers,
@@ -158,107 +152,81 @@ test.describe("订阅降级", () => {
target_plan_id: "nonexistent_plan", target_plan_id: "nonexistent_plan",
billing_cycle: "monthly", billing_cycle: "monthly",
}, },
}); })
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400); expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400)
expect(response.status()).toBeLessThan(500); expect(response.status()).toBeLessThan(500)
}); })
}); })
test.describe("订阅过期处理", () => { test.describe("订阅过期处理", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("取消订阅 - 反向(免费用户)", async ({ request }) => { test("取消订阅 - 反向(免费用户)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-cancel"); const { headers } = await createAuthedUser(request, "sub-cancel")
// 免费用户取消订阅应返回错误 // 免费用户取消订阅应返回错误
const response = await request.post(`${apiBase}/subscription/cancel`, { const response = await request.post(`${apiBase}/subscription/cancel`, {
headers, headers,
}); })
// 免费用户可能不需要取消,返回 400 或类似错误 // 免费用户可能不需要取消,返回 400 或类似错误
if (!response.ok()) { if (!response.ok()) {
const data = await response.json(); const data = await response.json()
expect( expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy()
data.error?.message || data.detail || data.message,
"应返回错误信息",
).toBeTruthy();
} }
}); })
test("未登录取消订阅 - 反向", async ({ request }) => { test("未登录取消订阅 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/subscription/cancel`); const response = await request.post(`${apiBase}/subscription/cancel`)
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
test("切换自动续费 - 正向", async ({ request }) => { test("切换自动续费 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-autorenew"); const { headers } = await createAuthedUser(request, "sub-autorenew")
// 关闭自动续费 // 关闭自动续费
const disableResp = await request.post( const disableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
`${apiBase}/subscription/toggle-auto-renew`, headers,
{ data: { enabled: false },
headers, })
data: { enabled: false }, expect(disableResp.ok(), `关闭自动续费应成功: ${await disableResp.text()}`).toBeTruthy()
},
);
expect(
disableResp.ok(),
`关闭自动续费应成功: ${await disableResp.text()}`,
).toBeTruthy();
// 重新开启自动续费 // 重新开启自动续费
const enableResp = await request.post( const enableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
`${apiBase}/subscription/toggle-auto-renew`, headers,
{ data: { enabled: true },
headers, })
data: { enabled: true }, expect(enableResp.ok(), `开启自动续费应成功: ${await enableResp.text()}`).toBeTruthy()
}, })
);
expect(
enableResp.ok(),
`开启自动续费应成功: ${await enableResp.text()}`,
).toBeTruthy();
});
test("无效参数切换自动续费 - 反向", async ({ request }) => { test("无效参数切换自动续费 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-autoren-bad"); const { headers } = await createAuthedUser(request, "sub-autoren-bad")
// 缺少 enabled 字段 // 缺少 enabled 字段
const response = await request.post( const response = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
`${apiBase}/subscription/toggle-auto-renew`, headers,
{ data: {},
headers, })
data: {},
},
);
expect([400, 422]).toContain(response.status()); expect([400, 422]).toContain(response.status())
}); })
}); })
test.describe("账单记录", () => { test.describe("账单记录", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("获取账单记录 - 正向", async ({ request }) => { test("获取账单记录 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-bills"); const { headers } = await createAuthedUser(request, "sub-bills")
const response = await request.get( const response = await request.get(`${apiBase}/subscription/billing-records`, { headers })
`${apiBase}/subscription/billing-records`,
{ headers },
);
expect( expect(response.ok(), `获取账单记录应返回 2xx,实际: ${response.status()}`).toBeTruthy()
response.ok(),
`获取账单记录应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json(); const data = await response.json()
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy(); expect(Array.isArray(data), "账单记录应为数组").toBeTruthy()
}); })
test("未登录获取账单记录 - 反向", async ({ request }) => { test("未登录获取账单记录 - 反向", async ({ request }) => {
const response = await request.get( const response = await request.get(`${apiBase}/subscription/billing-records`)
`${apiBase}/subscription/billing-records`, expect([401, 403]).toContain(response.status())
); })
expect([401, 403]).toContain(response.status()); })
});
});
+206 -230
View File
@@ -6,36 +6,29 @@
* *
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!"; const PASSWORD = "Test123456!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return; if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -48,30 +41,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json(); const regData = await reg.json()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
}; }
} }
/** 设置页面认证状态(localStorage) */ /** 设置页面认证状态(localStorage) */
@@ -90,14 +83,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token, token,
@@ -111,95 +104,88 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
); )
} }
test.describe("模板库页面 - 未登录重定向", () => { test.describe("模板库页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => { test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/templates"); await page.goto("/app/templates")
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/)
}); })
}); })
test.describe("模板库页面 - 页面加载", () => { test.describe("模板库页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("模板库页面加载成功", async ({ page, request }) => { test("模板库页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-load")
request,
"tpl-load",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-load", display_name: "E2E tpl-load",
}); })
await page.goto("/app/templates"); await page.goto("/app/templates")
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
}); })
test("模板库头部和搜索栏存在", async ({ page, request }) => { test("模板库头部和搜索栏存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-head")
request,
"tpl-head",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-head", display_name: "E2E tpl-head",
}); })
await page.goto("/app/templates"); await page.goto("/app/templates")
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证搜索框 // 验证搜索框
const searchInput = page.locator(".xx-templates-search-input"); const searchInput = page.locator(".xx-templates-search-input")
await expect(searchInput).toBeVisible({ timeout: 10_000 }); await expect(searchInput).toBeVisible({ timeout: 10_000 })
}); })
test("分类切换按钮存在", async ({ page, request }) => { test("分类切换按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-cat")
request,
"tpl-cat",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-cat", display_name: "E2E tpl-cat",
}); })
await page.goto("/app/templates"); await page.goto("/app/templates")
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证分类按钮存在 // 验证分类按钮存在
const categoryBtns = page.locator(".xx-templates-cat-btn"); const categoryBtns = page.locator(".xx-templates-cat-btn")
await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 }); await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 })
const count = await categoryBtns.count(); const count = await categoryBtns.count()
expect(count).toBeGreaterThan(0); expect(count).toBeGreaterThan(0)
}); })
}); })
test.describe("模板库 - 模板展示", () => { test.describe("模板库 - 模板展示", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("模板卡片展示", async ({ page, request }) => { test("模板卡片展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username, headers } = const { accessToken, userId, email, username, headers } = await createAuthedUser(
await createAuthedUser(request, "tpl-cards"); request,
const suffix = Date.now().toString(36); "tpl-cards",
)
const suffix = Date.now().toString(36)
// 创建一个模板 // 创建一个模板
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
@@ -220,32 +206,34 @@ test.describe("模板库 - 模板展示", () => {
tags: ["e2e", "展示"], tags: ["e2e", "展示"],
category: "种草", category: "种草",
}, },
}); })
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-cards", display_name: "E2E tpl-cards",
}); })
await page.goto("/app/templates"); await page.goto("/app/templates")
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 等待模板卡片出现 // 等待模板卡片出现
const cards = page.locator(".xx-template-card"); const cards = page.locator(".xx-template-card")
await expect(cards.first()).toBeVisible({ timeout: 15_000 }); await expect(cards.first()).toBeVisible({ timeout: 15_000 })
const count = await cards.count(); const count = await cards.count()
expect(count).toBeGreaterThan(0); expect(count).toBeGreaterThan(0)
}); })
test("模板卡片包含名称和类型", async ({ page, request }) => { test("模板卡片包含名称和类型", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username, headers } = const { accessToken, userId, email, username, headers } = await createAuthedUser(
await createAuthedUser(request, "tpl-info"); request,
const suffix = Date.now().toString(36); "tpl-info",
)
const suffix = Date.now().toString(36)
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -263,33 +251,35 @@ test.describe("模板库 - 模板展示", () => {
}, },
], ],
}, },
}); })
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-info", display_name: "E2E tpl-info",
}); })
await page.goto("/app/templates"); await page.goto("/app/templates")
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const firstCard = page.locator(".xx-template-card").first(); const firstCard = page.locator(".xx-template-card").first()
if (await firstCard.isVisible({ timeout: 15_000 })) { if (await firstCard.isVisible({ timeout: 15_000 })) {
// 验证信息区域存在 // 验证信息区域存在
const info = firstCard.locator(".xx-template-info"); const info = firstCard.locator(".xx-template-info")
await expect(info).toBeVisible(); await expect(info).toBeVisible()
} }
}); })
test("模板预览弹窗功能", async ({ page, request }) => { test("模板预览弹窗功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username, headers } = const { accessToken, userId, email, username, headers } = await createAuthedUser(
await createAuthedUser(request, "tpl-preview"); request,
const suffix = Date.now().toString(36); "tpl-preview",
)
const suffix = Date.now().toString(36)
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -308,78 +298,75 @@ test.describe("模板库 - 模板展示", () => {
}, },
], ],
}, },
}); })
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-preview", display_name: "E2E tpl-preview",
}); })
await page.goto("/app/templates"); await page.goto("/app/templates")
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 点击第一个模板卡片打开预览 // 点击第一个模板卡片打开预览
const firstCard = page.locator(".xx-template-card").first(); const firstCard = page.locator(".xx-template-card").first()
if (await firstCard.isVisible({ timeout: 15_000 })) { if (await firstCard.isVisible({ timeout: 15_000 })) {
await firstCard.click(); await firstCard.click()
// 预览弹窗应该出现 // 预览弹窗应该出现
const modal = page.locator(".xx-template-modal"); const modal = page.locator(".xx-template-modal")
if (await modal.isVisible({ timeout: 5_000 })) { if (await modal.isVisible({ timeout: 5_000 })) {
await expect(modal).toBeVisible(); await expect(modal).toBeVisible()
// 验证预览内容存在 // 验证预览内容存在
await expect( await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible()
modal.locator(".xx-template-modal-title-row"),
).toBeVisible();
} }
} }
}); })
}); })
test.describe("模板库 - 分类切换", () => { test.describe("模板库 - 分类切换", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("切换分类筛选", async ({ page, request }) => { test("切换分类筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-switch")
request,
"tpl-switch",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-switch", display_name: "E2E tpl-switch",
}); })
await page.goto("/app/templates"); await page.goto("/app/templates")
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const categoryBtns = page.locator(".xx-templates-cat-btn"); const categoryBtns = page.locator(".xx-templates-cat-btn")
const firstBtn = categoryBtns.first(); const firstBtn = categoryBtns.first()
if (await firstBtn.isVisible({ timeout: 10_000 })) { if (await firstBtn.isVisible({ timeout: 10_000 })) {
await firstBtn.click(); await firstBtn.click()
// 验证按钮被选中 // 验证按钮被选中
await expect(firstBtn).toHaveClass(/active/); await expect(firstBtn).toHaveClass(/active/)
} }
}); })
}); })
test.describe("模板库 - 搜索", () => { test.describe("模板库 - 搜索", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("搜索框可输入并筛选", async ({ page, request }) => { test("搜索框可输入并筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username, headers } = const { accessToken, userId, email, username, headers } = await createAuthedUser(
await createAuthedUser(request, "tpl-search"); request,
const suffix = Date.now().toString(36); "tpl-search",
const templateName = `E2E 搜索测试模板 ${suffix}`; )
const suffix = Date.now().toString(36)
const templateName = `E2E 搜索测试模板 ${suffix}`
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -397,35 +384,35 @@ test.describe("模板库 - 搜索", () => {
}, },
], ],
}, },
}); })
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-search", display_name: "E2E tpl-search",
}); })
await page.goto("/app/templates"); await page.goto("/app/templates")
await expect(page.locator(".xx-templates-page")).toBeVisible({ await expect(page.locator(".xx-templates-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const searchInput = page.locator(".xx-templates-search-input"); const searchInput = page.locator(".xx-templates-search-input")
if (await searchInput.isVisible({ timeout: 10_000 })) { if (await searchInput.isVisible({ timeout: 10_000 })) {
await searchInput.fill(suffix); await searchInput.fill(suffix)
// 验证页面正常响应 // 验证页面正常响应
await expect(page.locator(".xx-templates-page")).toBeVisible(); await expect(page.locator(".xx-templates-page")).toBeVisible()
} }
}); })
}); })
test.describe("模板库 - API 操作", () => { test.describe("模板库 - API 操作", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("获取模板列表 - 正向", async ({ request }) => { test("获取模板列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-api-list"); const { headers } = await createAuthedUser(request, "tpl-api-list")
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -442,23 +429,20 @@ test.describe("模板库 - API 操作", () => {
}, },
], ],
}, },
}); })
const response = await request.get(`${apiBase}/templates`, { headers }); const response = await request.get(`${apiBase}/templates`, { headers })
expect( expect(response.ok(), `获取模板列表应返回 2xx,实际: ${response.status()}`).toBeTruthy()
response.ok(),
`获取模板列表应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json(); const data = await response.json()
const items = data.items || data.templates || []; const items = data.items || data.templates || []
expect(Array.isArray(items), "模板列表应为数组").toBeTruthy(); expect(Array.isArray(items), "模板列表应为数组").toBeTruthy()
expect(items.length).toBeGreaterThan(0); expect(items.length).toBeGreaterThan(0)
}); })
test("收藏/取消收藏模板 - 正向", async ({ request }) => { test("收藏/取消收藏模板 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-fav"); const { headers } = await createAuthedUser(request, "tpl-fav")
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
// 创建模板 // 创建模板
const createResp = await request.post(`${apiBase}/templates`, { const createResp = await request.post(`${apiBase}/templates`, {
@@ -476,33 +460,26 @@ test.describe("模板库 - API 操作", () => {
}, },
], ],
}, },
}); })
expect(createResp.ok()).toBeTruthy(); expect(createResp.ok()).toBeTruthy()
const created = await createResp.json(); const created = await createResp.json()
const templateId = created.id; const templateId = created.id
// 收藏 // 收藏
const favResp = await request.post( const favResp = await request.post(`${apiBase}/templates/${templateId}/favorite`, { headers })
`${apiBase}/templates/${templateId}/favorite`,
{ headers },
);
// 收藏可能成功或接口不存在 // 收藏可能成功或接口不存在
expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy(); expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy()
// 取消收藏 // 取消收藏
const unfavResp = await request.delete( const unfavResp = await request.delete(`${apiBase}/templates/${templateId}/favorite`, {
`${apiBase}/templates/${templateId}/favorite`, headers,
{ headers }, })
); expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy()
expect( })
unfavResp.status() < 500,
"取消收藏请求应返回 2xx 或 4xx",
).toBeTruthy();
});
test("获取模板详情 - 正向", async ({ request }) => { test("获取模板详情 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-api-detail"); const { headers } = await createAuthedUser(request, "tpl-api-detail")
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
const createResp = await request.post(`${apiBase}/templates`, { const createResp = await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -521,22 +498,22 @@ test.describe("模板库 - API 操作", () => {
}, },
], ],
}, },
}); })
expect(createResp.ok()).toBeTruthy(); expect(createResp.ok()).toBeTruthy()
const created = await createResp.json(); const created = await createResp.json()
const detailResp = await request.get(`${apiBase}/templates/${created.id}`, { const detailResp = await request.get(`${apiBase}/templates/${created.id}`, {
headers, headers,
}); })
expect(detailResp.ok(), "获取详情应成功").toBeTruthy(); expect(detailResp.ok(), "获取详情应成功").toBeTruthy()
const detail = await detailResp.json(); const detail = await detailResp.json()
expect(detail.id).toBe(created.id); expect(detail.id).toBe(created.id)
expect(detail.name).toBe(`详情测试 ${suffix}`); expect(detail.name).toBe(`详情测试 ${suffix}`)
}); })
test("使用模板接口 - 正向", async ({ request }) => { test("使用模板接口 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "tpl-use"); const { headers } = await createAuthedUser(request, "tpl-use")
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
const createResp = await request.post(`${apiBase}/templates`, { const createResp = await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -553,52 +530,51 @@ test.describe("模板库 - API 操作", () => {
}, },
], ],
}, },
}); })
expect(createResp.ok()).toBeTruthy(); expect(createResp.ok()).toBeTruthy()
const created = await createResp.json(); const created = await createResp.json()
// 使用模板(生成) // 使用模板(生成)
const genResp = await request.post( const genResp = await request.post(`${apiBase}/templates/${created.id}/generate`, {
`${apiBase}/templates/${created.id}/generate`, headers,
{ headers, data: {} }, data: {},
); })
// 生成可能成功或返回业务错误 // 生成可能成功或返回业务错误
expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy(); expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy()
}); })
test("未登录获取模板列表 - 反向", async ({ request }) => { test("未登录获取模板列表 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/templates`); const response = await request.get(`${apiBase}/templates`)
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
}); })
test.describe("模板库 - 我的模板 Tab", () => { test.describe("模板库 - 我的模板 Tab", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("我的模板页面可访问", async ({ page, request }) => { test("我的模板页面可访问", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-my")
request,
"tpl-my",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-my", display_name: "E2E tpl-my",
}); })
await page.goto("/app/my-templates"); await page.goto("/app/my-templates")
await expect(page.locator(".mt-page")).toBeVisible({ await expect(page.locator(".mt-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
}); })
test("我的模板页面展示已创建的模板", async ({ page, request }) => { test("我的模板页面展示已创建的模板", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username, headers } = const { accessToken, userId, email, username, headers } = await createAuthedUser(
await createAuthedUser(request, "tpl-my-data"); request,
const suffix = Date.now().toString(36); "tpl-my-data",
)
const suffix = Date.now().toString(36)
await request.post(`${apiBase}/templates`, { await request.post(`${apiBase}/templates`, {
headers, headers,
@@ -616,22 +592,22 @@ test.describe("模板库 - 我的模板 Tab", () => {
}, },
], ],
}, },
}); })
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E tpl-my-data", display_name: "E2E tpl-my-data",
}); })
await page.goto("/app/my-templates"); await page.goto("/app/my-templates")
await expect(page.locator(".mt-page")).toBeVisible({ await expect(page.locator(".mt-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证卡片容器存在 // 验证卡片容器存在
const cards = page.locator(".mt-card"); const cards = page.locator(".mt-card")
await expect(cards.first()).toBeVisible({ timeout: 15_000 }); await expect(cards.first()).toBeVisible({ timeout: 15_000 })
}); })
}); })
+86 -106
View File
@@ -4,17 +4,17 @@
* 覆盖:创建素材库、列出素材库、创建素材记录 * 覆盖:创建素材库、列出素材库、创建素材记录
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test"
const PASSWORD = "Test123456!"; const PASSWORD = "Test123456!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -27,37 +27,37 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json(); const regData = await reg.json()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
email, email,
username, username,
userId: regData.user_id, userId: regData.user_id,
}; }
} }
/** 创建一个项目并返回 project id */ /** 创建一个项目并返回 project id */
@@ -69,23 +69,19 @@ async function createProject(
const resp = await request.post(`${apiBase}/projects`, { const resp = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `Asset Test Proj ${suffix}`, description: "E2E asset test" }, data: { name: `Asset Test Proj ${suffix}`, description: "E2E asset test" },
}); })
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy(); expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy()
const data = await resp.json(); const data = await resp.json()
return data.id; return data.id
} }
test.describe("素材库流程", () => { test.describe("素材库流程", () => {
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时 // 登录限流 10次/60s,测试可能触发限流等待,给足够超时
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 })
test("创建素材库", async ({ request }) => { test("创建素材库", async ({ request }) => {
const { headers } = await createAuthedUser(request, "lib-create"); const { headers } = await createAuthedUser(request, "lib-create")
const projectId = await createProject( const projectId = await createProject(request, headers, Date.now().toString())
request,
headers,
Date.now().toString(),
);
const response = await request.post(`${apiBase}/asset-libraries`, { const response = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
@@ -94,27 +90,23 @@ test.describe("素材库流程", () => {
name: `视频素材库 ${Date.now()}`, name: `视频素材库 ${Date.now()}`,
kind: "video", kind: "video",
}, },
}); })
expect( expect(
response.ok(), response.ok(),
`创建素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`, `创建素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
expect(data.id, "应返回素材库 ID").toBeTruthy(); expect(data.id, "应返回素材库 ID").toBeTruthy()
expect(data.name).toContain("视频素材库"); expect(data.name).toContain("视频素材库")
expect(data.kind).toBe("video"); expect(data.kind).toBe("video")
expect(data.project_id).toBe(projectId); expect(data.project_id).toBe(projectId)
}); })
test("创建素材库 - 无效 kind 反向", async ({ request }) => { test("创建素材库 - 无效 kind 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "lib-badkind"); const { headers } = await createAuthedUser(request, "lib-badkind")
const projectId = await createProject( const projectId = await createProject(request, headers, Date.now().toString())
request,
headers,
Date.now().toString(),
);
const response = await request.post(`${apiBase}/asset-libraries`, { const response = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
@@ -123,14 +115,14 @@ test.describe("素材库流程", () => {
name: "Bad Kind Library", name: "Bad Kind Library",
kind: "invalid_kind", kind: "invalid_kind",
}, },
}); })
// kind 有 pattern 校验 ^(video|voice|image)$,应返回 422 // kind 有 pattern 校验 ^(video|voice|image)$,应返回 422
expect([400, 422]).toContain(response.status()); expect([400, 422]).toContain(response.status())
}); })
test("创建素材库 - 不存在的项目反向", async ({ request }) => { test("创建素材库 - 不存在的项目反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "lib-nopj"); const { headers } = await createAuthedUser(request, "lib-nopj")
const response = await request.post(`${apiBase}/asset-libraries`, { const response = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
@@ -139,18 +131,14 @@ test.describe("素材库流程", () => {
name: "Orphan Library", name: "Orphan Library",
kind: "video", kind: "video",
}, },
}); })
expect(response.status(), "不存在的项目应返回 404").toBe(404); expect(response.status(), "不存在的项目应返回 404").toBe(404)
}); })
test("列出素材库", async ({ request }) => { test("列出素材库", async ({ request }) => {
const { headers } = await createAuthedUser(request, "lib-list"); const { headers } = await createAuthedUser(request, "lib-list")
const projectId = await createProject( const projectId = await createProject(request, headers, Date.now().toString())
request,
headers,
Date.now().toString(),
);
// 创建 2 个不同类型的素材库 // 创建 2 个不同类型的素材库
await request.post(`${apiBase}/asset-libraries`, { await request.post(`${apiBase}/asset-libraries`, {
@@ -160,7 +148,7 @@ test.describe("素材库流程", () => {
name: `Video Lib ${Date.now()}`, name: `Video Lib ${Date.now()}`,
kind: "video", kind: "video",
}, },
}); })
await request.post(`${apiBase}/asset-libraries`, { await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
data: { data: {
@@ -168,35 +156,31 @@ test.describe("素材库流程", () => {
name: `Image Lib ${Date.now()}`, name: `Image Lib ${Date.now()}`,
kind: "image", kind: "image",
}, },
}); })
// 列出(按 project_id 过滤) // 列出(按 project_id 过滤)
const response = await request.get(`${apiBase}/asset-libraries`, { const response = await request.get(`${apiBase}/asset-libraries`, {
headers, headers,
params: { project_id: projectId }, params: { project_id: projectId },
}); })
expect( expect(
response.ok(), response.ok(),
`列出素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`, `列出素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
const items = data.items || []; const items = data.items || []
expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2); expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2)
const kinds = items.map((i: { kind: string }) => i.kind); const kinds = items.map((i: { kind: string }) => i.kind)
expect(kinds).toContain("video"); expect(kinds).toContain("video")
expect(kinds).toContain("image"); expect(kinds).toContain("image")
}); })
test("创建素材记录", async ({ request }) => { test("创建素材记录", async ({ request }) => {
const { headers, userId } = await createAuthedUser(request, "asset-create"); const { headers, userId } = await createAuthedUser(request, "asset-create")
const projectId = await createProject( const projectId = await createProject(request, headers, Date.now().toString())
request,
headers,
Date.now().toString(),
);
// 创建素材库 // 创建素材库
const lib = await request.post(`${apiBase}/asset-libraries`, { const lib = await request.post(`${apiBase}/asset-libraries`, {
@@ -206,9 +190,9 @@ test.describe("素材库流程", () => {
name: `Asset Lib ${Date.now()}`, name: `Asset Lib ${Date.now()}`,
kind: "video", kind: "video",
}, },
}); })
expect(lib.ok()).toBeTruthy(); expect(lib.ok()).toBeTruthy()
const libData = await lib.json(); const libData = await lib.json()
// 创建素材记录 // 创建素材记录
const response = await request.post(`${apiBase}/assets`, { const response = await request.post(`${apiBase}/assets`, {
@@ -224,27 +208,23 @@ test.describe("素材库流程", () => {
status: "ready", status: "ready",
uploaded_by_user_id: userId, uploaded_by_user_id: userId,
}, },
}); })
expect( expect(
response.ok(), response.ok(),
`创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`, `创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
expect(data.id, "应返回素材 ID").toBeTruthy(); expect(data.id, "应返回素材 ID").toBeTruthy()
expect(data.name).toContain("test_video"); expect(data.name).toContain("test_video")
expect(data.mime_type).toBe("video/mp4"); expect(data.mime_type).toBe("video/mp4")
expect(data.library_id).toBe(libData.id); expect(data.library_id).toBe(libData.id)
}); })
test("列出素材", async ({ request }) => { test("列出素材", async ({ request }) => {
const { headers, userId } = await createAuthedUser(request, "asset-list"); const { headers, userId } = await createAuthedUser(request, "asset-list")
const projectId = await createProject( const projectId = await createProject(request, headers, Date.now().toString())
request,
headers,
Date.now().toString(),
);
// 创建素材库 // 创建素材库
const lib = await request.post(`${apiBase}/asset-libraries`, { const lib = await request.post(`${apiBase}/asset-libraries`, {
@@ -254,9 +234,9 @@ test.describe("素材库流程", () => {
name: `List Lib ${Date.now()}`, name: `List Lib ${Date.now()}`,
kind: "video", kind: "video",
}, },
}); })
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy(); expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy()
const libData = await lib.json(); const libData = await lib.json()
// 创建 2 个素材 // 创建 2 个素材
await request.post(`${apiBase}/assets`, { await request.post(`${apiBase}/assets`, {
@@ -270,7 +250,7 @@ test.describe("素材库流程", () => {
status: "ready", status: "ready",
uploaded_by_user_id: userId, uploaded_by_user_id: userId,
}, },
}); })
await request.post(`${apiBase}/assets`, { await request.post(`${apiBase}/assets`, {
headers, headers,
data: { data: {
@@ -282,23 +262,23 @@ test.describe("素材库流程", () => {
status: "ready", status: "ready",
uploaded_by_user_id: userId, uploaded_by_user_id: userId,
}, },
}); })
// 列出素材 // 列出素材
const response = await request.get(`${apiBase}/assets`, { const response = await request.get(`${apiBase}/assets`, {
headers, headers,
params: { library_id: libData.id }, params: { library_id: libData.id },
}); })
expect( expect(
response.ok(), response.ok(),
`列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`, `列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
const items = data.items || []; const items = data.items || []
expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2); expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2)
}); })
test("未登录创建素材库 - 反向", async ({ request }) => { test("未登录创建素材库 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/asset-libraries`, { const response = await request.post(`${apiBase}/asset-libraries`, {
@@ -307,8 +287,8 @@ test.describe("素材库流程", () => {
name: "Unauthorized Library", name: "Unauthorized Library",
kind: "video", kind: "video",
}, },
}); })
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
}); })
+113 -127
View File
@@ -4,29 +4,29 @@
* 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息 * 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息
* 每个测试独立,使用随机邮箱避免冲突。 * 每个测试独立,使用随机邮箱避免冲突。
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test"
const PASSWORD = "Test123456!"; const PASSWORD = "Test123456!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 从错误响应中提取错误消息文本,兼容新老格式 */ /** 从错误响应中提取错误消息文本,兼容新老格式 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractErrorMessage(body: any): string { function extractErrorMessage(body: any): string {
if (!body) return ""; if (!body) return ""
// 新格式: { error: { code: "...", message: "..." } } // 新格式: { error: { code: "...", message: "..." } }
if (body.error && typeof body.error === "object" && body.error.message) { if (body.error && typeof body.error === "object" && body.error.message) {
return String(body.error.message); return String(body.error.message)
} }
// 老格式: { detail: "..." } 或 { message: "..." } 或 { error: "..." } // 老格式: { detail: "..." } 或 { message: "..." } 或 { error: "..." }
return String(body.detail || body.message || body.error || ""); return String(body.detail || body.message || body.error || "")
} }
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */ /** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
@@ -39,27 +39,27 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
// 被限流了,等窗口重置 // 被限流了,等窗口重置
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
// 最后一次直接返回 // 最后一次直接返回
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
test.describe("认证流程", () => { test.describe("认证流程", () => {
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时 // 登录限流 10次/60s,测试可能触发限流等待,给足够超时
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 })
// ─── 注册 ──────────────────────────────────────────── // ─── 注册 ────────────────────────────────────────────
test("注册新用户 - 正向", async ({ request }) => { test("注册新用户 - 正向", async ({ request }) => {
const email = uniqueEmail("reg-ok"); const email = uniqueEmail("reg-ok")
const username = uniqueUsername("regok"); const username = uniqueUsername("regok")
const response = await request.post(`${apiBase}/auth/register`, { const response = await request.post(`${apiBase}/auth/register`, {
data: { data: {
@@ -68,23 +68,23 @@ test.describe("认证流程", () => {
username, username,
display_name: "E2E 注册测试", display_name: "E2E 注册测试",
}, },
}); })
expect( expect(
response.ok(), response.ok(),
`注册应返回 2xx,实际: ${response.status()} ${await response.text()}`, `注册应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
expect(data.user_id, "应返回 user_id").toBeTruthy(); expect(data.user_id, "应返回 user_id").toBeTruthy()
expect(data.email).toBe(email); expect(data.email).toBe(email)
expect(data.username).toBe(username); expect(data.username).toBe(username)
}); })
test("注册已存在邮箱 - 反向", async ({ request }) => { test("注册已存在邮箱 - 反向", async ({ request }) => {
const email = uniqueEmail("reg-dup"); const email = uniqueEmail("reg-dup")
const username1 = uniqueUsername("regdup1"); const username1 = uniqueUsername("regdup1")
const username2 = uniqueUsername("regdup2"); const username2 = uniqueUsername("regdup2")
// 第一次注册 // 第一次注册
const first = await request.post(`${apiBase}/auth/register`, { const first = await request.post(`${apiBase}/auth/register`, {
@@ -94,8 +94,8 @@ test.describe("认证流程", () => {
username: username1, username: username1,
display_name: "User 1", display_name: "User 1",
}, },
}); })
expect(first.ok(), "第一次注册应成功").toBeTruthy(); expect(first.ok(), "第一次注册应成功").toBeTruthy()
// 第二次使用相同邮箱 // 第二次使用相同邮箱
const second = await request.post(`${apiBase}/auth/register`, { const second = await request.post(`${apiBase}/auth/register`, {
@@ -105,24 +105,22 @@ test.describe("认证流程", () => {
username: username2, username: username2,
display_name: "User 2", display_name: "User 2",
}, },
}); })
expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual( expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual(400)
400, expect(second.status()).toBeLessThan(500)
);
expect(second.status()).toBeLessThan(500);
const body = await second.json(); const body = await second.json()
// 错误信息应包含"已注册"或"exists"相关提示 // 错误信息应包含"已注册"或"exists"相关提示
const detail = extractErrorMessage(body).toLowerCase(); const detail = extractErrorMessage(body).toLowerCase()
expect( expect(
detail.includes("已") || detail.includes("已") ||
detail.includes("exist") || detail.includes("exist") ||
detail.includes("registered") || detail.includes("registered") ||
detail.includes("duplicate"), detail.includes("duplicate"),
`错误信息应提示邮箱已注册,实际: "${detail}"`, `错误信息应提示邮箱已注册,实际: "${detail}"`,
).toBeTruthy(); ).toBeTruthy()
}); })
test("注册无效邮箱格式 - 反向", async ({ request }) => { test("注册无效邮箱格式 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/auth/register`, { const response = await request.post(`${apiBase}/auth/register`, {
@@ -132,11 +130,11 @@ test.describe("认证流程", () => {
username: uniqueUsername("bademail"), username: uniqueUsername("bademail"),
display_name: "Bad Email", display_name: "Bad Email",
}, },
}); })
// 422 是 FastAPI 参数校验失败的标准状态码 // 422 是 FastAPI 参数校验失败的标准状态码
expect([400, 422]).toContain(response.status()); expect([400, 422]).toContain(response.status())
}); })
test("注册弱密码 - 反向", async ({ request }) => { test("注册弱密码 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/auth/register`, { const response = await request.post(`${apiBase}/auth/register`, {
@@ -146,10 +144,10 @@ test.describe("认证流程", () => {
username: uniqueUsername("weakpwd"), username: uniqueUsername("weakpwd"),
display_name: "Weak", display_name: "Weak",
}, },
}); })
expect([400, 422]).toContain(response.status()); expect([400, 422]).toContain(response.status())
}); })
test("注册用户名为空 - 反向", async ({ request }) => { test("注册用户名为空 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/auth/register`, { const response = await request.post(`${apiBase}/auth/register`, {
@@ -159,12 +157,10 @@ test.describe("认证流程", () => {
username: "", username: "",
display_name: "Empty Username", display_name: "Empty Username",
}, },
}); })
expect([400, 422], "用户名为空应返回 4xx 校验错误").toContain( expect([400, 422], "用户名为空应返回 4xx 校验错误").toContain(response.status())
response.status(), })
);
});
test("注册缺用户名字段 - 反向", async ({ request }) => { test("注册缺用户名字段 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/auth/register`, { const response = await request.post(`${apiBase}/auth/register`, {
@@ -173,76 +169,72 @@ test.describe("认证流程", () => {
password: PASSWORD, password: PASSWORD,
display_name: "No Username Field", display_name: "No Username Field",
}, },
}); })
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain( expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(response.status())
response.status(), })
);
});
// ─── 登录 ──────────────────────────────────────────── // ─── 登录 ────────────────────────────────────────────
test("登录成功 - 正向", async ({ request }) => { test("登录成功 - 正向", async ({ request }) => {
const email = uniqueEmail("login-ok"); const email = uniqueEmail("login-ok")
const username = uniqueUsername("loginok"); const username = uniqueUsername("loginok")
// 先注册 // 先注册
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: "Login Test" }, data: { email, password: PASSWORD, username, display_name: "Login Test" },
}); })
expect(reg.ok(), "注册应成功").toBeTruthy(); expect(reg.ok(), "注册应成功").toBeTruthy()
// 登录(带限流重试) // 登录(带限流重试)
const response = await loginWithRetry(request, email, PASSWORD); const response = await loginWithRetry(request, email, PASSWORD)
expect( expect(
response.ok(), response.ok(),
`登录应返回 2xx,实际: ${response.status()} ${await response.text()}`, `登录应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
expect(data.access_token, "应返回 access_token").toBeTruthy(); expect(data.access_token, "应返回 access_token").toBeTruthy()
expect(data.token_type).toBe("bearer"); expect(data.token_type).toBe("bearer")
expect(data.email).toBe(email); expect(data.email).toBe(email)
}); })
test("登录错误密码 - 反向", async ({ request }) => { test("登录错误密码 - 反向", async ({ request }) => {
const email = uniqueEmail("login-bad"); const email = uniqueEmail("login-bad")
const username = uniqueUsername("loginbad"); const username = uniqueUsername("loginbad")
// 先注册 // 先注册
await request.post(`${apiBase}/auth/register`, { await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: "Bad Login" }, data: { email, password: PASSWORD, username, display_name: "Bad Login" },
}); })
// 使用错误密码登录(带限流重试) // 使用错误密码登录(带限流重试)
const response = await loginWithRetry(request, email, "WrongPassword999!"); const response = await loginWithRetry(request, email, "WrongPassword999!")
expect(response.status(), "错误密码应返回 401").toBe(401); expect(response.status(), "错误密码应返回 401").toBe(401)
}); })
test("登录不存在的邮箱 - 反向", async ({ request }) => { test("登录不存在的邮箱 - 反向", async ({ request }) => {
// 带限流重试的反向登录测试 // 带限流重试的反向登录测试
let response; let response
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
response = await request.post(`${apiBase}/auth/login`, { response = await request.post(`${apiBase}/auth/login`, {
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD }, data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
}); })
if (response.status() !== 429) break; if (response.status() !== 429) break
console.log( console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`)
`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`, await new Promise((r) => setTimeout(r, 65_000))
);
await new Promise((r) => setTimeout(r, 65_000));
} }
expect(response.status(), "不存在的用户应返回 401").toBe(401); expect(response.status(), "不存在的用户应返回 401").toBe(401)
}); })
// ─── 登出 ──────────────────────────────────────────── // ─── 登出 ────────────────────────────────────────────
test("登出成功", async ({ request }) => { test("登出成功", async ({ request }) => {
const email = uniqueEmail("logout"); const email = uniqueEmail("logout")
const username = uniqueUsername("logout"); const username = uniqueUsername("logout")
// 注册 & 登录 // 注册 & 登录
await request.post(`${apiBase}/auth/register`, { await request.post(`${apiBase}/auth/register`, {
@@ -252,93 +244,87 @@ test.describe("认证流程", () => {
username, username,
display_name: "Logout Test", display_name: "Logout Test",
}, },
}); })
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
const { access_token } = await login.json(); const { access_token } = await login.json()
const headers = { Authorization: `Bearer ${access_token}` }; const headers = { Authorization: `Bearer ${access_token}` }
// 登出 // 登出
const logout = await request.post(`${apiBase}/auth/logout`, { headers }); const logout = await request.post(`${apiBase}/auth/logout`, { headers })
expect( expect(logout.ok(), `登出应返回 2xx,实际: ${logout.status()}`).toBeTruthy()
logout.ok(),
`登出应返回 2xx,实际: ${logout.status()}`,
).toBeTruthy();
const body = await logout.json(); const body = await logout.json()
expect(body.message).toBeTruthy(); expect(body.message).toBeTruthy()
// 登出后 token 应失效,尝试访问 /auth/me // 登出后 token 应失效,尝试访问 /auth/me
const me = await request.get(`${apiBase}/auth/me`, { headers }); const me = await request.get(`${apiBase}/auth/me`, { headers })
expect([401, 403]).toContain(me.status()); expect([401, 403]).toContain(me.status())
}); })
// ─── 获取当前用户信息 ───────────────────────────────── // ─── 获取当前用户信息 ─────────────────────────────────
test("获取当前用户信息 - 正向", async ({ request }) => { test("获取当前用户信息 - 正向", async ({ request }) => {
const email = uniqueEmail("me-ok"); const email = uniqueEmail("me-ok")
const username = uniqueUsername("meok"); const username = uniqueUsername("meok")
await request.post(`${apiBase}/auth/register`, { await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: "Me Test" }, data: { email, password: PASSWORD, username, display_name: "Me Test" },
}); })
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
const { access_token } = await login.json(); const { access_token } = await login.json()
const response = await request.get(`${apiBase}/auth/me`, { const response = await request.get(`${apiBase}/auth/me`, {
headers: { Authorization: `Bearer ${access_token}` }, headers: { Authorization: `Bearer ${access_token}` },
}); })
expect( expect(response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`).toBeTruthy()
response.ok(),
`获取用户信息应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json(); const data = await response.json()
expect(data.user_id).toBeTruthy(); expect(data.user_id).toBeTruthy()
expect(data.email).toBe(email); expect(data.email).toBe(email)
expect(data.username).toBe(username); expect(data.username).toBe(username)
}); })
test("无 token 获取用户信息 - 反向", async ({ request }) => { test("无 token 获取用户信息 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/auth/me`); const response = await request.get(`${apiBase}/auth/me`)
// HTTPBearer 无凭证返回 403 // HTTPBearer 无凭证返回 403
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
test("无效 token 获取用户信息 - 反向", async ({ request }) => { test("无效 token 获取用户信息 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/auth/me`, { const response = await request.get(`${apiBase}/auth/me`, {
headers: { Authorization: "Bearer invalid.token.here" }, headers: { Authorization: "Bearer invalid.token.here" },
}); })
expect(response.status()).toBe(401); expect(response.status()).toBe(401)
}); })
test("过期 token 获取用户信息 - 反向", async ({ request }) => { test("过期 token 获取用户信息 - 反向", async ({ request }) => {
// 使用一个伪造的过期 JWT(header.payload.signature) // 使用一个伪造的过期 JWT(header.payload.signature)
// eyJhbGciOiJIUzI1NiJ9 = {"alg":"HS256"} // eyJhbGciOiJIUzI1NiJ9 = {"alg":"HS256"}
// eyJleHAiOjF9 = {"exp":1} (1970-01-01 过期) // eyJleHAiOjF9 = {"exp":1} (1970-01-01 过期)
const expiredToken = const expiredToken =
"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature"; "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature"
const response = await request.get(`${apiBase}/auth/me`, { const response = await request.get(`${apiBase}/auth/me`, {
headers: { Authorization: `Bearer ${expiredToken}` }, headers: { Authorization: `Bearer ${expiredToken}` },
}); })
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
test("token 格式错误 - 反向", async ({ request }) => { test("token 格式错误 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/auth/me`, { const response = await request.get(`${apiBase}/auth/me`, {
headers: { Authorization: "Bearer not-a-jwt" }, headers: { Authorization: "Bearer not-a-jwt" },
}); })
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
test("空 Bearer token - 反向", async ({ request }) => { test("空 Bearer token - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/auth/me`, { const response = await request.get(`${apiBase}/auth/me`, {
headers: { Authorization: "Bearer " }, headers: { Authorization: "Bearer " },
}); })
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
}); })
+84 -94
View File
@@ -4,17 +4,17 @@
* 覆盖:创建项目、列出项目、获取项目详情 * 覆盖:创建项目、列出项目、获取项目详情
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test"
const PASSWORD = "Test123456!"; const PASSWORD = "Test123456!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -27,46 +27,46 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json(); const regData = await reg.json()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
email, email,
username, username,
userId: regData.user_id, userId: regData.user_id,
}; }
} }
test.describe("项目流程", () => { test.describe("项目流程", () => {
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时 // 登录限流 10次/60s,测试可能触发限流等待,给足够超时
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 })
test("创建项目", async ({ request }) => { test("创建项目", async ({ request }) => {
const { headers } = await createAuthedUser(request, "proj-create"); const { headers } = await createAuthedUser(request, "proj-create")
const projectName = `E2E 测试项目 ${Date.now()}`; const projectName = `E2E 测试项目 ${Date.now()}`
const response = await request.post(`${apiBase}/projects`, { const response = await request.post(`${apiBase}/projects`, {
headers, headers,
@@ -74,156 +74,146 @@ test.describe("项目流程", () => {
name: projectName, name: projectName,
description: "Playwright E2E 回归测试创建", description: "Playwright E2E 回归测试创建",
}, },
}); })
expect( expect(
response.ok(), response.ok(),
`创建项目应返回 2xx,实际: ${response.status()} ${await response.text()}`, `创建项目应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
expect(data.id, "应返回项目 ID").toBeTruthy(); expect(data.id, "应返回项目 ID").toBeTruthy()
expect(data.name).toBe(projectName); expect(data.name).toBe(projectName)
expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy(); expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy()
}); })
test("创建项目名称为空 - 反向", async ({ request }) => { test("创建项目名称为空 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "proj-empty"); const { headers } = await createAuthedUser(request, "proj-empty")
const response = await request.post(`${apiBase}/projects`, { const response = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: "", description: "Should fail" }, data: { name: "", description: "Should fail" },
}); })
// name 有 min_length=1 约束,应返回 422 // name 有 min_length=1 约束,应返回 422
expect([400, 422]).toContain(response.status()); expect([400, 422]).toContain(response.status())
}); })
test("列出项目", async ({ request }) => { test("列出项目", async ({ request }) => {
const { headers } = await createAuthedUser(request, "proj-list"); const { headers } = await createAuthedUser(request, "proj-list")
// 先创建 2 个项目 // 先创建 2 个项目
await request.post(`${apiBase}/projects`, { await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `List Proj A ${Date.now()}` }, data: { name: `List Proj A ${Date.now()}` },
}); })
await request.post(`${apiBase}/projects`, { await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `List Proj B ${Date.now()}` }, data: { name: `List Proj B ${Date.now()}` },
}); })
// 列出 // 列出
const response = await request.get(`${apiBase}/projects`, { headers }); const response = await request.get(`${apiBase}/projects`, { headers })
expect( expect(
response.ok(), response.ok(),
`列出项目应返回 2xx,实际: ${response.status()} ${await response.text()}`, `列出项目应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
const items = data.items || data.projects || data || []; const items = data.items || data.projects || data || []
expect(Array.isArray(items)).toBeTruthy(); expect(Array.isArray(items)).toBeTruthy()
expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2); expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2)
}); })
test("获取项目详情", async ({ request }) => { test("获取项目详情", async ({ request }) => {
const { headers } = await createAuthedUser(request, "proj-detail"); const { headers } = await createAuthedUser(request, "proj-detail")
// 先创建 // 先创建
const created = await request.post(`${apiBase}/projects`, { const created = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `Detail Proj ${Date.now()}`, description: "Detail test" }, data: { name: `Detail Proj ${Date.now()}`, description: "Detail test" },
}); })
expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy(); expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy()
const { id: projectId } = await created.json(); const { id: projectId } = await created.json()
// 获取详情 // 获取详情
const response = await request.get(`${apiBase}/projects/${projectId}`, { const response = await request.get(`${apiBase}/projects/${projectId}`, {
headers, headers,
}); })
expect( expect(
response.ok(), response.ok(),
`获取详情应返回 2xx,实际: ${response.status()} ${await response.text()}`, `获取详情应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
expect(data.id).toBe(projectId); expect(data.id).toBe(projectId)
expect(data.name).toBeTruthy(); expect(data.name).toBeTruthy()
expect(data.owner_user_id).toBeTruthy(); expect(data.owner_user_id).toBeTruthy()
}); })
test("获取不存在的项目 - 反向", async ({ request }) => { test("获取不存在的项目 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "proj-404"); const { headers } = await createAuthedUser(request, "proj-404")
const response = await request.get( const response = await request.get(`${apiBase}/projects/nonexistent-project-id-999`, {
`${apiBase}/projects/nonexistent-project-id-999`, headers,
{ headers }, })
);
expect(response.status(), "不存在的项目应返回 404").toBe(404); expect(response.status(), "不存在的项目应返回 404").toBe(404)
}); })
test("未登录列出项目 - 反向", async ({ request }) => { test("未登录列出项目 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/projects`); const response = await request.get(`${apiBase}/projects`)
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
test("未授权访问他人项目 - 反向", async ({ request }) => { test("未授权访问他人项目 - 反向", async ({ request }) => {
// 用户 A 创建项目 // 用户 A 创建项目
const { headers: headersA } = await createAuthedUser(request, "proj-owner"); const { headers: headersA } = await createAuthedUser(request, "proj-owner")
const created = await request.post(`${apiBase}/projects`, { const created = await request.post(`${apiBase}/projects`, {
headers: headersA, headers: headersA,
data: { name: `Owner Proj ${Date.now()}`, description: "Owner test" }, data: { name: `Owner Proj ${Date.now()}`, description: "Owner test" },
}); })
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy(); expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy()
const { id: projectId } = await created.json(); const { id: projectId } = await created.json()
// 用户 B 尝试访问用户 A 的项目 // 用户 B 尝试访问用户 A 的项目
const { headers: headersB } = await createAuthedUser( const { headers: headersB } = await createAuthedUser(request, "proj-intruder")
request,
"proj-intruder",
);
const response = await request.get(`${apiBase}/projects/${projectId}`, { const response = await request.get(`${apiBase}/projects/${projectId}`, {
headers: headersB, headers: headersB,
}); })
// 应返回 403 (Forbidden) 或 404 (Not Found) — 不应泄露资源存在性 // 应返回 403 (Forbidden) 或 404 (Not Found) — 不应泄露资源存在性
expect([403, 404]).toContain(response.status()); expect([403, 404]).toContain(response.status())
}); })
test("未授权删除他人项目 - 反向", async ({ request }) => { test("未授权删除他人项目 - 反向", async ({ request }) => {
// 用户 A 创建项目 // 用户 A 创建项目
const { headers: headersA } = await createAuthedUser( const { headers: headersA } = await createAuthedUser(request, "proj-del-owner")
request,
"proj-del-owner",
);
const created = await request.post(`${apiBase}/projects`, { const created = await request.post(`${apiBase}/projects`, {
headers: headersA, headers: headersA,
data: { name: `Delete Test Proj ${Date.now()}` }, data: { name: `Delete Test Proj ${Date.now()}` },
}); })
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy(); expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy()
const { id: projectId } = await created.json(); const { id: projectId } = await created.json()
// 用户 B 尝试删除用户 A 的项目 // 用户 B 尝试删除用户 A 的项目
const { headers: headersB } = await createAuthedUser( const { headers: headersB } = await createAuthedUser(request, "proj-del-attempt")
request,
"proj-del-attempt",
);
const response = await request.delete(`${apiBase}/projects/${projectId}`, { const response = await request.delete(`${apiBase}/projects/${projectId}`, {
headers: headersB, headers: headersB,
}); })
expect([403, 404]).toContain(response.status()); expect([403, 404]).toContain(response.status())
}); })
test("使用无效项目 ID 获取详情 - 反向", async ({ request }) => { test("使用无效项目 ID 获取详情 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "proj-badid"); const { headers } = await createAuthedUser(request, "proj-badid")
const response = await request.get(`${apiBase}/projects/`, { headers }); const response = await request.get(`${apiBase}/projects/`, { headers })
// 空 ID 或无效格式应返回 404 或 422 // 空 ID 或无效格式应返回 404 或 422
expect([400, 404, 422]).toContain(response.status()); expect([400, 404, 422]).toContain(response.status())
}); })
}); })
+191 -216
View File
@@ -9,36 +9,29 @@
* *
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!"; const PASSWORD = "Test123456!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return; if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -51,30 +44,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json(); const regData = await reg.json()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -82,7 +75,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
}; }
} }
/** 设置页面认证状态(localStorage) */ /** 设置页面认证状态(localStorage) */
@@ -93,14 +86,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token, token,
@@ -114,7 +107,7 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
); )
} }
/** 创建一个标题并返回 id */ /** 创建一个标题并返回 id */
@@ -133,77 +126,76 @@ async function createTitle(
tags: ["e2e", "test"], tags: ["e2e", "test"],
...overrides, ...overrides,
}, },
}); })
expect(resp.ok(), `创建标题应成功: ${await resp.text()}`).toBeTruthy(); expect(resp.ok(), `创建标题应成功: ${await resp.text()}`).toBeTruthy()
const data = await resp.json(); const data = await resp.json()
return data.id; return data.id
} }
test.describe("标题库 - 空状态", () => { test.describe("标题库 - 空状态", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("新用户标题页面显示空状态", async ({ page, request }) => { test("新用户标题页面显示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "title-empty")
request,
"title-empty",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E title-empty", display_name: "E2E title-empty",
}); })
await page.goto("/app/titles"); await page.goto("/app/titles")
await expect(page.locator(".xx-titles-page")).toBeVisible({ await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 新用户应该能看到页面主体 // 新用户应该能看到页面主体
await expect(page.locator(".xx-titles-page")).toBeVisible(); await expect(page.locator(".xx-titles-page")).toBeVisible()
}); })
}); })
test.describe("标题库 - 搜索功能", () => { test.describe("标题库 - 搜索功能", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("搜索框存在且可输入", async ({ page, request }) => { test("搜索框存在且可输入", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username, headers } = const { accessToken, userId, email, username, headers } = await createAuthedUser(
await createAuthedUser(request, "title-search"); request,
const suffix = Date.now().toString(36); "title-search",
)
const suffix = Date.now().toString(36)
await createTitle(request, headers, suffix); await createTitle(request, headers, suffix)
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E title-search", display_name: "E2E title-search",
}); })
await page.goto("/app/titles"); await page.goto("/app/titles")
await expect(page.locator(".xx-titles-page")).toBeVisible({ await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 查找搜索框 // 查找搜索框
const searchInput = page.locator( const searchInput = page.locator(
"input[placeholder*='搜索标题关键词'], input[placeholder*='搜索']", "input[placeholder*='搜索标题关键词'], input[placeholder*='搜索']",
); )
if (await searchInput.first().isVisible({ timeout: 10_000 })) { if (await searchInput.first().isVisible({ timeout: 10_000 })) {
await searchInput.first().fill("测试搜索"); await searchInput.first().fill("测试搜索")
await expect(searchInput.first()).toHaveValue("测试搜索"); await expect(searchInput.first()).toHaveValue("测试搜索")
} }
}); })
}); })
test.describe("标题库 - API 完整操作", () => { test.describe("标题库 - API 完整操作", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("创建标题 - 完整参数", async ({ request }) => { test("创建标题 - 完整参数", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-create-full"); const { headers } = await createAuthedUser(request, "title-create-full")
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
const response = await request.post(`${apiBase}/titles`, { const response = await request.post(`${apiBase}/titles`, {
headers, headers,
@@ -214,29 +206,25 @@ test.describe("标题库 - API 完整操作", () => {
tags: ["e2e", "完整测试", "种草"], tags: ["e2e", "完整测试", "种草"],
status: "active", status: "active",
}, },
}); })
expect( expect(
response.ok(), response.ok(),
`创建标题应返回 2xx,实际: ${response.status()} ${await response.text()}`, `创建标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
expect(data.id, "应返回标题 ID").toBeTruthy(); expect(data.id, "应返回标题 ID").toBeTruthy()
expect(data.name).toBe(`完整参数测试 ${suffix}`); expect(data.name).toBe(`完整参数测试 ${suffix}`)
expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`); expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`)
}); })
test("编辑标题 - 正向", async ({ request }) => { test("编辑标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-update"); const { headers } = await createAuthedUser(request, "title-update")
const titleId = await createTitle( const titleId = await createTitle(request, headers, Date.now().toString(36))
request,
headers,
Date.now().toString(36),
);
const newName = `更新后的标题 ${Date.now()}`; const newName = `更新后的标题 ${Date.now()}`
const newText = "这是更新后的标题内容"; const newText = "这是更新后的标题内容"
const response = await request.patch(`${apiBase}/titles/${titleId}`, { const response = await request.patch(`${apiBase}/titles/${titleId}`, {
headers, headers,
data: { data: {
@@ -244,52 +232,48 @@ test.describe("标题库 - API 完整操作", () => {
text: newText, text: newText,
category: "知识", category: "知识",
}, },
}); })
expect( expect(
response.ok(), response.ok(),
`更新标题应返回 2xx,实际: ${response.status()} ${await response.text()}`, `更新标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
const data = await response.json(); const data = await response.json()
expect(data.name).toBe(newName); expect(data.name).toBe(newName)
// 验证更新 // 验证更新
const verify = await request.get(`${apiBase}/titles/${titleId}`, { const verify = await request.get(`${apiBase}/titles/${titleId}`, {
headers, headers,
}); })
const verifyData = await verify.json(); const verifyData = await verify.json()
expect(verifyData.name).toBe(newName); expect(verifyData.name).toBe(newName)
expect(verifyData.text).toBe(newText); expect(verifyData.text).toBe(newText)
}); })
test("删除标题 - 正向", async ({ request }) => { test("删除标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-delete"); const { headers } = await createAuthedUser(request, "title-delete")
const titleId = await createTitle( const titleId = await createTitle(request, headers, Date.now().toString(36))
request,
headers,
Date.now().toString(36),
);
// 删除 // 删除
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, { const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
headers, headers,
}); })
expect( expect(
[200, 204].includes(deleteResp.status()), [200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`, `删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy(); ).toBeTruthy()
// 验证已删除 // 验证已删除
const getResp = await request.get(`${apiBase}/titles/${titleId}`, { const getResp = await request.get(`${apiBase}/titles/${titleId}`, {
headers, headers,
}); })
expect([404, 410]).toContain(getResp.status()); expect([404, 410]).toContain(getResp.status())
}); })
test("批量导入标题 - 正向", async ({ request }) => { test("批量导入标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-batch"); const { headers } = await createAuthedUser(request, "title-batch")
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
const titles = [ const titles = [
{ {
@@ -307,29 +291,27 @@ test.describe("标题库 - API 完整操作", () => {
text: `内容 3 ${suffix}`, text: `内容 3 ${suffix}`,
category: "知识", category: "知识",
}, },
]; ]
const response = await request.post(`${apiBase}/titles/batch-import`, { const response = await request.post(`${apiBase}/titles/batch-import`, {
headers, headers,
data: { titles }, data: { titles },
}); })
// 批量导入可能成功或接口不存在 // 批量导入可能成功或接口不存在
expect( expect(
response.status() < 500, response.status() < 500,
`批量导入应返回 2xx 或 4xx,实际: ${response.status()}`, `批量导入应返回 2xx 或 4xx,实际: ${response.status()}`,
).toBeTruthy(); ).toBeTruthy()
if (response.ok()) { if (response.ok()) {
const data = await response.json(); const data = await response.json()
expect( expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy()
Array.isArray(data) || data.success_count !== undefined,
).toBeTruthy();
} }
}); })
test("创建标题 - 名称为空反向", async ({ request }) => { test("创建标题 - 名称为空反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-empty-name"); const { headers } = await createAuthedUser(request, "title-empty-name")
const response = await request.post(`${apiBase}/titles`, { const response = await request.post(`${apiBase}/titles`, {
headers, headers,
@@ -338,13 +320,13 @@ test.describe("标题库 - API 完整操作", () => {
text: "有内容但名称为空", text: "有内容但名称为空",
category: "default", category: "default",
}, },
}); })
expect([400, 422]).toContain(response.status()); expect([400, 422]).toContain(response.status())
}); })
test("创建标题 - 缺少必要字段反向", async ({ request }) => { test("创建标题 - 缺少必要字段反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-missing"); const { headers } = await createAuthedUser(request, "title-missing")
const response = await request.post(`${apiBase}/titles`, { const response = await request.post(`${apiBase}/titles`, {
headers, headers,
@@ -352,46 +334,37 @@ test.describe("标题库 - API 完整操作", () => {
name: "缺少 text 字段", name: "缺少 text 字段",
// 缺少 text 字段 // 缺少 text 字段
}, },
}); })
expect([400, 422]).toContain(response.status()); expect([400, 422]).toContain(response.status())
}); })
test("获取不存在的标题 - 反向", async ({ request }) => { test("获取不存在的标题 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-404"); const { headers } = await createAuthedUser(request, "title-404")
const response = await request.get( const response = await request.get(`${apiBase}/titles/nonexistent-title-999`, { headers })
`${apiBase}/titles/nonexistent-title-999`, expect(response.status(), "不存在的标题应返回 404").toBe(404)
{ headers }, })
);
expect(response.status(), "不存在的标题应返回 404").toBe(404);
});
test("更新不存在的标题 - 反向", async ({ request }) => { test("更新不存在的标题 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-update-404"); const { headers } = await createAuthedUser(request, "title-update-404")
const response = await request.patch( const response = await request.patch(`${apiBase}/titles/nonexistent-title-999`, {
`${apiBase}/titles/nonexistent-title-999`, headers,
{ data: { name: "不存在的标题", text: "测试" },
headers, })
data: { name: "不存在的标题", text: "测试" }, expect(response.status(), "更新不存在的标题应返回 404").toBe(404)
}, })
);
expect(response.status(), "更新不存在的标题应返回 404").toBe(404);
});
test("删除不存在的标题 - 反向", async ({ request }) => { test("删除不存在的标题 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-del-404"); const { headers } = await createAuthedUser(request, "title-del-404")
const response = await request.delete( const response = await request.delete(`${apiBase}/titles/nonexistent-title-999`, { headers })
`${apiBase}/titles/nonexistent-title-999`,
{ headers },
);
expect( expect(
[404, 200, 204].includes(response.status()), [404, 200, 204].includes(response.status()),
"删除不存在的标题应返回 404 或幂等 2xx", "删除不存在的标题应返回 404 或幂等 2xx",
).toBeTruthy(); ).toBeTruthy()
}); })
test("未登录创建标题 - 反向", async ({ request }) => { test("未登录创建标题 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/titles`, { const response = await request.post(`${apiBase}/titles`, {
@@ -400,39 +373,39 @@ test.describe("标题库 - API 完整操作", () => {
text: "未登录创建标题", text: "未登录创建标题",
category: "default", category: "default",
}, },
}); })
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
test("未登录删除标题 - 反向", async ({ request }) => { test("未登录删除标题 - 反向", async ({ request }) => {
const response = await request.delete(`${apiBase}/titles/some-id`); const response = await request.delete(`${apiBase}/titles/some-id`)
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
}); })
test.describe("标题库 - 分类/标签筛选", () => { test.describe("标题库 - 分类/标签筛选", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("标题分类 API 返回数据", async ({ request }) => { test("标题分类 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-cat"); const { headers } = await createAuthedUser(request, "title-cat")
// 获取标题列表,检查分类字段 // 获取标题列表,检查分类字段
const response = await request.get(`${apiBase}/titles`, { headers }); const response = await request.get(`${apiBase}/titles`, { headers })
expect(response.ok()).toBeTruthy(); expect(response.ok()).toBeTruthy()
const data = await response.json(); const data = await response.json()
const items = data.items || data.titles || []; const items = data.items || data.titles || []
expect(Array.isArray(items)).toBeTruthy(); expect(Array.isArray(items)).toBeTruthy()
// 如果有标题,验证有分类字段 // 如果有标题,验证有分类字段
if (items.length > 0) { if (items.length > 0) {
expect(items[0].category !== undefined).toBeTruthy(); expect(items[0].category !== undefined).toBeTruthy()
} }
}); })
test("按分类筛选标题", async ({ request }) => { test("按分类筛选标题", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-filter-cat"); const { headers } = await createAuthedUser(request, "title-filter-cat")
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
// 创建不同分类的标题 // 创建不同分类的标题
await request.post(`${apiBase}/titles`, { await request.post(`${apiBase}/titles`, {
@@ -442,7 +415,7 @@ test.describe("标题库 - 分类/标签筛选", () => {
text: "种草内容", text: "种草内容",
category: "种草", category: "种草",
}, },
}); })
await request.post(`${apiBase}/titles`, { await request.post(`${apiBase}/titles`, {
headers, headers,
data: { data: {
@@ -450,116 +423,118 @@ test.describe("标题库 - 分类/标签筛选", () => {
text: "知识内容", text: "知识内容",
category: "知识", category: "知识",
}, },
}); })
// 按分类筛选 // 按分类筛选
const response = await request.get(`${apiBase}/titles`, { const response = await request.get(`${apiBase}/titles`, {
headers, headers,
params: { category: "种草" }, params: { category: "种草" },
}); })
// 筛选可能支持也可能不支持 // 筛选可能支持也可能不支持
expect( expect(response.ok(), `筛选请求应成功,实际: ${response.status()}`).toBeTruthy()
response.ok(), })
`筛选请求应成功,实际: ${response.status()}`, })
).toBeTruthy();
});
});
test.describe("标题库 - 页面交互", () => { test.describe("标题库 - 页面交互", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("标题卡片展示完整信息", async ({ page, request }) => { test("标题卡片展示完整信息", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username, headers } = const { accessToken, userId, email, username, headers } = await createAuthedUser(
await createAuthedUser(request, "title-card"); request,
const suffix = Date.now().toString(36); "title-card",
)
const suffix = Date.now().toString(36)
await createTitle(request, headers, suffix); await createTitle(request, headers, suffix)
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E title-card", display_name: "E2E title-card",
}); })
await page.goto("/app/titles"); await page.goto("/app/titles")
await expect(page.locator(".xx-titles-page")).toBeVisible({ await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const firstCard = page.locator(".xx-title-card").first(); const firstCard = page.locator(".xx-title-card").first()
if (await firstCard.isVisible({ timeout: 15_000 })) { if (await firstCard.isVisible({ timeout: 15_000 })) {
// 验证标题文本 // 验证标题文本
const titleText = firstCard.locator(".xx-title-card-text"); const titleText = firstCard.locator(".xx-title-card-text")
if (await titleText.isVisible()) { if (await titleText.isVisible()) {
await expect(titleText).toBeVisible(); await expect(titleText).toBeVisible()
} }
// 验证统计信息 // 验证统计信息
const titleStat = firstCard.locator(".xx-title-card-stat"); const titleStat = firstCard.locator(".xx-title-card-stat")
if (await titleStat.isVisible()) { if (await titleStat.isVisible()) {
await expect(titleStat).toBeVisible(); await expect(titleStat).toBeVisible()
} }
} }
}); })
test("标题卡片可点击查看详情", async ({ page, request }) => { test("标题卡片可点击查看详情", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username, headers } = const { accessToken, userId, email, username, headers } = await createAuthedUser(
await createAuthedUser(request, "title-detail"); request,
const suffix = Date.now().toString(36); "title-detail",
)
const suffix = Date.now().toString(36)
await createTitle(request, headers, suffix); await createTitle(request, headers, suffix)
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E title-detail", display_name: "E2E title-detail",
}); })
await page.goto("/app/titles"); await page.goto("/app/titles")
await expect(page.locator(".xx-titles-page")).toBeVisible({ await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const firstCard = page.locator(".xx-title-card").first(); const firstCard = page.locator(".xx-title-card").first()
if (await firstCard.isVisible({ timeout: 15_000 })) { if (await firstCard.isVisible({ timeout: 15_000 })) {
await firstCard.click(); await firstCard.click()
// 点击后页面应该有响应(可能是弹窗或跳转) // 点击后页面应该有响应(可能是弹窗或跳转)
await expect(page.locator(".xx-titles-page")).toBeVisible(); await expect(page.locator(".xx-titles-page")).toBeVisible()
} }
}); })
}); })
test.describe("标题库 - 批量操作", () => { test.describe("标题库 - 批量操作", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("多选复选框存在", async ({ page, request }) => { test("多选复选框存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username, headers } = const { accessToken, userId, email, username, headers } = await createAuthedUser(
await createAuthedUser(request, "title-batch-ui"); request,
const suffix = Date.now().toString(36); "title-batch-ui",
)
const suffix = Date.now().toString(36)
// 创建多个标题 // 创建多个标题
await createTitle(request, headers, `${suffix}-1`); await createTitle(request, headers, `${suffix}-1`)
await createTitle(request, headers, `${suffix}-2`); await createTitle(request, headers, `${suffix}-2`)
await createTitle(request, headers, `${suffix}-3`); await createTitle(request, headers, `${suffix}-3`)
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E title-batch-ui", display_name: "E2E title-batch-ui",
}); })
await page.goto("/app/titles"); await page.goto("/app/titles")
await expect(page.locator(".xx-titles-page")).toBeVisible({ await expect(page.locator(".xx-titles-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 检查是否有批量操作相关 UI // 检查是否有批量操作相关 UI
const checkboxes = page.locator(".xx-title-card input[type='checkbox']");
// 页面正常加载即可,批量操作是可选功能 // 页面正常加载即可,批量操作是可选功能
await expect(page.locator(".xx-titles-page")).toBeVisible(); await expect(page.locator(".xx-titles-page")).toBeVisible()
}); })
}); })
+165 -210
View File
@@ -6,36 +6,29 @@
* *
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!"; const PASSWORD = "Test123456!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return; if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -48,30 +41,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json(); const regData = await reg.json()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
}; }
} }
/** 设置页面认证状态(localStorage) */ /** 设置页面认证状态(localStorage) */
@@ -90,14 +83,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token, token,
@@ -111,140 +104,123 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
); )
} }
test.describe("声音克隆页面 - 未登录重定向", () => { test.describe("声音克隆页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => { test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/voice-clone"); await page.goto("/app/voice-clone")
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/)
}); })
}); })
test.describe("声音克隆页面 - 页面加载", () => { test.describe("声音克隆页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("声音克隆页面加载成功", async ({ page, request }) => { test("声音克隆页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-load")
request,
"vc-load",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-load", display_name: "E2E vc-load",
}); })
await page.goto("/app/voice-clone"); await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
}); })
test("页面标题和描述存在", async ({ page, request }) => { test("页面标题和描述存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-title")
request,
"vc-title",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-title", display_name: "E2E vc-title",
}); })
await page.goto("/app/voice-clone"); await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证页面标题包含"克隆"或"音色"相关文字 // 验证页面标题包含"克隆"或"音色"相关文字
const pageTitle = page.getByRole("heading", { level: 1 });
// 只要页面正常加载即可,标题可能在 PageHead 组件中 // 只要页面正常加载即可,标题可能在 PageHead 组件中
await expect(page.locator(".vc-page")).toBeVisible(); await expect(page.locator(".vc-page")).toBeVisible()
}); })
test("克隆新音色按钮存在", async ({ page, request }) => { test("克隆新音色按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-newbtn")
request,
"vc-newbtn",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-newbtn", display_name: "E2E vc-newbtn",
}); })
await page.goto("/app/voice-clone"); await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证克隆新音色按钮存在 // 验证克隆新音色按钮存在
const cloneBtn = page.getByRole("button", { name: /克隆新音色|新建|创建/ });
// 按钮可能在不同位置,只要页面加载成功即可 // 按钮可能在不同位置,只要页面加载成功即可
await expect(page.locator(".vc-page")).toBeVisible(); await expect(page.locator(".vc-page")).toBeVisible()
}); })
}); })
test.describe("声音克隆 - 空状态", () => { test.describe("声音克隆 - 空状态", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("无克隆音色时显示空状态", async ({ page, request }) => { test("无克隆音色时显示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-empty")
request,
"vc-empty",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-empty", display_name: "E2E vc-empty",
}); })
await page.goto("/app/voice-clone"); await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 新用户应该显示空状态 // 新用户应该显示空状态
const emptyState = page.locator(".vc-empty"); const emptyState = page.locator(".vc-empty")
if (await emptyState.isVisible({ timeout: 10_000 })) { if (await emptyState.isVisible({ timeout: 10_000 })) {
await expect(emptyState.locator(".vc-empty-title")).toBeVisible(); await expect(emptyState.locator(".vc-empty-title")).toBeVisible()
await expect(emptyState.locator(".vc-empty-desc")).toBeVisible(); await expect(emptyState.locator(".vc-empty-desc")).toBeVisible()
} }
}); })
}); })
test.describe("声音克隆 - API 操作", () => { test.describe("声音克隆 - API 操作", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("获取克隆列表 - 正向", async ({ request }) => { test("获取克隆列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-list"); const { headers } = await createAuthedUser(request, "vc-list")
const response = await request.get(`${apiBase}/voice-clones`, { const response = await request.get(`${apiBase}/voice-clones`, {
headers, headers,
}); })
expect( expect(response.ok(), `获取克隆列表应返回 2xx,实际: ${response.status()}`).toBeTruthy()
response.ok(),
`获取克隆列表应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json(); const data = await response.json()
const items = data.items || data.voice_clones || []; const items = data.items || data.voice_clones || []
expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy(); expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy()
}); })
test("创建音色克隆 - 正向", async ({ request }) => { test("创建音色克隆 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-create"); const { headers } = await createAuthedUser(request, "vc-create")
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
// 创建一个克隆任务(上传音频文件) // 创建一个克隆任务(上传音频文件)
const response = await request.post(`${apiBase}/voice-clones`, { const response = await request.post(`${apiBase}/voice-clones`, {
@@ -258,52 +234,49 @@ test.describe("声音克隆 - API 操作", () => {
buffer: Buffer.from("fake audio data for e2e test"), buffer: Buffer.from("fake audio data for e2e test"),
}, },
}, },
}); })
// 克隆创建可能成功也可能因为缺少实际音频处理返回错误 // 克隆创建可能成功也可能因为缺少实际音频处理返回错误
// 只要不是 500 错误即可 // 只要不是 500 错误即可
expect( expect(
response.status() < 500, response.status() < 500,
`创建克隆应返回 2xx 或 4xx,实际: ${response.status()} ${await response.text()}`, `创建克隆应返回 2xx 或 4xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy(); ).toBeTruthy()
if (response.ok()) { if (response.ok()) {
const data = await response.json(); const data = await response.json()
expect(data.id, "应返回克隆 ID").toBeTruthy(); expect(data.id, "应返回克隆 ID").toBeTruthy()
expect(data.status, "应返回状态").toBeTruthy(); expect(data.status, "应返回状态").toBeTruthy()
} }
}); })
test("获取克隆详情 - 正向(如存在克隆数据)", async ({ request }) => { test("获取克隆详情 - 正向(如存在克隆数据)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-detail"); const { headers } = await createAuthedUser(request, "vc-detail")
// 先获取列表看看有没有数据 // 先获取列表看看有没有数据
const listResp = await request.get(`${apiBase}/voice-clones`, { const listResp = await request.get(`${apiBase}/voice-clones`, {
headers, headers,
}); })
expect(listResp.ok()).toBeTruthy(); expect(listResp.ok()).toBeTruthy()
const listData = await listResp.json(); const listData = await listResp.json()
const items = listData.items || listData.voice_clones || []; const items = listData.items || listData.voice_clones || []
if (items.length > 0) { if (items.length > 0) {
const cloneId = items[0].id; const cloneId = items[0].id
const detailResp = await request.get( const detailResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, { headers })
`${apiBase}/voice-clones/${cloneId}`, expect(detailResp.ok(), "获取详情应成功").toBeTruthy()
{ headers }, const detail = await detailResp.json()
); expect(detail.id).toBe(cloneId)
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
const detail = await detailResp.json();
expect(detail.id).toBe(cloneId);
} }
// 如果没有数据,测试也通过(新用户正常情况) // 如果没有数据,测试也通过(新用户正常情况)
}); })
test("删除克隆 - 正向(如存在克隆数据)", async ({ request }) => { test("删除克隆 - 正向(如存在克隆数据)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-del"); const { headers } = await createAuthedUser(request, "vc-del")
// 先创建一个克隆 // 先创建一个克隆
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36)
const createResp = await request.post(`${apiBase}/voice-clones`, { const createResp = await request.post(`${apiBase}/voice-clones`, {
headers, headers,
multipart: { multipart: {
@@ -314,75 +287,63 @@ test.describe("声音克隆 - API 操作", () => {
buffer: Buffer.from("delete me"), buffer: Buffer.from("delete me"),
}, },
}, },
}); })
if (createResp.ok()) { if (createResp.ok()) {
const created = await createResp.json(); const created = await createResp.json()
const cloneId = created.id; const cloneId = created.id
// 删除 // 删除
const deleteResp = await request.delete( const deleteResp = await request.delete(`${apiBase}/voice-clones/${cloneId}`, { headers })
`${apiBase}/voice-clones/${cloneId}`,
{ headers },
);
expect( expect(
[200, 204].includes(deleteResp.status()), [200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`, `删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy(); ).toBeTruthy()
// 验证已删除 // 验证已删除
const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, { const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, {
headers, headers,
}); })
expect([404, 410]).toContain(getResp.status()); expect([404, 410]).toContain(getResp.status())
} }
// 如果创建失败(比如音频格式问题),测试也通过 // 如果创建失败(比如音频格式问题),测试也通过
}); })
test("重试克隆 - 正向(如存在失败的克隆)", async ({ request }) => { test("重试克隆 - 正向(如存在失败的克隆)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-retry"); const { headers } = await createAuthedUser(request, "vc-retry")
// 先获取列表 // 先获取列表
const listResp = await request.get(`${apiBase}/voice-clones`, { const listResp = await request.get(`${apiBase}/voice-clones`, {
headers, headers,
}); })
expect(listResp.ok()).toBeTruthy(); expect(listResp.ok()).toBeTruthy()
const listData = await listResp.json(); const listData = await listResp.json()
const items = listData.items || listData.voice_clones || []; const items = listData.items || listData.voice_clones || []
// 找一个失败状态的克隆进行重试 // 找一个失败状态的克隆进行重试
const failedClone = items.find( const failedClone = items.find((item: { status: string }) => item.status === "failed")
(item: { status: string }) => item.status === "failed",
);
if (failedClone) { if (failedClone) {
const retryResp = await request.post( const retryResp = await request.post(`${apiBase}/voice-clones/${failedClone.id}/retry`, {
`${apiBase}/voice-clones/${failedClone.id}/retry`, headers,
{ headers }, })
); expect(retryResp.ok(), `重试应返回 2xx,实际: ${retryResp.status()}`).toBeTruthy()
expect(
retryResp.ok(),
`重试应返回 2xx,实际: ${retryResp.status()}`,
).toBeTruthy();
} }
// 如果没有失败的克隆,测试通过 // 如果没有失败的克隆,测试通过
}); })
test("获取不存在的克隆详情 - 反向", async ({ request }) => { test("获取不存在的克隆详情 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-404"); const { headers } = await createAuthedUser(request, "vc-404")
const response = await request.get( const response = await request.get(`${apiBase}/voice-clones/nonexistent-clone-999`, { headers })
`${apiBase}/voice-clones/nonexistent-clone-999`, expect(response.status(), "不存在的克隆应返回 404").toBe(404)
{ headers }, })
);
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
});
test("未登录获取克隆列表 - 反向", async ({ request }) => { test("未登录获取克隆列表 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voice-clones`); const response = await request.get(`${apiBase}/voice-clones`)
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
test("未登录创建克隆 - 反向", async ({ request }) => { test("未登录创建克隆 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/voice-clones`, { const response = await request.post(`${apiBase}/voice-clones`, {
@@ -394,47 +355,46 @@ test.describe("声音克隆 - API 操作", () => {
buffer: Buffer.from("test"), buffer: Buffer.from("test"),
}, },
}, },
}); })
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
}); })
test.describe("声音克隆 - 克隆列表展示", () => { test.describe("声音克隆 - 克隆列表展示", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("克隆卡片网格布局展示", async ({ page, request }) => { test("克隆卡片网格布局展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-grid")
request,
"vc-grid",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-grid", display_name: "E2E vc-grid",
}); })
await page.goto("/app/voice-clone"); await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证网格容器或空状态存在 // 验证网格容器或空状态存在
const grid = page.locator(".vc-grid"); const grid = page.locator(".vc-grid")
const empty = page.locator(".vc-empty"); const empty = page.locator(".vc-empty")
// 至少一个应该可见 // 至少一个应该可见
const gridVisible = await grid.isVisible().catch(() => false); const gridVisible = await grid.isVisible().catch(() => false)
const emptyVisible = await empty.isVisible().catch(() => false); const emptyVisible = await empty.isVisible().catch(() => false)
expect(gridVisible || emptyVisible).toBeTruthy(); expect(gridVisible || emptyVisible).toBeTruthy()
}); })
test("克隆状态标签展示", async ({ page, request }) => { test("克隆状态标签展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username, headers } = const { accessToken, userId, email, username, headers } = await createAuthedUser(
await createAuthedUser(request, "vc-status"); request,
const suffix = Date.now().toString(36); "vc-status",
)
const suffix = Date.now().toString(36)
// 创建一个克隆任务 // 创建一个克隆任务
await request.post(`${apiBase}/voice-clones`, { await request.post(`${apiBase}/voice-clones`, {
@@ -447,66 +407,61 @@ test.describe("声音克隆 - 克隆列表展示", () => {
buffer: Buffer.from("status test data"), buffer: Buffer.from("status test data"),
}, },
}, },
}); })
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-status", display_name: "E2E vc-status",
}); })
await page.goto("/app/voice-clone"); await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 如果有卡片,验证状态标签存在 // 如果有卡片,验证状态标签存在
const cards = page.locator(".vc-card"); const cards = page.locator(".vc-card")
if ((await cards.count()) > 0) { if ((await cards.count()) > 0) {
const firstCard = cards.first(); const firstCard = cards.first()
const statusPill = firstCard.locator(".vc-status-pill"); const statusPill = firstCard.locator(".vc-status-pill")
if (await statusPill.isVisible()) { if (await statusPill.isVisible()) {
await expect(statusPill).toBeVisible(); await expect(statusPill).toBeVisible()
} }
} }
}); })
}); })
test.describe("声音克隆 - 上传区域", () => { test.describe("声音克隆 - 上传区域", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("克隆弹窗上传区域可打开", async ({ page, request }) => { test("克隆弹窗上传区域可打开", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-upload")
request,
"vc-upload",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E vc-upload", display_name: "E2E vc-upload",
}); })
await page.goto("/app/voice-clone"); await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({ await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 尝试点击克隆新音色按钮 // 尝试点击克隆新音色按钮
const cloneBtn = page.getByRole("button", { const cloneBtn = page.getByRole("button", {
name: /克隆新音色|立即克隆|新建/, name: /克隆新音色|立即克隆|新建/,
}); })
if (await cloneBtn.isVisible()) { if (await cloneBtn.isVisible()) {
await cloneBtn.click(); await cloneBtn.click()
// 弹窗应该出现 // 弹窗应该出现
const modal = page.locator( const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']")
".ant-modal, .vc-edit-dialog, [role='dialog']",
);
if (await modal.first().isVisible({ timeout: 5_000 })) { if (await modal.first().isVisible({ timeout: 5_000 })) {
await expect(modal.first()).toBeVisible(); await expect(modal.first()).toBeVisible()
} }
} }
}); })
}); })
+145 -192
View File
@@ -6,36 +6,29 @@
* *
* 每个测试独立,先注册登录获取 auth token。 * 每个测试独立,先注册登录获取 auth token。
*/ */
import { import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!"; const PASSWORD = "Test123456!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: Page) => { const routeBrowserApiToTestApi = async (page: Page) => {
if (!apiOrigin) return; if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()); const sourceUrl = new URL(route.request().url())
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}); })
await route.fulfill({ response }); await route.fulfill({ response })
}); })
}; }
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -48,30 +41,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
if (response.status() !== 429) return response; if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000)); await new Promise((r) => setTimeout(r, 65000))
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}); })
} }
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */ /** 注册并登录,返回 { headers, email, username, userId, accessToken } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label); const email = uniqueEmail(label)
const username = uniqueUsername(label); const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}); })
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json(); const regData = await reg.json()
const login = await loginWithRetry(request, email, PASSWORD); const login = await loginWithRetry(request, email, PASSWORD)
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
const loginData = await login.json(); const loginData = await login.json()
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
}; }
} }
/** 设置页面认证状态(localStorage) */ /** 设置页面认证状态(localStorage) */
@@ -90,14 +83,14 @@ async function setupAuth(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token); localStorage.setItem("access_token", token)
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
); )
}, },
{ {
token, token,
@@ -111,329 +104,289 @@ async function setupAuth(
email_verified: true, email_verified: true,
}, },
}, },
); )
} }
test.describe("音色库页面 - 未登录重定向", () => { test.describe("音色库页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => { test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/voices"); await page.goto("/app/voices")
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/)
}); })
}); })
test.describe("音色库页面 - 页面加载", () => { test.describe("音色库页面 - 页面加载", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("音色库页面加载成功", async ({ page, request }) => { test("音色库页面加载成功", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-load")
request,
"voice-load",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-load", display_name: "E2E voice-load",
}); })
await page.goto("/app/voices"); await page.goto("/app/voices")
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
}); })
test("页面头部和搜索栏存在", async ({ page, request }) => { test("页面头部和搜索栏存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-head")
request,
"voice-head",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-head", display_name: "E2E voice-head",
}); })
await page.goto("/app/voices"); await page.goto("/app/voices")
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证搜索框存在 // 验证搜索框存在
const searchInput = page.locator( const searchInput = page.locator(
"input[type='search'], .xx-voices-search input, input[placeholder*='搜索']", "input[type='search'], .xx-voices-search input, input[placeholder*='搜索']",
); )
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 }); await expect(searchInput.first()).toBeVisible({ timeout: 10_000 })
}); })
}); })
test.describe("音色库 - 预设音色", () => { test.describe("音色库 - 预设音色", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("预设音色列表 API 返回数据", async ({ request }) => { test("预设音色列表 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "voice-preset"); const { headers } = await createAuthedUser(request, "voice-preset")
const response = await request.get(`${apiBase}/voices/preset`, { const response = await request.get(`${apiBase}/voices/preset`, {
headers, headers,
}); })
// 预设音色接口可能返回数组或包装对象 // 预设音色接口可能返回数组或包装对象
expect( expect(response.ok(), `获取预设音色应返回 2xx,实际: ${response.status()}`).toBeTruthy()
response.ok(),
`获取预设音色应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json(); const data = await response.json()
const items = data.items || data.voices || data; const items = data.items || data.voices || data
expect(Array.isArray(items), "预设音色应为数组").toBeTruthy(); expect(Array.isArray(items), "预设音色应为数组").toBeTruthy()
}); })
test("预设音色卡片在页面中展示", async ({ page, request }) => { test("预设音色卡片在页面中展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-cards")
request,
"voice-cards",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-cards", display_name: "E2E voice-cards",
}); })
await page.goto("/app/voices"); await page.goto("/app/voices")
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 等待音色卡片加载(预设音色应该有数据) // 等待音色卡片加载(预设音色应该有数据)
const voiceCards = page.locator(".xx-voice-card"); const voiceCards = page.locator(".xx-voice-card")
// 等待至少一张卡片出现 // 等待至少一张卡片出现
await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 }); await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 })
const count = await voiceCards.count(); const count = await voiceCards.count()
expect(count).toBeGreaterThan(0); expect(count).toBeGreaterThan(0)
}); })
test("音色卡片包含名称和信息", async ({ page, request }) => { test("音色卡片包含名称和信息", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-info")
request,
"voice-info",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-info", display_name: "E2E voice-info",
}); })
await page.goto("/app/voices"); await page.goto("/app/voices")
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const firstCard = page.locator(".xx-voice-card").first(); const firstCard = page.locator(".xx-voice-card").first()
await expect(firstCard).toBeVisible({ timeout: 15_000 }); await expect(firstCard).toBeVisible({ timeout: 15_000 })
// 验证音色名称存在 // 验证音色名称存在
await expect(firstCard.locator(".xx-voice-name")).toBeVisible(); await expect(firstCard.locator(".xx-voice-name")).toBeVisible()
// 验证头像存在 // 验证头像存在
await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible(); await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible()
}); })
}); })
test.describe("音色库 - 我的克隆音色", () => { test.describe("音色库 - 我的克隆音色", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("克隆音色列表 API 返回数据", async ({ request }) => { test("克隆音色列表 API 返回数据", async ({ request }) => {
const { headers } = await createAuthedUser(request, "voice-cln-api"); const { headers } = await createAuthedUser(request, "voice-cln-api")
const response = await request.get(`${apiBase}/voice-clones`, { const response = await request.get(`${apiBase}/voice-clones`, {
headers, headers,
}); })
expect( expect(response.ok(), `获取克隆音色应返回 2xx,实际: ${response.status()}`).toBeTruthy()
response.ok(),
`获取克隆音色应返回 2xx,实际: ${response.status()}`,
).toBeTruthy();
const data = await response.json(); const data = await response.json()
const items = data.items || data.voice_clones || []; const items = data.items || data.voice_clones || []
expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy(); expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy()
}); })
test("空状态展示 - 无克隆音色时", async ({ page, request }) => { test("空状态展示 - 无克隆音色时", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-empty")
request,
"voice-empty",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-empty", display_name: "E2E voice-empty",
}); })
await page.goto("/app/voices"); await page.goto("/app/voices")
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 切换到"我的克隆"tab(如果有tab的话) // 切换到"我的克隆"tab(如果有tab的话)
const clonedTab = page.getByText("我的克隆").first(); const clonedTab = page.getByText("我的克隆").first()
if (await clonedTab.isVisible()) { if (await clonedTab.isVisible()) {
await clonedTab.click(); await clonedTab.click()
} }
// 页面至少应该是可访问的 // 页面至少应该是可访问的
await expect(page.locator(".xx-voices-page")).toBeVisible(); await expect(page.locator(".xx-voices-page")).toBeVisible()
}); })
test("创建克隆音色入口存在", async ({ page, request }) => { test("创建克隆音色入口存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-create")
request,
"voice-create",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-create", display_name: "E2E voice-create",
}); })
await page.goto("/app/voices"); await page.goto("/app/voices")
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证创建克隆音色按钮存在(可能是"克隆音色"或"新建"按钮) // 验证创建克隆音色按钮存在(可能是"克隆音色"或"新建"按钮)
const createBtn = page.getByRole("button", {
name: /克隆|新建|创建|\+/,
});
// 不强制断言一定存在,因为不同页面结构可能不同 // 不强制断言一定存在,因为不同页面结构可能不同
// 只验证页面正常加载即可 // 只验证页面正常加载即可
await expect(page.locator(".xx-voices-page")).toBeVisible(); await expect(page.locator(".xx-voices-page")).toBeVisible()
}); })
}); })
test.describe("音色库 - 搜索和筛选", () => { test.describe("音色库 - 搜索和筛选", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("搜索框存在且可输入", async ({ page, request }) => { test("搜索框存在且可输入", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-search")
request,
"voice-search",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-search", display_name: "E2E voice-search",
}); })
await page.goto("/app/voices"); await page.goto("/app/voices")
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 查找搜索输入框 // 查找搜索输入框
const searchInput = page.locator( const searchInput = page.locator(
"input[placeholder*='搜索'], input[type='search'], .xx-voices-search input", "input[placeholder*='搜索'], input[type='search'], .xx-voices-search input",
); )
const firstInput = searchInput.first(); const firstInput = searchInput.first()
if (await firstInput.isVisible({ timeout: 5_000 })) { if (await firstInput.isVisible({ timeout: 5_000 })) {
await firstInput.fill("测试搜索"); await firstInput.fill("测试搜索")
await expect(firstInput).toHaveValue("测试搜索"); await expect(firstInput).toHaveValue("测试搜索")
} }
}); })
test("性别/语言筛选选项存在", async ({ page, request }) => { test("性别/语言筛选选项存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-filter")
request,
"voice-filter",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-filter", display_name: "E2E voice-filter",
}); })
await page.goto("/app/voices"); await page.goto("/app/voices")
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
// 验证筛选相关元素存在(可能是下拉选择器或标签) // 验证筛选相关元素存在(可能是下拉选择器或标签)
const filterSelect = page.locator("select, .xx-voices-filter");
// 页面正常加载即通过 // 页面正常加载即通过
await expect(page.locator(".xx-voices-page")).toBeVisible(); await expect(page.locator(".xx-voices-page")).toBeVisible()
}); })
}); })
test.describe("音色库 - 播放试听", () => { test.describe("音色库 - 播放试听", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("音色播放按钮存在", async ({ page, request }) => { test("音色播放按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page); await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser( const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-play")
request,
"voice-play",
);
await setupAuth(page, accessToken, { await setupAuth(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
display_name: "E2E voice-play", display_name: "E2E voice-play",
}); })
await page.goto("/app/voices"); await page.goto("/app/voices")
await expect(page.locator(".xx-voices-page")).toBeVisible({ await expect(page.locator(".xx-voices-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); })
const firstCard = page.locator(".xx-voice-card").first(); const firstCard = page.locator(".xx-voice-card").first()
if (await firstCard.isVisible({ timeout: 15_000 })) { if (await firstCard.isVisible({ timeout: 15_000 })) {
// 验证播放按钮存在 // 验证播放按钮存在
const playBtn = firstCard.locator(".xx-voice-play-btn"); const playBtn = firstCard.locator(".xx-voice-play-btn")
if (await playBtn.isVisible()) { if (await playBtn.isVisible()) {
await expect(playBtn).toBeVisible(); await expect(playBtn).toBeVisible()
} }
} }
}); })
}); })
test.describe("音色库 - API 边界测试", () => { test.describe("音色库 - API 边界测试", () => {
test.describe.configure({ timeout: 120_000 }); test.describe.configure({ timeout: 120_000 })
test("未登录获取预设音色 - 反向", async ({ request }) => { test("未登录获取预设音色 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voices/preset`); const response = await request.get(`${apiBase}/voices/preset`)
// 预设音色可能不需要登录,也可能需要,两种情况都接受 // 预设音色可能不需要登录,也可能需要,两种情况都接受
// 但如果需要登录,应返回 401/403 // 但如果需要登录,应返回 401/403
if (!response.ok()) { if (!response.ok()) {
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
} }
}); })
test("未登录获取克隆音色 - 反向", async ({ request }) => { test("未登录获取克隆音色 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voice-clones`); const response = await request.get(`${apiBase}/voice-clones`)
expect([401, 403]).toContain(response.status()); expect([401, 403]).toContain(response.status())
}); })
test("获取不存在的克隆音色详情 - 反向", async ({ request }) => { test("获取不存在的克隆音色详情 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "voice-404"); const { headers } = await createAuthedUser(request, "voice-404")
const response = await request.get( const response = await request.get(`${apiBase}/voice-clones/nonexistent-999`, { headers })
`${apiBase}/voice-clones/nonexistent-999`, expect(response.status(), "不存在的克隆应返回 404").toBe(404)
{ headers }, })
); })
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
});
});
+222 -3
View File
@@ -23,16 +23,19 @@
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/node": "^20.14.9", "@types/node": "^20.14.9",
"@types/react": "^18.3.3", "@types/react": "^18.3.31",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1", "@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^1.6.1",
"@vitest/ui": "^1.6.0", "@vitest/ui": "^1.6.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0", "jsdom": "^24.1.0",
"prettier": "^3.9.5",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"vite": "^5.3.1", "vite": "^5.3.1",
"vitest": "^1.6.0" "vitest": "^1.6.0"
@@ -45,6 +48,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@ant-design/colors": { "node_modules/@ant-design/colors": {
"version": "7.2.1", "version": "7.2.1",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz",
@@ -474,6 +491,13 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bcoe/v8-coverage": {
"version": "0.2.3",
"resolved": "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true,
"license": "MIT"
},
"node_modules/@csstools/color-helpers": { "node_modules/@csstools/color-helpers": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -1141,6 +1165,16 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jest/schemas": { "node_modules/@jest/schemas": {
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@@ -1973,14 +2007,14 @@
}, },
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.31", "version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
@@ -2220,6 +2254,34 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/coverage-v8": {
"version": "1.6.1",
"resolved": "https://registry.npmmirror.com/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz",
"integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
"@bcoe/v8-coverage": "^0.2.3",
"debug": "^4.3.4",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.4",
"istanbul-reports": "^3.1.6",
"magic-string": "^0.30.5",
"magicast": "^0.3.3",
"picocolors": "^1.0.0",
"std-env": "^3.5.0",
"strip-literal": "^2.0.0",
"test-exclude": "^6.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "1.6.1"
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@@ -3254,6 +3316,19 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint-config-prettier": {
"version": "9.1.2",
"resolved": "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz",
"integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-plugin-react-hooks": { "node_modules/eslint-plugin-react-hooks": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz",
@@ -3876,6 +3951,13 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/http-proxy-agent": { "node_modules/http-proxy-agent": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -4084,6 +4166,60 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmmirror.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4351,6 +4487,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/magicast": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4828,6 +4992,22 @@
"node": ">= 0.8.0" "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": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -5993,6 +6173,45 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/test-exclude/-/test-exclude-6.0.0.tgz",
"integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^7.1.4",
"minimatch": "^3.0.4"
},
"engines": {
"node": ">=8"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/text-table": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+9 -4
View File
@@ -4,16 +4,18 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"dev": "vite",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage", "test:coverage": "vitest --coverage",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ci": "npx playwright test --project=chromium --reporter=line", "test:e2e:ci": "npx playwright test --project=chromium --reporter=line",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "test:ui": "vitest --ui",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -32,16 +34,19 @@
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/node": "^20.14.9", "@types/node": "^20.14.9",
"@types/react": "^18.3.3", "@types/react": "^18.3.31",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1", "@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^1.6.1",
"@vitest/ui": "^1.6.0", "@vitest/ui": "^1.6.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0", "jsdom": "^24.1.0",
"prettier": "^3.9.5",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"vite": "^5.3.1", "vite": "^5.3.1",
"vitest": "^1.6.0" "vitest": "^1.6.0"
+3 -3
View File
@@ -1,9 +1,9 @@
/** /**
* Playwright E2E 测试配置 * Playwright E2E 测试配置
*/ */
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from "@playwright/test"
const externalBaseURL = process.env.E2E_BASE_URL; const externalBaseURL = process.env.E2E_BASE_URL
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
@@ -68,4 +68,4 @@ export default defineConfig({
url: "http://localhost:3000", url: "http://localhost:3000",
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
}, },
}); })
Regular → Executable
+236 -228
View File
@@ -2,213 +2,219 @@
* 素材相关 API * 素材相关 API
* Phase 1 重构:去掉 project_id,素材直接归属用户 * Phase 1 重构:去掉 project_id,素材直接归属用户
*/ */
import apiClient from "./client"; import apiClient from "./client"
import { getOrCreateDefaultProject } from "./projects"; import { getOrCreateDefaultProject } from "./projects"
/** 素材元数据 */ /** 素材元数据 */
export interface AssetMetadata { export interface AssetMetadata {
/** 时长(秒) */ /** 时长(秒) */
duration?: number; duration?: number
/** 宽度(像素) */ /** 宽度(像素) */
width?: number; width?: number
/** 高度(像素) */ /** 高度(像素) */
height?: number; height?: number
/** 比特率(bps) */ /** 比特率(bps) */
bitrate?: number; bitrate?: number
/** 编码格式 */ /** 编码格式 */
codec?: string; codec?: string
/** 帧率 */ /** 帧率 */
fps?: number; fps?: number
/** 采样率(Hz) */ /** 采样率(Hz) */
sample_rate?: number; sample_rate?: number
/** 声道数 */ /** 声道数 */
channels?: number; channels?: number
/** 其他扩展字段 */ /** 其他扩展字段 */
[key: string]: unknown; [key: string]: unknown
} }
/** 素材分类状态 */ /** 素材分类状态 */
export type AssetClassificationStatus = export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed"
"pending" | "processing" | "completed" | "failed";
/** 素材条目 */ /** 素材条目 */
export interface AssetItem { export interface AssetItem {
id: string; id: string
library_id: string; library_id: string
name: string; name: string
storage_key: string; storage_key: string
mime_type: string; mime_type: string
metadata: AssetMetadata; metadata: AssetMetadata
file_size?: number; file_size?: number
file_url?: string; file_url?: string
thumbnail_url?: string; thumbnail_url?: string
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */ /** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
duration?: number; duration?: number
status?: string; status?: string
classification_status?: AssetClassificationStatus | null; classification_status?: AssetClassificationStatus | null
quality_score?: number | null; quality_score?: number | null
tag_ids?: string[]; tag_ids?: string[]
created_at?: string; created_at?: string
} }
/** 素材库 */ /** 素材库 */
export interface AssetLibraryItem { export interface AssetLibraryItem {
id: string; id: string
name: string; name: string
kind: "video" | "voice" | "image"; kind: "video" | "voice" | "image"
asset_count?: number; asset_count?: number
total_size?: number; total_size?: number
created_at?: string; created_at?: string
} }
/** 入库任务 */ /** 入库任务 */
export interface IngestJob { export interface IngestJob {
id: string; id: string
library_id: string; library_id: string
storage_key: string; storage_key: string
status: "pending" | "processing" | "completed" | "failed"; status: "pending" | "processing" | "completed" | "failed"
error_message: string; error_message: string
result_asset_id: string; result_asset_id: string
} }
/** 分类任务 */ /** 分类任务 */
export interface ClassificationJob { export interface ClassificationJob {
id: string; id: string
asset_id: string; asset_id: string
status: "pending" | "processing" | "completed" | "failed"; status: "pending" | "processing" | "completed" | "failed"
classification: string; classification: string
confidence: number; confidence: number
error_message: string; error_message: string
} }
/** 素材诊断信息 */ /** 素材诊断信息 */
export interface AssetDiagnosis { export interface AssetDiagnosis {
readiness_score: number; readiness_score: number
readiness_label: string; readiness_label: string
total_assets: number; total_assets: number
ready_assets: number; ready_assets: number
video_assets: number; video_assets: number
image_assets: number; image_assets: number
voice_assets: number; voice_assets: number
total_duration_seconds: number; total_duration_seconds: number
estimated_video_count: number; estimated_video_count: number
used_assets: number; used_assets: number
unused_assets: number; unused_assets: number
pending_review_assets: number; pending_review_assets: number
smart_views: Array<{ smart_views: Array<{
key: string; key: string
label: string; label: string
count: number; count: number
description: string; description: string
}>; }>
gaps: Array<{ gaps: Array<{
key: string; key: string
severity: "critical" | "warning" | "info"; severity: "critical" | "warning" | "info"
message: string; message: string
recommendation: string; recommendation: string
}>; }>
} }
// ─── 素材诊断 ────────────────────────────────────────────── // ─── 素材诊断 ──────────────────────────────────────────────
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */ /** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
export const getAssetDiagnosis = async ( export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
assetId?: string, const params: Record<string, string> = {}
): Promise<AssetDiagnosis> => { if (assetId) params.asset_id = assetId
const params: Record<string, string> = {}; const response = await apiClient.get("/asset-diagnosis", { params })
if (assetId) params.asset_id = assetId; return response.data
const response = await apiClient.get("/asset-diagnosis", { params }); }
return response.data;
};
// ─── 素材库 ──────────────────────────────────────────────── // ─── 素材库 ────────────────────────────────────────────────
/** 获取当前用户的所有素材库 */ /** 获取当前用户的所有素材库 */
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => { export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get("/asset-libraries"); const response = await apiClient.get("/asset-libraries")
return response.data.items || []; return response.data.items || []
}; }
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */ /** 创建素材库(自动获取或创建默认项目以提供 project_id) */
export const createAssetLibrary = async (data: { export const createAssetLibrary = async (data: {
name: string; name: string
kind: "video" | "voice" | "image"; kind: "video" | "voice" | "image"
}): Promise<AssetLibraryItem> => { }): Promise<AssetLibraryItem> => {
// 后端要求 project_id,前端自动管理默认项目 // 后端要求 project_id,前端自动管理默认项目
const project = await getOrCreateDefaultProject(); const project = await getOrCreateDefaultProject()
const response = await apiClient.post("/asset-libraries", { const response = await apiClient.post("/asset-libraries", {
project_id: project.id, project_id: project.id,
...data, ...data,
}); })
return response.data; return response.data
}; }
/** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */ /** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */
export const ensureDefaultLibrary = async (data: { export const ensureDefaultLibrary = async (data: {
project_id: string; project_id: string
kind: "video" | "voice" | "image"; kind: "video" | "voice" | "image"
}): Promise<AssetLibraryItem> => { }): Promise<AssetLibraryItem> => {
const response = await apiClient.post( const response = await apiClient.post("/asset-libraries/ensure-default", data)
"/asset-libraries/ensure-default", return response.data
data, }
);
return response.data;
};
/** 删除素材库 */ /** 删除素材库 */
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => { export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
await apiClient.delete(`/asset-libraries/${libraryId}`); await apiClient.delete(`/asset-libraries/${libraryId}`)
}; }
// ─── 素材 ────────────────────────────────────────────────── // ─── 素材 ──────────────────────────────────────────────────
/** 获取素材库下的所有素材 */ /** 获取素材库下的所有素材 */
export const getAssets = async (libraryId: string): Promise<AssetItem[]> => { export const getAssets = async (
const response = await apiClient.get("/assets", { libraryId: string,
params: { library_id: libraryId }, options?: { status?: string; page?: number; page_size?: number },
}); ): Promise<{ items: AssetItem[]; total: number }> => {
return response.data.items || []; const params: Record<string, string | number> = { library_id: libraryId }
}; // 默认拉取所有非删除状态的素材(ready/ingesting/processing/uploading/error/failed)
// 让用户能看到"处理中"的素材,不会以为上传失败了
if (options?.status) {
params.status = options.status
}
if (options?.page) params.page = options.page
if (options?.page_size) params.page_size = options.page_size
const response = await apiClient.get("/assets", { params })
const data = response.data || {}
const items: AssetItem[] = data.items || []
const total: number = typeof data.total === "number" ? data.total : items.length
return { items, total }
}
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */ /** 按类型获取素材(如 voice/video/image),支持可选筛选 */
export const getAssetsByKind = async ( export const getAssetsByKind = async (
kind: string, kind: string,
filters?: { filters?: {
keyword?: string; keyword?: string
gender?: string; gender?: string
style?: string; style?: string
tag_ids?: string[]; tag_ids?: string[]
}, },
): Promise<AssetItem[]> => { ): Promise<AssetItem[]> => {
const params: Record<string, string> = { kind }; const params: Record<string, string> = { kind }
if (filters?.keyword) params.keyword = filters.keyword; if (filters?.keyword) params.keyword = filters.keyword
if (filters?.gender) params.gender = filters.gender; if (filters?.gender) params.gender = filters.gender
if (filters?.style) params.style = filters.style; if (filters?.style) params.style = filters.style
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(","); if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
const response = await apiClient.get("/assets", { params }); const response = await apiClient.get("/assets", { params })
return response.data.items || []; return response.data.items || []
}; }
/** 创建素材(上传文件后调用,附带 metadata) */ /** 创建素材(上传文件后调用,附带 metadata) */
export const createAsset = async (data: { export const createAsset = async (data: {
library_id: string; library_id: string
name: string; name: string
storage_key: string; storage_key: string
mime_type: string; mime_type: string
metadata?: AssetMetadata; metadata?: AssetMetadata
}): Promise<AssetItem> => { }): Promise<AssetItem> => {
const response = await apiClient.post("/assets", data); const response = await apiClient.post("/assets", data)
return response.data; return response.data
}; }
/** 更新素材(名称、metadata 等) */ /** 更新素材(名称、metadata 等) */
export const updateAsset = async ( export const updateAsset = async (
assetId: string, assetId: string,
data: { name?: string; metadata?: AssetMetadata }, data: { name?: string; metadata?: AssetMetadata },
): Promise<AssetItem> => { ): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data); const response = await apiClient.put(`/assets/${assetId}`, data)
return response.data; return response.data
}; }
/** 更新素材审核状态 */ /** 更新素材审核状态 */
export const updateAssetReviewStatus = async ( export const updateAssetReviewStatus = async (
@@ -217,14 +223,14 @@ export const updateAssetReviewStatus = async (
): Promise<AssetItem> => { ): Promise<AssetItem> => {
const response = await apiClient.patch(`/assets/${assetId}/review`, { const response = await apiClient.patch(`/assets/${assetId}/review`, {
review_status: reviewStatus, review_status: reviewStatus,
}); })
return response.data; return response.data
}; }
/** 删除素材 */ /** 删除素材 */
export const deleteAsset = async (assetId: string): Promise<void> => { export const deleteAsset = async (assetId: string): Promise<void> => {
await apiClient.delete(`/assets/${assetId}`); await apiClient.delete(`/assets/${assetId}`)
}; }
// ─── 上传 ────────────────────────────────────────────────── // ─── 上传 ──────────────────────────────────────────────────
@@ -235,47 +241,47 @@ export const uploadAsset = async (
const response = await apiClient.post("/upload", formData, { const response = await apiClient.post("/upload", formData, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
timeout: 30 * 60 * 1000, timeout: 30 * 60 * 1000,
}); })
return response.data; return response.data
}; }
/** 预签名直传准备 */ /** 预签名直传准备 */
export const prepareDirectUpload = async (data: { export const prepareDirectUpload = async (data: {
project_id: string; project_id: string
library_id: string; library_id: string
filename: string; filename: string
content_type: string; content_type: string
file_size: number; file_size: number
}): Promise<{ }): Promise<{
upload_url: string; upload_url: string
method: string; method: string
storage_key: string; storage_key: string
expires_at: string; expires_at: string
fields: Record<string, string>; fields: Record<string, string>
max_size_bytes: number; max_size_bytes: number
}> => { }> => {
const response = await apiClient.post("/upload/direct/prepare", data); const response = await apiClient.post("/upload/direct/prepare", data)
return response.data; return response.data
}; }
/** 直传完成确认 */ /** 直传完成确认 */
export const completeDirectUpload = async (data: { export const completeDirectUpload = async (data: {
project_id: string; project_id: string
library_id: string; library_id: string
storage_key: string; storage_key: string
}): Promise<{ storage_key: string; ingest_job_id: string }> => { }): Promise<{ storage_key: string; ingest_job_id: string }> => {
const response = await apiClient.post("/upload/direct/complete", data); const response = await apiClient.post("/upload/direct/complete", data)
return response.data; return response.data
}; }
/** 直传上传(大文件推荐),支持可选进度回调 */ /** 直传上传(大文件推荐),支持可选进度回调 */
export const uploadAssetDirect = async (data: { export const uploadAssetDirect = async (data: {
file: File; file: File
library_id: string; library_id: string
onProgress?: (percent: number) => void; onProgress?: (percent: number) => void
}): Promise<{ storage_key: string; ingest_job_id: string }> => { }): Promise<{ storage_key: string; ingest_job_id: string }> => {
// 后端要求 project_id,前端自动获取默认项目 // 后端要求 project_id,前端自动获取默认项目
const project = await getOrCreateDefaultProject(); const project = await getOrCreateDefaultProject()
const prepared = await prepareDirectUpload({ const prepared = await prepareDirectUpload({
project_id: project.id, project_id: project.id,
@@ -283,147 +289,149 @@ export const uploadAssetDirect = async (data: {
filename: data.file.name, filename: data.file.name,
content_type: data.file.type || "application/octet-stream", content_type: data.file.type || "application/octet-stream",
file_size: data.file.size, file_size: data.file.size,
}); })
const directForm = new FormData(); const directForm = new FormData()
Object.entries(prepared.fields).forEach(([key, value]) => Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
directForm.append(key, value), directForm.append("file", data.file)
);
directForm.append("file", data.file);
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断 // 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest()
xhr.open(prepared.method, prepared.upload_url); xhr.open(prepared.method, prepared.upload_url)
// 超时 10 分钟 // 超时 10 分钟
xhr.timeout = 10 * 60 * 1000; xhr.timeout = 10 * 60 * 1000
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable && data.onProgress) { if (e.lengthComputable && data.onProgress) {
data.onProgress(Math.round((e.loaded / e.total) * 100)); data.onProgress(Math.round((e.loaded / e.total) * 100))
} }
}; }
xhr.onload = () => { xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
resolve(); resolve()
} else { } else {
// 解析 OSS 返回的 XML 错误信息 // 解析 OSS 返回的 XML 错误信息
let ossError = ""; let ossError = ""
try { try {
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/); const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/)
const msgMatch = xhr.responseText.match( const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/Message>/)
/<Message>([^<]+)<\/Message>/,
);
if (codeMatch || msgMatch) { if (codeMatch || msgMatch) {
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`; ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`
} }
} catch { } catch {
// 无法解析响应体 // 无法解析响应体
} }
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`; const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`
console.error("[OSS Upload] 直传失败:", { console.error("[OSS Upload] 直传失败:", {
url: prepared.upload_url, url: prepared.upload_url,
storage_key: prepared.storage_key, storage_key: prepared.storage_key,
status: xhr.status, status: xhr.status,
statusText: xhr.statusText, statusText: xhr.statusText,
}); })
reject(new Error(detail)); reject(new Error(detail))
} }
}; }
xhr.onerror = () => { xhr.onerror = () => {
console.error("[OSS Upload] 网络错误:", { console.error("[OSS Upload] 网络错误:", {
url: prepared.upload_url, url: prepared.upload_url,
storage_key: prepared.storage_key, storage_key: prepared.storage_key,
}); })
reject(new Error("OSS 上传网络错误,请检查网络连接")); reject(new Error("OSS 上传网络错误,请检查网络连接"))
}; }
xhr.ontimeout = () => { xhr.ontimeout = () => {
console.error("[OSS Upload] 上传超时:", { console.error("[OSS Upload] 上传超时:", {
url: prepared.upload_url, url: prepared.upload_url,
storage_key: prepared.storage_key, storage_key: prepared.storage_key,
}); })
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件")); reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"))
}; }
xhr.send(directForm); xhr.send(directForm)
}); })
return completeDirectUpload({ return completeDirectUpload({
project_id: project.id, project_id: project.id,
library_id: data.library_id, library_id: data.library_id,
storage_key: prepared.storage_key, storage_key: prepared.storage_key,
}); })
}; }
// ─── 入库 / 分类任务 ─────────────────────────────────────── // ─── 入库 / 分类任务 ───────────────────────────────────────
/** 查询入库任务状态 */ /** 查询入库任务状态 */
export const getIngestJob = async (jobId: string): Promise<IngestJob> => { export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
const response = await apiClient.get(`/ingest-jobs/${jobId}`); const response = await apiClient.get(`/ingest-jobs/${jobId}`)
return response.data; return response.data
}; }
/** 提交素材分类任务 */ /** 提交素材分类任务 */
export const submitClassificationJob = async (data: { export const submitClassificationJob = async (data: {
asset_id: string; asset_id: string
}): Promise<ClassificationJob> => { }): Promise<ClassificationJob> => {
const response = await apiClient.post("/classification-jobs", data); const response = await apiClient.post("/classification-jobs", data)
return response.data; return response.data
}; }
/** 查询分类任务状态 */ /** 查询分类任务状态 */
export const getClassificationJob = async ( export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
jobId: string, const response = await apiClient.get(`/classification-jobs/${jobId}`)
): Promise<ClassificationJob> => { return response.data
const response = await apiClient.get(`/classification-jobs/${jobId}`); }
return response.data;
};
// ─── 批量操作 ─────────────────────────────────────────────── // ─── 批量操作 ───────────────────────────────────────────────
/** 批量操作结果 */ /** 批量操作结果 */
export interface BatchOperationResult { export interface BatchOperationResult {
succeeded: string[]; succeeded: string[]
failed: string[]; failed: string[]
total: number; total: number
success_count: number; success_count: number
failure_count: number; failure_count: number
}
/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */
const normalizeBatchResult = (raw: Record<string, unknown>): BatchOperationResult => {
const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : []
const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : []
const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length
const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length
const total = typeof raw.total === "number" ? raw.total : success_count + failure_count
return { succeeded, failed, total, success_count, failure_count }
} }
/** 批量删除素材 */ /** 批量删除素材 */
export const batchDeleteAssets = async ( export const batchDeleteAssets = async (assetIds: string[]): Promise<BatchOperationResult> => {
assetIds: string[],
): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-delete", { const response = await apiClient.post("/assets/batch-delete", {
asset_ids: assetIds, asset_ids: assetIds,
}); })
return response.data; return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}; }
/** 批量打标签 */ /** 批量打标签 */
export const batchTagAssets = async (data: { export const batchTagAssets = async (data: {
asset_ids: string[]; asset_ids: string[]
tags: string[]; tags: string[]
mode: "add" | "replace"; mode: "add" | "replace"
}): Promise<BatchOperationResult> => { }): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-tag", data); const response = await apiClient.post("/assets/batch-tag", data)
return response.data; return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}; }
/** 批量改分类 */ /** 批量改分类 */
export const batchClassifyAssets = async (data: { export const batchClassifyAssets = async (data: {
asset_ids: string[]; asset_ids: string[]
category: string; category: string
}): Promise<BatchOperationResult> => { }): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-classify", data); const response = await apiClient.post("/assets/batch-classify", data)
return response.data; return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}; }
/** 批量智能标记 */ /** 批量智能标记 */
export const batchMarkAssets = async (data: { export const batchMarkAssets = async (data: {
asset_ids: string[]; asset_ids: string[]
smart_view: "recommended" | "caution" | "high_risk"; smart_view: "recommended" | "caution" | "high_risk"
}): Promise<BatchOperationResult> => { }): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-mark", data); const response = await apiClient.post("/assets/batch-mark", data)
return response.data; return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}; }
+64 -72
View File
@@ -1,58 +1,58 @@
/** /**
* 认证相关 API * 认证相关 API
*/ */
import axios from "axios"; import axios from "axios"
import apiClient from "./client"; import apiClient from "./client"
// 类型定义 // 类型定义
export interface LoginRequest { export interface LoginRequest {
email: string; email: string
password: string; password: string
} }
export interface LoginResponse { export interface LoginResponse {
access_token: string; access_token: string
refresh_token?: string | null; refresh_token?: string | null
token_type: string; token_type: string
expires_in: number; expires_in: number
user_id: string; user_id: string
email: string; email: string
username: string; username: string
display_name: string; display_name: string
} }
export interface RegisterRequest { export interface RegisterRequest {
email: string; email: string
password: string; password: string
username: string; username: string
display_name?: string; display_name?: string
} }
export interface User { export interface User {
id: string; id: string
user_id: string; user_id: string
email: string; email: string
username: string; username: string
display_name: string; display_name: string
is_email_verified: boolean; is_email_verified: boolean
email_verified: boolean; email_verified: boolean
created_at?: string; created_at?: string
} }
export interface UserResponse { export interface UserResponse {
id?: string; id?: string
user_id?: string; user_id?: string
email: string; email: string
username: string; username: string
display_name: string; display_name: string
is_email_verified?: boolean; is_email_verified?: boolean
email_verified?: boolean; email_verified?: boolean
created_at?: string; created_at?: string
} }
export const normalizeUser = (data: UserResponse): User => { export const normalizeUser = (data: UserResponse): User => {
const userId = data.id ?? data.user_id ?? ""; const userId = data.id ?? data.user_id ?? ""
const emailVerified = data.is_email_verified ?? data.email_verified ?? false; const emailVerified = data.is_email_verified ?? data.email_verified ?? false
return { return {
id: userId, id: userId,
@@ -63,52 +63,46 @@ export const normalizeUser = (data: UserResponse): User => {
is_email_verified: emailVerified, is_email_verified: emailVerified,
email_verified: emailVerified, email_verified: emailVerified,
created_at: data.created_at, created_at: data.created_at,
}; }
}; }
// 登录 // 登录
export const login = async (data: LoginRequest): Promise<LoginResponse> => { export const login = async (data: LoginRequest): Promise<LoginResponse> => {
const response = await apiClient.post("/auth/login", data); const response = await apiClient.post("/auth/login", data)
return response.data; return response.data
}; }
// 刷新 access_token(使用裸 axios 避免拦截器递归) // 刷新 access_token(使用裸 axios 避免拦截器递归)
export const refreshAccessToken = async ( export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
refreshToken: string, const baseURL = apiClient.defaults.baseURL ?? ""
): Promise<LoginResponse> => {
const baseURL = apiClient.defaults.baseURL ?? "";
const response = await axios.post(`${baseURL}/auth/refresh`, { const response = await axios.post(`${baseURL}/auth/refresh`, {
refresh_token: refreshToken, refresh_token: refreshToken,
}); })
return response.data; return response.data
}; }
// 注册 // 注册
export const register = async ( export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
data: RegisterRequest, const response = await apiClient.post("/auth/register", data)
): Promise<{ message: string }> => { return response.data
const response = await apiClient.post("/auth/register", data); }
return response.data;
};
// 登出 // 登出
export const logout = async (): Promise<void> => { export const logout = async (): Promise<void> => {
await apiClient.post("/auth/logout"); await apiClient.post("/auth/logout")
}; }
// 获取当前用户 // 获取当前用户
export const getCurrentUser = async (): Promise<User> => { export const getCurrentUser = async (): Promise<User> => {
const response = await apiClient.get<UserResponse>("/auth/me"); const response = await apiClient.get<UserResponse>("/auth/me")
return normalizeUser(response.data); return normalizeUser(response.data)
}; }
// 请求密码重置 // 请求密码重置
export const requestPasswordReset = async ( export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
email: string, const response = await apiClient.post("/auth/forgot-password", { email })
): Promise<{ message: string }> => { return response.data
const response = await apiClient.post("/auth/forgot-password", { email }); }
return response.data;
};
// 重置密码 // 重置密码
export const resetPassword = async ( export const resetPassword = async (
@@ -118,14 +112,12 @@ export const resetPassword = async (
const response = await apiClient.post("/auth/reset-password", { const response = await apiClient.post("/auth/reset-password", {
token, token,
new_password: newPassword, new_password: newPassword,
}); })
return response.data; return response.data
}; }
// 验证邮箱 // 验证邮箱
export const verifyEmail = async ( export const verifyEmail = async (token: string): Promise<{ message: string }> => {
token: string, const response = await apiClient.post("/auth/verify-email", { token })
): Promise<{ message: string }> => { return response.data
const response = await apiClient.post("/auth/verify-email", { token }); }
return response.data;
};
+25 -27
View File
@@ -2,48 +2,48 @@
* BGM 预设音乐 API * BGM 预设音乐 API
* 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索) * 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索)
*/ */
import apiClient from "./client"; import apiClient from "./client"
/* ──────────── 类型 ──────────── */ /* ──────────── 类型 ──────────── */
/** BGM 风格分类 */ /** BGM 风格分类 */
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"; export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"
/** BGM 预设项 */ /** BGM 预设项 */
export interface BgmPreset { export interface BgmPreset {
id: string; id: string
name: string; name: string
category: BgmCategory; category: BgmCategory
/** 音频文件 URL */ /** 音频文件 URL */
url: string; url: string
/** 时长(秒) */ /** 时长(秒) */
duration: number; duration: number
/** 关键词标签 */ /** 关键词标签 */
tags: string[]; tags: string[]
/** 封面图 URL */ /** 封面图 URL */
cover_url?: string; cover_url?: string
} }
/** BGM 预设列表查询参数 */ /** BGM 预设列表查询参数 */
export interface BgmPresetsQuery { export interface BgmPresetsQuery {
category?: BgmCategory | string; category?: BgmCategory | string
keyword?: string; keyword?: string
} }
/** BGM 混音配置(嵌入剪辑计划) */ /** BGM 混音配置(嵌入剪辑计划) */
export interface BgmMixConfig { export interface BgmMixConfig {
/** 是否启用 BGM */ /** 是否启用 BGM */
enabled: boolean; enabled: boolean
/** 选中的 BGM ID */ /** 选中的 BGM ID */
music_id: string; music_id: string
/** BGM 音量 0-100 */ /** BGM 音量 0-100 */
volume: number; volume: number
/** 淡入时长(秒) 0-3 */ /** 淡入时长(秒) 0-3 */
fade_in: number; fade_in: number
/** 淡出时长(秒) 0-3 */ /** 淡出时长(秒) 0-3 */
fade_out: number; fade_out: number
/** 人声闪避(sidechain) */ /** 人声闪避(sidechain) */
voice_dodge: boolean; voice_dodge: boolean
} }
/** 默认 BGM 混音配置 */ /** 默认 BGM 混音配置 */
@@ -54,17 +54,15 @@ export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
fade_in: 0.5, fade_in: 0.5,
fade_out: 0.5, fade_out: 0.5,
voice_dodge: true, voice_dodge: true,
}; }
/* ──────────── API ──────────── */ /* ──────────── API ──────────── */
/** 获取 BGM 预设列表 */ /** 获取 BGM 预设列表 */
export const getBgmPresets = async ( export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
params?: BgmPresetsQuery, const searchParams: Record<string, string> = {}
): Promise<BgmPreset[]> => { if (params?.category) searchParams.category = params.category
const searchParams: Record<string, string> = {}; if (params?.keyword) searchParams.keyword = params.keyword
if (params?.category) searchParams.category = params.category; const res = await apiClient.get("/bgm/presets", { params: searchParams })
if (params?.keyword) searchParams.keyword = params.keyword; return res.data?.data ?? res.data ?? []
const res = await apiClient.get("/bgm/presets", { params: searchParams }); }
return res.data?.data ?? res.data ?? [];
};
+78 -89
View File
@@ -2,10 +2,10 @@
* API 客户端配置 * API 客户端配置
* 封装 Axios 实例,配置拦截器和 Token 管理 * 封装 Axios 实例,配置拦截器和 Token 管理
*/ */
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
import { message } from "antd"; import { message } from "antd"
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore"
import { refreshAccessToken } from "./auth"; import { refreshAccessToken } from "./auth"
// 创建 Axios 实例 // 创建 Axios 实例
const apiClient = axios.create({ const apiClient = axios.create({
@@ -14,161 +14,150 @@ const apiClient = axios.create({
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); })
// ── Token 刷新队列 ───────────────────────────────────────── // ── Token 刷新队列 ─────────────────────────────────────────
let isRefreshing = false; let isRefreshing = false
let failedQueue: Array<{ let failedQueue: Array<{
resolve: (value?: unknown) => void; resolve: (value?: unknown) => void
reject: (reason?: unknown) => void; reject: (reason?: unknown) => void
}> = []; }> = []
const processQueue = (error: unknown | null, token: string | null = null) => { const processQueue = (error: unknown | null, token: string | null = null) => {
failedQueue.forEach((prom) => { failedQueue.forEach((prom) => {
if (error) { if (error) {
prom.reject(error); prom.reject(error)
} else { } else {
prom.resolve(token); prom.resolve(token)
} }
}); })
failedQueue = []; failedQueue = []
}; }
// 请求拦截器:添加 Token // 请求拦截器:添加 Token
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token")
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`
} }
return config; return config
}, },
(error: AxiosError) => { (error: AxiosError) => {
return Promise.reject(error); return Promise.reject(error)
}, },
); )
// 响应拦截器:统一错误提示 + 401 自动刷新 Token // 响应拦截器:统一错误提示 + 401 自动刷新 Token
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
async ( async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
error: AxiosError<{ detail?: string; message?: string; msg?: string }>,
) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean; _retry?: boolean
}; }
// 401 → 尝试刷新 Token // 401 → 尝试刷新 Token
if ( if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
error.response?.status === 401 && const refreshToken = useAuthStore.getState().refreshToken
originalRequest &&
!originalRequest._retry
) {
const refreshToken = useAuthStore.getState().refreshToken;
// 无 refresh_token → 直接登出 // 无 refresh_token → 直接登出
if (!refreshToken) { if (!refreshToken) {
useAuthStore.getState().clearAuth(); useAuthStore.getState().clearAuth()
window.location.href = "/"; window.location.href = "/"
return Promise.reject(error); return Promise.reject(error)
} }
// 已在刷新中 → 排队等待 // 已在刷新中 → 排队等待
if (isRefreshing) { if (isRefreshing) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject }); failedQueue.push({ resolve, reject })
}).then((token) => { }).then((token) => {
if (originalRequest.headers) { if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`; originalRequest.headers.Authorization = `Bearer ${token}`
} }
return apiClient(originalRequest); return apiClient(originalRequest)
}); })
} }
originalRequest._retry = true; originalRequest._retry = true
isRefreshing = true; isRefreshing = true
try { try {
const data = await refreshAccessToken(refreshToken); const data = await refreshAccessToken(refreshToken)
const newAccessToken = data.access_token; const newAccessToken = data.access_token
const newRefreshToken = data.refresh_token ?? refreshToken; const newRefreshToken = data.refresh_token ?? refreshToken
// 更新 Zustand + localStorage // 更新 Zustand + localStorage
useAuthStore useAuthStore
.getState() .getState()
.setAuth( .setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
useAuthStore.getState().user!,
newAccessToken,
newRefreshToken,
);
// 处理排队的请求 // 处理排队的请求
processQueue(null, newAccessToken); processQueue(null, newAccessToken)
// 重试原始请求 // 重试原始请求
if (originalRequest.headers) { if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
} }
return apiClient(originalRequest); return apiClient(originalRequest)
} catch (refreshError) { } catch (refreshError) {
// 刷新失败 → 登出 // 刷新失败 → 登出
processQueue(refreshError, null); processQueue(refreshError, null)
useAuthStore.getState().clearAuth(); useAuthStore.getState().clearAuth()
window.location.href = "/"; window.location.href = "/"
return Promise.reject(refreshError); return Promise.reject(refreshError)
} finally { } finally {
isRefreshing = false; isRefreshing = false
} }
} }
// 提取后端返回的错误信息(detail / message / msg) // 提取后端返回的错误信息(detail / message / msg)
// 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取 // 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取
const data = error.response?.data; const data = error.response?.data
const rawServerMsg = data?.detail || data?.message || data?.msg; const rawServerMsg = data?.detail || data?.message || data?.msg
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等) // 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
const safeExtractString = (val: unknown): string => { const safeExtractString = (val: unknown): string => {
if (typeof val === "string") return val; if (typeof val === "string") return val
if (typeof val === "object" && val !== null) { if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>; const obj = val as Record<string, any>
if (typeof obj.message === "string") return obj.message; if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg; if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail; if (typeof obj.detail === "string") return obj.detail
// 嵌套对象:递归提取 // 嵌套对象:递归提取
if (typeof obj.message === "object" && obj.message !== null) if (typeof obj.message === "object" && obj.message !== null)
return safeExtractString(obj.message); return safeExtractString(obj.message)
if (typeof obj.msg === "object" && obj.msg !== null) if (typeof obj.msg === "object" && obj.msg !== null) return safeExtractString(obj.msg)
return safeExtractString(obj.msg); return JSON.stringify(val)
return JSON.stringify(val);
} }
return ""; return ""
}; }
const serverMsg = safeExtractString(rawServerMsg); const serverMsg = safeExtractString(rawServerMsg)
let handled = false; let handled = false
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) { if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
message.error("请求超时,请检查网络后重试"); message.error("请求超时,请检查网络后重试")
handled = true; handled = true
} else if (!error.response) { } else if (!error.response) {
message.error("网络连接异常,请检查网络设置"); message.error("网络连接异常,请检查网络设置")
handled = true; handled = true
} else if (serverMsg) { } else if (serverMsg) {
message.error(serverMsg); message.error(serverMsg)
handled = true; handled = true
} else { } else {
const status = error.response?.status; const status = error.response?.status
if (status === 413) { if (status === 413) {
message.error("文件过大,请缩小后重试"); message.error("文件过大,请缩小后重试")
handled = true; handled = true
} else if (status === 415) { } else if (status === 415) {
message.error("不支持的文件格式"); message.error("不支持的文件格式")
handled = true; handled = true
} else if (status === 503) { } else if (status === 503) {
message.error("服务暂不可用,请稍后再试"); message.error("服务暂不可用,请稍后再试")
handled = true; handled = true
} else if (status && status >= 500) { } else if (status && status >= 500) {
message.error("服务器繁忙,请稍后再试"); message.error("服务器繁忙,请稍后再试")
handled = true; handled = true
} }
// 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理 // 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理
} }
@@ -176,11 +165,11 @@ apiClient.interceptors.response.use(
// 标记已展示过提示,组件 onError 可据此跳过重复 toast // 标记已展示过提示,组件 onError 可据此跳过重复 toast
if (handled) { if (handled) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(error as any).__msgShown = true; ;(error as any).__msgShown = true
} }
return Promise.reject(error); return Promise.reject(error)
}, },
); )
export default apiClient; export default apiClient
+43 -54
View File
@@ -2,109 +2,98 @@
* 查重 API 模块 * 查重 API 模块
* 提供视频查重相关接口 * 提供视频查重相关接口
*/ */
import apiClient from "./client"; import apiClient from "./client"
/** 查重记录状态 */ /** 查重记录状态 */
export type DuplicationStatus = export type DuplicationStatus = "pending" | "processing" | "completed" | "failed"
"pending" | "processing" | "completed" | "failed";
/** 查重记录 */ /** 查重记录 */
export interface DuplicationRecord { export interface DuplicationRecord {
id: string; id: string
/** 原始文件名 */ /** 原始文件名 */
filename: string; filename: string
/** 文件大小(字节) */ /** 文件大小(字节) */
file_size: number; file_size: number
/** 时长(秒) */ /** 时长(秒) */
duration_seconds?: number; duration_seconds?: number
/** 状态 */ /** 状态 */
status: DuplicationStatus; status: DuplicationStatus
/** 查重率(0-100) */ /** 查重率(0-100) */
duplicate_rate?: number; duplicate_rate?: number
/** 重复片段数 */ /** 重复片段数 */
duplicate_count?: number; duplicate_count?: number
/** 创建时间 */ /** 创建时间 */
created_at: string; created_at: string
/** 更新时间 */ /** 更新时间 */
updated_at: string; updated_at: string
} }
/** 重复片段详情 */ /** 重复片段详情 */
export interface DuplicateSegment { export interface DuplicateSegment {
id: string; id: string
/** 在原始视频中的起始时间(秒) */ /** 在原始视频中的起始时间(秒) */
source_start: number; source_start: number
/** 在原始视频中的结束时间(秒) */ /** 在原始视频中的结束时间(秒) */
source_end: number; source_end: number
/** 匹配到的已有视频 ID */ /** 匹配到的已有视频 ID */
matched_video_id: string; matched_video_id: string
/** 匹配到的已有视频名称 */ /** 匹配到的已有视频名称 */
matched_video_name: string; matched_video_name: string
/** 匹配片段在已有视频中的起始时间 */ /** 匹配片段在已有视频中的起始时间 */
matched_start: number; matched_start: number
/** 匹配片段在已有视频中的结束时间 */ /** 匹配片段在已有视频中的结束时间 */
matched_end: number; matched_end: number
/** 相似度(0-100) */ /** 相似度(0-100) */
similarity: number; similarity: number
} }
/** 查重详情 */ /** 查重详情 */
export interface DuplicationDetail extends DuplicationRecord { export interface DuplicationDetail extends DuplicationRecord {
/** 重复片段列表 */ /** 重复片段列表 */
segments: DuplicateSegment[]; segments: DuplicateSegment[]
} }
/** 上传查重响应 */ /** 上传查重响应 */
export interface DuplicationUploadResponse { export interface DuplicationUploadResponse {
/** 查重记录 ID */ /** 查重记录 ID */
id: string; id: string
/** 状态 */ /** 状态 */
status: DuplicationStatus; status: DuplicationStatus
/** 消息 */ /** 消息 */
message: string; message: string
} }
// ============ API 函数 ============ // ============ API 函数 ============
/** 上传视频进行查重 */ /** 上传视频进行查重 */
export const uploadForDuplication = async ( export const uploadForDuplication = async (file: File): Promise<DuplicationUploadResponse> => {
file: File, const formData = new FormData()
): Promise<DuplicationUploadResponse> => { formData.append("file", file)
const formData = new FormData();
formData.append("file", file);
const response = await apiClient.post("/duplication/upload", formData, { const response = await apiClient.post("/duplication/upload", formData, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
}); })
return response.data; return response.data
}; }
/** 获取查重记录列表 */ /** 获取查重记录列表 */
export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => { export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
const response = await apiClient.get("/duplication/records"); const response = await apiClient.get("/duplication/records")
return response.data; return response.data
}; }
/** 获取查重详情 */ /** 获取查重详情 */
export const getDuplicationDetail = async ( export const getDuplicationDetail = async (recordId: string): Promise<DuplicationDetail> => {
recordId: string, const response = await apiClient.get(`/duplication/records/${recordId}`)
): Promise<DuplicationDetail> => { return response.data
const response = await apiClient.get(`/duplication/records/${recordId}`); }
return response.data;
};
/** 删除查重记录 */ /** 删除查重记录 */
export const deleteDuplicationRecord = async ( export const deleteDuplicationRecord = async (recordId: string): Promise<void> => {
recordId: string, await apiClient.delete(`/duplication/records/${recordId}`)
): Promise<void> => { }
await apiClient.delete(`/duplication/records/${recordId}`);
};
/** 重新查重 */ /** 重新查重 */
export const retryDuplication = async ( export const retryDuplication = async (recordId: string): Promise<DuplicationUploadResponse> => {
recordId: string, const response = await apiClient.post(`/duplication/records/${recordId}/retry`)
): Promise<DuplicationUploadResponse> => { return response.data
const response = await apiClient.post( }
`/duplication/records/${recordId}/retry`,
);
return response.data;
};
Regular → Executable
+420 -254
View File
@@ -2,8 +2,8 @@
* 剪辑计划 API — 对接后端 Edit Plans Schema * 剪辑计划 API — 对接后端 Edit Plans Schema
* 字段名严格匹配后端 API 响应 * 字段名严格匹配后端 API 响应
*/ */
import apiClient from "./client"; import apiClient from "./client"
import type { AssetItem } from "./assets"; import type { AssetItem } from "./assets"
import type { import type {
WatermarkConfig, WatermarkConfig,
IntroOutroConfig, IntroOutroConfig,
@@ -12,7 +12,7 @@ import type {
ChromaKeyConfig, ChromaKeyConfig,
StickerConfig, StickerConfig,
CoverConfig, CoverConfig,
} from "@/pages/editing-planner/types"; } from "@/pages/editing-planner/types"
/* ============================================================ /* ============================================================
* 后端 API 类型(严格匹配后端 Schema) * 后端 API 类型(严格匹配后端 Schema)
@@ -20,194 +20,203 @@ import type {
/** 剪辑计划状态枚举 */ /** 剪辑计划状态枚举 */
export type EditPlanStatus = export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed"; "draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"
/** 标题配置(对齐后端 title_config) */ /** 标题配置(对齐后端 title_config) */
export interface TitleConfig { export interface TitleConfig {
ai_auto_select: boolean; ai_auto_select: boolean
content: string; content: string
font_preset: string; font_preset: string
font_color: string; font_color: string
font_size: number; font_size: number
position: string; position: string
} }
/** 字幕配置 */ /** 字幕配置 */
export interface SubtitleConfig { export interface SubtitleConfig {
enabled: boolean; enabled: boolean
position: string; position: string
font: string; font: string
color: string; color: string
size: number; size: number
animation: string; animation: string
} }
/** BGM 配置 */ /** BGM 配置 */
export interface BgmConfig { export interface BgmConfig {
enabled: boolean; enabled: boolean
music_id: string; music_id: string
} }
/** 片段 TTS 配置 */ /** 片段 TTS 配置 */
export interface SegmentTtsConfig { export interface SegmentTtsConfig {
mode: string; mode: string
text: string; text: string
voice_id: string; voice_id: string
speed: number; speed: number
pitch: number; pitch: number
volume: number; volume: number
subtitle_sync: boolean; subtitle_sync: boolean
} }
/** 片段裁剪配置 */ /** 片段裁剪配置 */
export interface SegmentTrimConfig { export interface SegmentTrimConfig {
start_time: number; start_time: number
end_time: number; end_time: number
} }
/** 片段转场配置 */ /** 片段转场配置 */
export interface SegmentTransitionConfig { export interface SegmentTransitionConfig {
type: string; type: string
duration: number; duration: number
} }
/** 剪辑计划中的单个片段(config 内部 segments 项) */ /** 剪辑计划中的单个片段(config 内部 segments 项) */
export interface EditPlanSegment { export interface EditPlanSegment {
segment_order: number; segment_order: number
duration_min: number; duration_min: number
duration_max: number; duration_max: number
material_type: string; material_type: string
transition?: SegmentTransitionConfig; transition?: SegmentTransitionConfig
playback_speed?: number; playback_speed?: number
tts_config?: SegmentTtsConfig; tts_config?: SegmentTtsConfig
trim_config?: SegmentTrimConfig; trim_config?: SegmentTrimConfig
} }
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */ /** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
export interface EditPlanConfig { export interface EditPlanConfig {
title_config?: TitleConfig; title_config?: TitleConfig
subtitle_config?: SubtitleConfig; subtitle_config?: SubtitleConfig
bgm_config?: BgmConfig; bgm_config?: BgmConfig
estimated_duration?: number; estimated_duration?: number
segments?: EditPlanSegment[]; segments?: EditPlanSegment[]
watermark_config?: WatermarkConfig; watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig; intro_outro_config?: IntroOutroConfig
pip_config?: PipConfig; pip_config?: PipConfig
filter_config?: FilterConfig; filter_config?: FilterConfig
green_screen_config?: ChromaKeyConfig; green_screen_config?: ChromaKeyConfig
sticker_config?: StickerConfig; sticker_config?: StickerConfig
cover_config?: CoverConfig; cover_config?: CoverConfig
/** 前端扩展:关联的素材 ID 列表 */ /** 前端扩展:关联的素材 ID 列表 */
asset_ids?: string[]; asset_ids?: string[]
/** 配音 ID */ /** 配音 ID */
voice_id?: string; voice_id?: string
/** 克隆音色档案 ID */ /** 克隆音色档案 ID */
voice_clone_profile_id?: string; voice_clone_profile_id?: string
/** 自定义配音音频 URL */ /** 自定义配音音频 URL */
custom_audio_url?: string; custom_audio_url?: string
/** 自定义配音文本 */ /** 自定义配音文本 */
custom_text?: string; custom_text?: string
/** 视频比例 */ /** 视频比例 */
ratio?: string; ratio?: string
/** 视频风格 */ /** 视频风格 */
style?: string; style?: string
/** 目标时长(秒) */ /** 目标时长(秒) */
duration?: number; duration?: number
/** 是否自动生成字幕 */ /** 是否自动生成字幕 */
auto_subtitles?: boolean; auto_subtitles?: boolean
/** 是否启用 BGM */ /** 是否启用 BGM */
bgm?: boolean; bgm?: boolean
/** 生成数量 */ /** 生成数量 */
generate_count?: number; generate_count?: number
/** 素材模式 */ /** 素材模式 */
material_mode?: string; material_mode?: string
} }
/** 剪辑计划(后端响应) */ /** 剪辑计划(后端响应) */
export interface EditPlan { export interface EditPlan {
id: string; id: string
template_id: string; template_id: string
name: string; name: string
status: EditPlanStatus; status: EditPlanStatus
total_duration: number; total_duration: number
config: EditPlanConfig; /** 生成视频数量(后端 EditPlanResponse.result_count) */
created_at: string; result_count: number
updated_at: string; config: EditPlanConfig
created_at: string
updated_at: string
} }
/** 创建剪辑计划请求(后端要求 template_id + name 必填) */ /** 创建剪辑计划请求(后端要求 template_id + name 必填) */
export interface CreateEditPlanRequest { export interface CreateEditPlanRequest {
template_id: string; template_id: string
name: string; name: string
config?: EditPlanConfig; config?: EditPlanConfig
total_duration?: number; total_duration?: number
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */ /** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
source_edit_plan_id?: string; source_edit_plan_id?: string
} }
/** 更新剪辑计划请求 */ /** 更新剪辑计划请求 */
export interface UpdateEditPlanRequest { export interface UpdateEditPlanRequest {
name?: string; name?: string
config?: EditPlanConfig; config?: EditPlanConfig
total_duration?: number; total_duration?: number
status?: EditPlanStatus; status?: EditPlanStatus
} }
/** 生成响应 */ /** 生成响应 */
export interface GenerateResponse { export interface GenerateResponse {
plan_id: string; plan_id: string
plan_status: EditPlanStatus; plan_status: EditPlanStatus
generation_task_id: string; generation_task_id: string
clip_count: number; clip_count: number
} }
/** 剪辑计划关联的生成记录 */ /** 剪辑计划关联的生成记录(实际是 GenerationTask 对象) */
export interface EditPlanGeneration { export interface EditPlanGeneration {
id: string; id: string // 即 generation_task_id
edit_plan_id: string; source_edit_plan_id: string
generation_task_id: string; template_id: string
status: EditPlanStatus; asset_ids: string[]
created_at: string; status: EditPlanStatus
updated_at: string; progress: number
result_count: number
error_message: string
error_info: Record<string, unknown>
logs: Array<Record<string, unknown>>
retry_count: number
created_at?: string
updated_at?: string
} }
/** 片段生成状态 */ /** 片段生成状态 */
export interface ClipStatusItem { export interface ClipStatusItem {
clip_id: string; clip_id: string
clip_type: string; clip_type: string
order: number; order: number
status: string; status: string
asset_id?: string; asset_id?: string
text_content?: string; text_content?: string
duration?: number; duration?: number
} }
/** 生成状态轮询响应 */ /** 生成状态轮询响应 */
export interface GenerationStatusResponse { export interface GenerationStatusResponse {
plan_id: string; plan_id: string
plan_status: EditPlanStatus; plan_status: EditPlanStatus
generation_task_id?: string; generation_task_id?: string
clips: ClipStatusItem[]; clips: ClipStatusItem[]
} }
/** 生成视频详情(对应后端 GeneratedVideoResponse) */ /** 生成视频详情(对应后端 GeneratedVideoResponse) */
export interface GeneratedVideo { export interface GeneratedVideo {
id: string; id: string
project_id?: string; project_id?: string
generation_task_id?: string; generation_task_id?: string
name: string; name: string
file_url: string; file_url: string
file_size?: number; file_size?: number
duration?: number; duration?: number
thumbnail_url?: string; thumbnail_url?: string
width?: number; width?: number
height?: number; height?: number
fps?: number; fps?: number
status: string; status: string
review_status?: string; review_status?: string
download_url?: string; download_url?: string
created_at?: string; created_at?: string
updated_at?: string; updated_at?: string
} }
/* ============================================================ /* ============================================================
@@ -216,75 +225,57 @@ export interface GeneratedVideo {
/** AI 推荐请求 */ /** AI 推荐请求 */
export interface AIRecommendRequest { export interface AIRecommendRequest {
asset_ids: string[]; asset_ids: string[]
editing_mode?: string; editing_mode?: string
target_duration?: number; target_duration?: number
} }
/** AI 推荐单个片段 */ /** AI 推荐单个片段 */
export interface AIRecommendClipItem { export interface AIRecommendClipItem {
clip_type: string; clip_type: string
order: number; order: number
text_content: string; text_content: string
duration: number; duration: number
transition_effect: string; transition_effect: string
asset_id: string; asset_id: string
start_time: number; start_time: number
config: EditPlanConfig; config: EditPlanConfig
} }
/** AI 推荐响应 */ /** AI 推荐响应 */
export interface AIRecommendResponse { export interface AIRecommendResponse {
plan_id: string; plan_id: string
clips: AIRecommendClipItem[]; clips: AIRecommendClipItem[]
config: EditPlanConfig; config: EditPlanConfig
total_duration: number; total_duration: number
confidence: number; confidence: number
} }
/** AI 封面生成请求 */ /** AI 封面生成请求 */
export interface GenerateCoverRequest { export interface GenerateCoverRequest {
asset_ids: string[]; asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"; cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number; frame_time?: number
} }
/** AI 封面生成响应 */ /** AI 封面生成响应 */
export interface GenerateCoverResponse { export interface GenerateCoverResponse {
plan_id: string; plan_id: string
cover: CoverResult; cover: CoverResult
} }
/** 封面生成结果 */ /** 封面生成结果 */
export interface CoverResult { export interface CoverResult {
scheme?: string; scheme?: string
asset_id?: string; asset_id?: string
frame_time?: number; frame_time?: number
thumbnail_url?: string; thumbnail_url?: string
} }
/* ============================================================ /* ============================================================
* 前端 UI 类型(EditingPlanner 组件依赖,保留兼容) * 前端 UI 类型(EditingPlanner 组件依赖,保留兼容)
* ============================================================ */ * ============================================================ */
/** 剪辑计划中的片段(UI 层类型) */
export interface EditPlanClip {
id: string;
template_segment_id: string;
/** 素材库中的素材 ID */
media_asset_id?: string;
/** 素材类型 */
material_type: "video" | "image" | "audio" | "voiceover";
/** 片段文案 */
script_text: string;
/** 实际时长(秒) */
duration: number;
/** 转场效果 */
transition?: TransitionEffect;
/** 排序 */
order: number;
}
/** 转场效果(14 种预设) */ /** 转场效果(14 种预设) */
export interface TransitionEffect { export interface TransitionEffect {
type: type:
@@ -302,30 +293,30 @@ export interface TransitionEffect {
| "wipe_up" | "wipe_up"
| "wipe_down" | "wipe_down"
| "circlecrop" | "circlecrop"
| "rectcrop"; | "rectcrop"
duration: number; // 转场时长(秒) duration: number // 转场时长(秒)
/** 播放速度倍率 */ /** 播放速度倍率 */
playback_speed?: number; playback_speed?: number
} }
/** 素材库资产(UI 层类型,映射自后端 AssetResponse) */ /** 素材库资产(UI 层类型,映射自后端 AssetResponse) */
export interface MediaAsset { export interface MediaAsset {
id: string; id: string
name: string; name: string
type: "video" | "image" | "audio"; type: "video" | "image" | "audio"
/** 缩略图 URL */ /** 缩略图 URL */
thumbnail_url?: string; thumbnail_url?: string
/** 时长(秒),仅 video/audio */ /** 时长(秒),仅 video/audio */
duration?: number; duration?: number
/** 文件大小(字节) */ /** 文件大小(字节) */
size?: number; size?: number
/** 标签 */ /** 标签 */
tags: string[]; tags: string[]
created_at: string; created_at: string
/** 质量分 0-100 */ /** 质量分 0-100 */
quality_score?: number; quality_score?: number
/** 分类状态 */ /** 分类状态 */
classification_status?: "pending" | "processing" | "completed" | "failed"; classification_status?: "pending" | "processing" | "completed" | "failed"
} }
/* ============================================================ /* ============================================================
@@ -334,42 +325,38 @@ export interface MediaAsset {
/** 剪辑计划列表查询参数 */ /** 剪辑计划列表查询参数 */
export interface EditPlanListParams { export interface EditPlanListParams {
page?: number; page?: number
page_size?: number; page_size?: number
template_id?: string; template_id?: string
status?: string; status?: string
} }
/** 剪辑计划列表分页响应 */ /** 剪辑计划列表分页响应 */
export interface EditPlanListResponse { export interface EditPlanListResponse {
items: EditPlan[]; items: EditPlan[]
total: number; total: number
page: number; page: number
page_size: number; page_size: number
} }
/** 获取剪辑计划列表(支持分页和筛选) */ /** 获取剪辑计划列表(支持分页和筛选) */
export async function getEditPlans( export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
params?: EditPlanListParams,
): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", { const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
params, params,
}); })
return response.data; return response.data
} }
/** 获取单个剪辑计划 */ /** 获取单个剪辑计划 */
export async function getEditPlan(planId: string): Promise<EditPlan> { export async function getEditPlan(planId: string): Promise<EditPlan> {
const response = await apiClient.get(`/edit-plans/${planId}`); const response = await apiClient.get(`/edit-plans/${planId}`)
return response.data; return response.data
} }
/** 创建剪辑计划 */ /** 创建剪辑计划 */
export async function createEditPlan( export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
data: CreateEditPlanRequest, const response = await apiClient.post("/edit-plans", data)
): Promise<EditPlan> { return response.data
const response = await apiClient.post("/edit-plans", data);
return response.data;
} }
/** 更新剪辑计划 */ /** 更新剪辑计划 */
@@ -377,31 +364,25 @@ export async function updateEditPlan(
planId: string, planId: string,
data: UpdateEditPlanRequest, data: UpdateEditPlanRequest,
): Promise<EditPlan> { ): Promise<EditPlan> {
const response = await apiClient.put(`/edit-plans/${planId}`, data); const response = await apiClient.put(`/edit-plans/${planId}`, data)
return response.data; return response.data
} }
/** 删除剪辑计划 */ /** 删除剪辑计划 */
export async function deleteEditPlan(planId: string): Promise<void> { export async function deleteEditPlan(planId: string): Promise<void> {
await apiClient.delete(`/edit-plans/${planId}`); await apiClient.delete(`/edit-plans/${planId}`)
} }
/** 触发剪辑计划生成 */ /** 触发剪辑计划生成 */
export async function generateEditPlan( export async function generateEditPlan(planId: string): Promise<GenerateResponse> {
planId: string, const response = await apiClient.post(`/edit-plans/${planId}/generate`)
): Promise<GenerateResponse> { return response.data
const response = await apiClient.post(`/edit-plans/${planId}/generate`);
return response.data;
} }
/** 获取剪辑计划生成状态(轮询用) */ /** 获取剪辑计划生成状态(轮询用) */
export async function getGenerationStatus( export async function getGenerationStatus(planId: string): Promise<GenerationStatusResponse> {
planId: string, const response = await apiClient.get(`/edit-plans/${planId}/generation-status`)
): Promise<GenerationStatusResponse> { return response.data
const response = await apiClient.get(
`/edit-plans/${planId}/generation-status`,
);
return response.data;
} }
/** AI 推荐片段方案 */ /** AI 推荐片段方案 */
@@ -409,11 +390,8 @@ export async function aiRecommendClips(
planId: string, planId: string,
data: AIRecommendRequest, data: AIRecommendRequest,
): Promise<AIRecommendResponse> { ): Promise<AIRecommendResponse> {
const response = await apiClient.post( const response = await apiClient.post(`/edit-plans/${planId}/ai-recommend`, data)
`/edit-plans/${planId}/ai-recommend`, return response.data
data,
);
return response.data;
} }
/** AI 生成封面 */ /** AI 生成封面 */
@@ -421,47 +399,236 @@ export async function generateCover(
planId: string, planId: string,
data: GenerateCoverRequest, data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> { ): Promise<GenerateCoverResponse> {
const response = await apiClient.post( const response = await apiClient.post(`/edit-plans/${planId}/generate-cover`, data)
`/edit-plans/${planId}/generate-cover`, return response.data
data,
);
return response.data;
} }
/** 获取剪辑计划关联的生成记录 */ /** 获取剪辑计划关联的生成记录 */
export async function getEditPlanGenerations( export async function getEditPlanGenerations(planId: string): Promise<EditPlanGeneration[]> {
planId: string, const response = await apiClient.get(`/edit-plans/${planId}/generations`)
): Promise<EditPlanGeneration[]> { return response.data.items || []
const response = await apiClient.get(`/edit-plans/${planId}/generations`);
return response.data.items || [];
} }
/** 获取生成任务的视频结果列表 */ /** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults( export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
taskId: string, const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
): Promise<GeneratedVideo[]> { return response.data.items || response.data || []
const response = await apiClient.get(`/generation/tasks/${taskId}/results`); }
return response.data.items || response.data || [];
/** 取消生成任务 */
export async function cancelGeneration(planId: string): Promise<void> {
await apiClient.post(`/edit-plans/${planId}/cancel`)
}
/* ============================================================
* 片段 CRUD(后端 EditPlanClip 独立表)
* ============================================================ */
/** 片段状态 */
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed"
/** 剪辑片段(后端响应) */
export interface EditPlanClip {
id: string
plan_id: string
clip_type: string // main / intro / outro / overlay / background / b_roll 等
order: number
asset_id: string
text_content: string
start_time: number
duration: number
transition_effect: string
transition_duration: number
playback_speed: number
status: EditPlanClipStatus
config: Record<string, unknown>
created_at?: string
updated_at?: string
}
/** 创建片段请求 */
export interface CreateEditPlanClipRequest {
clip_type: string
order: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 更新片段请求 */
export interface UpdateEditPlanClipRequest {
clip_type?: string
order?: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 片段列表响应 */
export interface EditPlanClipListResponse {
items: EditPlanClip[]
total: number
}
/** 片段列表查询参数 */
export interface EditPlanClipListParams {
status?: string
skip?: number
limit?: number
}
/** 获取片段列表 */
export async function getEditPlanClips(
planId: string,
params?: EditPlanClipListParams,
): Promise<EditPlanClipListResponse> {
const response = await apiClient.get<EditPlanClipListResponse>(`/edit-plans/${planId}/clips`, {
params,
})
return response.data
}
/** 获取单个片段详情 */
export async function getEditPlanClip(planId: string, clipId: string): Promise<EditPlanClip> {
const response = await apiClient.get<EditPlanClip>(`/edit-plans/${planId}/clips/${clipId}`)
return response.data
}
/** 创建片段 */
export async function createEditPlanClip(
planId: string,
data: CreateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.post<EditPlanClip>(`/edit-plans/${planId}/clips`, data)
return response.data
}
/** 更新片段 */
export async function updateEditPlanClip(
planId: string,
clipId: string,
data: UpdateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.put<EditPlanClip>(`/edit-plans/${planId}/clips/${clipId}`, data)
return response.data
}
/** 删除片段 */
export async function deleteEditPlanClip(planId: string, clipId: string): Promise<void> {
await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`)
}
/* ============================================================
* 片段批量操作
* ============================================================ */
/** 重排序条目 */
export interface ClipReorderItem {
clip_id: string
new_order: number
}
/** 重排序响应 */
export interface ClipReorderResponse {
success: boolean
updated_count: number
message: string
}
/** 批量删除响应 */
export interface ClipBatchDeleteResponse {
success: boolean
deleted_count: number
message: string
}
/** 从素材批量创建响应 */
export interface ClipsFromAssetsResponse {
success: boolean
created_count: number
message: string
clip_ids: string[]
}
/** 片段重排序(拖拽排序后一次性提交) */
export async function reorderEditPlanClips(
planId: string,
items: ClipReorderItem[],
): Promise<ClipReorderResponse> {
const response = await apiClient.post<ClipReorderResponse>(
`/edit-plans/${planId}/clips/reorder`,
{ items },
)
return response.data
}
/** 批量删除片段 */
export async function batchDeleteEditPlanClips(
planId: string,
clipIds: string[],
): Promise<ClipBatchDeleteResponse> {
const response = await apiClient.post<ClipBatchDeleteResponse>(
`/edit-plans/${planId}/clips/batch-delete`,
{ clip_ids: clipIds },
)
return response.data
}
/** 从素材批量创建片段(追加到时间线末尾) */
export async function createClipsFromAssets(
planId: string,
assetIds: string[],
clipType = "main",
): Promise<ClipsFromAssetsResponse> {
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/edit-plans/${planId}/clips/from-assets`,
{ asset_ids: assetIds, clip_type: clipType },
)
return response.data
}
/* ============================================================
* 复制计划
* ============================================================ */
/** 复制计划请求 */
export interface CopyEditPlanRequest {
name?: string
project_id?: string
}
/** 复制剪辑计划(含所有片段配置) */
export async function copyEditPlan(planId: string, data?: CopyEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post<EditPlan>(`/edit-plans/${planId}/copy`, data || {})
return response.data
} }
/** /**
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx * 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
* 将后端 AssetResponse 映射为前端 MediaAsset 类型 * 将后端 AssetResponse 映射为前端 MediaAsset 类型
*/ */
export async function getMediaAssets( export async function getMediaAssets(libraryId?: string): Promise<MediaAsset[]> {
libraryId?: string,
): Promise<MediaAsset[]> {
const response = await apiClient.get("/assets", { const response = await apiClient.get("/assets", {
params: libraryId ? { library_id: libraryId } : undefined, params: libraryId ? { library_id: libraryId } : undefined,
}); })
const items: AssetItem[] = response.data.items || []; const items: AssetItem[] = response.data.items || []
return items.map(mapAssetToMediaAsset); return items.map(mapAssetToMediaAsset)
} }
/** 获取单个素材 */ /** 获取单个素材 */
export async function getMediaAsset(id: string): Promise<MediaAsset> { export async function getMediaAsset(id: string): Promise<MediaAsset> {
const response = await apiClient.get(`/assets/${id}`); const response = await apiClient.get(`/assets/${id}`)
return mapAssetToMediaAsset(response.data); return mapAssetToMediaAsset(response.data)
} }
/* ============================================================ /* ============================================================
@@ -469,17 +636,15 @@ export async function getMediaAsset(id: string): Promise<MediaAsset> {
* ============================================================ */ * ============================================================ */
function inferMediaType(mimeType: string): "video" | "image" | "audio" { function inferMediaType(mimeType: string): "video" | "image" | "audio" {
if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("image/")) return "image"
return "audio"; return "audio"
} }
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset { function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
// 优先取顶层 duration,其次从 metadata 回退 // 优先取顶层 duration,其次从 metadata 回退
const metaDuration = const metaDuration =
typeof asset.metadata?.duration === "number" typeof asset.metadata?.duration === "number" ? asset.metadata.duration : undefined
? asset.metadata.duration
: undefined;
return { return {
id: asset.id, id: asset.id,
name: asset.name, name: asset.name,
@@ -491,7 +656,7 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
created_at: asset.created_at ?? "", created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined, quality_score: asset.quality_score ?? undefined,
classification_status: asset.classification_status ?? undefined, classification_status: asset.classification_status ?? undefined,
}; }
} }
/* ============================================================ /* ============================================================
@@ -500,9 +665,9 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
/** 转场效果选项(14 种预设) */ /** 转场效果选项(14 种预设) */
export const TRANSITION_OPTIONS: { export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"]; value: TransitionEffect["type"]
label: string; label: string
icon: string; icon: string
}[] = [ }[] = [
{ value: "none", label: "无转场", icon: "⊘" }, { value: "none", label: "无转场", icon: "⊘" },
{ value: "cut", label: "硬切", icon: "✂" }, { value: "cut", label: "硬切", icon: "✂" },
@@ -519,7 +684,7 @@ export const TRANSITION_OPTIONS: {
{ value: "wipe_down", label: "下擦除", icon: "▾̄" }, { value: "wipe_down", label: "下擦除", icon: "▾̄" },
{ value: "circlecrop", label: "圆形裁切", icon: "●" }, { value: "circlecrop", label: "圆形裁切", icon: "●" },
{ value: "rectcrop", label: "矩形裁切", icon: "■" }, { value: "rectcrop", label: "矩形裁切", icon: "■" },
]; ]
/** 素材类型标签 */ /** 素材类型标签 */
export const MATERIAL_TYPE_LABELS: Record<string, string> = { export const MATERIAL_TYPE_LABELS: Record<string, string> = {
@@ -527,7 +692,7 @@ export const MATERIAL_TYPE_LABELS: Record<string, string> = {
image: "图片", image: "图片",
audio: "音频", audio: "音频",
voiceover: "配音", voiceover: "配音",
}; }
/** 素材类型图标 */ /** 素材类型图标 */
export const MATERIAL_TYPE_ICONS: Record<string, string> = { export const MATERIAL_TYPE_ICONS: Record<string, string> = {
@@ -535,7 +700,7 @@ export const MATERIAL_TYPE_ICONS: Record<string, string> = {
image: "🖼️", image: "🖼️",
audio: "🎵", audio: "🎵",
voiceover: "🎙️", voiceover: "🎙️",
}; }
/** 计划状态标签 */ /** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = { export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
@@ -544,17 +709,18 @@ export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
rendering: "渲染中", rendering: "渲染中",
completed: "已完成", completed: "已完成",
failed: "失败", failed: "失败",
}; cancelled: "已取消",
}
/** 质量分筛选选项 */ /** 质量分筛选选项 */
export const QUALITY_OPTIONS: { export const QUALITY_OPTIONS: {
value: string; value: string
label: string; label: string
min?: number; min?: number
max?: number; max?: number
}[] = [ }[] = [
{ value: "all", label: "全部质量" }, { value: "all", label: "全部质量" },
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 }, { value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 }, { value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
{ value: "low", label: "低质量 (0-49)", min: 0, max: 49 }, { value: "low", label: "低质量 (0-49)", min: 0, max: 49 },
]; ]
+104 -112
View File
@@ -2,7 +2,7 @@
* 剪辑计划编辑器 API * 剪辑计划编辑器 API
* 对接后端 /api/v1/templates 路由 * 对接后端 /api/v1/templates 路由
*/ */
import apiClient from "./client"; import apiClient from "./client"
import type { import type {
WatermarkConfig, WatermarkConfig,
IntroOutroConfig, IntroOutroConfig,
@@ -11,12 +11,12 @@ import type {
ChromaKeyConfig, ChromaKeyConfig,
StickerConfig, StickerConfig,
CoverConfig, CoverConfig,
} from "@/pages/editing-planner/types"; } from "@/pages/editing-planner/types"
/* ──────────── 类型定义 ──────────── */ /* ──────────── 类型定义 ──────────── */
/** 模板模式(后端枚举值) */ /** 模板模式(后端枚举值) */
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"; export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
/** 模式显示名称映射 */ /** 模式显示名称映射 */
export const MODE_LABELS: Record<TemplateMode, string> = { export const MODE_LABELS: Record<TemplateMode, string> = {
@@ -24,7 +24,7 @@ export const MODE_LABELS: Record<TemplateMode, string> = {
voice_over: "人物口播", voice_over: "人物口播",
one_take: "一镜到底", one_take: "一镜到底",
voice_pip: "口播+混剪", voice_pip: "口播+混剪",
}; }
/** 模式颜色映射 */ /** 模式颜色映射 */
export const MODE_COLORS: Record<TemplateMode, string> = { export const MODE_COLORS: Record<TemplateMode, string> = {
@@ -32,211 +32,203 @@ export const MODE_COLORS: Record<TemplateMode, string> = {
voice_over: "green", voice_over: "green",
one_take: "orange", one_take: "orange",
voice_pip: "purple", voice_pip: "purple",
}; }
/** 标题配置 */ /** 标题配置 */
export interface TitleConfig { export interface TitleConfig {
ai_auto_select: boolean; ai_auto_select: boolean
content: string; content: string
font_preset: string; font_preset: string
font_color: string; font_color: string
font_size: number; font_size: number
position: string; position: string
} }
/** 字幕配置 */ /** 字幕配置 */
export interface SubtitleConfig { export interface SubtitleConfig {
enabled: boolean; enabled: boolean
position: string; position: string
font: string; font: string
color: string; color: string
size: number; size: number
animation: string; animation: string
} }
/** BGM 配置 */ /** BGM 配置 */
export interface BgmConfig { export interface BgmConfig {
enabled: boolean; enabled: boolean
music_id: string; music_id: string
} }
/** 模板片段 */ /** 模板片段 */
export interface TemplateSegment { export interface TemplateSegment {
id?: string; id?: string
segment_order: number; segment_order: number
duration_min: number; duration_min: number
duration_max: number; duration_max: number
material_type: string | null; material_type: string | null
} }
/** 剪辑模板 */ /** 剪辑模板 */
export interface EditingTemplate { export interface EditingTemplate {
id: string; id: string
name: string; name: string
mode: TemplateMode; mode: TemplateMode
category: string; category: string
tags: string[]; tags: string[]
title_config: TitleConfig; title_config: TitleConfig
subtitle_config: SubtitleConfig; subtitle_config: SubtitleConfig
bgm_config: BgmConfig; bgm_config: BgmConfig
estimated_duration: number; estimated_duration: number
segments: TemplateSegment[]; segments: TemplateSegment[]
/** 水印配置(后端就绪后启用) */ /** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig; watermark_config?: WatermarkConfig
/** 片头片尾配置(后端就绪后启用) */ /** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig; intro_outro_config?: IntroOutroConfig
/** 画中画配置 */ /** 画中画配置 */
pip_config?: PipConfig; pip_config?: PipConfig
/** 滤镜调色配置 */ /** 滤镜调色配置 */
filter_config?: FilterConfig; filter_config?: FilterConfig
/** 绿幕抠像配置 */ /** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig; green_screen_config?: ChromaKeyConfig
/** 贴纸配置 */ /** 贴纸配置 */
sticker_config?: StickerConfig; sticker_config?: StickerConfig
/** 封面配置 */ /** 封面配置 */
cover_config?: CoverConfig; cover_config?: CoverConfig
is_active?: boolean; is_active?: boolean
created_at: string; created_at: string
updated_at: string; updated_at: string
} }
/** 模板分类 */ /** 模板分类 */
export interface TemplateCategory { export interface TemplateCategory {
id: string; id: string
name: string; name: string
created_at?: string; created_at?: string
} }
/** 创建/更新模板请求体 */ /** 创建/更新模板请求体 */
export interface SaveTemplatePayload { export interface SaveTemplatePayload {
name: string; name: string
mode: TemplateMode; mode: TemplateMode
category: string; category: string
tags: string[]; tags: string[]
title_config: TitleConfig; title_config: TitleConfig
subtitle_config: SubtitleConfig; subtitle_config: SubtitleConfig
bgm_config: BgmConfig; bgm_config: BgmConfig
estimated_duration: number; estimated_duration: number
segments: Omit<TemplateSegment, "id">[]; segments: Omit<TemplateSegment, "id">[]
/** 水印配置(后端就绪后启用) */ /** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig; watermark_config?: WatermarkConfig
/** 片头片尾配置(后端就绪后启用) */ /** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig; intro_outro_config?: IntroOutroConfig
/** 画中画配置 */ /** 画中画配置 */
pip_config?: PipConfig; pip_config?: PipConfig
/** 滤镜调色配置 */ /** 滤镜调色配置 */
filter_config?: FilterConfig; filter_config?: FilterConfig
/** 绿幕抠像配置 */ /** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig; green_screen_config?: ChromaKeyConfig
/** 贴纸配置 */ /** 贴纸配置 */
sticker_config?: StickerConfig; sticker_config?: StickerConfig
/** 封面配置 */ /** 封面配置 */
cover_config?: CoverConfig; cover_config?: CoverConfig
} }
/** 使用模板生成请求体 */ /** 使用模板生成请求体 */
export interface GenerateFromTemplatePayload { export interface GenerateFromTemplatePayload {
voiceover_duration: number; voiceover_duration: number
} }
/** 验证警告详情 */ /** 验证警告详情 */
export interface ValidationWarningDetails { export interface ValidationWarningDetails {
/** 相关字段名 */ /** 相关字段名 */
field?: string; field?: string
/** 期望值 */ /** 期望值 */
expected?: string | number; expected?: string | number
/** 实际值 */ /** 实际值 */
actual?: string | number; actual?: string | number
/** 建议值 */ /** 建议值 */
suggested?: string | number; suggested?: string | number
} }
/** 验证/生成响应 */ /** 验证/生成响应 */
export interface ValidateWarning { export interface ValidateWarning {
code: string; code: string
message: string; message: string
details?: ValidationWarningDetails; details?: ValidationWarningDetails
} }
/** 使用模板生成响应 */ /** 使用模板生成响应 */
export interface GenerateFromTemplateResponse { export interface GenerateFromTemplateResponse {
template: EditingTemplate; template: EditingTemplate
warnings: ValidateWarning[]; warnings: ValidateWarning[]
} }
/** 列表响应(带分页) */ /** 列表响应(带分页) */
export interface ListTemplatesResponse { export interface ListTemplatesResponse {
items: EditingTemplate[]; items: EditingTemplate[]
total: number; total: number
} }
/** 分类列表响应 */ /** 分类列表响应 */
export interface ListCategoriesResponse { export interface ListCategoriesResponse {
items: TemplateCategory[]; items: TemplateCategory[]
} }
// ============ API 函数 ============ // ============ API 函数 ============
/** 获取模板列表 */ /** 获取模板列表 */
export const getEditingTemplates = async (params?: { export const getEditingTemplates = async (params?: {
category?: string; category?: string
tag?: string; tag?: string
skip?: number; skip?: number
limit?: number; limit?: number
}): Promise<EditingTemplate[]> => { }): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>("/templates", { const response = await apiClient.get<ListTemplatesResponse>("/templates", {
params: { params: {
skip: params?.skip ?? 0, skip: params?.skip ?? 0,
limit: params?.limit ?? 50, limit: params?.limit ?? 50,
}, },
}); })
let list = response.data.items; let list = response.data.items
if (params?.category) if (params?.category) list = list.filter((t) => t.category === params.category)
list = list.filter((t) => t.category === params.category); if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!)); return list
return list; }
};
/** 获取模板详情 */ /** 获取模板详情 */
export const getEditingTemplate = async ( export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
id: string, const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
): Promise<EditingTemplate> => { return response.data
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`); }
return response.data;
};
/** 创建模板 */ /** 创建模板 */
export const createEditingTemplate = async ( export const createEditingTemplate = async (
data: SaveTemplatePayload, data: SaveTemplatePayload,
): Promise<EditingTemplate> => { ): Promise<EditingTemplate> => {
const response = await apiClient.post<EditingTemplate>("/templates", data); const response = await apiClient.post<EditingTemplate>("/templates", data)
return response.data; return response.data
}; }
/** 更新模板 */ /** 更新模板 */
export const updateEditingTemplate = async ( export const updateEditingTemplate = async (
id: string, id: string,
data: SaveTemplatePayload, data: SaveTemplatePayload,
): Promise<EditingTemplate> => { ): Promise<EditingTemplate> => {
const response = await apiClient.patch<EditingTemplate>( const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
`/templates/${id}`, return response.data
data, }
);
return response.data;
};
/** 删除模板 */ /** 删除模板 */
export const deleteEditingTemplate = async (id: string): Promise<void> => { export const deleteEditingTemplate = async (id: string): Promise<void> => {
await apiClient.delete(`/templates/${id}`); await apiClient.delete(`/templates/${id}`)
}; }
/** 获取模板分类列表 */ /** 获取模板分类列表 */
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => { export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
const response = await apiClient.get<ListCategoriesResponse>( const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
"/templates/categories/list", return response.data.items
); }
return response.data.items;
};
/** 使用模板生成视频(调用 validate 端点) */ /** 使用模板生成视频(调用 validate 端点) */
export const generateFromTemplate = async ( export const generateFromTemplate = async (
@@ -246,6 +238,6 @@ export const generateFromTemplate = async (
const response = await apiClient.post<GenerateFromTemplateResponse>( const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/validate`, `/templates/${templateId}/validate`,
data, data,
); )
return response.data; return response.data
}; }
Regular → Executable
+116 -87
View File
@@ -1,145 +1,174 @@
/** /**
* 成品 / 视频相关 API * 成品 / 视频相关 API
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据 * 后端实际接口:/videos
*/ */
import apiClient from "./client"; import apiClient from "./client"
import { getGenerationTaskResults } from "./editPlans";
import type { GeneratedVideo } from "./editPlans";
/** 复核状态 */ /** 复核状态 */
export type ReviewStatus = "pending_review" | "approved" | "rejected"; export type ReviewStatus = "pending_review" | "approved" | "rejected"
/** 成品条目 */ /** 成品条目 */
export interface ProductItem { export interface ProductItem {
id: string; id: string
title: string; title: string
video_url?: string; video_url?: string
thumbnail_url?: string; thumbnail_url?: string
duration_seconds?: number; duration_seconds?: number
file_size?: number; file_size?: number
resolution?: string; resolution?: string
status: "processing" | "completed" | "failed"; status: "processing" | "completed" | "failed"
/** 复核状态 */ /** 复核状态 */
review_status?: ReviewStatus; review_status?: ReviewStatus
/** 所属项目 ID */ /** 所属项目 ID */
project_id?: string; project_id?: string
/** 所属项目名称 */ /** 所属项目名称 */
project_name?: string; project_name?: string
/** 查重率(百分比) */ /** 查重率(百分比) */
duplicate_rate?: number; duplicate_rate?: number
created_at?: string; created_at?: string
updated_at?: string; updated_at?: string
} }
/** 列表查询参数 */ /** 列表查询参数 */
export interface ProductListParams { export interface ProductListParams {
page?: number; page?: number
page_size?: number; page_size?: number
project_id?: string; project_id?: string
review_status?: ReviewStatus | "all"; review_status?: ReviewStatus | "all"
} }
/** 分页响应 */ /** 分页响应 */
export interface ProductListResponse { export interface ProductListResponse {
items: ProductItem[]; items: ProductItem[]
total: number; total: number
page: number; page: number
page_size: number; page_size: number
} }
/** 批量下载任务状态 */ /** 批量下载任务状态 */
export interface BatchDownloadStatus { export interface BatchDownloadStatus {
job_id: string; job_id: string
status: "processing" | "completed" | "failed"; status: "processing" | "completed" | "failed"
/** 完成后返回的下载 URL */ /** 完成后返回的下载 URL */
download_url?: string; download_url?: string
/** 进度百分比 */ /** 进度百分比 */
progress?: number; progress?: number
}
/** 后端 /videos 接口返回的原始视频条目 */
interface VideoItem {
id: string
project_id: string
generation_task_id: string
name: string
file_url: string
file_size: number
duration: number
thumbnail_url: string | null
width: number
height: number
fps: number
status: string
review_status: ReviewStatus
generation_params: Record<string, unknown>
download_url: string
generated_at: string
} }
/** /**
* 将 generation task 数据映射为 ProductItem 格式 * 将后端 VideoItem 映射为 ProductItem 格式
*/ */
function mapTaskToProductItem(task: GeneratedVideo): ProductItem { function mapVideoToProductItem(video: VideoItem): ProductItem {
return { return {
id: task.id, id: video.id,
title: task.name || "未命名视频", title: video.name || "未命名视频",
video_url: task.file_url, // 优先用 download_url(带签名)播放,file_url 无签名无法访问
thumbnail_url: task.thumbnail_url, video_url: video.download_url || video.file_url,
duration_seconds: task.duration, thumbnail_url: video.thumbnail_url || undefined,
file_size: task.file_size, duration_seconds: video.duration,
resolution: file_size: video.file_size,
task.width && task.height ? `${task.width}x${task.height}` : undefined, resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
status: status:
task.status === "completed" video.status === "completed"
? "completed" ? "completed"
: task.status === "failed" : video.status === "failed"
? "failed" ? "failed"
: "processing", : "processing",
review_status: task.review_status as ReviewStatus | undefined, review_status: video.review_status,
project_id: task.project_id, project_id: video.project_id,
created_at: task.created_at, // 后端 /videos 接口暂无 project_name 字段
updated_at: task.updated_at, project_name: undefined,
}; // 后端字段名为 generated_at,映射为 created_at 供前端统一使用
created_at: video.generated_at,
updated_at: video.generated_at,
// 后端 /videos 接口暂无 duplicate_rate 字段
duplicate_rate: undefined,
}
} }
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */ /** 获取成品列表(支持分页和筛选) */
export const getProducts = async ( export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
params?: ProductListParams, const response = await apiClient.get("/videos", { params })
): Promise<ProductItem[]> => { const data = response.data
const response = await apiClient.get("/generation/tasks", { params }); const videos: VideoItem[] = Array.isArray(data?.items)
const tasks = response.data.items || response.data || []; ? data.items
return tasks.map(mapTaskToProductItem); : Array.isArray(data)
}; ? data
: []
return videos.map(mapVideoToProductItem)
}
/** 获取单个成品详情 — 通过 task ID 获取结果 */ /** 获取单个成品详情 */
export const getProduct = async (productId: string): Promise<ProductItem> => { export const getProduct = async (productId: string): Promise<ProductItem> => {
const response = await apiClient.get(`/generation/tasks/${productId}`); const response = await apiClient.get(`/videos/${productId}`)
return mapTaskToProductItem(response.data); return mapVideoToProductItem(response.data as VideoItem)
}; }
/** 删除成品 — 删除 generation task */ /**
* 删除成品
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
* 待后端实现后自动生效
*/
export const deleteProduct = async (productId: string): Promise<void> => { export const deleteProduct = async (productId: string): Promise<void> => {
await apiClient.delete(`/generation/tasks/${productId}`); await apiClient.delete(`/videos/${productId}`)
}; }
/** 获取成品下载链接 — 从 generation task results 获取 */ /**
* 获取成品下载链接
* 直接使用列表返回的 download_url(带OSS签名)
*/
export const getProductDownloadUrl = async ( export const getProductDownloadUrl = async (
productId: string, productId: string,
): Promise<{ url: string; expires_at: string }> => { ): Promise<{ url: string; expires_at: string }> => {
const videos = await getGenerationTaskResults(productId); // 优先从列表缓存取;如果没有则调详情接口
const video = videos[0]; const product = await getProduct(productId)
if (!video?.download_url) throw new Error("下载链接不可用"); if (!product.video_url) throw new Error("下载链接不可用")
return { url: video.download_url, expires_at: "" }; return { url: product.video_url, expires_at: "" }
}; }
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */ /** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
export const updateReviewStatus = async ( export const updateReviewStatus = async (
productId: string, productId: string,
status: ReviewStatus, status: ReviewStatus,
): Promise<ProductItem> => { ): Promise<ProductItem> => {
// 后端暂无 /generation/tasks/{id}/review 端点 // 后端暂无 /videos/{id}/review 端点
// 暂时返回当前状态,后续可扩展 // 暂时返回当前状态,后续可扩展
const product = await getProduct(productId); const product = await getProduct(productId)
return { ...product, review_status: status }; return { ...product, review_status: status }
}; }
/** 发起批量下载 — TODO: 后端暂无对应端点 */ /** 发起批量下载 — TODO: 后端暂无对应端点 */
export const batchDownload = async ( export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
videoIds: string[], // 后端暂无 /videos/batch-download 端点
): Promise<{ job_id: string }> => {
// 后端暂无 /generation/tasks/batch-download 端点
// 暂时返回模拟 job_id,后续可扩展 // 暂时返回模拟 job_id,后续可扩展
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds); console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
return { job_id: `mock-${Date.now()}` }; return { job_id: `mock-${Date.now()}` }
}; }
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */ /** 查询批量下载状态 — TODO: 后端暂无对应端点 */
export const getBatchDownloadStatus = async ( export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
jobId: string, // 后端暂无 /videos/batch-download/{jobId} 端点
): Promise<BatchDownloadStatus> => {
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
// 暂时返回模拟状态,后续可扩展 // 暂时返回模拟状态,后续可扩展
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId); console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
return { job_id: jobId, status: "processing", progress: 0 }; return { job_id: jobId, status: "processing", progress: 0 }
}; }
+21 -22
View File
@@ -2,60 +2,59 @@
* 项目相关 API * 项目相关 API
* 素材库需要 project_id,前端自动管理默认项目 * 素材库需要 project_id,前端自动管理默认项目
*/ */
import apiClient from "./client"; import apiClient from "./client"
export interface ProjectItem { export interface ProjectItem {
id: string; id: string
name: string; name: string
description: string; description: string
} }
/** 后端 ProjectResponse 只返回 id, name, description */ /** 后端 ProjectResponse 只返回 id, name, description */
interface BackendProjectResponse { interface BackendProjectResponse {
id: string; id: string
name: string; name: string
description: string; description: string
} }
/** 后端 ListProjectsResponse 返回 { items: [...] } */ /** 后端 ListProjectsResponse 返回 { items: [...] } */
interface BackendListProjectsResponse { interface BackendListProjectsResponse {
items: BackendProjectResponse[]; items: BackendProjectResponse[]
} }
const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({ const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({
id: item.id, id: item.id,
name: item.name, name: item.name,
description: item.description, description: item.description,
}); })
/** 获取当前用户的项目列表 */ /** 获取当前用户的项目列表 */
export const getProjects = async (): Promise<ProjectItem[]> => { export const getProjects = async (): Promise<ProjectItem[]> => {
const response = const response = await apiClient.get<BackendListProjectsResponse>("/projects")
await apiClient.get<BackendListProjectsResponse>("/projects"); return (response.data.items || []).map(toProjectItem)
return (response.data.items || []).map(toProjectItem); }
};
/** 创建项目 */ /** 创建项目 */
export const createProject = async (data: { export const createProject = async (data: {
name: string; name: string
description?: string; description?: string
}): Promise<ProjectItem> => { }): Promise<ProjectItem> => {
const response = await apiClient.post<BackendProjectResponse>("/projects", { const response = await apiClient.post<BackendProjectResponse>("/projects", {
name: data.name, name: data.name,
description: data.description || "", description: data.description || "",
}); })
return toProjectItem(response.data); return toProjectItem(response.data)
}; }
/** 获取或创建默认项目(素材库需要 project_id) */ /** 获取或创建默认项目(素材库需要 project_id) */
export const getOrCreateDefaultProject = async (): Promise<ProjectItem> => { export const getOrCreateDefaultProject = async (): Promise<ProjectItem> => {
const projects = await getProjects(); const projects = await getProjects()
if (projects.length > 0) { if (projects.length > 0) {
return projects[0]; return projects[0]
} }
// 没有项目时自动创建默认项目 // 没有项目时自动创建默认项目
return createProject({ return createProject({
name: "默认项目", name: "默认项目",
description: "系统自动创建的默认项目", description: "系统自动创建的默认项目",
}); })
}; }
+53 -55
View File
@@ -2,100 +2,98 @@
* 订阅 API 模块 * 订阅 API 模块
* 对接后端订阅管理接口 * 对接后端订阅管理接口
*/ */
import apiClient from "./client"; import apiClient from "./client"
/** 套餐类型 */ /** 套餐类型 */
export type PlanType = "free" | "standard" | "pro" | "enterprise"; export type PlanType = "free" | "standard" | "pro" | "enterprise"
/** 订阅状态 */ /** 订阅状态 */
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial"; export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial"
/** 账单状态 */ /** 账单状态 */
export type BillingStatus = "paid" | "pending" | "failed" | "refunded"; export type BillingStatus = "paid" | "pending" | "failed" | "refunded"
/** 计费周期 */ /** 计费周期 */
export type BillingCycle = "monthly" | "yearly"; export type BillingCycle = "monthly" | "yearly"
/** 套餐信息 */ /** 套餐信息 */
export interface Plan { export interface Plan {
id: PlanType; id: PlanType
name: string; name: string
price: number | null; price: number | null
yearly_price?: number | null; yearly_price?: number | null
description: string; description: string
recommended: boolean; recommended: boolean
features: string[]; features: string[]
} }
/** 当前订阅信息 */ /** 当前订阅信息 */
export interface SubscriptionInfo { export interface SubscriptionInfo {
id: string; id: string
plan_id: PlanType; plan_id: PlanType
plan_name: string; plan_name: string
status: SubscriptionStatus; status: SubscriptionStatus
billing_cycle: BillingCycle; billing_cycle: BillingCycle
current_period_start: string; current_period_start: string
current_period_end: string; current_period_end: string
amount: number; amount: number
auto_renew: boolean; auto_renew: boolean
created_at: string; created_at: string
} }
/** 账单记录 */ /** 账单记录 */
export interface BillingRecord { export interface BillingRecord {
id: string; id: string
plan_name: string; plan_name: string
amount: number; amount: number
billing_cycle: BillingCycle; billing_cycle: BillingCycle
status: BillingStatus; status: BillingStatus
payment_method: string; payment_method: string
created_at: string; created_at: string
invoice_url?: string; invoice_url?: string
} }
/** 升级/降级请求 */ /** 升级/降级请求 */
export interface ChangePlanRequest { export interface ChangePlanRequest {
target_plan_id: PlanType; target_plan_id: PlanType
billing_cycle: BillingCycle; billing_cycle: BillingCycle
} }
/** 升级/降级响应 */ /** 升级/降级响应 */
export interface ChangePlanResponse { export interface ChangePlanResponse {
success: boolean; success: boolean
message: string; message: string
new_subscription?: SubscriptionInfo; new_subscription?: SubscriptionInfo
} }
// ============ API 函数 ============ // ============ API 函数 ============
/** 获取当前订阅信息 */ /** 获取当前订阅信息 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => { export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
const response = await apiClient.get("/subscription/current"); const response = await apiClient.get("/subscription/current")
return response.data; return response.data
}; }
/** 获取账单记录列表 */ /** 获取账单记录列表 */
export const getBillingRecords = async (): Promise<BillingRecord[]> => { export const getBillingRecords = async (): Promise<BillingRecord[]> => {
const response = await apiClient.get("/subscription/billing-records"); const response = await apiClient.get("/subscription/billing-records")
return response.data; return response.data
}; }
/** 升级/降级套餐 */ /** 升级/降级套餐 */
export const changePlan = async ( export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
request: ChangePlanRequest, const response = await apiClient.post("/subscription/change-plan", request)
): Promise<ChangePlanResponse> => { return response.data
const response = await apiClient.post("/subscription/change-plan", request); }
return response.data;
};
/** 取消订阅 */ /** 取消订阅 */
export const cancelSubscription = async (): Promise<{ export const cancelSubscription = async (): Promise<{
success: boolean; success: boolean
message: string; message: string
}> => { }> => {
const response = await apiClient.post("/subscription/cancel"); const response = await apiClient.post("/subscription/cancel")
return response.data; return response.data
}; }
/** 切换自动续费 */ /** 切换自动续费 */
export const toggleAutoRenew = async ( export const toggleAutoRenew = async (
@@ -103,6 +101,6 @@ export const toggleAutoRenew = async (
): Promise<{ success: boolean; message: string }> => { ): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post("/subscription/toggle-auto-renew", { const response = await apiClient.post("/subscription/toggle-auto-renew", {
enabled, enabled,
}); })
return response.data; return response.data
}; }
+20 -26
View File
@@ -2,45 +2,39 @@
* 标签 CRUD API * 标签 CRUD API
* P3 标签体系:对接后端标签表 * P3 标签体系:对接后端标签表
*/ */
import apiClient from "./client"; import apiClient from "./client"
export interface TagItem { export interface TagItem {
id: string; id: string
name: string; name: string
created_at?: string; created_at?: string
usage_count?: number; usage_count?: number
} }
/** 获取当前用户所有标签 */ /** 获取当前用户所有标签 */
export const getTags = async (): Promise<TagItem[]> => { export const getTags = async (): Promise<TagItem[]> => {
const response = await apiClient.get("/tags"); const response = await apiClient.get("/tags")
return response.data.items || []; return response.data.items || []
}; }
/** 创建标签(同名返回 409) */ /** 创建标签(同名返回 409) */
export const createTag = async (name: string): Promise<TagItem> => { export const createTag = async (name: string): Promise<TagItem> => {
const response = await apiClient.post("/tags", { name }); const response = await apiClient.post("/tags", { name })
return response.data; return response.data
}; }
/** 删除标签(同时清理素材关联) */ /** 删除标签(同时清理素材关联) */
export const deleteTag = async (tagId: string): Promise<void> => { export const deleteTag = async (tagId: string): Promise<void> => {
await apiClient.delete(`/tags/${tagId}`); await apiClient.delete(`/tags/${tagId}`)
}; }
/** 为素材添加标签(最多 50 个) */ /** 为素材添加标签(最多 50 个) */
export const tagAsset = async ( export const tagAsset = async (assetId: string, tagIds: string[]): Promise<void> => {
assetId: string, if (tagIds.length === 0) return
tagIds: string[], await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds })
): Promise<void> => { }
if (tagIds.length === 0) return;
await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds });
};
/** 移除素材的某个标签 */ /** 移除素材的某个标签 */
export const untagAsset = async ( export const untagAsset = async (assetId: string, tagId: string): Promise<void> => {
assetId: string, await apiClient.delete(`/assets/${assetId}/tags/${tagId}`)
tagId: string, }
): Promise<void> => {
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`);
};
+63 -69
View File
@@ -6,85 +6,84 @@
* - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info) * - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info)
* - POST /api/v1/tasks/{task_id}/retry — 重试失败任务 * - POST /api/v1/tasks/{task_id}/retry — 重试失败任务
*/ */
import apiClient from "./client"; import apiClient from "./client"
/* ──────────── 类型定义 ──────────── */ /* ──────────── 类型定义 ──────────── */
/** 任务状态 */ /** 任务状态 */
export type TaskStatus = export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
"pending" | "waiting" | "running" | "completed" | "failed" | "cancelled";
/** 任务类型 */ /** 任务类型 */
export type TaskType = "ingest" | "generation" | string; export type TaskType = "ingest" | "generation" | string
/** 错误详情 */ /** 错误详情 */
export interface TaskErrorInfo { export interface TaskErrorInfo {
error_type: string; error_type: string
error_message: string; error_message: string
failed_step: string; failed_step: string
stack_trace?: string; stack_trace?: string
} }
/** 任务条目(对应用户级 UserTaskResponse) */ /** 任务条目(对应用户级 UserTaskResponse) */
export interface TaskItem { export interface TaskItem {
id: string; id: string
task_type: TaskType; task_type: TaskType
project_id: string; project_id: string
template_id?: string; template_id?: string
status: TaskStatus; status: TaskStatus
progress: number; progress: number
current_step: string; current_step: string
error_message: string; error_message: string
user_message: string; user_message: string
retryable: boolean; retryable: boolean
source_id: string; source_id: string
/** 错误详情(失败任务) */ /** 错误详情(失败任务) */
error_info?: TaskErrorInfo; error_info?: TaskErrorInfo
/** 耗时(秒) */ /** 耗时(秒) */
duration_seconds?: number; duration_seconds?: number
created_at?: string | null; created_at?: string | null
updated_at?: string | null; updated_at?: string | null
} }
/** 任务列表查询参数 */ /** 任务列表查询参数 */
export interface TaskListParams { export interface TaskListParams {
page?: number; page?: number
page_size?: number; page_size?: number
status?: TaskStatus | "all"; status?: TaskStatus | "all"
task_type?: TaskType | "all"; task_type?: TaskType | "all"
} }
/** 任务列表分页响应 */ /** 任务列表分页响应 */
export interface TaskListResponse { export interface TaskListResponse {
items: TaskItem[]; items: TaskItem[]
total: number; total: number
page: number; page: number
page_size: number; page_size: number
} }
/** 创建生成任务请求参数 */ /** 创建生成任务请求参数 */
export interface CreateGenerationTaskRequest { export interface CreateGenerationTaskRequest {
template_id: string; template_id: string
asset_ids: string[]; asset_ids: string[]
title_ids: string[]; title_ids: string[]
voice_ids: string[]; voice_ids: string[]
} }
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */ /** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
export interface CreateGenerationTaskResponse { export interface CreateGenerationTaskResponse {
id: string; id: string
project_id: string; project_id: string
asset_library_id: string; asset_library_id: string
strategy_id: string; strategy_id: string
voice_library_id: string; voice_library_id: string
template_id: string; template_id: string
asset_ids: string[]; asset_ids: string[]
title_ids: string[]; title_ids: string[]
voice_ids: string[]; voice_ids: string[]
status: string; status: string
progress: number; progress: number
result_count: number; result_count: number
error_message: string; error_message: string
} }
/* ──────────── API 函数 ──────────── */ /* ──────────── API 函数 ──────────── */
@@ -93,37 +92,32 @@ export interface CreateGenerationTaskResponse {
export const createGenerationTask = async ( export const createGenerationTask = async (
params: CreateGenerationTaskRequest, params: CreateGenerationTaskRequest,
): Promise<CreateGenerationTaskResponse> => { ): Promise<CreateGenerationTaskResponse> => {
const { data } = await apiClient.post<CreateGenerationTaskResponse>( const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
"/generation/tasks", return data
params, }
);
return data;
};
/** 获取任务列表(支持分页和筛选) */ /** 获取任务列表(支持分页和筛选) */
export const getTasks = async ( export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
params?: TaskListParams,
): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", { const { data } = await apiClient.get<TaskListResponse>("/tasks", {
params, params,
}); })
return data; return data
}; }
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */ /** 获取当前用户的所有任务(兼容旧接口,跨 project) */
export const getUserTasks = async (): Promise<TaskItem[]> => { export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get("/tasks"); const { data } = await apiClient.get("/tasks")
return data.items || data || []; return data.items || data || []
}; }
/** 获取单个任务详情(含 error_info) */ /** 获取单个任务详情(含 error_info) */
export const getTask = async (taskId: string): Promise<TaskItem> => { export const getTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.get(`/tasks/${taskId}`); const { data } = await apiClient.get(`/tasks/${taskId}`)
return data; return data
}; }
/** 重试失败的任务 */ /** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => { export const retryTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.post(`/tasks/${taskId}/retry`); const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
return data; return data
}; }
+76 -81
View File
@@ -7,137 +7,132 @@
* - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划 * - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏 * - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
*/ */
import apiClient from "./client"; import apiClient from "./client"
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner"; import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner"
import type { EditPlanConfig } from "./editPlans"; import type { EditPlanConfig } from "./editPlans"
/* ──────────── 类型定义 ──────────── */ /* ──────────── 类型定义 ──────────── */
/** 模板条目(后端 TemplateResponse) */ /** 模板条目(后端 TemplateResponse) */
export interface TemplateItem { export interface TemplateItem {
id: string; id: string
name: string; user_id?: string
description: string; name: string
category: string; description?: string
tags?: string[]; mode?: string
target_duration: number; category: string
clip_count: number; tags?: string[]
/** 预估时长(后端字段名 estimated_duration) */
estimated_duration?: number
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
target_duration?: number
clip_count?: number
/** 使用次数 */ /** 使用次数 */
usage_count?: number; usage_count?: number
thumbnail_url?: string; thumbnail_url?: string
preview_url?: string; preview_url?: string
is_active: boolean; is_active?: boolean
is_favorite?: boolean; is_favorite?: boolean
/** 素材规则(片段配置) */ /** 素材规则(片段配置) */
segments?: TemplateSegment[]; segments?: TemplateSegment[]
/** 字幕样式 */ /** 字幕样式 */
subtitle_config?: SubtitleConfig; subtitle_config?: SubtitleConfig
/** BGM 配置 */ /** BGM 配置 */
bgm_config?: BgmConfig; bgm_config?: BgmConfig
/** 标题配置 */ /** 标题配置 */
title_config?: TitleConfig; title_config?: TitleConfig
/** 视频比例 */ /** 视频比例 */
aspect_ratio?: string; aspect_ratio?: string
created_at?: string; created_at?: string
updated_at?: string; updated_at?: string
} }
/** 模板片段(素材规则) */ /** 模板片段(素材规则) */
export interface TemplateSegment { export interface TemplateSegment {
id?: string; id?: string
segment_order: number; segment_order: number
duration_min: number; duration_min: number
duration_max: number; duration_max: number
material_type: string | null; material_type: string | null
description?: string; description?: string
} }
/** 模板列表查询参数 */ /** 模板列表查询参数 */
export interface TemplateListParams { export interface TemplateListParams {
page?: number; page?: number
page_size?: number; page_size?: number
category?: string; category?: string
tags?: string; tags?: string
keyword?: string; keyword?: string
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */ /** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
duration_range?: "short" | "medium" | "long"; duration_range?: "short" | "medium" | "long"
} }
/** 模板列表分页响应 */ /** 模板列表分页响应 */
export interface TemplateListResponse { export interface TemplateListResponse {
items: TemplateItem[]; items: TemplateItem[]
total: number; total: number
page: number; page: number
page_size: number; page_size: number
} }
/** 从模板生成剪辑计划请求 */ /** 从模板生成剪辑计划请求 */
export interface GenerateFromTemplateRequest { export interface GenerateFromTemplateRequest {
asset_ids?: string[]; asset_ids?: string[]
name?: string; name?: string
config?: EditPlanConfig; config?: EditPlanConfig
} }
/** 从模板生成剪辑计划响应 */ /** 从模板生成剪辑计划响应 */
export interface GenerateFromTemplateResponse { export interface GenerateFromTemplateResponse {
plan_id: string; plan_id: string
template_id: string; template_id: string
status: string; status: string
name: string; name: string
} }
/** 复制模板响应 */ /** 复制模板响应 */
export interface CopyTemplateResponse { export interface CopyTemplateResponse {
id: string; id: string
name: string; name: string
source_template_id: string; source_template_id: string
} }
/* ──────────── API 函数 ──────────── */ /* ──────────── API 函数 ──────────── */
/** 获取模板列表(支持分页和筛选) */ /** 获取模板列表(支持分页和筛选) */
export const getTemplates = async ( export const getTemplates = async (params?: TemplateListParams): Promise<TemplateListResponse> => {
params?: TemplateListParams,
): Promise<TemplateListResponse> => {
const { data } = await apiClient.get<TemplateListResponse>("/templates", { const { data } = await apiClient.get<TemplateListResponse>("/templates", {
params, params,
}); })
return data; return data
}; }
/** 获取模板列表(兼容旧接口,返回数组) */ /** 获取模板列表(兼容旧接口,返回数组) */
export const getTemplatesList = async (): Promise<TemplateItem[]> => { export const getTemplatesList = async (): Promise<TemplateItem[]> => {
const response = await apiClient.get("/templates"); const response = await apiClient.get("/templates")
return response.data.items || response.data || []; return response.data.items || response.data || []
}; }
/** 获取单个模板详情 */ /** 获取单个模板详情 */
export const getTemplate = async ( export const getTemplate = async (templateId: string): Promise<TemplateItem> => {
templateId: string, const response = await apiClient.get(`/templates/${templateId}`)
): Promise<TemplateItem> => { return response.data
const response = await apiClient.get(`/templates/${templateId}`); }
return response.data;
};
/** 收藏 / 取消收藏模板 */ /** 收藏 / 取消收藏模板 */
export const toggleFavoriteTemplate = async ( export const toggleFavoriteTemplate = async (
templateId: string, templateId: string,
): Promise<{ is_favorite: boolean }> => { ): Promise<{ is_favorite: boolean }> => {
const response = await apiClient.post( const response = await apiClient.post(`/templates/${templateId}/toggle-favorite`)
`/templates/${templateId}/toggle-favorite`, return response.data
); }
return response.data;
};
/** 复制模板(创建副本到我的模板) */ /** 复制模板(创建副本到我的模板) */
export const copyTemplate = async ( export const copyTemplate = async (templateId: string): Promise<CopyTemplateResponse> => {
templateId: string, const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
): Promise<CopyTemplateResponse> => { return response.data
const response = await apiClient.post<CopyTemplateResponse>( }
`/templates/${templateId}/copy`,
);
return response.data;
};
/** 从模板生成剪辑计划 */ /** 从模板生成剪辑计划 */
export const generateFromTemplate = async ( export const generateFromTemplate = async (
@@ -147,9 +142,9 @@ export const generateFromTemplate = async (
const response = await apiClient.post<GenerateFromTemplateResponse>( const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`, `/templates/${templateId}/generate`,
data, data,
); )
return response.data; return response.data
}; }
/* ──────────── 常量 ──────────── */ /* ──────────── 常量 ──────────── */
@@ -162,7 +157,7 @@ export const TEMPLATE_CATEGORY_OPTIONS = [
{ value: "品牌", label: "品牌" }, { value: "品牌", label: "品牌" },
{ value: "混剪", label: "混剪" }, { value: "混剪", label: "混剪" },
{ value: "Vlog", label: "Vlog" }, { value: "Vlog", label: "Vlog" },
]; ]
/** 时长筛选选项 */ /** 时长筛选选项 */
export const TEMPLATE_DURATION_OPTIONS = [ export const TEMPLATE_DURATION_OPTIONS = [
@@ -170,4 +165,4 @@ export const TEMPLATE_DURATION_OPTIONS = [
{ value: "short", label: "30秒以内" }, { value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" }, { value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" }, { value: "long", label: "2分钟以上" },
]; ]
+57 -69
View File
@@ -3,51 +3,51 @@
* Phase 1 新增:全局标题库 * Phase 1 新增:全局标题库
* 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示 * 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示
*/ */
import apiClient from "./client"; import apiClient from "./client"
/** 标题条目(前端展示用) */ /** 标题条目(前端展示用) */
export interface TitleItem { export interface TitleItem {
id: string; id: string
content: string; content: string
category?: string; category?: string
source?: string; source?: string
word_count?: number; word_count?: number
is_favorite?: boolean; is_favorite?: boolean
created_at?: string; created_at?: string
updated_at?: string; updated_at?: string
} }
/** 后端标题响应格式 */ /** 后端标题响应格式 */
interface BackendTitleResponse { interface BackendTitleResponse {
id: string; id: string
user_id: string; user_id: string
name: string; name: string
text: string; text: string
category: string; category: string
description: string; description: string
tags: string[]; tags: string[]
usage_count: number; usage_count: number
is_active: boolean; is_active: boolean
created_at: string; created_at: string
updated_at: string; updated_at: string
} }
/** 后端创建标题请求格式 */ /** 后端创建标题请求格式 */
interface BackendCreateTitleRequest { interface BackendCreateTitleRequest {
name: string; name: string
text: string; text: string
category: string; category: string
description?: string; description?: string
tags?: string[]; tags?: string[]
} }
/** 后端更新标题请求格式 */ /** 后端更新标题请求格式 */
interface BackendUpdateTitleRequest { interface BackendUpdateTitleRequest {
name?: string; name?: string
text?: string; text?: string
category?: string; category?: string
description?: string; description?: string
tags?: string[]; tags?: string[]
} }
/** 将后端响应映射为前端 TitleItem */ /** 将后端响应映射为前端 TitleItem */
@@ -58,73 +58,61 @@ const toTitleItem = (item: BackendTitleResponse): TitleItem => ({
word_count: item.text?.length || 0, word_count: item.text?.length || 0,
created_at: item.created_at, created_at: item.created_at,
updated_at: item.updated_at, updated_at: item.updated_at,
}); })
/** 创建标题请求(前端接口,保持向后兼容) */ /** 创建标题请求(前端接口,保持向后兼容) */
export interface CreateTitleRequest { export interface CreateTitleRequest {
content: string; content: string
category?: string; category?: string
} }
/** 获取当前用户的所有标题 */ /** 获取当前用户的所有标题 */
export const getTitles = async (): Promise<TitleItem[]> => { export const getTitles = async (): Promise<TitleItem[]> => {
const response = await apiClient.get< const response = await apiClient.get<{ items: BackendTitleResponse[] } | BackendTitleResponse[]>(
{ items: BackendTitleResponse[] } | BackendTitleResponse[] "/titles",
>("/titles"); )
// 兼容两种后端返回格式:{ items: [...] } 或直接 [...] // 兼容两种后端返回格式:{ items: [...] } 或直接 [...]
const items = Array.isArray(response.data) const items = Array.isArray(response.data) ? response.data : response.data.items || []
? response.data return items.map(toTitleItem)
: response.data.items || []; }
return items.map(toTitleItem);
};
/** 创建标题 */ /** 创建标题 */
export const createTitle = async ( export const createTitle = async (data: CreateTitleRequest): Promise<TitleItem> => {
data: CreateTitleRequest,
): Promise<TitleItem> => {
// 后端要求 name(≤255)和 text(≤500),name 从 content 截取 // 后端要求 name(≤255)和 text(≤500),name 从 content 截取
const payload: BackendCreateTitleRequest = { const payload: BackendCreateTitleRequest = {
name: data.content.slice(0, 255), name: data.content.slice(0, 255),
text: data.content.slice(0, 500), text: data.content.slice(0, 500),
category: data.category || "default", category: data.category || "default",
}; }
const response = await apiClient.post<BackendTitleResponse>( const response = await apiClient.post<BackendTitleResponse>("/titles", payload)
"/titles", return toTitleItem(response.data)
payload, }
);
return toTitleItem(response.data);
};
/** 更新标题 */ /** 更新标题 */
export const updateTitle = async ( export const updateTitle = async (
titleId: string, titleId: string,
data: Partial<CreateTitleRequest>, data: Partial<CreateTitleRequest>,
): Promise<TitleItem> => { ): Promise<TitleItem> => {
const payload: BackendUpdateTitleRequest = {}; const payload: BackendUpdateTitleRequest = {}
if (data.content !== undefined) { if (data.content !== undefined) {
payload.name = data.content.slice(0, 255); payload.name = data.content.slice(0, 255)
payload.text = data.content.slice(0, 500); payload.text = data.content.slice(0, 500)
} }
if (data.category !== undefined) { if (data.category !== undefined) {
payload.category = data.category; payload.category = data.category
} }
// 后端用 PUT,非 PATCH // 后端用 PUT,非 PATCH
const response = await apiClient.put<BackendTitleResponse>( const response = await apiClient.put<BackendTitleResponse>(`/titles/${titleId}`, payload)
`/titles/${titleId}`, return toTitleItem(response.data)
payload, }
);
return toTitleItem(response.data);
};
/** 删除标题 */ /** 删除标题 */
export const deleteTitle = async (titleId: string): Promise<void> => { export const deleteTitle = async (titleId: string): Promise<void> => {
await apiClient.delete(`/titles/${titleId}`); await apiClient.delete(`/titles/${titleId}`)
}; }
/** 批量导入标题 */ /** 批量导入标题 */
export const batchImportTitles = async ( export const batchImportTitles = async (titles: string[]): Promise<{ imported_count: number }> => {
titles: string[], const response = await apiClient.post("/titles/batch-import", { titles })
): Promise<{ imported_count: number }> => { return response.data
const response = await apiClient.post("/titles/batch-import", { titles }); }
return response.data;
};
+95 -110
View File
@@ -4,91 +4,91 @@
* *
* 任务 3.14 新增 * 任务 3.14 新增
*/ */
import apiClient from "./client"; import apiClient from "./client"
/* ── 类型定义 ──────────────────────────────────── */ /* ── 类型定义 ──────────────────────────────────── */
/** TTS 元数据(合成时附带的扩展信息) */ /** TTS 元数据(合成时附带的扩展信息) */
export interface TTSMetadata { export interface TTSMetadata {
/** 语音时长(秒) */ /** 语音时长(秒) */
duration?: number; duration?: number
/** 采样率(Hz) */ /** 采样率(Hz) */
sample_rate?: number; sample_rate?: number
/** 语言 */ /** 语言 */
language?: string; language?: string
/** 其他扩展字段 */ /** 其他扩展字段 */
[key: string]: unknown; [key: string]: unknown
} }
/** TTS 合成请求参数 */ /** TTS 合成请求参数 */
export interface TTSSynthesizeRequest { export interface TTSSynthesizeRequest {
text: string; text: string
voice_id?: string; voice_id?: string
output_name?: string; output_name?: string
language?: string; language?: string
speed?: number; speed?: number
voice_model?: string; voice_model?: string
voice_clone_profile_id?: string; voice_clone_profile_id?: string
format?: string; format?: string
metadata?: TTSMetadata; metadata?: TTSMetadata
} }
/** TTS 合成创建响应 */ /** TTS 合成创建响应 */
export interface TTSSynthesizeResponse { export interface TTSSynthesizeResponse {
job_id: string; job_id: string
status: string; status: string
message: string; message: string
} }
/** TTS 任务详情 */ /** TTS 任务详情 */
export interface TTSJob { export interface TTSJob {
id: string; id: string
user_id: string; user_id: string
project_id: string | null; project_id: string | null
text: string; text: string
voice_id: string | null; voice_id: string | null
voice_model: string | null; voice_model: string | null
voice_clone_profile_id: string | null; voice_clone_profile_id: string | null
language: string; language: string
speed: number; speed: number
output_name: string | null; output_name: string | null
output_audio_url: string | null; output_audio_url: string | null
output_format: string; output_format: string
duration_seconds: number | null; duration_seconds: number | null
file_size_bytes: number | null; file_size_bytes: number | null
sample_rate: number | null; sample_rate: number | null
status: string; status: string
error_message: string | null; error_message: string | null
retry_count: number; retry_count: number
max_retries: number; max_retries: number
metadata_: TTSMetadata | null; metadata_: TTSMetadata | null
created_at: string; created_at: string
updated_at: string; updated_at: string
} }
/** TTS 任务状态(轻量轮询用) */ /** TTS 任务状态(轻量轮询用) */
export interface TTSJobStatus { export interface TTSJobStatus {
id: string; id: string
status: string; status: string
output_audio_url: string | null; output_audio_url: string | null
error_message: string | null; error_message: string | null
duration_seconds: number | null; duration_seconds: number | null
retry_count: number; retry_count: number
} }
/** TTS 任务列表响应 */ /** TTS 任务列表响应 */
export interface TTSJobListResponse { export interface TTSJobListResponse {
items: TTSJob[]; items: TTSJob[]
total: number; total: number
skip: number; skip: number
limit: number; limit: number
} }
/** TTS 任务列表查询参数 */ /** TTS 任务列表查询参数 */
export interface TTSJobListParams { export interface TTSJobListParams {
status?: string; status?: string
skip?: number; skip?: number
limit?: number; limit?: number
} }
/* ── API 函数 ──────────────────────────────────── */ /* ── API 函数 ──────────────────────────────────── */
@@ -97,47 +97,37 @@ export interface TTSJobListParams {
export const synthesizeSpeech = async ( export const synthesizeSpeech = async (
data: TTSSynthesizeRequest, data: TTSSynthesizeRequest,
): Promise<TTSSynthesizeResponse> => { ): Promise<TTSSynthesizeResponse> => {
const response = await apiClient.post<TTSSynthesizeResponse>( const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
"/tts/synthesize", return response.data
data, }
);
return response.data;
};
/** 获取 TTS 任务详情 */ /** 获取 TTS 任务详情 */
export const getTTSJob = async (jobId: string): Promise<TTSJob> => { export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`); const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
return response.data; return response.data
}; }
/** 获取 TTS 任务状态(轻量轮询) */ /** 获取 TTS 任务状态(轻量轮询) */
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => { export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
const response = await apiClient.get<TTSJobStatus>( const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
`/tts/jobs/${jobId}/status`, return response.data
); }
return response.data;
};
/** 获取 TTS 任务列表 */ /** 获取 TTS 任务列表 */
export const getTTSJobs = async ( export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
params?: TTSJobListParams, const searchParams = new URLSearchParams()
): Promise<TTSJobListResponse> => { if (params?.status) searchParams.set("status", params.status)
const searchParams = new URLSearchParams(); if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.status) searchParams.set("status", params.status); if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); const qs = searchParams.toString()
if (params?.limit !== undefined) const response = await apiClient.get<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
searchParams.set("limit", String(params.limit)); return response.data
const qs = searchParams.toString(); }
const response = await apiClient.get<TTSJobListResponse>(
`/tts/jobs${qs ? `?${qs}` : ""}`,
);
return response.data;
};
/** 存为素材请求参数 */ /** 存为素材请求参数 */
export interface SaveTtsToLibraryRequest { export interface SaveTtsToLibraryRequest {
name?: string; name?: string
tag_ids?: string[]; tag_ids?: string[]
} }
/** 将 TTS 合成结果保存到配音素材库 */ /** 将 TTS 合成结果保存到配音素材库 */
@@ -145,59 +135,54 @@ export const saveTtsToLibrary = async (
jobId: string, jobId: string,
data?: SaveTtsToLibraryRequest, data?: SaveTtsToLibraryRequest,
): Promise<void> => { ): Promise<void> => {
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {}); await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
}; }
/** 删除 TTS 任务 */ /** 删除 TTS 任务 */
export const deleteTTSJob = async (jobId: string): Promise<void> => { export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`); await apiClient.delete(`/tts/jobs/${jobId}`)
}; }
/* ── 音色列表 ──────────────────────────────────── */ /* ── 音色列表 ──────────────────────────────────── */
/** TTS 音色 */ /** TTS 音色 */
export interface TTSVoice { export interface TTSVoice {
id: string; id: string
name: string; name: string
/** 音色分类标签:male/female/young/service/news/emotion */ /** 音色分类标签:male/female/young/service/news/emotion */
category?: string; category?: string
/** 语言 */ /** 语言 */
language?: string; language?: string
/** 试听 URL */ /** 试听 URL */
preview_url?: string; preview_url?: string
/** 描述 */ /** 描述 */
description?: string; description?: string
} }
/** 获取 TTS 音色列表 */ /** 获取 TTS 音色列表 */
export const getTtsVoices = async (): Promise<TTSVoice[]> => { export const getTtsVoices = async (): Promise<TTSVoice[]> => {
const response = await apiClient.get<TTSVoice[]>("/tts/voices"); const response = await apiClient.get<TTSVoice[]>("/tts/voices")
return response.data; return response.data
}; }
/* ── TTS 试听 ──────────────────────────────────── */ /* ── TTS 试听 ──────────────────────────────────── */
/** TTS 试听请求参数 */ /** TTS 试听请求参数 */
export interface TTSPreviewRequest { export interface TTSPreviewRequest {
text: string; text: string
voice_id: string; voice_id: string
speed?: number; speed?: number
pitch?: number; pitch?: number
} }
/** TTS 试听响应 */ /** TTS 试听响应 */
export interface TTSPreviewResponse { export interface TTSPreviewResponse {
audio_url: string; audio_url: string
duration?: number; duration?: number
} }
/** TTS 试听 */ /** TTS 试听 */
export const previewTts = async ( export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewResponse> => {
data: TTSPreviewRequest, const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
): Promise<TTSPreviewResponse> => { return response.data
const response = await apiClient.post<TTSPreviewResponse>( }
"/tts/preview",
data,
);
return response.data;
};
+98 -123
View File
@@ -3,35 +3,35 @@
* 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05) * 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05)
* 任务 3.15:新增 progress 字段用于进度展示 * 任务 3.15:新增 progress 字段用于进度展示
*/ */
import apiClient from "./client"; import apiClient from "./client"
/* ── 前端兼容类型 ─────────────────────────────────────── */ /* ── 前端兼容类型 ─────────────────────────────────────── */
/** 克隆音色状态(前端展示用) */ /** 克隆音色状态(前端展示用) */
export type VoiceCloneStatus = "ready" | "processing" | "failed"; export type VoiceCloneStatus = "ready" | "processing" | "failed"
/** 克隆音色条目(前端展示用) */ /** 克隆音色条目(前端展示用) */
export interface VoiceClone { export interface VoiceClone {
id: string; id: string
name: string; name: string
description: string; description: string
duration_seconds: number; duration_seconds: number
status: VoiceCloneStatus; status: VoiceCloneStatus
/** 克隆进度 0-100,仅 processing 状态时有值 */ /** 克隆进度 0-100,仅 processing 状态时有值 */
progress: number; progress: number
sample_url?: string; sample_url?: string
language: string; language: string
gender: string; gender: string
error_message: string | null; error_message: string | null
created_at: string; created_at: string
updated_at: string; updated_at: string
} }
/** 创建克隆请求(前端简化版) */ /** 创建克隆请求(前端简化版) */
export interface CreateVoiceCloneRequest { export interface CreateVoiceCloneRequest {
name: string; name: string
audio_url: string; audio_url: string
description?: string; description?: string
} }
/* ── 后端 API 类型 ────────────────────────────────────── */ /* ── 后端 API 类型 ────────────────────────────────────── */
@@ -39,60 +39,60 @@ export interface CreateVoiceCloneRequest {
/** 音色克隆元数据(克隆时附带的扩展信息) */ /** 音色克隆元数据(克隆时附带的扩展信息) */
export interface VoiceCloneMetadata { export interface VoiceCloneMetadata {
/** 语音时长(秒) */ /** 语音时长(秒) */
duration?: number; duration?: number
/** 采样率(Hz) */ /** 采样率(Hz) */
sample_rate?: number; sample_rate?: number
/** 音色 ID(克隆完成后分配) */ /** 音色 ID(克隆完成后分配) */
voice_id?: string; voice_id?: string
/** 其他扩展字段 */ /** 其他扩展字段 */
[key: string]: unknown; [key: string]: unknown
} }
/** 后端克隆档案响应 */ /** 后端克隆档案响应 */
export interface VoiceCloneProfile { export interface VoiceCloneProfile {
id: string; id: string
user_id: string; user_id: string
name: string; name: string
description: string; description: string
source_audio_url: string; source_audio_url: string
voice_id: string | null; voice_id: string | null
voice_model: string; voice_model: string
language: string; language: string
gender: string; gender: string
status: "pending" | "processing" | "ready" | "failed"; status: "pending" | "processing" | "ready" | "failed"
error_message: string | null; error_message: string | null
retry_count: number; retry_count: number
max_retries: number; max_retries: number
metadata_: VoiceCloneMetadata | null; metadata_: VoiceCloneMetadata | null
created_at: string; created_at: string
updated_at: string; updated_at: string
} }
/** 后端克隆列表响应 */ /** 后端克隆列表响应 */
export interface ListVoiceCloneResponse { export interface ListVoiceCloneResponse {
items: VoiceCloneProfile[]; items: VoiceCloneProfile[]
total: number; total: number
} }
/** 后端克隆状态响应 */ /** 后端克隆状态响应 */
export interface VoiceCloneStatusResponse { export interface VoiceCloneStatusResponse {
id: string; id: string
status: "pending" | "processing" | "ready" | "failed"; status: "pending" | "processing" | "ready" | "failed"
error_message: string | null; error_message: string | null
voice_id: string | null; voice_id: string | null
retry_count: number; retry_count: number
} }
/** 后端创建克隆请求(完整版) */ /** 后端创建克隆请求(完整版) */
export interface CreateVoiceCloneRequestFull { export interface CreateVoiceCloneRequestFull {
name: string; name: string
description?: string; description?: string
source_audio_url: string; source_audio_url: string
voice_model?: string; voice_model?: string
language?: string; language?: string
gender?: string; gender?: string
max_retries?: number; max_retries?: number
metadata_?: VoiceCloneMetadata; metadata_?: VoiceCloneMetadata
} }
/* ── 辅助函数 ─────────────────────────────────────────── */ /* ── 辅助函数 ─────────────────────────────────────────── */
@@ -114,66 +114,54 @@ export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
error_message: profile.error_message || null, error_message: profile.error_message || null,
created_at: profile.created_at, created_at: profile.created_at,
updated_at: profile.updated_at, updated_at: profile.updated_at,
}); })
/** 格式化时长 */ /** 格式化时长 */
export const formatDuration = (seconds: number): string => { export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60)
const s = seconds % 60; const s = seconds % 60
return `${m}:${String(s).padStart(2, "0")}`; return `${m}:${String(s).padStart(2, "0")}`
}; }
/* ── 查询参数 ─────────────────────────────────────────── */ /* ── 查询参数 ─────────────────────────────────────────── */
export interface VoiceCloneListParams { export interface VoiceCloneListParams {
status?: string; status?: string
skip?: number; skip?: number
limit?: number; limit?: number
} }
/* ── API 函数 ─────────────────────────────────────────── */ /* ── API 函数 ─────────────────────────────────────────── */
/** 获取克隆音色列表(返回前端兼容数组) */ /** 获取克隆音色列表(返回前端兼容数组) */
export const getVoiceClones = async ( export const getVoiceClones = async (params?: VoiceCloneListParams): Promise<VoiceClone[]> => {
params?: VoiceCloneListParams, const searchParams = new URLSearchParams()
): Promise<VoiceClone[]> => { if (params?.status) searchParams.set("status", params.status)
const searchParams = new URLSearchParams(); if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.status) searchParams.set("status", params.status); if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); const qs = searchParams.toString()
if (params?.limit !== undefined) const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
searchParams.set("limit", String(params.limit)); return response.data.items.map(toVoiceClone)
const qs = searchParams.toString(); }
const response = await apiClient.get<ListVoiceCloneResponse>(
`/voice-clones${qs ? `?${qs}` : ""}`,
);
return response.data.items.map(toVoiceClone);
};
/** 获取克隆音色列表(返回完整响应含 total) */ /** 获取克隆音色列表(返回完整响应含 total) */
export const getVoiceClonesWithTotal = async ( export const getVoiceClonesWithTotal = async (
params?: VoiceCloneListParams, params?: VoiceCloneListParams,
): Promise<ListVoiceCloneResponse> => { ): Promise<ListVoiceCloneResponse> => {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status); if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
searchParams.set("limit", String(params.limit)); const qs = searchParams.toString()
const qs = searchParams.toString(); const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
const response = await apiClient.get<ListVoiceCloneResponse>( return response.data
`/voice-clones${qs ? `?${qs}` : ""}`, }
);
return response.data;
};
/** 获取单个克隆音色详情 */ /** 获取单个克隆音色详情 */
export const getVoiceCloneDetail = async ( export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
id: string, const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
): Promise<VoiceCloneProfile> => { return response.data
const response = await apiClient.get<VoiceCloneProfile>( }
`/voice-clones/${id}`,
);
return response.data;
};
/** 创建克隆音色 */ /** 创建克隆音色 */
export const createVoiceClone = async ( export const createVoiceClone = async (
@@ -183,18 +171,15 @@ export const createVoiceClone = async (
name: data.name, name: data.name,
description: data.description, description: data.description,
source_audio_url: data.audio_url, source_audio_url: data.audio_url,
}; }
const response = await apiClient.post<VoiceCloneProfile>( const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
"/voice-clones", return response.data
payload, }
);
return response.data;
};
/** 删除克隆音色 */ /** 删除克隆音色 */
export const deleteVoiceClone = async (id: string): Promise<void> => { export const deleteVoiceClone = async (id: string): Promise<void> => {
await apiClient.delete(`/voice-clones/${id}`); await apiClient.delete(`/voice-clones/${id}`)
}; }
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */ /** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
export const updateVoiceClone = async ( export const updateVoiceClone = async (
@@ -202,32 +187,22 @@ export const updateVoiceClone = async (
data: Partial<Pick<VoiceClone, "name">>, data: Partial<Pick<VoiceClone, "name">>,
): Promise<VoiceClone> => { ): Promise<VoiceClone> => {
// 后端暂未提供更新端点,暂用详情接口模拟 // 后端暂未提供更新端点,暂用详情接口模拟
const response = await apiClient.get<VoiceCloneProfile>( const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
`/voice-clones/${id}`,
);
return toVoiceClone({ return toVoiceClone({
...response.data, ...response.data,
...data, ...data,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}); })
}; }
/** 获取克隆状态 */ /** 获取克隆状态 */
export const getVoiceCloneStatus = async ( export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
id: string, const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
): Promise<VoiceCloneStatusResponse> => { return response.data
const response = await apiClient.get<VoiceCloneStatusResponse>( }
`/voice-clones/${id}/status`,
);
return response.data;
};
/** 重试克隆 */ /** 重试克隆 */
export const retryVoiceClone = async ( export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
id: string, const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
): Promise<VoiceCloneProfile> => { return response.data
const response = await apiClient.post<VoiceCloneProfile>( }
`/voice-clones/${id}/retry`,
);
return response.data;
};
+81 -87
View File
@@ -4,148 +4,142 @@
* *
* 任务 3.11:新增统一音色 API(对接后端 3.04),保留旧接口向后兼容 * 任务 3.11:新增统一音色 API(对接后端 3.04),保留旧接口向后兼容
*/ */
import apiClient from "./client"; import apiClient from "./client"
/* ── 统一音色 API(后端 3.04) ─────────────────────────── */ /* ── 统一音色 API(后端 3.04) ─────────────────────────── */
/** 统一音色条目(preset + clone 混合) */ /** 统一音色条目(preset + clone 混合) */
export interface UnifiedVoiceItem { export interface UnifiedVoiceItem {
id: string; id: string
type: "preset" | "clone"; type: "preset" | "clone"
name: string; name: string
description: string; description: string
gender: string; gender: string
language: string; language: string
voice_id: string; voice_id: string
voice_provider: string; voice_provider: string
audio_url: string | null; audio_url: string | null
preview_url: string | null; preview_url: string | null
duration: number | null; duration: number | null
file_size: number | null; file_size: number | null
status: string; status: string
tags: string[]; tags: string[]
user_id: string | null; user_id: string | null
project_id: string | null; project_id: string | null
voice_clone_profile_id: string | null; voice_clone_profile_id: string | null
created_at: string | null; created_at: string | null
updated_at: string | null; updated_at: string | null
} }
/** 统一音色列表响应 */ /** 统一音色列表响应 */
export interface UnifiedVoiceListResponse { export interface UnifiedVoiceListResponse {
items: UnifiedVoiceItem[]; items: UnifiedVoiceItem[]
total: number; total: number
preset_count: number; preset_count: number
clone_count: number; clone_count: number
} }
/** 预设音色条目 */ /** 预设音色条目 */
export interface PresetVoiceItem { export interface PresetVoiceItem {
voice_id: string; voice_id: string
name: string; name: string
description: string; description: string
gender: string; gender: string
language: string; language: string
preview_url: string | null; preview_url: string | null
tags: string[]; tags: string[]
} }
/** 预设音色列表响应 */ /** 预设音色列表响应 */
export interface PresetVoiceListResponse { export interface PresetVoiceListResponse {
items: PresetVoiceItem[]; items: PresetVoiceItem[]
total: number; total: number
} }
/** 统一列表查询参数 */ /** 统一列表查询参数 */
export interface UnifiedVoiceListParams { export interface UnifiedVoiceListParams {
type?: "preset" | "clone"; type?: "preset" | "clone"
status?: string; status?: string
skip?: number; skip?: number
limit?: number; limit?: number
} }
/** 获取统一音色列表(推荐) */ /** 获取统一音色列表(推荐) */
export const fetchVoices = async ( export const fetchVoices = async (
params?: UnifiedVoiceListParams, params?: UnifiedVoiceListParams,
): Promise<UnifiedVoiceListResponse> => { ): Promise<UnifiedVoiceListResponse> => {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams()
if (params?.type) searchParams.set("type", params.type); if (params?.type) searchParams.set("type", params.type)
if (params?.status) searchParams.set("status", params.status); if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
searchParams.set("limit", String(params.limit)); const qs = searchParams.toString()
const qs = searchParams.toString(); const response = await apiClient.get<UnifiedVoiceListResponse>(`/voices${qs ? `?${qs}` : ""}`)
const response = await apiClient.get<UnifiedVoiceListResponse>( return response.data
`/voices${qs ? `?${qs}` : ""}`, }
);
return response.data;
};
/** 获取预设音色列表(无需鉴权) */ /** 获取预设音色列表(无需鉴权) */
export const fetchPresetVoices = async (): Promise<PresetVoiceListResponse> => { export const fetchPresetVoices = async (): Promise<PresetVoiceListResponse> => {
const response = const response = await apiClient.get<PresetVoiceListResponse>("/voices/presets")
await apiClient.get<PresetVoiceListResponse>("/voices/presets"); return response.data
return response.data; }
};
/* ── 向后兼容(旧接口) ────────────────────────────────── */ /* ── 向后兼容(旧接口) ────────────────────────────────── */
/** 配音条目(旧) */ /** 配音条目(旧) */
export interface VoiceItem { export interface VoiceItem {
id: string; id: string
name: string; name: string
text: string; text: string
voice_type?: string; voice_type?: string
duration_seconds?: number; duration_seconds?: number
storage_key?: string; storage_key?: string
audio_url?: string; audio_url?: string
status?: string; status?: string
is_favorite?: boolean; is_favorite?: boolean
created_at?: string; created_at?: string
updated_at?: string; updated_at?: string
} }
/** 创建配音请求(旧) */ /** 创建配音请求(旧) */
export interface CreateVoiceRequest { export interface CreateVoiceRequest {
name: string; name: string
text: string; text: string
voice_type?: string; voice_type?: string
} }
/** 获取当前用户的所有配音(旧 → /voices/legacy) */ /** 获取当前用户的所有配音(旧 → /voices/legacy) */
export const getVoices = async (): Promise<VoiceItem[]> => { export const getVoices = async (): Promise<VoiceItem[]> => {
const response = await apiClient.get("/voices/legacy"); const response = await apiClient.get("/voices/legacy")
return response.data.items || response.data || []; return response.data.items || response.data || []
}; }
/** 创建配音 */ /** 创建配音 */
export const createVoice = async ( export const createVoice = async (data: CreateVoiceRequest): Promise<VoiceItem> => {
data: CreateVoiceRequest, const response = await apiClient.post("/voices", data)
): Promise<VoiceItem> => { return response.data
const response = await apiClient.post("/voices", data); }
return response.data;
};
/** 更新配音 */ /** 更新配音 */
export const updateVoice = async ( export const updateVoice = async (
voiceId: string, voiceId: string,
data: Partial<CreateVoiceRequest>, data: Partial<CreateVoiceRequest>,
): Promise<VoiceItem> => { ): Promise<VoiceItem> => {
const response = await apiClient.put(`/voices/${voiceId}`, data); const response = await apiClient.put(`/voices/${voiceId}`, data)
return response.data; return response.data
}; }
/** 删除配音 */ /** 删除配音 */
export const deleteVoice = async (voiceId: string): Promise<void> => { export const deleteVoice = async (voiceId: string): Promise<void> => {
await apiClient.delete(`/voices/${voiceId}`); await apiClient.delete(`/voices/${voiceId}`)
}; }
/** AI 生成配音 */ /** AI 生成配音 */
export const generateAIVoice = async (data: { export const generateAIVoice = async (data: {
text: string; text: string
voice_type?: string; voice_type?: string
speed?: number; speed?: number
}): Promise<VoiceItem> => { }): Promise<VoiceItem> => {
const response = await apiClient.post("/voices/generate", data); const response = await apiClient.post("/voices/generate", data)
return response.data; return response.data
}; }
@@ -9,72 +9,62 @@
* - 筛选增强:类型筛选 + 质量分筛选 * - 筛选增强:类型筛选 + 质量分筛选
* - 视图切换:网格视图 / 列表视图 * - 视图切换:网格视图 / 列表视图
*/ */
import React, { import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
useState, import "./AssetSelector.css"
useMemo, import { Input, Select, Button } from "@/components/ui"
useCallback, import type { MediaAsset } from "@/api/editPlans"
useRef, import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/editPlans"
useEffect,
} from "react";
import "./AssetSelector.css";
import { Input, Select, Button } from "@/components/ui";
import type { MediaAsset } from "@/api/editPlans";
import {
MATERIAL_TYPE_LABELS,
MATERIAL_TYPE_ICONS,
QUALITY_OPTIONS,
} from "@/api/editPlans";
/* ──────────── 类型 ──────────── */ /* ──────────── 类型 ──────────── */
export interface AssetSelectorProps { export interface AssetSelectorProps {
assets: MediaAsset[]; assets: MediaAsset[]
selectedIds?: string[]; selectedIds?: string[]
onSelectionChange?: (ids: string[]) => void; onSelectionChange?: (ids: string[]) => void
onAssetDragStart?: (asset: MediaAsset) => void; onAssetDragStart?: (asset: MediaAsset) => void
onReorder?: (fromIdx: number, toIdx: number) => void; onReorder?: (fromIdx: number, toIdx: number) => void
showQualityFilter?: boolean; showQualityFilter?: boolean
showBatchSelect?: boolean; showBatchSelect?: boolean
compact?: boolean; compact?: boolean
} }
type ViewMode = "grid" | "list"; type ViewMode = "grid" | "list"
/* ──────────── 工具函数 ──────────── */ /* ──────────── 工具函数 ──────────── */
/** 格式化文件大小 */ /** 格式化文件大小 */
const formatSize = (bytes?: number): string => { const formatSize = (bytes?: number): string => {
if (!bytes) return ""; if (!bytes) return ""
if (bytes < 1024) return `${bytes}B`; if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}; }
/** 格式化时长 */ /** 格式化时长 */
const formatDuration = (seconds?: number): string => { const formatDuration = (seconds?: number): string => {
if (!seconds) return ""; if (!seconds) return ""
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60); const s = Math.floor(seconds % 60)
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`; return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
}; }
/** 获取质量分等级 */ /** 获取质量分等级 */
const getQualityLevel = (score?: number): string => { const getQualityLevel = (score?: number): string => {
if (score == null) return "none"; if (score == null) return "none"
if (score >= 90) return "excellent"; if (score >= 90) return "excellent"
if (score >= 70) return "good"; if (score >= 70) return "good"
if (score >= 50) return "fair"; if (score >= 50) return "fair"
return "poor"; return "poor"
}; }
/** 质量分颜色 */ /** 质量分颜色 */
const getQualityColor = (score?: number): string => { const getQualityColor = (score?: number): string => {
if (score == null) return "var(--text-secondary)"; if (score == null) return "var(--text-secondary)"
if (score >= 90) return "var(--success-color, #10b981)"; if (score >= 90) return "var(--success-color, #10b981)"
if (score >= 70) return "var(--primary-color, #6366f1)"; if (score >= 70) return "var(--primary-color, #6366f1)"
if (score >= 50) return "var(--warning-color, #f59e0b)"; if (score >= 50) return "var(--warning-color, #f59e0b)"
return "var(--error-color, #ef4444)"; return "var(--error-color, #ef4444)"
}; }
/* ──────────── 类型筛选选项 ──────────── */ /* ──────────── 类型筛选选项 ──────────── */
@@ -83,7 +73,7 @@ const TYPE_OPTIONS = [
{ value: "video", label: "🎬 视频" }, { value: "video", label: "🎬 视频" },
{ value: "image", label: "🖼️ 图片" }, { value: "image", label: "🖼️ 图片" },
{ value: "audio", label: "🎵 音频" }, { value: "audio", label: "🎵 音频" },
]; ]
/* ──────────── 组件 ──────────── */ /* ──────────── 组件 ──────────── */
@@ -98,189 +88,171 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
compact = false, compact = false,
}) => { }) => {
/* ── 搜索 & 筛选 ── */ /* ── 搜索 & 筛选 ── */
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState(""); const [filterType, setFilterType] = useState("")
const [filterQuality, setFilterQuality] = useState(""); const [filterQuality, setFilterQuality] = useState("")
const [viewMode, setViewMode] = useState<ViewMode>("grid"); const [viewMode, setViewMode] = useState<ViewMode>("grid")
/* ── 拖拽状态 ── */ /* ── 拖拽状态 ── */
const [dragIdx, setDragIdx] = useState<number | null>(null); const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null); const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
/* ── 悬浮预览 ── */ /* ── 悬浮预览 ── */
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null); const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 }); const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
/* ── Shift 连选 ── */ /* ── Shift 连选 ── */
const lastClickedIdx = useRef<number | null>(null); const lastClickedIdx = useRef<number | null>(null)
/* ── 过滤后的素材列表 ── */ /* ── 过滤后的素材列表 ── */
const filteredAssets = useMemo(() => { const filteredAssets = useMemo(() => {
let list = assets; let list = assets
if (searchText) { if (searchText) {
const q = searchText.toLowerCase(); const q = searchText.toLowerCase()
list = list.filter( list = list.filter(
(a) => (a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
a.name.toLowerCase().includes(q) || )
a.tags.some((t) => t.toLowerCase().includes(q)),
);
} }
if (filterType) { if (filterType) {
list = list.filter((a) => a.type === filterType); list = list.filter((a) => a.type === filterType)
} }
if (filterQuality) { if (filterQuality) {
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality); const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
if (opt?.min != null && opt?.max != null) { if (opt?.min != null && opt?.max != null) {
list = list.filter( list = list.filter(
(a) => (a) =>
a.quality_score != null && a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
a.quality_score >= opt.min! && )
a.quality_score <= opt.max!,
);
} }
} }
return list; return list
}, [assets, searchText, filterType, filterQuality]); }, [assets, searchText, filterType, filterQuality])
/* ── 选中状态 ── */ /* ── 选中状态 ── */
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
/* ── 选择操作 ── */ /* ── 选择操作 ── */
const toggleSelect = useCallback( const toggleSelect = useCallback(
(asset: MediaAsset, idx: number, shiftKey: boolean) => { (asset: MediaAsset, idx: number, shiftKey: boolean) => {
if (!onSelectionChange) return; if (!onSelectionChange) return
if (shiftKey && lastClickedIdx.current !== null) { if (shiftKey && lastClickedIdx.current !== null) {
// Shift 连选 // Shift 连选
const start = Math.min(lastClickedIdx.current, idx); const start = Math.min(lastClickedIdx.current, idx)
const end = Math.max(lastClickedIdx.current, idx); const end = Math.max(lastClickedIdx.current, idx)
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id); const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
const newSet = new Set(selectedIds); const newSet = new Set(selectedIds)
rangeIds.forEach((id) => newSet.add(id)); rangeIds.forEach((id) => newSet.add(id))
onSelectionChange(Array.from(newSet)); onSelectionChange(Array.from(newSet))
} else { } else {
const newSet = new Set(selectedIds); const newSet = new Set(selectedIds)
if (newSet.has(asset.id)) { if (newSet.has(asset.id)) {
newSet.delete(asset.id); newSet.delete(asset.id)
} else { } else {
newSet.add(asset.id); newSet.add(asset.id)
} }
onSelectionChange(Array.from(newSet)); onSelectionChange(Array.from(newSet))
} }
lastClickedIdx.current = idx; lastClickedIdx.current = idx
}, },
[onSelectionChange, selectedIds, filteredAssets], [onSelectionChange, selectedIds, filteredAssets],
); )
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
onSelectionChange?.([]); onSelectionChange?.([])
}, [onSelectionChange]); }, [onSelectionChange])
/* ── 拖拽排序 ── */ /* ── 拖拽排序 ── */
const handleDragStart = useCallback( const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => { (e: React.DragEvent, idx: number) => {
setDragIdx(idx); setDragIdx(idx)
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", String(idx)); e.dataTransfer.setData("text/plain", String(idx))
// 设置素材数据,供 TimelinePanel 接收(P1-2 修复) // 设置素材数据,供 TimelinePanel 接收(P1-2 修复)
e.dataTransfer.setData( e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
"application/x-media-asset",
JSON.stringify(filteredAssets[idx]),
);
// 批量拖拽:如果有多个选中素材,一起携带 // 批量拖拽:如果有多个选中素材,一起携带
if ( if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
selectedIds.length > 1 && const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
selectedIds.includes(filteredAssets[idx].id) e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
) {
const batchAssets = filteredAssets.filter((a) =>
selectedIds.includes(a.id),
);
e.dataTransfer.setData(
"application/x-media-assets",
JSON.stringify(batchAssets),
);
} }
// 通知父组件素材拖拽开始 // 通知父组件素材拖拽开始
if (onAssetDragStart) { if (onAssetDragStart) {
onAssetDragStart(filteredAssets[idx]); onAssetDragStart(filteredAssets[idx])
} }
}, },
[filteredAssets, selectedIds, onAssetDragStart], [filteredAssets, selectedIds, onAssetDragStart],
); )
const handleDragOver = useCallback( const handleDragOver = useCallback(
(e: React.DragEvent, idx: number) => { (e: React.DragEvent, idx: number) => {
e.preventDefault(); e.preventDefault()
e.dataTransfer.dropEffect = "move"; e.dataTransfer.dropEffect = "move"
if (dragIdx === null || dragIdx === idx) return; if (dragIdx === null || dragIdx === idx) return
setDragOverIdx(idx); setDragOverIdx(idx)
}, },
[dragIdx], [dragIdx],
); )
const handleDrop = useCallback( const handleDrop = useCallback(
(e: React.DragEvent, toIdx: number) => { (e: React.DragEvent, toIdx: number) => {
e.preventDefault(); e.preventDefault()
if (dragIdx !== null && dragIdx !== toIdx && onReorder) { if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
onReorder(dragIdx, toIdx); onReorder(dragIdx, toIdx)
} }
setDragIdx(null); setDragIdx(null)
setDragOverIdx(null); setDragOverIdx(null)
}, },
[dragIdx, onReorder], [dragIdx, onReorder],
); )
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
setDragIdx(null); setDragIdx(null)
setDragOverIdx(null); setDragOverIdx(null)
}, []); }, [])
/* ── 悬浮预览 ── */ /* ── 悬浮预览 ── */
const handleMouseEnter = useCallback( const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
(asset: MediaAsset, e: React.MouseEvent) => { if (previewTimer.current) clearTimeout(previewTimer.current)
if (previewTimer.current) clearTimeout(previewTimer.current); previewTimer.current = setTimeout(() => {
previewTimer.current = setTimeout(() => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setPreviewAsset(asset)
setPreviewAsset(asset); setPreviewPos({
setPreviewPos({ x: rect.right + 12,
x: rect.right + 12, y: Math.max(8, rect.top - 20),
y: Math.max(8, rect.top - 20), })
}); }, 400)
}, 400); }, [])
},
[],
);
const handleMouseLeave = useCallback(() => { const handleMouseLeave = useCallback(() => {
if (previewTimer.current) { if (previewTimer.current) {
clearTimeout(previewTimer.current); clearTimeout(previewTimer.current)
previewTimer.current = null; previewTimer.current = null
} }
setPreviewAsset(null); setPreviewAsset(null)
}, []); }, [])
// 清理定时器 // 清理定时器
useEffect(() => { useEffect(() => {
return () => { return () => {
if (previewTimer.current) clearTimeout(previewTimer.current); if (previewTimer.current) clearTimeout(previewTimer.current)
}; }
}, []); }, [])
/* ── 点击卡片 ── */ /* ── 点击卡片 ── */
const handleCardClick = useCallback( const handleCardClick = useCallback(
(asset: MediaAsset, idx: number, e: React.MouseEvent) => { (asset: MediaAsset, idx: number, e: React.MouseEvent) => {
// 如果点击的是 checkbox 区域,不触发卡片点击 // 如果点击的是 checkbox 区域,不触发卡片点击
const target = e.target as HTMLElement; const target = e.target as HTMLElement
if (target.closest("[data-checkbox]")) return; if (target.closest("[data-checkbox]")) return
toggleSelect(asset, idx, e.shiftKey); toggleSelect(asset, idx, e.shiftKey)
}, },
[toggleSelect], [toggleSelect],
); )
/* ──────────── 渲染 ──────────── */ /* ──────────── 渲染 ──────────── */
const hasSelection = selectedIds.length > 0; const hasSelection = selectedIds.length > 0
return ( return (
<div className="as-container"> <div className="as-container">
@@ -329,9 +301,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
{/* ═══ 批量操作栏 ═══ */} {/* ═══ 批量操作栏 ═══ */}
{showBatchSelect && hasSelection && ( {showBatchSelect && hasSelection && (
<div className="as-batch-bar"> <div className="as-batch-bar">
<span className="as-batch-bar-count"> <span className="as-batch-bar-count">已选 {selectedIds.length} 项</span>
已选 {selectedIds.length} 项
</span>
<div className="as-batch-bar-actions"> <div className="as-batch-bar-actions">
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}> <Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
取消选择 取消选择
@@ -351,10 +321,10 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
/* ── 网格视图 ── */ /* ── 网格视图 ── */
<div className={`as-grid${compact ? " compact" : ""}`}> <div className={`as-grid${compact ? " compact" : ""}`}>
{filteredAssets.map((asset, idx) => { {filteredAssets.map((asset, idx) => {
const isSelected = selectedSet.has(asset.id); const isSelected = selectedSet.has(asset.id)
const isDragging = dragIdx === idx; const isDragging = dragIdx === idx
const isDragOver = dragOverIdx === idx; const isDragOver = dragOverIdx === idx
const qualityLevel = getQualityLevel(asset.quality_score); const qualityLevel = getQualityLevel(asset.quality_score)
return ( return (
<div <div
@@ -380,15 +350,9 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
{/* 缩略图 */} {/* 缩略图 */}
<div className="as-card-thumb"> <div className="as-card-thumb">
{asset.thumbnail_url ? ( {asset.thumbnail_url ? (
<img <img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
src={asset.thumbnail_url}
alt={asset.name}
loading="lazy"
/>
) : ( ) : (
<span className="as-card-thumb-icon"> <span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
{MATERIAL_TYPE_ICONS[asset.type]}
</span>
)} )}
{/* Checkbox */} {/* Checkbox */}
@@ -397,22 +361,18 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
data-checkbox data-checkbox
className={`as-card-checkbox${isSelected ? " checked" : ""}`} className={`as-card-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
toggleSelect(asset, idx, e.shiftKey); toggleSelect(asset, idx, e.shiftKey)
}} }}
/> />
)} )}
{/* 类型角标 */} {/* 类型角标 */}
<span className="as-card-type-badge"> <span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
{MATERIAL_TYPE_LABELS[asset.type]}
</span>
{/* 时长角标 */} {/* 时长角标 */}
{asset.duration != null && ( {asset.duration != null && (
<span className="as-card-duration"> <span className="as-card-duration">{formatDuration(asset.duration)}</span>
{formatDuration(asset.duration)}
</span>
)} )}
{/* 质量分角标 */} {/* 质量分角标 */}
@@ -434,16 +394,16 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
<div className="as-card-meta">{formatSize(asset.size)}</div> <div className="as-card-meta">{formatSize(asset.size)}</div>
</div> </div>
</div> </div>
); )
})} })}
</div> </div>
) : ( ) : (
/* ── 列表视图 ── */ /* ── 列表视图 ── */
<div className="as-list"> <div className="as-list">
{filteredAssets.map((asset, idx) => { {filteredAssets.map((asset, idx) => {
const isSelected = selectedSet.has(asset.id); const isSelected = selectedSet.has(asset.id)
const isDragging = dragIdx === idx; const isDragging = dragIdx === idx
const isDragOver = dragOverIdx === idx; const isDragOver = dragOverIdx === idx
return ( return (
<div <div
@@ -476,24 +436,21 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
data-checkbox data-checkbox
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`} className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
toggleSelect(asset, idx, e.shiftKey); toggleSelect(asset, idx, e.shiftKey)
}} }}
/> />
)} )}
{/* 图标 */} {/* 图标 */}
<span className="as-list-item-icon"> <span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
{MATERIAL_TYPE_ICONS[asset.type]}
</span>
{/* 信息 */} {/* 信息 */}
<div className="as-list-item-info"> <div className="as-list-item-info">
<div className="as-list-item-name">{asset.name}</div> <div className="as-list-item-name">{asset.name}</div>
<div className="as-list-item-meta"> <div className="as-list-item-meta">
{MATERIAL_TYPE_LABELS[asset.type]} {MATERIAL_TYPE_LABELS[asset.type]}
{asset.duration != null && {asset.duration != null && ` · ${formatDuration(asset.duration)}`}
` · ${formatDuration(asset.duration)}`}
{asset.size != null && ` · ${formatSize(asset.size)}`} {asset.size != null && ` · ${formatSize(asset.size)}`}
</div> </div>
</div> </div>
@@ -508,7 +465,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
</span> </span>
)} )}
</div> </div>
); )
})} })}
</div> </div>
)} )}
@@ -516,10 +473,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
{/* ═══ 悬浮预览 ═══ */} {/* ═══ 悬浮预览 ═══ */}
{previewAsset && ( {previewAsset && (
<div <div className="as-preview-overlay" style={{ left: previewPos.x, top: previewPos.y }}>
className="as-preview-overlay"
style={{ left: previewPos.x, top: previewPos.y }}
>
<div className="as-preview-overlay-thumb"> <div className="as-preview-overlay-thumb">
{previewAsset.thumbnail_url ? ( {previewAsset.thumbnail_url ? (
<img src={previewAsset.thumbnail_url} alt={previewAsset.name} /> <img src={previewAsset.thumbnail_url} alt={previewAsset.name} />
@@ -535,20 +489,16 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
{previewAsset.duration != null && ( {previewAsset.duration != null && (
<span>时长: {formatDuration(previewAsset.duration)}</span> <span>时长: {formatDuration(previewAsset.duration)}</span>
)} )}
{previewAsset.size != null && ( {previewAsset.size != null && <span>大小: {formatSize(previewAsset.size)}</span>}
<span>大小: {formatSize(previewAsset.size)}</span>
)}
{previewAsset.quality_score != null && ( {previewAsset.quality_score != null && (
<span>质量分: {previewAsset.quality_score}</span> <span>质量分: {previewAsset.quality_score}</span>
)} )}
{previewAsset.tags.length > 0 && ( {previewAsset.tags.length > 0 && <span>标签: {previewAsset.tags.join(", ")}</span>}
<span>标签: {previewAsset.tags.join(", ")}</span>
)}
</div> </div>
</div> </div>
)} )}
</div> </div>
); )
}; }
export default AssetSelector; export default AssetSelector
@@ -1,2 +1,2 @@
export { default as AssetSelector } from "./AssetSelector"; export { default as AssetSelector } from "./AssetSelector"
export type { AssetSelectorProps } from "./AssetSelector"; export type { AssetSelectorProps } from "./AssetSelector"
+7 -7
View File
@@ -4,12 +4,12 @@
* 语义化 HTML 结构:header + aside + main 三栏布局 * 语义化 HTML 结构:header + aside + main 三栏布局
* 由 MainLayout 管理侧边栏状态,AppLayout 负责渲染结构 * 由 MainLayout 管理侧边栏状态,AppLayout 负责渲染结构
*/ */
import React from "react"; import React from "react"
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom"
import Header from "./Header"; import Header from "./Header"
interface AppLayoutProps { interface AppLayoutProps {
sidebar: React.ReactNode; sidebar: React.ReactNode
} }
const AppLayout: React.FC<AppLayoutProps> = ({ sidebar }) => { const AppLayout: React.FC<AppLayoutProps> = ({ sidebar }) => {
@@ -23,7 +23,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ sidebar }) => {
</main> </main>
</div> </div>
</div> </div>
); )
}; }
export default AppLayout; export default AppLayout

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