Compare commits

...

185 Commits

Author SHA1 Message Date
XiaoXia Bot 71d1a8e78d test: trigger CI to validate runner log fix
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 Staging API Image (pull_request) 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 Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m50s
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
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
2026-07-16 19:57:28 +08:00
XiaoXia Bot a3df4fb324 ci: use refspec to create origin/develop remote branch after fetch
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 API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production 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) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
2026-07-16 18:43:56 +08:00
XiaoXia Bot e8e4928062 ci: fix migration safety check for tar.gz checkout environment
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 Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker 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 / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
2026-07-16 18:25:28 +08:00
XiaoXia Bot cf6b625ffb ci: init git repo before migration safety check for diff support
CI Build & Deploy Pipeline / Build Staging Web Image (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
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) 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) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
2026-07-16 18:03:35 +08:00
XiaoXia Bot 188fec52c6 ci: revert detect-secrets args, use simple redirect
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 API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web 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) 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) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
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 Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
2026-07-16 17:49:42 +08:00
XiaoXia Bot 2a6c7d4086 ci: silence detect-secrets stdout to preserve log output
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) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (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) Has been skipped
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 / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 11m44s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-16 17:43:24 +08:00
XiaoXia Bot 715e663f8b ci: reduce detect-secrets stdout output to avoid log truncation
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 Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API 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) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
2026-07-16 16:45:57 +08:00
XiaoXia Bot ee6034b5fd fix(ci): 修复YAML语法,alembic步骤改用literal block scalar
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) 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 / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
2026-07-16 16:11:35 +08:00
XiaoXia Bot 6f0bb8c25e chore: trigger CI re-run
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 Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) 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) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) 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) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-16 16:08:38 +08:00
XiaoXia Bot f4b7bec0cb fix(ci): PG启动+alembic合并为单步,修复GITHUB_ENV传参失效
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API 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) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) 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) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-16 16:07:21 +08:00
XiaoXia Bot 31aa3fbb86 fix(ci): 修复develop分支遗留的isort import排序问题
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production 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) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
修复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 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) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API 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) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
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 Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m45s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 9m2s
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 / Integration Tests (push) Successful in 3m31s
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 / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 4m4s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 5m49s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m31s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 54s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Successful in 20m57s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 23m24s
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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 6m58s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 2m31s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 8m24s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (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
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 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 API Image (push) Has been skipped
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 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 / 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
2026-07-16 09:01:07 +08:00
XiaoXia Bot 0386b9b34e fix(ci): migration safety检查稳定性加固
CI Build & Deploy Pipeline / Build Staging Web Image (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
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) 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 / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m7s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m8s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
- 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 Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m47s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1m26s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m9s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m43s
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) Failing after 15m35s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
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 / Build Staging API Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Staging Web Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Waiting to run
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) Waiting to run
CI Build & Deploy Pipeline / Build Production Web Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Production Worker Image (push) Waiting to run
CI Build & Deploy Pipeline / Deploy Production (push) Blocked by required conditions
CI Build & Deploy Pipeline / Production Browser E2E (push) Blocked by required conditions
2026-07-16 08:03:57 +08:00
CI Bot e9d2831850 feat(ci): mypy upgrade to hard gate + fix type errors
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 Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker 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) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m30s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
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 Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 4m35s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 6m9s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m55s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Successful in 1m12s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m54s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 19m36s
CI/CD Pipeline / Integration Tests (push) Successful in 1m19s
2026-07-16 00:35:51 +08:00
CI Bot 53e570a903 fix(ci): ruff排除hostexecutor目录 - 避免host executor模式下扫到runner脚本目录
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 Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) 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) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m30s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m30s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
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 Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m2s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 5m9s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 5m1s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 6m44s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Successful in 2m10s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 4m37s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
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 / Build Staging API Image (push) Successful in 6m16s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 4m43s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m29s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m4s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m35s
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 / Build Staging API Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Staging Web Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Waiting to run
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) Waiting to run
CI Build & Deploy Pipeline / Build Production Web Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Production Worker Image (push) Waiting to run
CI Build & Deploy Pipeline / Deploy Production (push) Blocked by required conditions
CI Build & Deploy Pipeline / Production Browser E2E (push) Blocked by required conditions
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 Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 6m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 3m6s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m10s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 4m30s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 4m37s
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 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 Staging API 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) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API 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) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
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 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 / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 6m21s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 4m58s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 8m5s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 3m22s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (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 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 API 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) 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) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
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 Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
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 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 API 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 / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
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 Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m4s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m30s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
- 移除 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 / 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
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 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 API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
将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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 30m1s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 4m47s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 38m51s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (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
2026-07-15 20:16:21 +08:00
XiaoXia Bot e5a96db948 fix(ci): 修复ci-build.yml checkout步骤Python语法错误
CI Build & Deploy Pipeline / Build Staging Web Image (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
CI Build & Deploy Pipeline / Build Production API 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) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker 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) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
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 Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker 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) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
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
2026-07-15 20:02:10 +08:00
XiaoXia Bot 6978ec66ed fix(ci): 修复ci-build.yml的YAML转义错误(buildx引号+deploy单引号)
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 API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production 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) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
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
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 / Frontend Lint (push) Successful in 40s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m37s
CI/CD Pipeline / Unit Tests (push) Successful in 1m45s
CI/CD Pipeline / Integration Tests (push) Successful in 1m16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23m57s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 51s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-15 18:58:07 +08:00
CI Bot 81779c4e1f feat(edit-plans): 列表页新增 result_count 视频数列
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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
- 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 Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m27s
CI/CD Pipeline / Unit Tests (push) Successful in 1m36s
CI/CD Pipeline / Integration Tests (push) Successful in 1m13s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m30s
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
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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
- 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 / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m30s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1s
CI/CD Pipeline / Unit Tests (push) Successful in 1m36s
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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m15s
- 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 / Frontend Lint (push) Successful in 40s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m37s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1s
CI/CD Pipeline / Unit Tests (push) Successful in 1m43s
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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been cancelled
2026-07-15 18:21:05 +08:00
XiaoXia Bot e1b6ccaf0a fix(ci): 修复check_migration_safety.py的import排序(isort I001)
CI/CD Pipeline / Frontend Lint (push) Successful in 35s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 1m35s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m8s
CI/CD Pipeline / Integration Tests (push) Successful in 1m15s
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
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 / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 47s
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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 1m46s
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m55s
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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m22s
CI/CD Pipeline / Integration Tests (push) Successful in 1m34s
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 / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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
- 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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m47s
CI/CD Pipeline / Unit Tests (push) Successful in 1m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m10s
CI/CD Pipeline / Frontend Lint (push) Successful in 44s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-15 16:16:54 +08:00
CI Bot 0382c4e697 fix(ci): migration diff step失败不阻断Validate — 加continue-on-error + 2分钟超时
CI/CD Pipeline / Unit Tests (push) Successful in 1m53s
CI/CD Pipeline / Frontend Lint (push) Successful in 42s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m24s
CI/CD Pipeline / Integration Tests (push) Successful in 1m36s
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
- 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 / Frontend Lint (push) Successful in 39s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m3s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m26s
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
- 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 / Frontend Lint (push) Successful in 34s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m33s
CI/CD Pipeline / Unit Tests (push) Successful in 1m37s
CI/CD Pipeline / Integration Tests (push) Successful in 1m10s
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
- 根因: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 / Frontend Lint (push) Successful in 37s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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
- \\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 / Frontend Lint (push) Successful in 35s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m9s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m15s
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 / Integration Tests (push) Successful in 1m27s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
- 仅修改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 / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 2s
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 / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Failing after 0s
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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 / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 1m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m4s
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 / Integration Tests (push) Successful in 1m10s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
全局搜索发现 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 / Frontend Lint (push) Successful in 34s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 1m35s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m57s
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 / Integration Tests (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
- 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 / Frontend Lint (push) Successful in 43s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m15s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
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 / Frontend Lint (push) Successful in 26s
CI/CD Pipeline / Unit Tests (push) Successful in 1m41s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m43s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m16s
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 / Frontend Lint (pull_request) Successful in 56s
CI/CD Pipeline / Frontend Lint (push) Successful in 59s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m10s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m36s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 3m34s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m13s
修复 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 / Unit Tests (push) Successful in 1m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m54s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m33s
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 / 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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m55s
CI/CD Pipeline / Unit Tests (push) Successful in 2m3s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m12s
- 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 / Frontend Lint (pull_request) Successful in 1m6s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m9s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m15s
CI/CD Pipeline / Unit Tests (push) Successful in 2m19s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m22s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m20s
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 / Frontend Lint (push) Successful in 38s
CI/CD Pipeline / Unit Tests (push) Successful in 1m37s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 2m9s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Deploy Production (push) Failing after 14m38s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
- 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 / Frontend Lint (push) Successful in 37s
CI/CD Pipeline / Unit Tests (push) Successful in 1m40s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m44s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m28s
- 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 / Frontend Lint (push) Successful in 38s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m35s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
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 / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 1m41s
CI/CD Pipeline / Integration Tests (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
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 / Frontend Lint (push) Successful in 28s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m38s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
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 / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 1m38s
CI/CD Pipeline / Integration Tests (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
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 / Frontend Lint (push) Successful in 34s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 32s
CI/CD Pipeline / Unit Tests (push) Successful in 1m58s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m59s
CI/CD Pipeline / Integration Tests (push) Successful in 1m35s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m35s
2026-07-15 13:14:27 +08:00
CI Bot 04d48d624a fix: 移除render_metrics依赖(该模块在develop上不存在),保持PR独立可合并
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m6s
CI/CD Pipeline / Frontend Lint (push) Successful in 46s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m10s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 48s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m18s
CI/CD Pipeline / Integration Tests (push) Successful in 1m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m25s
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 / Frontend Lint (push) Successful in 46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m11s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m17s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m35s
- 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 / Frontend Lint (push) Successful in 1m17s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m4s
CI/CD Pipeline / Unit Tests (push) Successful in 3m4s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m45s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m27s
CI/CD Pipeline / Integration Tests (push) Successful in 1m43s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m27s
2026-07-15 12:40:00 +08:00
xiaoxia 2977e27ede fix(ci): 修复增量扫描误检 - 改用 Gitea API 获取 PR 改动文件
CI/CD Pipeline / Frontend Lint (push) Successful in 59s
CI/CD Pipeline / Unit Tests (push) Successful in 3m3s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m49s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m21s
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
2026-07-15 12:39:33 +08:00
XiaoXia Bot 99fa146a5c fix(ci): 修复registry不稳定导致构建失败 - 构建与缓存推送解耦
CI/CD Pipeline / Frontend Lint (push) Successful in 1m32s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m31s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m44s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m49s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m52s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m54s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m29s
CI/CD Pipeline / Integration Tests (push) Successful in 1m25s
- 新增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 / Frontend Lint (push) Successful in 44s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m33s
CI/CD Pipeline / Unit Tests (push) Successful in 2m33s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 14m36s
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
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 / Frontend Lint (push) Successful in 1m7s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m49s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m51s
CI/CD Pipeline / Integration Tests (push) Successful in 1m50s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m59s
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
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 / Frontend Lint (push) Successful in 58s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 43s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m6s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 3m14s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m56s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m50s
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 / Frontend Lint (push) Successful in 49s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 47s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 59s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 3m8s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m10s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m56s
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 / 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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m58s
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
2026-07-15 11:21:31 +08:00
xiaoxia 7c8748ece6 perf(ci): 扩展CI触发分支模式 + 增加手动触发入口 (#349)
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: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 / Frontend Lint (push) Successful in 48s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
- 全硬切(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 / Frontend Lint (push) Successful in 1m35s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m29s
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
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 / 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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m33s
CI/CD Pipeline / Unit Tests (push) Successful in 3m6s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m8s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 3m14s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m10s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m58s
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 / 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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m22s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m27s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m34s
CI/CD Pipeline / Integration Tests (push) Successful in 1m34s
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
2026-07-15 11:09:07 +08:00
xiaoxia 68fe8aabd5 perf(ci): P1级Web构建缓存优化 - tsc增量 + Vite构建缓存 (#348)
CI/CD Pipeline / Frontend Lint (push) Successful in 1m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m41s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m56s
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m12s
CI/CD Pipeline / Integration Tests (push) Successful in 1m57s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 12m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 15m17s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m16s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m17s
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 / Frontend Lint (push) Successful in 2m0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m38s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m25s
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
2026-07-15 10:44:20 +08:00
xiaoxia 4397c670d8 perf(ci): 用 ruff 替换 flake8 - 规则完全对齐,速度提升10x+
CI/CD Pipeline / Frontend Lint (push) Successful in 39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m19s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 1m41s
CI/CD Pipeline / Integration Tests (push) Successful in 1m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m54s
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
2026-07-15 10:12:26 +08:00
xiaoxia 9c155d0561 fix(ci): 修复Frontend Lint缓存脏数据导致npm ci失败 (#345)
CI/CD Pipeline / Frontend Lint (push) Successful in 1m37s
CI/CD Pipeline / Unit Tests (push) Successful in 1m51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m10s
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m59s
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
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 / Unit Tests (pull_request) Successful in 1m54s
CI/CD Pipeline / Unit Tests (push) Successful in 1m57s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 2m19s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m25s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m10s
CI/CD Pipeline / Integration Tests (push) Successful in 1m9s
- 生成按钮调用 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 / Validate Code Quality And Tests (push) Successful in 1m48s
CI/CD Pipeline / Unit Tests (push) Successful in 1m50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m9s
CI/CD Pipeline / Frontend Lint (push) Failing after 16s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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 / Frontend Lint (push) Successful in 40s
CI/CD Pipeline / Unit Tests (push) Successful in 1m32s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m54s
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
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 / Frontend Lint (pull_request) Successful in 1m26s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m36s
CI/CD Pipeline / Unit Tests (push) Successful in 2m9s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m10s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m37s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m7s
CI/CD Pipeline / Integration Tests (push) Successful in 1m7s
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 / Unit Tests (push) Successful in 2m8s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m8s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m9s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m13s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m14s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m11s
- 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 / Unit Tests (push) Successful in 1m55s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m4s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m26s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m33s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m11s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m8s
# Conflicts:
#	apps/web/src/api/products.ts
#	apps/web/src/pages/products/ProductLibrary.tsx
2026-07-15 08:24:44 +08:00
xiaoxia ca8b945054 perf(ci): Worker镜像瘦身 - 多阶段构建+ffmpeg静态替换+Python依赖瘦身
CI/CD Pipeline / Frontend Lint (push) Successful in 1m4s
CI/CD Pipeline / Unit Tests (push) Successful in 1m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m38s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 44s
CI/CD Pipeline / Integration Tests (push) Successful in 1m13s
CI/CD Pipeline / Build Staging API Image (push) Successful in 10m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 9m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m0s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m52s
2026-07-15 07:49:15 +08:00
xiaoxia 9e9725015e Merge pull request 'fix(code-quality): 第一批代码质量修复 - 零风险项' (#336) from fix/code-quality-batch1 into develop
CI/CD Pipeline / Frontend Lint (push) Successful in 1m3s
CI/CD Pipeline / Unit Tests (push) Successful in 1m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m3s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m33s
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
2026-07-15 07:38:54 +08:00
xiaoxia 164246c0b0 Merge pull request 'fix(ci): 修复Staging部署脚本调用方式(cat而非bash)' (#339) from fix/staging-ssh-deploy-call into develop
CI/CD Pipeline / Frontend Lint (push) Successful in 1m17s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m46s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 1m54s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m11s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 10m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
2026-07-15 02:56:16 +08:00
xiaoxia d6549ebf40 fix(ci): 修复Staging部署脚本调用方式(cat而非bash)
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m51s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m10s
2026-07-15 02:52:33 +08:00
xiaoxia e2bc857e05 Merge pull request 'fix(ci): 修复Deploy Staging SSH密钥检测语法错误' (#338) from fix/staging-ssh-syntax into develop
CI/CD Pipeline / Frontend Lint (push) Successful in 1m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m54s
CI/CD Pipeline / Unit Tests (push) Successful in 1m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m9s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 5s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-15 02:20:51 +08:00
xiaoxia fb2785f037 fix(ci): 修复Deploy Staging SSH密钥检测语法错误
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m8s
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m13s
2026-07-15 02:17:32 +08:00
CI Bot 03c789c212 fix(import): 修复audio_merger的shared模块import路径
CI/CD Pipeline / Frontend Lint (push) Successful in 1m48s
CI/CD Pipeline / Unit Tests (push) Successful in 2m8s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m39s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 25m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 5s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-15 01:38:13 +08:00
xiaoxia 7d3328f462 Merge pull request 'feat(ci): Staging部署从Watchtower模式改为SSH push模式' (#337) from feat/staging-ssh-deploy into develop
CI/CD Pipeline / Unit Tests (push) Successful in 1m50s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m14s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m7s
CI/CD Pipeline / Build Staging API Image (push) Successful in 9m10s
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
2026-07-15 01:21:46 +08:00
xiaoxia c24f24258a Merge pull request 'fix(ci): 补PR - 修复CI Bot直接push的两个修复(isort + F811+单测魔数)' (#335) from fix/ci-bot-fix-pr 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/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 01:21:34 +08:00
XiaoXia Bot f2cea1831e feat(ci): Staging部署从Watchtower改为SSH push模式
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m41s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m44s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m52s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m21s
2026-07-15 00:52:18 +08:00
CI Bot 6cbd08f666 fix(code-quality): 第一批代码质量修复 - 零风险项
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m30s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m44s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m28s
- 修复 ruff 配置:移除已废弃规则 W503/E704(ruff 0.14+ 不兼容)
- 修复 F541 (13处):f-string 无占位符改为普通字符串
- 修复 B017 (1处):pytest.raises(Exception) 改为 ValidationError
- 修复 vulture 死代码:trim_engine 中 if False 的三元表达式
- 修复 mypy var-annotated:SUNSET_VERSIONS 加类型标注
2026-07-15 00:51:36 +08:00
CI Bot ede42dd3c4 fix(ci): 修复PR #332合并后CI失败 - F811重复import + url_security单测魔数校验
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m33s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m6s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m23s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m20s
- generation.py: 删除重复的shared.url_security import(保留video_processing向后兼容层)
- test_url_security.py: mock数据添加ID3魔数头,适配新增的文件魔数校验
2026-07-15 00:42:18 +08:00
CI Bot b797e88b26 fix(lint): 修复generation.py isort import排序问题 2026-07-15 00:42:18 +08:00
xiaoxia 62ab361940 feat(ci): deploy-production接入健康检查+自动回滚
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m56s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m27s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m54s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m57s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m2s
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m6s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m4s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 12m19s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
2026-07-15 00:31:08 +08:00
xiaoxia ed3ab34028 feat(ci): 升级部署通知脚本,卡片格式与CI通知对齐 2026-07-15 00:31:08 +08:00
xiaoxia df016b18fb feat(ci): 新增Production健康检查+自动回滚脚本 2026-07-15 00:31:08 +08:00
CI Bot c6aac862b1 fix(ci): 修复PR #332合并后CI失败 - F811重复import + url_security单测魔数校验
CI/CD Pipeline / Unit Tests (push) Successful in 1m52s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m17s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m22s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m50s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m55s
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
- generation.py: 删除重复的shared.url_security import(保留video_processing向后兼容层)
- test_url_security.py: mock数据添加ID3魔数头,适配新增的文件魔数校验
2026-07-15 00:20:36 +08:00
CI Bot 6232300fb1 fix(lint): 修复generation.py isort import排序问题
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47s
CI/CD Pipeline / Unit Tests (push) Failing after 1m41s
CI/CD Pipeline / Integration Tests (push) Successful in 1m20s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m55s
CI/CD Pipeline / Build Staging Worker Image (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 Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-15 00:14:18 +08:00
xiaoxia fe2ab121e7 fix(security): 后端安全技术债务第二轮(P1+P2+P2) (#332)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40s
CI/CD Pipeline / Unit Tests (push) Failing after 1m42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m20s
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
fix(security): 后端安全技术债务第二轮(P1+P2+P2)

- audio_merger裸subprocess下沉 + ffmpeg_utils架构下沉到packages/shared
- _verify_url_accessible重定向每跳SSRF校验
- url_security下载文件魔数校验
- 新增33个单测
2026-07-15 00:12:04 +08:00
xiaoxia 84db18a959 ci: re-trigger CI run after cache cleanup
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m50s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 3m14s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m16s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m24s
CI/CD Pipeline / Unit Tests (push) Successful in 1m47s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m31s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m57s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m52s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m6s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m57s
2026-07-14 22:50:30 +08:00
xiaoxia 9501afe9b3 feat(ci): deploy-staging接入健康检查+自动回滚 2026-07-14 22:50:30 +08:00
xiaoxia 7407ec2957 feat(ci): 新增部署通知脚本 2026-07-14 22:49:19 +08:00
xiaoxia e45426fe7d feat(ci): 新增Staging健康检查脚本 2026-07-14 22:49:19 +08:00
xiaoxia c540d88306 fix(ci): staging smoke test添加版本号校验,防止部署假绿 2026-07-14 22:11:37 +08:00
xiaoxia 5fb49480e5 Merge PR #333: 飞书通知系统完善
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m2s
CI/CD Pipeline / Unit Tests (push) Successful in 2m5s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m31s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m26s
Merge PR #333: feat(ci) 飞书通知系统完善 - 结构化卡片+Runner信息+流程规范
2026-07-14 21:56:25 +08:00
xiaoxia d73fb2cf2b Merge pull request 'refactor: 消除全部 Record<string, unknown>,TypeScript 严格类型治理' (#331) from fix/frontend-type-debt-round2 into develop
CI/CD Pipeline / Unit Tests (push) Successful in 1m59s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m15s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m25s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m52s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32m8s
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
2026-07-14 21:15:56 +08:00
xiaoxia 0eb1262db6 feat(ci): 飞书通知卡片优化 - 结构化字段+Runner信息+阶段分类
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m37s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m52s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m55s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m20s
2026-07-14 20:46:52 +08:00
CI Bot 9a6136c9fd Merge remote-tracking branch 'origin/develop' into fix/frontend-type-debt-round2
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m34s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m50s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m24s
2026-07-14 20:43:29 +08:00
xiaoxia 63f138e2d6 fix(ci): 确保飞书通知失败不阻塞部署流程
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m54s
CI/CD Pipeline / Unit Tests (push) Successful in 2m3s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m15s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m38s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 12m38s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m53s
- 所有notify步骤添加continue-on-error: true
- 通知标题增加'CI构建''部署'等关键词,提高飞书机器人匹配率
2026-07-14 20:22:56 +08:00
xiaoxia d898856f50 fix(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 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
- 所有notify步骤添加continue-on-error: true
- 通知标题增加'CI构建''部署'等关键词,提高飞书机器人匹配率
2026-07-14 20:22:56 +08:00
xiaoxia acffa364b3 fix(ci): 飞书通知返回错误码时也返回0,彻底不阻断CI流程
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m54s
CI/CD Pipeline / Unit Tests (push) Successful in 2m1s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m11s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m42s
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
2026-07-14 20:19:01 +08:00
CI Bot 1931cab504 style: 用项目Prettier配置格式化products.ts和GeneratePage.tsx
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m54s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m6s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m27s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m41s
修复CI Prettier检查失败——之前构建运维用默认参数格式化,
与项目实际配置不一致,导致2个文件仍然挂掉。
现在用项目本地npx prettier --write重新格式化。
2026-07-14 20:17:42 +08:00
xiaoxia 9f9fd1ccb8 style: fix prettier formatting
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m1s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m2s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m22s
2026-07-14 19:50:36 +08:00
xiaoxia 9fd12c7fb3 style: fix prettier formatting 2026-07-14 19:50:35 +08:00
xiaoxia 4805e961f0 fix(ci): Worker构建超时调优 - mode=min + 超时40分钟
CI/CD Pipeline / Unit Tests (push) Successful in 1m51s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m21s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-14 19:48:27 +08:00
xiaoxia 1aeeb23621 fix(ci): 飞书通知标题增加关键词(CI通知/CI告警),解决Key Words Not Found
CI/CD Pipeline / Unit Tests (push) Successful in 1m47s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m25s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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
2026-07-14 19:45:19 +08:00
xiaoxia 4a21b89a0b fix(ci): 所有构建Job超时从20min调整到30min,防止Worker镜像推送超时
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-14 19:45:06 +08:00
xiaoxia 2982ef5259 fix(ci): Frontend Lint缓存修复 - npm ci强制包含devDependencies
CI/CD Pipeline / Frontend Lint (push) Successful in 1m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m3s
CI/CD Pipeline / Unit Tests (push) Successful in 2m4s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m39s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m6s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 20m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-14 19:13:49 +08:00
xiaoxia 5fff7e518d fix(ci): 修复Frontend Lint缓存兼容性 - 先清空node_modules再安装 + Vitest fallback
CI/CD Pipeline / Frontend Lint (push) Failing after 1m51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m0s
CI/CD Pipeline / Unit Tests (push) Successful in 2m1s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been cancelled
2026-07-14 19:10:15 +08:00
xiaoxia a93149b8e5 fix(ci): 飞书通知失败不阻塞部署流程
CI/CD Pipeline / Frontend Lint (push) Successful in 1m49s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m9s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m21s
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
fix(ci): 飞书通知失败不阻塞部署流程
2026-07-14 19:04:57 +08:00
xiaoxia 21aaf642ec fix(ci): 通知失败不阻断CI主流程(飞书关键词不匹配不应导致部署失败)
CI/CD Pipeline / Frontend Lint (push) Failing after 1m53s
CI/CD Pipeline / Unit Tests (push) Successful in 2m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m14s
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 API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been cancelled
2026-07-14 19:02:30 +08:00
CI Bot 8d86707e2e refactor: 消除全部 Record<string, unknown>,替换为具体类型定义
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m2s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m2s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m9s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m57s
- GeneratePage.tsx: 3处防御性错误提取函数添加 eslint-disable 注释
  (safeExtract/extractString/safeExtractErr),移除 as Record<string, unknown> 断言
- TemplateLibrary.tsx: formatConfig 使用 ConfigDisplayFields 接口替代 Record 断言
- VoiceLibrary.tsx: buildVoiceMetadata 返回 VoiceUploadMetadata 接口
- VoiceMaterialLibrary.tsx: buildMetadata 返回 VoiceAssetMetadata 接口
- editPlans.ts: EditPlanConfig 去除索引签名,补充所有生成时字段
- assets.ts: AssetItem.metadata 使用 AssetMetadata 具体类型
- products.ts: GenerateFromTemplateRequest.config 改为 EditPlanConfig
- tts.ts/voiceClone.ts: metadata 参数使用具体接口
- editingPlanner.ts: ValidationWarningDetails 替代 Record<string, unknown>
- client.ts: 防御性错误提取添加 eslint-disable 注释

TypeScript 编译零错误,代码库中零 Record<string, unknown> 残留
2026-07-14 18:41:25 +08:00
xiaoxia 752de50557 feat(ci): 通知卡片化+Frontend Lint缓存+全Job耗时统计
CI/CD Pipeline / Unit Tests (push) Successful in 1m48s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3s
CI/CD Pipeline / Integration Tests (push) Successful in 1m14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 12m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 4s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-14 18:21:56 +08:00
xiaoxia ba6c5f2598 feat(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 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-14 18:21:41 +08:00
xiaoxia 78d1c88ca1 feat: 剪辑规划器完整交互 — 7大配置面板 + 时间轴拖拽/缩放/播放头 + 预览播放器 (#284)
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m44s
CI/CD Pipeline / Unit Tests (push) Successful in 1m44s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m8s
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
2026-07-14 18:15:55 +08:00
xiaoxia c88be032c1 fix: 渲染引擎全链路安全加固 P0+P1 (#317)
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-14 18:15:21 +08:00
xiaoxia 7f767e2dd1 feat(ci): Runner物理分层 - 构建任务专属旧服务器
CI/CD Pipeline / Unit Tests (push) Successful in 1m52s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m17s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m15s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m33s
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
2026-07-14 18:11:01 +08:00
xiaoxia ea93387f98 feat(ci): 代码质量深度加固 - mypy/ruff/vulture告警接入
CI/CD Pipeline / Unit Tests (push) Successful in 1m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m38s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m56s
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m46s
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
feat(ci): 代码质量深度加固 - mypy/ruff/vulture 告警模式接入Validate

- ruff lint 告警模式接入:规则集与原flake8对齐 + bugbear摸底
- mypy 类型检查告警模式接入:检查核心业务代码
- vulture死代码扫描升级:置信度阈值70% + 按置信度排序
- 新增pyproject.toml ruff配置(保留原有black/isort/coverage配置)
- 所有新检查均为告警模式,不阻断CI
2026-07-14 17:56:15 +08:00
xiaoxia 7e172c0907 fix: PR#312安全债务 4个P1修复(路径安全+数量上限) (#322)
CI/CD Pipeline / Unit Tests (push) Successful in 1m33s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m25s
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
2026-07-14 17:43:57 +08:00
xiaoxia 6db705a05d feat(ci): Runner物理分层 - 构建任务只调度到旧服务器
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m16s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m8s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m56s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
将6个构建Job(Staging+Production)的runs-on从saas改为[saas, build-farm],
只有带build-farm标签的旧服务器Runner能接构建任务,新服务器6个Runner
专注跑CI门禁检查,实现真正的物理分层,避免构建任务挤占CI检查资源。
2026-07-14 17:02:16 +08:00
xiaoxia 1c8cb20373 feat(ci): P2债务清理 + Production部署门禁
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m32s
CI/CD Pipeline / Unit Tests (push) Successful in 1m37s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m7s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m9s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m10s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m17s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 20m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
feat(ci): P2债务清理 + Production部署门禁

- 升级PyJWT 2.9.0 -> 2.13.0,修复4个CVE
- 清理5处死代码(未使用import和参数)
- Production部署加健康检查门禁(API health + 登录接口 + Web前端)
- deploy-production加checkout,修复通知脚本路径问题
2026-07-14 16:57:57 +08:00
xiaoxia 74c458e370 fix(ci): deploy-staging阶段添加checkout,修复通知脚本找不到的问题
CI/CD Pipeline / Unit Tests (push) Successful in 1m43s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m46s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m52s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m32s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m47s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22m36s
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
fix(ci): deploy-staging阶段添加checkout,修复通知脚本找不到的问题
2026-07-14 16:31:25 +08:00
xiaoxia a7d942f705 feat(ci): 接入安全扫描(detect-secrets + pip-audit + vulture)
CI/CD Pipeline / Unit Tests (push) Successful in 1m40s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m55s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m2s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m31s
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
feat(ci): 接入安全扫描(detect-secrets + pip-audit + vulture)
2026-07-14 16:25:07 +08:00
xiaoxia f867897348 fix(ci): 配置docker-container buildx builder
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 32s
CI/CD Pipeline / Unit Tests (push) Successful in 1m42s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m53s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m40s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 12m19s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
合并buildx builder修复
2026-07-14 15:37:57 +08:00
xiaoxia 3d1b739e7f fix(ci): 配置docker-container buildx builder以支持cache export
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m13s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m11s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
新CI服务器默认docker driver不支持buildx cache export功能,
导致Staging/Production构建直接失败:
ERROR: Cache export is not supported for the docker driver.

在6个镜像构建Job中统一添加builder初始化步骤,
自动创建/使用docker-container类型的builder实例。
2026-07-14 15:29:43 +08:00
xiaoxia f268e208de feat: 多轨道混音 + 字幕渲染引擎 + 视频拼接(后端) (#312)
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 33s
CI/CD Pipeline / Unit Tests (push) Successful in 1m35s
CI/CD Pipeline / Integration Tests (push) Successful in 1m18s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m27s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 8s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 11s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-14 15:20:47 +08:00
xiaoxia b05966ff48 feat(ci): 新增CHANGELOG自动生成脚本 (#307)
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-14 15:20:31 +08:00
xiaoxia 79b82978d8 feat(ci): 镜像并行构建 - 三镜像并行构建,构建时间从17min压缩到8-10min (#314)
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 35s
CI/CD Pipeline / Unit Tests (push) Successful in 1m8s
CI/CD Pipeline / Integration Tests (push) Successful in 1m15s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m18s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 8s
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 10s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-14 15:08:43 +08:00
xiaoxia 08b51ffa1d feat(ci): Runner标签分层路由 - 测试/构建分机运行 (#315)
CI/CD Pipeline / Unit Tests (push) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m11s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m21s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m35s
CI/CD Pipeline / Build & Push 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
2026-07-14 14:56:08 +08:00
xiaoxia 23d2406c27 feat(ci): 集成gitleaks + pip-audit + vulture安全扫描到Validate阶段 (#311)
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 & Push 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 Runtime Images (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-14 14:55:58 +08:00
xiaoxia def6ee2363 feat(ci): 新增发布与灰度部署脚本
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 46s
CI/CD Pipeline / Unit Tests (push) Successful in 1m40s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m52s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m42s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 16m34s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m52s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m55s
合并PR #316:新增release.sh / gray_deploy.sh / rollback_gray.sh三个脚本,适配生产环境架构(nginx upstream权重 + canary容器)
2026-07-14 13:12:41 +08:00
xiaoxia 2b5b650b9e fix(ci): 修复生产部署SSH密钥查找失败 + 通知脚本路径问题 (#313)
CI/CD Pipeline / Staging E2E Tests (push) Failing after 29m8s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 38s
CI/CD Pipeline / Unit Tests (push) Successful in 1m50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m40s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m25s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 29m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m13s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-14 11:53:02 +08:00
xiaoxia 2b8326987e Merge pull request 'fix(e2e): 5步向导测试末尾加unrouteAll兜底,避免页面关闭时报错' (#308) from fix/e2e-unroute-all-stability into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 28s
CI/CD Pipeline / Unit Tests (push) Successful in 1m3s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m36s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m8s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push 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
2026-07-14 11:47:12 +08:00
xiaoxia 2081c72be6 feat: 视频调速引擎(快进/慢放) (#294)
CI/CD Pipeline / Unit Tests (push) Successful in 1m8s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m3s
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Production Runtime Images (push) Successful in 5m31s
CI/CD Pipeline / Deploy Production (push) Failing after 11s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push 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
2026-07-14 11:36:24 +08:00
CI Bot a681844a44 fix(e2e): 5步向导测试末尾加unrouteAll兜底,避免页面关闭时报错
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 43s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m20s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m25s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m17s
2026-07-14 11:32:03 +08:00
xiaoxia 8a2d2df3cd feat: 视频封面生成 + 视频倒放 + 贴纸叠加三个渲染能力
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 30s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m12s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m20s
CI/CD Pipeline / Unit Tests (push) Successful in 4m17s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 17m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
Squash merge PR #305
2026-07-14 11:05:04 +08:00
xiaoxia ff38ee0f2b feat: 转场特效引擎 — 14种转场预设 + TransitionEngine抽象 + 降级策略
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 & Push 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 Runtime Images (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
Squash merge PR #293
2026-07-14 11:03:56 +08:00
xiaoxia 368baf683b fix: StubGenerationTaskRepository补list_by_user_filtered方法 (#306)
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 & Push 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 Runtime Images (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-14 11:02:57 +08:00
CI Bot a7b2cbfc8c feat(products): 成片中心 UI 4 大功能升级
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 45s
CI/CD Pipeline / Unit Tests (push) Successful in 1m19s
CI/CD Pipeline / Frontend Lint (push) Failing after 1m10s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m16s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m23s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 5m52s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 11m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 10m1s
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 / Frontend Lint (pull_request) Successful in 3m5s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
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
195 changed files with 30124 additions and 2665 deletions
+1
View File
@@ -0,0 +1 @@
re-trigger
+1 -1
View File
@@ -1 +1 @@
# CI trigger Fri Jun 26 09:53:28 PM CST 2026
trigger: 1784009947
File diff suppressed because one or more lines are too long
Regular → Executable
+335 -1106
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,34 @@
"""add transition_duration to edit_plan_clips
Revision ID: 039_transition_duration
Revises: 038_error_retry
Create Date: 2026-07-14 09:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "039_transition_duration"
down_revision = "038_error_retry"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plan_clips",
sa.Column(
"transition_duration",
sa.Float(),
nullable=False,
server_default="0.0",
),
)
def downgrade() -> None:
op.drop_column("edit_plan_clips", "transition_duration")
@@ -0,0 +1,29 @@
"""add playback_speed to edit_plan_clips
Revision ID: 040_playback_speed
Revises: 039_transition_duration
Create Date: 2026-07-14 10:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "040_playback_speed"
down_revision = "039_transition_duration"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plan_clips",
sa.Column("playback_speed", sa.Float(), nullable=False, server_default="1.0"),
)
def downgrade() -> None:
op.drop_column("edit_plan_clips", "playback_speed")
@@ -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")
+4 -2
View File
@@ -239,7 +239,9 @@ def get_duplication_detail(
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(
record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -287,7 +289,7 @@ def retry_duplication(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
) from e
if updated is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
+151 -9
View File
@@ -24,7 +24,7 @@ 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.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 pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -64,6 +64,13 @@ 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):
"""剪辑计划响应体"""
@@ -72,6 +79,7 @@ class EditPlanResponse(BaseModel):
name: str
status: str
total_duration: float
result_count: int = 0
project_id: str = ""
created_by_user_id: str = ""
config: dict[str, Any]
@@ -108,6 +116,10 @@ class EditPlanGenerationStatusResponse(BaseModel):
plan_id: str
plan_status: str
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]
@@ -146,6 +158,7 @@ class AIRecommendClipItem(BaseModel):
text_content: str = Field(default="", description="文字内容")
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
transition_effect: str = Field(default="cut", description="转场效果")
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
asset_id: str = Field(default="", description="关联素材 ID")
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
@@ -209,6 +222,8 @@ class _PlanClipItem(BaseModel):
start_time: float
duration: float
transition_effect: str
transition_duration: float
playback_speed: float = 1.0
status: str
config: Optional[dict[str, Any]] = None
created_at: datetime
@@ -234,6 +249,7 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
name=p.name,
status=p.status.value if hasattr(p.status, "value") else p.status,
total_duration=p.total_duration,
result_count=getattr(p, "result_count", 0),
project_id=p.project_id or "",
created_by_user_id=p.created_by_user_id or "",
config=p.config,
@@ -272,11 +288,11 @@ def list_plans(
if status_filter:
try:
status_enum = EditPlanStatus(status_filter)
except ValueError:
except ValueError as _e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的筛选条件,请选择正确的状态",
)
) from _e
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
if project_id:
@@ -319,7 +335,7 @@ def get_plan(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
) from exc
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
@@ -355,7 +371,7 @@ def create_plan(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
) from exc
logger.info(
"创建剪辑计划: id=%s name=%s by user=%s",
created.id,
@@ -398,11 +414,11 @@ def update_plan(
if body.status is not None:
try:
target_status = EditPlanStatus(body.status)
except ValueError:
except ValueError as _e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的状态值,请选择正确的状态",
)
) from _e
svc.transition_status(plan_id, target_status)
except ValueError as exc:
err_msg = str(exc)
@@ -410,11 +426,11 @@ def update_plan(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=err_msg,
)
) from exc
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=err_msg,
)
) from exc
# 返回最新状态
result = svc.get_plan_or_raise(plan_id)
@@ -448,12 +464,138 @@ 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 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 (拆分模块) ────────────────────────────────────────────
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_generation import router as generation_router
from .edit_plans_timeline import router as timeline_router
router.include_router(generation_router)
router.include_router(ai_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"])
+4 -4
View File
@@ -58,7 +58,7 @@ def ai_recommend_clips(
try:
plan = svc.get_plan_or_raise(plan_id)
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:
check_project_access(plan.project_id, current_user.user.id, project_repository)
@@ -103,7 +103,7 @@ def ai_recommend_clips(
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception:
except Exception as _e:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
try:
db.rollback()
@@ -116,7 +116,7 @@ def ai_recommend_clips(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
)
) from _e
logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
@@ -167,7 +167,7 @@ def generate_cover(
try:
plan = svc.get_plan_or_raise(plan_id)
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:
check_project_access(plan.project_id, current_user.user.id, project_repository)
+278
View File
@@ -0,0 +1,278 @@
"""剪辑计划片段(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
+241
View File
@@ -0,0 +1,241 @@
"""剪辑计划片段批量操作 API。"""
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, 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,
)
+25 -9
View File
@@ -183,9 +183,7 @@ def _auto_fallback_auto_material_mode(
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查"""
try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
gen_task_repo, "count_pending_total"
)
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(gen_task_repo, "count_pending_total")
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
@@ -241,7 +239,7 @@ def generate_plan(
try:
can_gen, reason = svc.can_generate(plan_id)
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:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
@@ -255,12 +253,15 @@ def generate_plan(
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
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(
CreateGenerationTaskCommand(
project_id="",
project_id=plan.project_id or "",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
asset_ids=list(config_asset_ids) if config_asset_ids else [],
)
)
@@ -286,7 +287,7 @@ def generate_plan(
)
except HTTPException:
raise
except Exception:
except Exception as _e:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
@@ -295,7 +296,7 @@ def generate_plan(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
)
) from _e
@router.get(
@@ -313,7 +314,7 @@ def get_generation_status(
try:
gen_status = svc.get_generation_status(plan_id)
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"]
if plan.project_id:
@@ -333,10 +334,25 @@ def get_generation_status(
for c in clips
]
# 从 plan.config 中取渲染结果 URL
video_url = (plan.config or {}).get("rendered_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(
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_status=gen_task_status,
progress=progress,
video_url=video_url,
error_message=error_message,
clips=clip_items,
)
+2 -1
View File
@@ -173,7 +173,7 @@ def generate_from_template(
try:
template = template_svc.get_template_or_raise(body.template_id)
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)
@@ -210,6 +210,7 @@ def generate_from_template(
start_time=c.start_time,
duration=c.duration,
transition_effect=c.transition_effect,
transition_duration=c.transition_duration,
status=c.status.value if hasattr(c.status, "value") else c.status,
config=c.config,
created_at=c.created_at,
+6 -6
View File
@@ -105,7 +105,7 @@ async def list_feature_flags(
return sorted(result, key=lambda x: x.name)
except Exception as 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)
@@ -120,7 +120,7 @@ async def get_feature_flag(
return FeatureFlagResponse.from_config(config)
except Exception as 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)
@@ -136,7 +136,7 @@ async def check_feature_flag(
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
except Exception as 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)
@@ -170,7 +170,7 @@ async def update_feature_flag(
return FeatureFlagResponse.from_config(config)
except Exception as 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)
@@ -178,7 +178,7 @@ async def delete_feature_flag(
name: str,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) :
):
"""删除 Feature Flag。
只允许删除 ALLOWED_FLAGS 列表中的 flag。
@@ -191,4 +191,4 @@ async def delete_feature_flag(
pass
except Exception as exc:
logger.error("Failed to delete feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") from exc
+6 -5
View File
@@ -43,6 +43,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse(
id=task.id,
@@ -282,28 +283,28 @@ def create_generation_task(
created_tasks.append(task)
else:
failed_tasks.append(task)
except UserPendingLimitExceeded:
except UserPendingLimitExceeded as _e:
# 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
)
) from _e
break
except GlobalQueueFull:
except GlobalQueueFull as _e:
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
) from _e
break
except HTTPException:
raise
except Exception as e:
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]
return BatchGenerationTaskResponse(items=items, total=len(items))
+3 -3
View File
@@ -81,11 +81,11 @@ def delete_project(
use_case = DeleteProjectUseCase(project_repository)
try:
deleted = use_case.execute(project_id, authenticated_user.user.id)
except PermissionError:
except PermissionError as _e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the project owner can delete this project",
)
) from _e
if not deleted:
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
import logging
from dataclasses import replace
from datetime import datetime, timezone
from typing import List
@@ -20,6 +21,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
from packages.ports.user_repository import UserRepository
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -254,7 +257,9 @@ async def payment_callback(
return {"success": True, "message": "支付成功", "record_id": record_id}
except Exception as e:
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:
session.close()
+3 -3
View File
@@ -368,9 +368,9 @@ def retry_project_task(
raise HTTPException(status_code=404, detail="Ingest job not found")
if _status_value(job.status) != "failed":
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(
SubmitIngestJobCommand(
SubmitIngestJobCommand( # type: ignore[arg-type]
project_id=job.project_id,
library_id=job.library_id,
storage_key=job.storage_key,
@@ -386,6 +386,6 @@ def retry_project_task(
current_step=_ingest_step(retried),
source_id=retried.id,
created_at=retried.created_at,
updated_at=retried.updated_at,
updated_at=retried.updated_at, # type: ignore[attr-defined]
)
raise HTTPException(status_code=400, detail="Unsupported task type")
+17 -16
View File
@@ -45,7 +45,6 @@ from packages.application.template.use_cases import (
CreateTemplateUseCase,
DeleteCategoryUseCase,
DeleteTemplateUseCase,
GetTemplateUsageUseCase,
GetTemplateUseCase,
ListCategoriesUseCase,
ListTagsUseCase,
@@ -148,9 +147,9 @@ def get_template(
use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id)
usage = template_repository.get_usage_count(template_id)
except Exception:
except Exception as _e:
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:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template, usage_count=usage)
@@ -187,7 +186,7 @@ def create_template(
try:
template = use_case.execute(command)
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)
@@ -227,10 +226,10 @@ def update_template(
use_case = UpdateTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
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)
@@ -265,10 +264,10 @@ def copy_template(
use_case = CopyTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
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)
@@ -300,9 +299,9 @@ def toggle_favorite(
use_case = GetTemplateUseCase(template_repository)
try:
template = use_case.execute(template_id, user_id)
except Exception:
except Exception as _e:
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:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return ToggleFavoriteResponse(id=template_id, is_favorite=False)
@@ -327,10 +326,10 @@ def validate_template(
use_case = ValidateTemplateUseCase(template_repository)
try:
result = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
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(
template=_to_response(result.template),
@@ -376,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(
category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
+3 -3
View File
@@ -148,7 +148,7 @@ def create_title(
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
)
) from exc
return _to_response(item)
@@ -172,8 +172,8 @@ def update_title(
use_case = UpdateTitleLibraryUseCase(title_repository)
try:
item = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e
return _to_response(item)
+7 -7
View File
@@ -236,8 +236,8 @@ def get_tts_job(
use_case = GetTTSJobUseCase(repository)
try:
job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
except TTSJobNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
return _to_response(job, sign_url)
@@ -253,8 +253,8 @@ def get_tts_job_status(
use_case = GetTTSJobStatusUseCase(repository)
try:
job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
except TTSJobNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
output_url = job.output_audio_url
if output_url:
output_url = sign_url(output_url)
@@ -309,8 +309,8 @@ def save_tts_job_to_library(
get_use_case = GetTTSJobUseCase(tts_repository)
try:
job = get_use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
except TTSJobNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
# 校验已完成
if not job.is_completed:
@@ -363,7 +363,7 @@ def save_tts_job_to_library(
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
)
) from exc
return SaveToLibraryResponse(
id=item.id,
+8 -8
View File
@@ -141,8 +141,8 @@ def get_voice_clone(
use_case = GetVoiceCloneUseCase(repository)
try:
profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
return _to_response(profile)
@@ -157,8 +157,8 @@ def get_voice_clone_status(
use_case = GetVoiceCloneStatusUseCase(repository)
try:
profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
return VoiceCloneStatusResponse(
id=profile.id,
status=profile.status,
@@ -201,13 +201,13 @@ def retry_voice_clone(
user_id = authenticated_user.user.id
try:
profile = workflow.retry_clone(clone_id, user_id)
except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
except VoiceCloneNotRetryableError:
except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
except VoiceCloneNotRetryableError as _e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Voice clone is not retryable (only failed clones can be retried)",
)
) from _e
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
+3 -3
View File
@@ -287,7 +287,7 @@ def create_voice(
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
)
) from exc
return _to_response(item, sign_url)
@@ -317,8 +317,8 @@ def update_voice(
use_case = UpdateVoiceLibraryUseCase(voice_repository)
try:
item = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") from _e
return _to_response(item, sign_url)
+2 -2
View File
@@ -141,7 +141,7 @@ def safe_enqueue_generation_task(
global_pending_limit,
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))
raise exc
@@ -194,7 +194,7 @@ def safe_enqueue_generation_task(
if global_over or user_over:
if global_over:
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:
reason = f"用户 pending 超限(入队后): {user_after}/{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),
) -> TagRepository:
"""Provide the SQLAlchemy tag repository implementation."""
return SQLAlchemyTagRepository(session)
return SQLAlchemyTagRepository(session) # type: ignore[return-value]
def get_user_repository(
+1 -1
View File
@@ -105,7 +105,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self.max_requests = max_requests
self.window_seconds = window_seconds
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):
# 如果配置了路径过滤,只对指定路径限流
+1 -1
View File
@@ -61,7 +61,7 @@ class APIVersionMiddleware(BaseHTTPMiddleware):
class VersionNotFoundMiddleware(BaseHTTPMiddleware):
"""处理已下线的 API 版本"""
SUNSET_VERSIONS = [] # 已下线的版本列表
SUNSET_VERSIONS: list[str] = [] # 已下线的版本列表
async def dispatch(self, request: Request, call_next):
version = self._extract_version(request.url.path)
+2 -1
View File
@@ -12,6 +12,7 @@
from __future__ import annotations
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from sqlalchemy.orm import Session
@@ -155,7 +156,7 @@ class AutoClipService:
self,
clip: EditPlanClip,
project_id: str,
config_map: dict[str, object],
config_map: Mapping[str, object],
) -> ClipAssignDetail:
"""为单个片段分配素材。"""
config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None
+209 -1
View File
@@ -141,6 +141,19 @@ class EditPlanService:
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
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(
self,
plan_id: str,
@@ -156,6 +169,10 @@ class EditPlanService:
"""
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(
id=existing.id,
template_id=existing.template_id,
@@ -212,8 +229,24 @@ class EditPlanService:
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 = {
EditPlanStatus.EDITING: plan.start_editing,
EditPlanStatus.RENDERING: plan.start_rendering,
EditPlanStatus.COMPLETED: plan.mark_completed,
EditPlanStatus.FAILED: plan.mark_failed,
@@ -281,6 +314,8 @@ class EditPlanService:
start_time: float = 0.0,
duration: float = 0.0,
transition_effect: str = "cut",
transition_duration: float = 0.0,
playback_speed: float = 1.0,
config: Optional[dict[str, Any]] = None,
) -> EditPlanClip:
"""创建片段
@@ -290,6 +325,8 @@ class EditPlanService:
"""
# 确保计划存在
self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
clip = EditPlanClip.create(
plan_id=plan_id,
@@ -301,6 +338,8 @@ class EditPlanService:
start_time=start_time,
duration=duration,
transition_effect=transition_effect,
transition_duration=transition_duration,
playback_speed=playback_speed,
config=config,
)
created = self._clip_repo.create(clip)
@@ -324,6 +363,8 @@ class EditPlanService:
start_time: Optional[float] = None,
duration: Optional[float] = None,
transition_effect: Optional[str] = None,
transition_duration: Optional[float] = None,
playback_speed: Optional[float] = None,
config: Optional[dict[str, Any]] = None,
) -> EditPlanClip:
"""更新片段
@@ -333,6 +374,18 @@ class EditPlanService:
"""
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 <= 0:
playback_speed = 1.0
elif playback_speed < 0.25:
playback_speed = 0.25
elif playback_speed > 4.0:
playback_speed = 4.0
updated = EditPlanClip(
id=existing.id,
plan_id=existing.plan_id,
@@ -346,6 +399,10 @@ class EditPlanService:
transition_effect=(
transition_effect.strip() if transition_effect is not None else existing.transition_effect
),
transition_duration=(
transition_duration if transition_duration is not None else existing.transition_duration
),
playback_speed=playback_speed if playback_speed is not None else existing.playback_speed,
status=existing.status,
config=config if config is not None else existing.config,
created_at=existing.created_at,
@@ -362,6 +419,8 @@ class EditPlanService:
ValueError: 片段不存在或 asset_id 为空
"""
clip = self.get_clip_or_raise(clip_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(clip.plan_id)
clip.assign_asset(asset_id)
result = self._clip_repo.update(clip)
logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id)
@@ -388,6 +447,62 @@ class EditPlanService:
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, 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 get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
@@ -412,6 +527,8 @@ class EditPlanService:
"clips": List[EditPlanClip],
"generation_task_id": Optional[str],
"generation_task_status": Optional[str],
"progress": float,
"error_message": str,
}
Raises:
@@ -423,17 +540,23 @@ class EditPlanService:
# 从 plan.config 中获取 generation_task_id
generation_task_id = plan.config.get("generation_task_id")
generation_task_status = None
progress = 0.0
error_message = ""
if generation_task_id:
task = self._generation_task_repo.get(generation_task_id)
if task:
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 {
"plan": plan,
"clips": clips,
"generation_task_id": generation_task_id,
"generation_task_status": generation_task_status,
"progress": progress,
"error_message": error_message,
}
def can_generate(self, plan_id: str) -> tuple[bool, str]:
@@ -484,6 +607,9 @@ class EditPlanService:
更新后的计划
"""
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}
updated = EditPlan(
@@ -500,3 +626,85 @@ class EditPlanService:
updated_at=plan.updated_at,
)
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)
+141
View File
@@ -12,9 +12,13 @@ from typing import Any, List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanClipRepository,
SQLAlchemyEditPlanRepository,
SQLAlchemyEditTemplateRepository,
SQLAlchemyTemplateClipConfigRepository,
)
from packages.domain.edit_plan import EditPlan
from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
from packages.domain.template_clip_config import (
ClipType,
@@ -37,6 +41,9 @@ class EditTemplateService:
def __init__(self, db: Session) -> None:
self._template_repo = SQLAlchemyEditTemplateRepository(db)
self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._plan_clip_repo = SQLAlchemyEditPlanClipRepository(db)
self._db = db
# ── 模板 CRUD ──────────────────────────────────────────────────────────
@@ -394,3 +401,137 @@ class EditTemplateService:
"template": template,
"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,
}
@@ -224,7 +224,7 @@ class PlanGeneratorService:
)
order += 1
# 剩余为 overlay
for i in range(1, n):
for _ in range(1, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
@@ -237,7 +237,7 @@ class PlanGeneratorService:
elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clipsB-roll
for i in range(n):
for _ in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
@@ -271,7 +271,7 @@ class PlanGeneratorService:
)
order += 1
# 剩余为 b_roll
for i in range(2, n):
for _ in range(2, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
@@ -284,7 +284,7 @@ class PlanGeneratorService:
else:
# ONE_TAKE: N 个 main clips
for i in range(n):
for _ in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
+222 -68
View File
@@ -86,7 +86,10 @@ async function createProject(
): Promise<string> {
const resp = await request.post(`${apiBase}/projects`, {
headers,
data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" },
data: {
name: `Assets Test Proj ${suffix}`,
description: "E2E assets test",
},
});
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json();
@@ -178,20 +181,30 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("素材库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-load");
const projectId = await createProject(
request,
"assets-load",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "默认视频库", "video");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
// 页面布局容器
await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-page")).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
// 左侧素材库列表
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
@@ -211,23 +224,33 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-create");
const projectId = await createProject(
request,
"assets-create",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "初始库", "video");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
// 点击新建素材库
await page.locator(".xx-asset-library-add").click();
// 弹窗出现
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" });
const modal = page
.locator(".ant-modal-content")
.filter({ hasText: "新建素材库" });
await expect(modal).toBeVisible();
// 填写表单
@@ -259,11 +282,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("切换不同素材库", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-switch");
const projectId = await createProject(
request,
"assets-switch",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
const videoLibName = "视频素材库 A";
const imageLibName = "图片素材库 B";
@@ -292,10 +317,16 @@ test.describe("素材库页面 - 完整交互测试", () => {
"demo_video.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
// 点击视频库,应显示素材
const videoLibItem = page
@@ -305,7 +336,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await expect(videoLibItem).toHaveClass(/active/);
// 验证视频素材出现
await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("demo_video.mp4")).toBeVisible({
timeout: 10_000,
});
// 点击图片库,应切换且不显示视频
const imageLibItem = page
@@ -315,18 +348,22 @@ test.describe("素材库页面 - 完整交互测试", () => {
await expect(imageLibItem).toHaveClass(/active/);
// 空状态或图片库内容
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 });
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
timeout: 5_000,
});
});
// ─── 素材搜索 ──────────────────────────────────────
test("素材搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-search");
const projectId = await createProject(
request,
"assets-search",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -336,13 +373,33 @@ test.describe("素材库页面 - 完整交互测试", () => {
);
// 创建两个不同名称的素材
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4");
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"apple_clip.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"banana_clip.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
// 确保在测试库中
const libItem = page
@@ -351,7 +408,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await libItem.click({ force: true });
// 两个素材都应可见
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
// 搜索 apple,只显示 apple
@@ -361,7 +420,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
// 清空搜索,两个都显示
await page.getByPlaceholder("搜索素材名称...").fill("");
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
});
@@ -369,11 +430,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("素材类型筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-filter");
const projectId = await createProject(
request,
"assets-filter",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -383,12 +446,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
);
// 创建视频素材
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4");
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"video_clip.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
const libItem = page
.locator(".xx-asset-library-item")
@@ -396,7 +472,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await libItem.click({ force: true });
// 素材应可见
await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("video_clip.mp4")).toBeVisible({
timeout: 10_000,
});
// 筛选类型下拉存在
const filterSelect = page.locator(".xx-assets-filters-left select").first();
@@ -407,11 +485,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-detail");
const projectId = await createProject(
request,
"assets-detail",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -419,12 +499,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
"详情测试库",
"video",
);
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4");
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"play_test.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
const libItem = page
.locator(".xx-asset-library-item")
@@ -441,7 +534,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await assetCard.locator(".xx-asset-play").click({ force: true });
// 播放弹窗出现
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" });
const modal = page
.locator(".ant-modal-content")
.filter({ hasText: "播放" });
await expect(modal).toBeVisible();
// 关闭弹窗
@@ -453,11 +548,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("删除素材 - 带确认对话框", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-delete");
const projectId = await createProject(
request,
"assets-delete",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -465,12 +562,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
"删除测试库",
"video",
);
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4");
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"to_delete.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
const libItem = page
.locator(".xx-asset-library-item")
@@ -491,14 +601,15 @@ test.describe("素材库页面 - 完整交互测试", () => {
await deleteBtn.click({ force: true });
// 确认对话框出现
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" });
const confirmModal = page
.locator(".ant-popover")
.filter({ hasText: "确认删除" });
await expect(confirmModal).toBeVisible();
// 监听删除请求
const deletePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/assets/") &&
resp.request().method() === "DELETE",
resp.url().includes("/assets/") && resp.request().method() === "DELETE",
{ timeout: 10_000 },
);
@@ -518,11 +629,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("批量删除素材", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-batch");
const projectId = await createProject(
request,
"assets-batch",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
const libraryId = await createLibrary(
request,
headers,
@@ -532,14 +645,41 @@ test.describe("素材库页面 - 完整交互测试", () => {
);
// 创建多个素材
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4");
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4");
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"batch_1.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"batch_2.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"batch_3.mp4",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
const libItem = page
.locator(".xx-asset-library-item")
@@ -547,7 +687,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await libItem.click({ force: true });
// 所有素材应可见
await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("batch_1.mp4")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("batch_2.mp4")).toBeVisible();
await expect(page.getByText("batch_3.mp4")).toBeVisible();
@@ -567,7 +709,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await batchDeleteBtn.click();
// 确认对话框
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" });
const confirmPop = page
.locator(".ant-popover")
.filter({ hasText: "确定删除" });
await expect(confirmPop).toBeVisible();
// 确认删除
@@ -595,17 +739,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
test("空素材库展示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-empty");
const projectId = await createProject(
request,
"assets-empty",
headers,
Date.now().toString(),
);
const projectId = await createProject(request, headers, Date.now().toString());
await createLibrary(request, headers, projectId, "空素材库", "video");
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000,
});
const libItem = page
.locator(".xx-asset-library-item")
@@ -613,7 +765,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
await libItem.click({ force: true });
// 空状态应显示
await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 });
await expect(page.locator(".xx-assets-empty")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
});
+3
View File
@@ -251,6 +251,9 @@ test.describe("Core generation flow", () => {
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 15_000,
});
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
await page.unrouteAll({ behavior: "ignoreErrors" });
});
test("generation task API creates and lists tasks", async ({ request }) => {
+5 -3
View File
@@ -180,9 +180,11 @@ test.describe("Core media upload flow", () => {
await expect(page.locator(".xx-assets-content")).toBeVisible({
timeout: 20_000,
});
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
timeout: 20_000,
});
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible(
{
timeout: 20_000,
},
);
// Verify asset card shows status
const assetCard = page
+76 -38
View File
@@ -121,7 +121,11 @@ test.describe("去重流程", () => {
"dup-load",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication");
@@ -146,7 +150,11 @@ test.describe("去重流程", () => {
"dup-upload-zone",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -156,12 +164,12 @@ test.describe("去重流程", () => {
await expect(uploadZone).toBeVisible();
// 上传图标和文字
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible();
await expect(
uploadZone.getByText("点击或拖拽视频文件到此区域"),
).toBeVisible();
// 格式提示
await expect(
uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/),
).toBeVisible();
await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible();
// 格式标签
await expect(page.locator(".dup-upload-formats")).toBeVisible();
@@ -184,7 +192,11 @@ test.describe("去重流程", () => {
"dup-info",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -215,7 +227,11 @@ test.describe("去重流程", () => {
"dup-list",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication/results");
@@ -239,7 +255,11 @@ test.describe("去重流程", () => {
"dup-list-empty",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -257,7 +277,11 @@ test.describe("去重流程", () => {
"dup-filter",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -287,7 +311,11 @@ test.describe("去重流程", () => {
"dup-nav",
);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
@@ -306,10 +334,8 @@ test.describe("去重流程", () => {
request,
}) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-detail",
);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "dup-detail");
// 先上传一个文件进行查重,获取 record id
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -335,7 +361,11 @@ test.describe("去重流程", () => {
const recordId = uploadData.id;
expect(recordId, "应返回查重记录 ID").toBeTruthy();
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
// 访问详情页
await page.goto(`/app/duplication/${recordId}`);
@@ -353,10 +383,8 @@ test.describe("去重流程", () => {
test("去重记录删除 - API 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-delete",
);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "dup-delete");
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -419,10 +447,8 @@ test.describe("去重流程", () => {
test("去重记录删除 - UI 验证", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-delete-ui",
);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "dup-delete-ui");
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -443,14 +469,20 @@ test.describe("去重流程", () => {
return;
}
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 记录卡片应存在
const resultCard = page.locator(".dup-result-card").first();
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
const cardVisible = await resultCard
.isVisible({ timeout: 10_000 })
.catch(() => false);
if (cardVisible) {
// 删除按钮存在
@@ -467,12 +499,14 @@ test.describe("去重流程", () => {
});
// 监听删除请求
const deletePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/duplication/records/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
).catch(() => null);
const deletePromise = page
.waitForResponse(
(resp) =>
resp.url().includes("/duplication/records/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
)
.catch(() => null);
await deleteBtn.click();
@@ -487,10 +521,8 @@ test.describe("去重流程", () => {
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser(
request,
"dup-retry",
);
const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "dup-retry");
// 创建查重记录
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
@@ -511,14 +543,20 @@ test.describe("去重流程", () => {
return;
}
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/duplication/results");
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
// 记录列表中至少有一条记录
const resultCard = page.locator(".dup-result-card").first();
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
const cardVisible = await resultCard
.isVisible({ timeout: 10_000 })
.catch(() => false);
if (cardVisible) {
// 验证记录卡片基本结构
+18 -3
View File
@@ -6,7 +6,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -258,7 +263,12 @@ test.describe("剪辑计划 - API 操作", () => {
mode: "pip",
estimated_duration: 30,
segments: [
{ segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" },
{
segment_order: 1,
duration_min: 5,
duration_max: 10,
material_type: "video",
},
],
},
});
@@ -269,7 +279,12 @@ test.describe("剪辑计划 - API 操作", () => {
mode: "voice_over",
estimated_duration: 60,
segments: [
{ segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" },
{
segment_order: 1,
duration_min: 10,
duration_max: 30,
material_type: "video",
},
],
},
});
+112 -29
View File
@@ -93,7 +93,8 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) {
resolution: "1080x1920",
file_size: (5 + i) * 1024 * 1024,
duplicate_rate: i * 5,
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
video_url:
status === "completed" ? "https://example.com/video.mp4" : undefined,
thumbnail_url: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
@@ -218,7 +219,11 @@ test.describe("作品库页面", () => {
const products = mockProducts(3, ["completed", "processing", "failed"]);
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
@@ -255,12 +260,24 @@ test.describe("作品库页面", () => {
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
{ ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` },
{ ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` },
{
...mockProducts(1, ["processing"])[0],
title: "处理中作品",
id: `mock-prod-${Date.now()}-p`,
},
{
...mockProducts(1, ["failed"])[0],
title: "失败作品",
id: `mock-prod-${Date.now()}-f`,
},
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-page")).toBeVisible({
@@ -276,9 +293,9 @@ test.describe("作品库页面", () => {
const completedCard = page
.locator(".xx-product-card")
.filter({ hasText: "已完成作品" });
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText(
"已完成",
);
await expect(
completedCard.locator(".xx-product-status.completed"),
).toHaveText("已完成");
const processingCard = page
.locator(".xx-product-card")
@@ -309,7 +326,11 @@ test.describe("作品库页面", () => {
const productId = products[0].id;
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
// 直接访问详情页
await page.goto(`/app/products/${productId}`);
@@ -337,7 +358,11 @@ test.describe("作品库页面", () => {
products[0].video_url = "https://example.com/test-video.mp4";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -356,7 +381,10 @@ test.describe("作品库页面", () => {
// 播放弹窗出现 - 验证有视频元素或播放器容器
// (通过 Mock 的 video_urlvideo 元素应能渲染)
const videoEl = page.locator("video");
const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false);
const videoVisible = await videoEl
.first()
.isVisible({ timeout: 5000 })
.catch(() => false);
// 或弹窗容器可见
const modalVisible = await page
.locator(".ant-modal-content")
@@ -380,7 +408,11 @@ test.describe("作品库页面", () => {
products[0].title = "下载测试作品";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -409,7 +441,11 @@ test.describe("作品库页面", () => {
products[0].title = "处理中下载测试";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -484,7 +520,11 @@ test.describe("作品库页面", () => {
route.continue();
});
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -507,9 +547,12 @@ test.describe("作品库页面", () => {
const { headers } = await createAuthedUser(request, "products-del-api");
// 测试删除不存在的产品,验证 API 端点存在
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
headers,
});
const resp = await request.delete(
`${apiBase}/products/nonexistent-test-id`,
{
headers,
},
);
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
// 404 表示资源不存在但端点存在
@@ -529,7 +572,11 @@ test.describe("作品库页面", () => {
// Mock 空列表
await mockProductsApi(page, []);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-page")).toBeVisible({
@@ -553,12 +600,24 @@ test.describe("作品库页面", () => {
);
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` },
{ ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` },
{
...mockProducts(1, ["completed"])[0],
title: "苹果宣传视频",
id: `mock-prod-${Date.now()}-apple`,
},
{
...mockProducts(1, ["completed"])[0],
title: "香蕉推广视频",
id: `mock-prod-${Date.now()}-banana`,
},
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -566,7 +625,9 @@ test.describe("作品库页面", () => {
});
// 两个作品都可见
await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("苹果宣传视频")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByText("香蕉推广视频")).toBeVisible();
// 搜索"苹果"
@@ -576,7 +637,9 @@ test.describe("作品库页面", () => {
// 清空搜索
await page.getByPlaceholder("搜索成片名称...").fill("");
await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("香蕉推广视频")).toBeVisible({
timeout: 5_000,
});
});
test("作品状态筛选", async ({ page, request }) => {
@@ -587,12 +650,24 @@ test.describe("作品库页面", () => {
);
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` },
{ ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` },
{
...mockProducts(1, ["completed"])[0],
title: "已完成筛选",
id: `mock-prod-${Date.now()}-done`,
},
{
...mockProducts(1, ["processing"])[0],
title: "处理中筛选",
id: `mock-prod-${Date.now()}-proc`,
},
];
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -629,7 +704,11 @@ test.describe("作品库页面", () => {
products[2].title = "批量测试 3";
await mockProductsApi(page, products);
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
});
await page.goto("/app/products");
await expect(page.locator(".xx-products-grid")).toBeVisible({
@@ -653,8 +732,12 @@ test.describe("作品库页面", () => {
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
// 批量按钮存在
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible();
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible();
await expect(
batchBar.getByRole("button", { name: "批量下载" }),
).toBeVisible();
await expect(
batchBar.getByRole("button", { name: "批量删除" }),
).toBeVisible();
// 取消选择
await batchBar.getByRole("button", { name: "取消选择" }).click();
+10 -2
View File
@@ -6,7 +6,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -401,7 +406,10 @@ test.describe("个人设置 - 退出登录", () => {
test.describe.configure({ timeout: 120_000 });
test("登出 API - 正向", async ({ request }) => {
const { headers, email } = await createAuthedUser(request, "profile-logout");
const { headers, email } = await createAuthedUser(
request,
"profile-logout",
);
const response = await request.post(`${apiBase}/auth/logout`, {
headers,
+47 -12
View File
@@ -49,7 +49,9 @@ test.describe("注册页面", () => {
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
// 标题/描述
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible();
await expect(
page.getByText("创建账户,开启智能视频创作之旅"),
).toBeVisible();
// 表单字段
await expect(page.getByLabel("邮箱")).toBeVisible();
@@ -69,7 +71,10 @@ test.describe("注册页面", () => {
await page.goto("/register");
// 直接点击注册按钮
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示必填错误
await expect(page.getByText("请输入邮箱")).toBeVisible();
@@ -86,7 +91,10 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示邮箱格式错误
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
@@ -100,7 +108,10 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill("123");
await page.getByLabel("确认密码").fill("123");
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示密码长度错误
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
@@ -114,7 +125,10 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill("Different123!");
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示密码不一致错误
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
@@ -128,7 +142,10 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
await expect(page.getByText("请输入用户名")).toBeVisible();
});
@@ -154,10 +171,16 @@ test.describe("注册页面", () => {
{ timeout: 15_000 },
);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
const resp = await registerResponse;
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy();
expect(
resp.ok(),
`注册请求应返回 2xx,实际: ${resp.status()}`,
).toBeTruthy();
// 注册成功后应跳转到登录页或显示成功消息
// 页面应停留在可识别的状态(成功提示或跳转)
@@ -199,14 +222,19 @@ test.describe("注册页面", () => {
await page.getByLabel("密码").fill(PASSWORD);
await page.getByLabel("确认密码").fill(PASSWORD);
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
await page
.locator("button[type='submit']")
.filter({ hasText: "注册" })
.click();
// 应显示错误提示(通过 antd message 或表单错误)
await expect
.poll(
async () => {
// 检查是否有错误消息
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible();
const hasError = await page
.getByText(/注册失败|已注册|已存在|exists/)
.isVisible();
return hasError ? "error_shown" : "waiting";
},
{ timeout: 10_000 },
@@ -254,7 +282,12 @@ test.describe("注册页面", () => {
// 注册
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" },
data: {
email,
password: PASSWORD,
username,
display_name: "Reg Auth Test",
},
});
// 登录
@@ -293,6 +326,8 @@ test.describe("注册页面", () => {
// 注册页对已登录用户也可访问(注册页是公开页面)
// 验证页面正常渲染
await expect(page.getByLabel("邮箱")).toBeVisible();
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible();
await expect(
page.locator("button[type='submit']").filter({ hasText: "注册" }),
).toBeVisible();
});
});
+30 -14
View File
@@ -9,7 +9,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -243,8 +248,13 @@ test.describe("订阅套餐页 - 升级交互", () => {
const url = page.url();
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
expect(
url.includes("/subscription/upgrade") || url.includes("/subscription") ||
(await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)),
url.includes("/subscription/upgrade") ||
url.includes("/subscription") ||
(await page
.locator(".ant-modal, [role='dialog']")
.first()
.isVisible()
.catch(() => false)),
).toBeTruthy();
}
});
@@ -538,13 +548,16 @@ test.describe("订阅 - 支付流程", () => {
test("创建支付订单 - 正向 API", async ({ request }) => {
const { headers } = await createAuthedUser(request, "sub-pay-api");
const response = await request.post(`${apiBase}/subscription/create-order`, {
headers,
data: {
plan_id: "pro",
billing_cycle: "monthly",
const response = await request.post(
`${apiBase}/subscription/create-order`,
{
headers,
data: {
plan_id: "pro",
billing_cycle: "monthly",
},
},
});
);
// 创建支付订单可能成功或接口不存在
expect(
@@ -560,12 +573,15 @@ test.describe("订阅 - 支付流程", () => {
});
test("未登录创建订单 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/subscription/create-order`, {
data: {
plan_id: "pro",
billing_cycle: "monthly",
const response = await request.post(
`${apiBase}/subscription/create-order`,
{
data: {
plan_id: "pro",
billing_cycle: "monthly",
},
},
});
);
expect([401, 403, 404]).toContain(response.status());
});
});
+4 -1
View File
@@ -178,7 +178,10 @@ test.describe("订阅过期处理", () => {
// 免费用户可能不需要取消,返回 400 或类似错误
if (!response.ok()) {
const data = await response.json();
expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy();
expect(
data.error?.message || data.detail || data.message,
"应返回错误信息",
).toBeTruthy();
}
});
+16 -7
View File
@@ -6,7 +6,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -326,7 +331,9 @@ test.describe("模板库 - 模板展示", () => {
if (await modal.isVisible({ timeout: 5_000 })) {
await expect(modal).toBeVisible();
// 验证预览内容存在
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible();
await expect(
modal.locator(".xx-template-modal-title-row"),
).toBeVisible();
}
}
});
@@ -487,7 +494,10 @@ test.describe("模板库 - API 操作", () => {
`${apiBase}/templates/${templateId}/favorite`,
{ headers },
);
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy();
expect(
unfavResp.status() < 500,
"取消收藏请求应返回 2xx 或 4xx",
).toBeTruthy();
});
test("获取模板详情 - 正向", async ({ request }) => {
@@ -515,10 +525,9 @@ test.describe("模板库 - API 操作", () => {
expect(createResp.ok()).toBeTruthy();
const created = await createResp.json();
const detailResp = await request.get(
`${apiBase}/templates/${created.id}`,
{ headers },
);
const detailResp = await request.get(`${apiBase}/templates/${created.id}`, {
headers,
});
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
const detail = await detailResp.json();
expect(detail.id).toBe(created.id);
+6 -5
View File
@@ -175,10 +175,9 @@ test.describe("认证流程", () => {
},
});
expect(
[400, 422],
"缺少用户名字段应返回 4xx 校验错误",
).toContain(response.status());
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(
response.status(),
);
});
// ─── 登录 ────────────────────────────────────────────
@@ -230,7 +229,9 @@ test.describe("认证流程", () => {
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
});
if (response.status() !== 429) break;
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`);
console.log(
`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`,
);
await new Promise((r) => setTimeout(r, 65_000));
}
+34 -7
View File
@@ -9,7 +9,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -224,7 +229,11 @@ test.describe("标题库 - API 完整操作", () => {
test("编辑标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-update");
const titleId = await createTitle(request, headers, Date.now().toString(36));
const titleId = await createTitle(
request,
headers,
Date.now().toString(36),
);
const newName = `更新后的标题 ${Date.now()}`;
const newText = "这是更新后的标题内容";
@@ -256,7 +265,11 @@ test.describe("标题库 - API 完整操作", () => {
test("删除标题 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "title-delete");
const titleId = await createTitle(request, headers, Date.now().toString(36));
const titleId = await createTitle(
request,
headers,
Date.now().toString(36),
);
// 删除
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
@@ -279,9 +292,21 @@ test.describe("标题库 - API 完整操作", () => {
const suffix = Date.now().toString(36);
const titles = [
{ name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" },
{ name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" },
{ name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" },
{
name: `批量标题 1 ${suffix}`,
text: `内容 1 ${suffix}`,
category: "default",
},
{
name: `批量标题 2 ${suffix}`,
text: `内容 2 ${suffix}`,
category: "种草",
},
{
name: `批量标题 3 ${suffix}`,
text: `内容 3 ${suffix}`,
category: "知识",
},
];
const response = await request.post(`${apiBase}/titles/batch-import`, {
@@ -297,7 +322,9 @@ test.describe("标题库 - API 完整操作", () => {
if (response.ok()) {
const data = await response.json();
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy();
expect(
Array.isArray(data) || data.success_count !== undefined,
).toBeTruthy();
}
});
+15 -7
View File
@@ -6,7 +6,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -326,10 +331,9 @@ test.describe("声音克隆 - API 操作", () => {
).toBeTruthy();
// 验证已删除
const getResp = await request.get(
`${apiBase}/voice-clones/${cloneId}`,
{ headers },
);
const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, {
headers,
});
expect([404, 410]).toContain(getResp.status());
}
// 如果创建失败(比如音频格式问题),测试也通过
@@ -491,11 +495,15 @@ test.describe("声音克隆 - 上传区域", () => {
});
// 尝试点击克隆新音色按钮
const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ });
const cloneBtn = page.getByRole("button", {
name: /克隆新音色|立即克隆|新建/,
});
if (await cloneBtn.isVisible()) {
await cloneBtn.click();
// 弹窗应该出现
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']");
const modal = page.locator(
".ant-modal, .vc-edit-dialog, [role='dialog']",
);
if (await modal.first().isVisible({ timeout: 5_000 })) {
await expect(modal.first()).toBeVisible();
}
+9 -2
View File
@@ -6,7 +6,12 @@
*
* 每个测试独立,先注册登录获取 auth token。
*/
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import {
expect,
test,
type APIRequestContext,
type Page,
} from "@playwright/test";
const PASSWORD = "Test123456!";
const apiBase = process.env.E2E_API_BASE || "/api/v1";
@@ -157,7 +162,9 @@ test.describe("音色库页面 - 页面加载", () => {
});
// 验证搜索框存在
const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']");
const searchInput = page.locator(
"input[type='search'], .xx-voices-search input, input[placeholder*='搜索']",
);
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
});
});
+17
View File
@@ -33,6 +33,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0",
"prettier": "^3.0.0",
"typescript": "^5.5.3",
"vite": "^5.3.1",
"vitest": "^1.6.0"
@@ -4828,6 +4829,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+1
View File
@@ -42,6 +42,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0",
"prettier": "^3.0.0",
"typescript": "^5.5.3",
"vite": "^5.3.1",
"vitest": "^1.6.0"
+81 -4
View File
@@ -5,6 +5,32 @@
import apiClient from "./client";
import { getOrCreateDefaultProject } from "./projects";
/** 素材元数据 */
export interface AssetMetadata {
/** 时长(秒) */
duration?: number;
/** 宽度(像素) */
width?: number;
/** 高度(像素) */
height?: number;
/** 比特率(bps */
bitrate?: number;
/** 编码格式 */
codec?: string;
/** 帧率 */
fps?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 声道数 */
channels?: number;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** 素材分类状态 */
export type AssetClassificationStatus =
"pending" | "processing" | "completed" | "failed";
/** 素材条目 */
export interface AssetItem {
id: string;
@@ -12,12 +38,14 @@ export interface AssetItem {
name: string;
storage_key: string;
mime_type: string;
metadata: Record<string, unknown>;
metadata: AssetMetadata;
file_size?: number;
file_url?: string;
thumbnail_url?: string;
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
duration?: number;
status?: string;
classification_status?: string | null;
classification_status?: AssetClassificationStatus | null;
quality_score?: number | null;
tag_ids?: string[];
created_at?: string;
@@ -167,7 +195,7 @@ export const createAsset = async (data: {
name: string;
storage_key: string;
mime_type: string;
metadata?: Record<string, unknown>;
metadata?: AssetMetadata;
}): Promise<AssetItem> => {
const response = await apiClient.post("/assets", data);
return response.data;
@@ -176,7 +204,7 @@ export const createAsset = async (data: {
/** 更新素材(名称、metadata 等) */
export const updateAsset = async (
assetId: string,
data: { name?: string; metadata?: Record<string, unknown> },
data: { name?: string; metadata?: AssetMetadata },
): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data);
return response.data;
@@ -350,3 +378,52 @@ export const getClassificationJob = async (
const response = await apiClient.get(`/classification-jobs/${jobId}`);
return response.data;
};
// ─── 批量操作 ───────────────────────────────────────────────
/** 批量操作结果 */
export interface BatchOperationResult {
succeeded: string[];
failed: string[];
total: number;
success_count: number;
failure_count: number;
}
/** 批量删除素材 */
export const batchDeleteAssets = async (
assetIds: string[],
): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-delete", {
asset_ids: assetIds,
});
return response.data;
};
/** 批量打标签 */
export const batchTagAssets = async (data: {
asset_ids: string[];
tags: string[];
mode: "add" | "replace";
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-tag", data);
return response.data;
};
/** 批量改分类 */
export const batchClassifyAssets = async (data: {
asset_ids: string[];
category: string;
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-classify", data);
return response.data;
};
/** 批量智能标记 */
export const batchMarkAssets = async (data: {
asset_ids: string[];
smart_view: "recommended" | "caution" | "high_risk";
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-mark", data);
return response.data;
};
+70
View File
@@ -0,0 +1,70 @@
/**
* BGM 预设音乐 API
* 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索)
*/
import apiClient from "./client";
/* ──────────── 类型 ──────────── */
/** BGM 风格分类 */
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商";
/** BGM 预设项 */
export interface BgmPreset {
id: string;
name: string;
category: BgmCategory;
/** 音频文件 URL */
url: string;
/** 时长(秒) */
duration: number;
/** 关键词标签 */
tags: string[];
/** 封面图 URL */
cover_url?: string;
}
/** BGM 预设列表查询参数 */
export interface BgmPresetsQuery {
category?: BgmCategory | string;
keyword?: string;
}
/** BGM 混音配置(嵌入剪辑计划) */
export interface BgmMixConfig {
/** 是否启用 BGM */
enabled: boolean;
/** 选中的 BGM ID */
music_id: string;
/** BGM 音量 0-100 */
volume: number;
/** 淡入时长(秒) 0-3 */
fade_in: number;
/** 淡出时长(秒) 0-3 */
fade_out: number;
/** 人声闪避(sidechain */
voice_dodge: boolean;
}
/** 默认 BGM 混音配置 */
export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
enabled: false,
music_id: "",
volume: 50,
fade_in: 0.5,
fade_out: 0.5,
voice_dodge: true,
};
/* ──────────── API ──────────── */
/** 获取 BGM 预设列表 */
export const getBgmPresets = async (
params?: BgmPresetsQuery,
): Promise<BgmPreset[]> => {
const searchParams: Record<string, string> = {};
if (params?.category) searchParams.category = params.category;
if (params?.keyword) searchParams.keyword = params.keyword;
const res = await apiClient.get("/bgm/presets", { params: searchParams });
return res.data?.data ?? res.data ?? [];
};
+2 -1
View File
@@ -129,7 +129,8 @@ apiClient.interceptors.response.use(
const safeExtractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
+231 -38
View File
@@ -4,6 +4,15 @@
*/
import apiClient from "./client";
import type { AssetItem } from "./assets";
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types";
/* ============================================================
* 后端 API 类型(严格匹配后端 Schema)
@@ -13,6 +22,107 @@ import type { AssetItem } from "./assets";
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed";
/** 标题配置(对齐后端 title_config */
export interface TitleConfig {
ai_auto_select: boolean;
content: string;
font_preset: string;
font_color: string;
font_size: number;
position: string;
}
/** 字幕配置 */
export interface SubtitleConfig {
enabled: boolean;
position: string;
font: string;
color: string;
size: number;
animation: string;
}
/** BGM 配置 */
export interface BgmConfig {
enabled: boolean;
music_id: string;
}
/** 片段 TTS 配置 */
export interface SegmentTtsConfig {
mode: string;
text: string;
voice_id: string;
speed: number;
pitch: number;
volume: number;
subtitle_sync: boolean;
}
/** 片段裁剪配置 */
export interface SegmentTrimConfig {
start_time: number;
end_time: number;
}
/** 片段转场配置 */
export interface SegmentTransitionConfig {
type: string;
duration: number;
}
/** 剪辑计划中的单个片段(config 内部 segments 项) */
export interface EditPlanSegment {
segment_order: number;
duration_min: number;
duration_max: number;
material_type: string;
transition?: SegmentTransitionConfig;
playback_speed?: number;
tts_config?: SegmentTtsConfig;
trim_config?: SegmentTrimConfig;
}
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
export interface EditPlanConfig {
title_config?: TitleConfig;
subtitle_config?: SubtitleConfig;
bgm_config?: BgmConfig;
estimated_duration?: number;
segments?: EditPlanSegment[];
watermark_config?: WatermarkConfig;
intro_outro_config?: IntroOutroConfig;
pip_config?: PipConfig;
filter_config?: FilterConfig;
green_screen_config?: ChromaKeyConfig;
sticker_config?: StickerConfig;
cover_config?: CoverConfig;
/** 前端扩展:关联的素材 ID 列表 */
asset_ids?: string[];
/** 配音 ID */
voice_id?: string;
/** 克隆音色档案 ID */
voice_clone_profile_id?: string;
/** 自定义配音音频 URL */
custom_audio_url?: string;
/** 自定义配音文本 */
custom_text?: string;
/** 视频比例 */
ratio?: string;
/** 视频风格 */
style?: string;
/** 目标时长(秒) */
duration?: number;
/** 是否自动生成字幕 */
auto_subtitles?: boolean;
/** 是否启用 BGM */
bgm?: boolean;
/** 生成数量 */
generate_count?: number;
/** 素材模式 */
material_mode?: string;
}
/** 剪辑计划(后端响应) */
export interface EditPlan {
id: string;
@@ -20,7 +130,9 @@ export interface EditPlan {
name: string;
status: EditPlanStatus;
total_duration: number;
config: Record<string, unknown>;
/** 生成视频数量(后端 EditPlanResponse.result_count */
result_count: number;
config: EditPlanConfig;
created_at: string;
updated_at: string;
}
@@ -29,7 +141,7 @@ export interface EditPlan {
export interface CreateEditPlanRequest {
template_id: string;
name: string;
config?: Record<string, unknown>;
config?: EditPlanConfig;
total_duration?: number;
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
source_edit_plan_id?: string;
@@ -38,7 +150,7 @@ export interface CreateEditPlanRequest {
/** 更新剪辑计划请求 */
export interface UpdateEditPlanRequest {
name?: string;
config?: Record<string, unknown>;
config?: EditPlanConfig;
total_duration?: number;
status?: EditPlanStatus;
}
@@ -51,14 +163,21 @@ export interface GenerateResponse {
clip_count: number;
}
/** 剪辑计划关联的生成记录 */
/** 剪辑计划关联的生成记录(实际是 GenerationTask 对象) */
export interface EditPlanGeneration {
id: string;
edit_plan_id: string;
generation_task_id: string;
id: string; // 即 generation_task_id
source_edit_plan_id: string;
template_id: string;
asset_ids: string[];
status: EditPlanStatus;
created_at: string;
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;
}
/** 片段生成状态 */
@@ -80,6 +199,26 @@ export interface GenerationStatusResponse {
clips: ClipStatusItem[];
}
/** 生成视频详情(对应后端 GeneratedVideoResponse */
export interface GeneratedVideo {
id: string;
project_id?: string;
generation_task_id?: string;
name: string;
file_url: string;
file_size?: number;
duration?: number;
thumbnail_url?: string;
width?: number;
height?: number;
fps?: number;
status: string;
review_status?: string;
download_url?: string;
created_at?: string;
updated_at?: string;
}
/* ============================================================
* AI 推荐 & 封面生成(任务 3.09
* ============================================================ */
@@ -100,14 +239,14 @@ export interface AIRecommendClipItem {
transition_effect: string;
asset_id: string;
start_time: number;
config: Record<string, unknown>;
config: EditPlanConfig;
}
/** AI 推荐响应 */
export interface AIRecommendResponse {
plan_id: string;
clips: AIRecommendClipItem[];
config: Record<string, unknown>;
config: EditPlanConfig;
total_duration: number;
confidence: number;
}
@@ -122,7 +261,15 @@ export interface GenerateCoverRequest {
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string;
cover: Record<string, unknown>;
cover: CoverResult;
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string;
asset_id?: string;
frame_time?: number;
thumbnail_url?: string;
}
/* ============================================================
@@ -147,10 +294,27 @@ export interface EditPlanClip {
order: number;
}
/** 转场效果 */
/** 转场效果14 种预设) */
export interface TransitionEffect {
type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide";
type:
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop";
duration: number; // 转场时长(秒)
/** 播放速度倍率 */
playback_speed?: number;
}
/** 素材库资产(UI 层类型,映射自后端 AssetResponse */
@@ -177,15 +341,30 @@ export interface MediaAsset {
* API 函数 — 严格对接后端
* ============================================================ */
/** 获取剪辑计划列表 */
export async function getEditPlans(params?: {
/** 剪辑计划列表查询参数 */
export interface EditPlanListParams {
page?: number;
page_size?: number;
template_id?: string;
status?: string;
}): Promise<EditPlan[]> {
const response = await apiClient.get("/edit-plans", { params });
return response.data.items || [];
}
/** 剪辑计划列表分页响应 */
export interface EditPlanListResponse {
items: EditPlan[];
total: number;
page: number;
page_size: number;
}
/** 获取剪辑计划列表(支持分页和筛选) */
export async function getEditPlans(
params?: EditPlanListParams,
): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
params,
});
return response.data;
}
/** 获取单个剪辑计划 */
@@ -266,6 +445,14 @@ export async function getEditPlanGenerations(
return response.data.items || [];
}
/** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults(
taskId: string,
): Promise<GeneratedVideo[]> {
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
return response.data.items || response.data || [];
}
/**
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
@@ -297,26 +484,22 @@ function inferMediaType(mimeType: string): "video" | "image" | "audio" {
}
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
const meta = (asset.metadata || {}) as Record<string, unknown>;
const ext = asset as AssetItem & Record<string, unknown>;
// 优先取顶层 duration,其次从 metadata 回退
const metaDuration =
typeof asset.metadata?.duration === "number"
? asset.metadata.duration
: undefined;
return {
id: asset.id,
name: asset.name,
type: inferMediaType(asset.mime_type || ""),
thumbnail_url:
typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined,
duration:
typeof ext.duration === "number"
? ext.duration
: typeof meta.duration === "number"
? (meta.duration as number)
: undefined,
thumbnail_url: asset.thumbnail_url,
duration: asset.duration ?? metaDuration,
size: asset.file_size ?? undefined,
tags: [],
created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined,
classification_status: (asset.classification_status ??
undefined) as MediaAsset["classification_status"],
classification_status: asset.classification_status ?? undefined,
};
}
@@ -324,17 +507,27 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
* 常量
* ============================================================ */
/** 转场效果选项 */
/** 转场效果选项14 种预设) */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"];
label: string;
icon: string;
}[] = [
{ value: "none", label: "无转场" },
{ value: "fade", label: "淡入淡出" },
{ value: "dissolve", label: "溶解" },
{ value: "wipe", label: "擦除" },
{ value: "zoom", label: "缩放" },
{ value: "slide", label: "滑动" },
{ value: "none", label: "无转场", icon: "⊘" },
{ value: "cut", label: "硬切", icon: "✂" },
{ value: "fade", label: "淡入淡出", icon: "◐" },
{ value: "dissolve", label: "溶解", icon: "◈" },
{ value: "zoom", label: "缩放", icon: "⊕" },
{ value: "slide_left", label: "左滑", icon: "←" },
{ value: "slide_right", label: "右滑", icon: "→" },
{ value: "slide_up", label: "上滑", icon: "↑" },
{ value: "slide_down", label: "下滑", icon: "↓" },
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
];
/** 素材类型标签 */
+50 -1
View File
@@ -3,6 +3,15 @@
* 对接后端 /api/v1/templates 路由
*/
import apiClient from "./client";
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types";
/* ──────────── 类型定义 ──────────── */
@@ -72,6 +81,20 @@ export interface EditingTemplate {
bgm_config: BgmConfig;
estimated_duration: number;
segments: TemplateSegment[];
/** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig;
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig;
/** 画中画配置 */
pip_config?: PipConfig;
/** 滤镜调色配置 */
filter_config?: FilterConfig;
/** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig;
/** 贴纸配置 */
sticker_config?: StickerConfig;
/** 封面配置 */
cover_config?: CoverConfig;
is_active?: boolean;
created_at: string;
updated_at: string;
@@ -95,6 +118,20 @@ export interface SaveTemplatePayload {
bgm_config: BgmConfig;
estimated_duration: number;
segments: Omit<TemplateSegment, "id">[];
/** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig;
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig;
/** 画中画配置 */
pip_config?: PipConfig;
/** 滤镜调色配置 */
filter_config?: FilterConfig;
/** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig;
/** 贴纸配置 */
sticker_config?: StickerConfig;
/** 封面配置 */
cover_config?: CoverConfig;
}
/** 使用模板生成请求体 */
@@ -102,11 +139,23 @@ export interface GenerateFromTemplatePayload {
voiceover_duration: number;
}
/** 验证警告详情 */
export interface ValidationWarningDetails {
/** 相关字段名 */
field?: string;
/** 期望值 */
expected?: string | number;
/** 实际值 */
actual?: string | number;
/** 建议值 */
suggested?: string | number;
}
/** 验证/生成响应 */
export interface ValidateWarning {
code: string;
message: string;
details?: Record<string, unknown>;
details?: ValidationWarningDetails;
}
/** 使用模板生成响应 */
+114 -14
View File
@@ -1,8 +1,14 @@
/**
* 成品相关 API
* Phase 1 重构:去掉 projectId,成品直接归属用户
* 成品 / 视频相关 API
* 包含:列表查询、复核状态、批量下载
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
*/
import apiClient from "./client";
import { getGenerationTaskResults } from "./editPlans";
import type { GeneratedVideo } from "./editPlans";
/** 复核状态 */
export type ReviewStatus = "pending_review" | "approved" | "rejected";
/** 成品条目 */
export interface ProductItem {
@@ -14,33 +20,127 @@ export interface ProductItem {
file_size?: number;
resolution?: string;
status: "processing" | "completed" | "failed";
/** 复核状态 */
review_status?: ReviewStatus;
/** 所属项目 ID */
project_id?: string;
/** 所属项目名称 */
project_name?: string;
/** 查重率(百分比) */
duplicate_rate?: number;
created_at?: string;
updated_at?: string;
}
/** 获取当前用户的所有成品 */
export const getProducts = async (): Promise<ProductItem[]> => {
const response = await apiClient.get("/products");
return response.data.items || response.data || [];
/** 列表查询参数 */
export interface ProductListParams {
page?: number;
page_size?: number;
project_id?: string;
review_status?: ReviewStatus | "all";
}
/** 分页响应 */
export interface ProductListResponse {
items: ProductItem[];
total: number;
page: number;
page_size: number;
}
/** 批量下载任务状态 */
export interface BatchDownloadStatus {
job_id: string;
status: "processing" | "completed" | "failed";
/** 完成后返回的下载 URL */
download_url?: string;
/** 进度百分比 */
progress?: number;
}
/**
* 将 generation task 数据映射为 ProductItem 格式
*/
function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
return {
id: task.id,
title: task.name || "未命名视频",
video_url: task.file_url,
thumbnail_url: task.thumbnail_url,
duration_seconds: task.duration,
file_size: task.file_size,
resolution:
task.width && task.height ? `${task.width}x${task.height}` : undefined,
status:
task.status === "completed"
? "completed"
: task.status === "failed"
? "failed"
: "processing",
review_status: task.review_status as ReviewStatus | undefined,
project_id: task.project_id,
created_at: task.created_at,
updated_at: task.updated_at,
};
}
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */
export const getProducts = async (
params?: ProductListParams,
): Promise<ProductItem[]> => {
const response = await apiClient.get("/generation/tasks", { params });
const tasks = response.data.items || response.data || [];
return tasks.map(mapTaskToProductItem);
};
/** 获取单个成品详情 */
/** 获取单个成品详情 — 通过 task ID 获取结果 */
export const getProduct = async (productId: string): Promise<ProductItem> => {
const response = await apiClient.get(`/products/${productId}`);
return response.data;
const response = await apiClient.get(`/generation/tasks/${productId}`);
return mapTaskToProductItem(response.data);
};
/** 删除成品 */
/** 删除成品 — 删除 generation task */
export const deleteProduct = async (productId: string): Promise<void> => {
await apiClient.delete(`/products/${productId}`);
await apiClient.delete(`/generation/tasks/${productId}`);
};
/** 获取成品下载链接 */
/** 获取成品下载链接 — 从 generation task results 获取 */
export const getProductDownloadUrl = async (
productId: string,
): Promise<{ url: string; expires_at: string }> => {
const response = await apiClient.get(`/products/${productId}/download-url`);
return response.data;
const videos = await getGenerationTaskResults(productId);
const video = videos[0];
if (!video?.download_url) throw new Error("下载链接不可用");
return { url: video.download_url, expires_at: "" };
};
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
export const updateReviewStatus = async (
productId: string,
status: ReviewStatus,
): Promise<ProductItem> => {
// 后端暂无 /generation/tasks/{id}/review 端点
// 暂时返回当前状态,后续可扩展
const product = await getProduct(productId);
return { ...product, review_status: status };
};
/** 发起批量下载 — TODO: 后端暂无对应端点 */
export const batchDownload = async (
videoIds: string[],
): Promise<{ job_id: string }> => {
// 后端暂无 /generation/tasks/batch-download 端点
// 暂时返回模拟 job_id,后续可扩展
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds);
return { job_id: `mock-${Date.now()}` };
};
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
export const getBatchDownloadStatus = async (
jobId: string,
): Promise<BatchDownloadStatus> => {
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
// 暂时返回模拟状态,后续可扩展
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId);
return { job_id: jobId, status: "processing", progress: 0 };
};
+58 -12
View File
@@ -1,31 +1,67 @@
/**
* 任务相关 API
* 对接后端方案 A 扩展后的端点(PR #109)
* - POST /api/v1/generation/tasks — 创建生成任务template_id + asset_ids 细粒度模式)
* - GET /api/v1/tasks — 用户级任务列表(跨 project
* - POST /api/v1/tasks/{task_id}/retry — 简化重试
* 对接后端任务中心 API
* - POST /api/v1/generation/tasks — 创建生成任务
* - GET /api/v1/tasks — 用户级任务列表(支持分页/筛选
* - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info
* - POST /api/v1/tasks/{task_id}/retry — 重试失败任务
*/
import apiClient from "./client";
/* ──────────── 类型定义 ──────────── */
/** 任务状态 */
export type TaskStatus =
"pending" | "waiting" | "running" | "completed" | "failed" | "cancelled";
/** 任务类型 */
export type TaskType = "ingest" | "generation" | string;
/** 错误详情 */
export interface TaskErrorInfo {
error_type: string;
error_message: string;
failed_step: string;
stack_trace?: string;
}
/** 任务条目(对应用户级 UserTaskResponse */
export interface TaskItem {
id: string;
task_type: "ingest" | "generation" | string;
task_type: TaskType;
project_id: string;
template_id: string;
status: string;
template_id?: string;
status: TaskStatus;
progress: number;
current_step: string;
error_message: string;
user_message: string;
retryable: boolean;
source_id: string;
/** 错误详情(失败任务) */
error_info?: TaskErrorInfo;
/** 耗时(秒) */
duration_seconds?: number;
created_at?: string | null;
updated_at?: string | null;
}
/** 任务列表查询参数 */
export interface TaskListParams {
page?: number;
page_size?: number;
status?: TaskStatus | "all";
task_type?: TaskType | "all";
}
/** 任务列表分页响应 */
export interface TaskListResponse {
items: TaskItem[];
total: number;
page: number;
page_size: number;
}
/** 创建生成任务请求参数 */
export interface CreateGenerationTaskRequest {
template_id: string;
@@ -64,13 +100,23 @@ export const createGenerationTask = async (
return data;
};
/** 获取当前用户的所有任务(跨 project */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get("/tasks");
return data.items || [];
/** 获取任务列表(支持分页和筛选 */
export const getTasks = async (
params?: TaskListParams,
): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
params,
});
return data;
};
/** 获取单个任务详情(用于轮询进度 */
/** 获取当前用户的所有任务(兼容旧接口,跨 project */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get("/tasks");
return data.items || data || [];
};
/** 获取单个任务详情(含 error_info */
export const getTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.get(`/tasks/${taskId}`);
return data;
+142 -8
View File
@@ -1,26 +1,117 @@
/**
* 模板相关 API
* Phase 1 新增:全局模板库
* 对接后端模板管理接口:
* - GET /api/v1/templates — 模板列表(分页/筛选)
* - GET /api/v1/templates/{id} — 模板详情
* - POST /api/v1/templates/{id}/copy — 复制模板
* - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
*/
import apiClient from "./client";
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner";
import type { EditPlanConfig } from "./editPlans";
/** 模板条目 */
/* ──────────── 类型定义 ──────────── */
/** 模板条目(后端 TemplateResponse */
export interface TemplateItem {
id: string;
user_id?: string;
name: string;
description: string;
description?: string;
mode?: string;
category: string;
target_duration: number;
clip_count: number;
tags?: string[];
/** 预估时长(后端字段名 estimated_duration */
estimated_duration?: number;
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
target_duration?: number;
clip_count?: number;
/** 使用次数 */
usage_count?: number;
thumbnail_url?: string;
preview_url?: string;
is_active: boolean;
is_active?: boolean;
is_favorite?: boolean;
/** 素材规则(片段配置) */
segments?: TemplateSegment[];
/** 字幕样式 */
subtitle_config?: SubtitleConfig;
/** BGM 配置 */
bgm_config?: BgmConfig;
/** 标题配置 */
title_config?: TitleConfig;
/** 视频比例 */
aspect_ratio?: string;
created_at?: string;
updated_at?: string;
}
/** 获取全局模板列表 */
export const getTemplates = async (): Promise<TemplateItem[]> => {
/** 模板片段(素材规则) */
export interface TemplateSegment {
id?: string;
segment_order: number;
duration_min: number;
duration_max: number;
material_type: string | null;
description?: string;
}
/** 模板列表查询参数 */
export interface TemplateListParams {
page?: number;
page_size?: number;
category?: string;
tags?: string;
keyword?: string;
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
duration_range?: "short" | "medium" | "long";
}
/** 模板列表分页响应 */
export interface TemplateListResponse {
items: TemplateItem[];
total: number;
page: number;
page_size: number;
}
/** 从模板生成剪辑计划请求 */
export interface GenerateFromTemplateRequest {
asset_ids?: string[];
name?: string;
config?: EditPlanConfig;
}
/** 从模板生成剪辑计划响应 */
export interface GenerateFromTemplateResponse {
plan_id: string;
template_id: string;
status: string;
name: string;
}
/** 复制模板响应 */
export interface CopyTemplateResponse {
id: string;
name: string;
source_template_id: string;
}
/* ──────────── API 函数 ──────────── */
/** 获取模板列表(支持分页和筛选) */
export const getTemplates = async (
params?: TemplateListParams,
): Promise<TemplateListResponse> => {
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
params,
});
return data;
};
/** 获取模板列表(兼容旧接口,返回数组) */
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
const response = await apiClient.get("/templates");
return response.data.items || response.data || [];
};
@@ -42,3 +133,46 @@ export const toggleFavoriteTemplate = async (
);
return response.data;
};
/** 复制模板(创建副本到我的模板) */
export const copyTemplate = async (
templateId: string,
): Promise<CopyTemplateResponse> => {
const response = await apiClient.post<CopyTemplateResponse>(
`/templates/${templateId}/copy`,
);
return response.data;
};
/** 从模板生成剪辑计划 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
);
return response.data;
};
/* ──────────── 常量 ──────────── */
/** 模板分类选项 */
export const TEMPLATE_CATEGORY_OPTIONS = [
{ value: "", label: "全部分类" },
{ value: "口播", label: "口播" },
{ value: "种草", label: "种草" },
{ value: "产品", label: "产品" },
{ value: "品牌", label: "品牌" },
{ value: "混剪", label: "混剪" },
{ value: "Vlog", label: "Vlog" },
];
/** 时长筛选选项 */
export const TEMPLATE_DURATION_OPTIONS = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
];
+63 -2
View File
@@ -8,6 +8,18 @@ import apiClient from "./client";
/* ── 类型定义 ──────────────────────────────────── */
/** TTS 元数据(合成时附带的扩展信息) */
export interface TTSMetadata {
/** 语音时长(秒) */
duration?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 语言 */
language?: string;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string;
@@ -18,7 +30,7 @@ export interface TTSSynthesizeRequest {
voice_model?: string;
voice_clone_profile_id?: string;
format?: string;
metadata?: Record<string, unknown>;
metadata?: TTSMetadata;
}
/** TTS 合成创建响应 */
@@ -49,7 +61,7 @@ export interface TTSJob {
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: Record<string, unknown> | null;
metadata_: TTSMetadata | null;
created_at: string;
updated_at: string;
}
@@ -140,3 +152,52 @@ export const saveTtsToLibrary = async (
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`);
};
/* ── 音色列表 ──────────────────────────────────── */
/** TTS 音色 */
export interface TTSVoice {
id: string;
name: string;
/** 音色分类标签:male/female/young/service/news/emotion */
category?: string;
/** 语言 */
language?: string;
/** 试听 URL */
preview_url?: string;
/** 描述 */
description?: string;
}
/** 获取 TTS 音色列表 */
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
const response = await apiClient.get<TTSVoice[]>("/tts/voices");
return response.data;
};
/* ── TTS 试听 ──────────────────────────────────── */
/** TTS 试听请求参数 */
export interface TTSPreviewRequest {
text: string;
voice_id: string;
speed?: number;
pitch?: number;
}
/** TTS 试听响应 */
export interface TTSPreviewResponse {
audio_url: string;
duration?: number;
}
/** TTS 试听 */
export const previewTts = async (
data: TTSPreviewRequest,
): Promise<TTSPreviewResponse> => {
const response = await apiClient.post<TTSPreviewResponse>(
"/tts/preview",
data,
);
return response.data;
};
+14 -2
View File
@@ -36,6 +36,18 @@ export interface CreateVoiceCloneRequest {
/* ── 后端 API 类型 ────────────────────────────────────── */
/** 音色克隆元数据(克隆时附带的扩展信息) */
export interface VoiceCloneMetadata {
/** 语音时长(秒) */
duration?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 音色 ID(克隆完成后分配) */
voice_id?: string;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** 后端克隆档案响应 */
export interface VoiceCloneProfile {
id: string;
@@ -51,7 +63,7 @@ export interface VoiceCloneProfile {
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: Record<string, unknown> | null;
metadata_: VoiceCloneMetadata | null;
created_at: string;
updated_at: string;
}
@@ -80,7 +92,7 @@ export interface CreateVoiceCloneRequestFull {
language?: string;
gender?: string;
max_retries?: number;
metadata_?: Record<string, unknown>;
metadata_?: VoiceCloneMetadata;
}
/* ── 辅助函数 ─────────────────────────────────────────── */
-3
View File
@@ -384,7 +384,6 @@
border-bottom: 1px solid var(--border-light) !important;
}
/* ============================================================
响应式
============================================================ */
@@ -409,7 +408,6 @@
.xx-modal .ant-modal-header {
padding: var(--space-md) !important;
}
}
@media (max-width: 480px) {
@@ -423,7 +421,6 @@
}
}
/* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */
/* AdminComingSoon 等页面使用 <Card className="xx-card"> 时需要 */
/* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */
+25
View File
@@ -17,6 +17,7 @@ import {
ScanOutlined,
ControlOutlined,
CrownOutlined,
UnorderedListOutlined,
} from "@ant-design/icons";
/** 导航项类型 */
@@ -80,6 +81,12 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/my-templates",
icon: React.createElement(FolderOutlined),
},
{
key: "edit-plans",
label: "剪辑计划",
path: "/app/edit-plans",
icon: React.createElement(UnorderedListOutlined),
},
{
key: "generate",
label: "一键生成",
@@ -104,6 +111,12 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/duplication",
icon: React.createElement(ScanOutlined),
},
{
key: "tasks",
label: "任务中心",
path: "/app/tasks",
icon: React.createElement(UnorderedListOutlined),
},
];
/** 侧边栏导航分组(Sidebar 分组列表使用) */
@@ -129,6 +142,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/editing-planner",
icon: React.createElement(EditOutlined),
},
{
key: "edit-plans",
label: "剪辑计划",
path: "/app/edit-plans",
icon: React.createElement(UnorderedListOutlined),
},
],
},
{
@@ -181,6 +200,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/history",
icon: React.createElement(HistoryOutlined),
},
{
key: "tasks",
label: "任务中心",
path: "/app/tasks",
icon: React.createElement(UnorderedListOutlined),
},
{
key: "duplication",
label: "查重",
+4 -2
View File
@@ -6,7 +6,7 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConfigProvider } from "antd";
import { ConfigProvider, App as AntApp } from "antd";
import zhCN from "antd/locale/zh_CN";
import router from "./router";
import "./index.css";
@@ -91,7 +91,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={zhCN} theme={theme}>
<RouterProvider router={router} />
<AntApp>
<RouterProvider router={router} />
</AntApp>
</ConfigProvider>
</QueryClientProvider>
</React.StrictMode>,
+417 -12
View File
@@ -4,7 +4,17 @@
* 使用 useQuery 对接后端真实 APIapi/assets.ts
*/
import React, { useMemo, useState } from "react";
import { Upload, Modal as AntModal, message, Popconfirm } from "antd";
import {
Upload,
Modal as AntModal,
message,
Popconfirm,
Drawer,
Tag,
Input as AntInput,
Radio,
Select as AntSelect,
} from "antd";
import {
PlusOutlined,
SearchOutlined,
@@ -17,6 +27,11 @@ import {
ExperimentOutlined,
LoadingOutlined,
ExclamationCircleOutlined,
TagsOutlined,
FolderOutlined,
ThunderboltOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
@@ -27,8 +42,13 @@ import {
deleteAsset,
uploadAssetDirect,
getAssetDiagnosis,
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type AssetLibraryItem,
type AssetItem as ApiAssetItem,
type BatchOperationResult,
} from "@/api/assets";
import { Button, Input, Select } from "@/components/ui";
import "./assets.css";
@@ -407,6 +427,33 @@ const AssetLibrary: React.FC = () => {
/* 诊断中状态 — 记录正在诊断的素材 ID */
const [diagnosingId, setDiagnosingId] = useState<string | null>(null);
/* ── 批量操作弹窗状态 ── */
const [tagModalOpen, setTagModalOpen] = useState(false);
const [classifyModalOpen, setClassifyModalOpen] = useState(false);
const [markModalOpen, setMarkModalOpen] = useState(false);
const [resultDrawerOpen, setResultDrawerOpen] = useState(false);
/* 批量打标签 */
const [batchTagInput, setBatchTagInput] = useState("");
const [batchTags, setBatchTags] = useState<string[]>([]);
const [tagMode, setTagMode] = useState<"add" | "replace">("add");
/* 批量改分类 */
const [batchCategory, setBatchCategory] = useState("");
/* 批量智能标记 */
const [batchSmartView, setBatchSmartView] = useState<
"recommended" | "caution" | "high_risk"
>("recommended");
/* 操作结果 */
const [operationResult, setOperationResult] =
useState<BatchOperationResult | null>(null);
const [operationTitle, setOperationTitle] = useState("");
/* 批量操作 loading */
const [batchLoading, setBatchLoading] = useState(false);
/* 派生数据 */
const filteredAssets = useMemo(() => {
let list = assets;
@@ -563,19 +610,152 @@ const AssetLibrary: React.FC = () => {
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds);
let successCount = 0;
for (const id of ids) {
try {
await deleteAsset(id);
successCount++;
} catch {
// 忽略单个失败
setBatchLoading(true);
try {
const result = await batchDeleteAssets(ids);
setOperationResult(result);
setOperationTitle("批量删除");
setResultDrawerOpen(true);
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
setSelectedIds(new Set());
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`);
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
);
}
} catch {
message.error("批量删除失败,请重试");
} finally {
setBatchLoading(false);
}
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
setSelectedIds(new Set());
message.success(`已删除 ${successCount}/${ids.length} 个素材`);
};
/* 批量打标签 */
const handleBatchTag = async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签");
return;
}
const ids = Array.from(selectedIds);
setBatchLoading(true);
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
});
setOperationResult(result);
setOperationTitle("批量打标签");
setResultDrawerOpen(true);
setTagModalOpen(false);
setBatchTags([]);
setBatchTagInput("");
setTagMode("add");
queryClient.invalidateQueries({ queryKey: ["assets"] });
setSelectedIds(new Set());
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`);
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
);
}
} catch {
message.error("批量打标签失败,请重试");
} finally {
setBatchLoading(false);
}
};
/* 批量改分类 */
const handleBatchClassify = async () => {
if (!batchCategory) {
message.warning("请选择分类");
return;
}
const ids = Array.from(selectedIds);
setBatchLoading(true);
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
});
setOperationResult(result);
setOperationTitle("批量改分类");
setResultDrawerOpen(true);
setClassifyModalOpen(false);
setBatchCategory("");
queryClient.invalidateQueries({ queryKey: ["assets"] });
setSelectedIds(new Set());
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材改为「${batchCategory}`,
);
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
);
}
} catch {
message.error("批量改分类失败,请重试");
} finally {
setBatchLoading(false);
}
};
/* 批量智能标记 */
const handleBatchMark = async () => {
const ids = Array.from(selectedIds);
setBatchLoading(true);
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
});
setOperationResult(result);
setOperationTitle("批量智能标记");
setResultDrawerOpen(true);
setMarkModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["assets"] });
setSelectedIds(new Set());
const labelMap = {
recommended: "推荐",
caution: "慎用",
high_risk: "高风险",
};
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}`,
);
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
);
}
} catch {
message.error("批量智能标记失败,请重试");
} finally {
setBatchLoading(false);
}
};
/* 标签输入处理 */
const handleTagInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault();
const tag = batchTagInput.trim();
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag]);
}
setBatchTagInput("");
}
};
const removeBatchTag = (tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag));
};
// ── Loading 状态 ──
@@ -769,6 +949,30 @@ const AssetLibrary: React.FC = () => {
<Button buttonType="ghost" buttonSize="sm" onClick={deselectAll}>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<TagsOutlined />}
onClick={() => setTagModalOpen(true)}
>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<FolderOutlined />}
onClick={() => setClassifyModalOpen(true)}
>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<ThunderboltOutlined />}
onClick={() => setMarkModalOpen(true)}
>
</Button>
<Popconfirm
title={`确定删除 ${selectedIds.size} 个素材?`}
onConfirm={handleBatchDelete}
@@ -898,6 +1102,207 @@ const AssetLibrary: React.FC = () => {
</div>
)}
</AntModal>
{/* ─── 批量打标签弹窗 ─── */}
<AntModal
title={`批量打标签(${selectedIds.size} 个素材)`}
open={tagModalOpen}
onCancel={() => {
setTagModalOpen(false);
setBatchTags([]);
setBatchTagInput("");
}}
onOk={handleBatchTag}
confirmLoading={batchLoading}
okText="确认打标签"
cancelText="取消"
>
<div className="xx-batch-tag-modal">
<div className="xx-batch-tag-mode">
<span className="xx-batch-tag-mode-label"></span>
<Radio.Group
value={tagMode}
onChange={(e) => setTagMode(e.target.value)}
>
<Radio value="add"></Radio>
<Radio value="replace"></Radio>
</Radio.Group>
</div>
<div className="xx-batch-tag-input-row">
<AntInput
placeholder="输入标签后按 Enter 添加"
value={batchTagInput}
onChange={(e) => setBatchTagInput(e.target.value)}
onKeyDown={handleTagInputKeyDown}
style={{ flex: 1 }}
/>
</div>
{batchTags.length > 0 && (
<div className="xx-batch-tag-list">
{batchTags.map((tag) => (
<Tag
key={tag}
closable
onClose={() => removeBatchTag(tag)}
color="blue"
>
{tag}
</Tag>
))}
</div>
)}
{tagMode === "replace" && batchTags.length > 0 && (
<div className="xx-batch-tag-warning">
<ExclamationCircleOutlined />
</div>
)}
</div>
</AntModal>
{/* ─── 批量改分类弹窗 ─── */}
<AntModal
title={`批量改分类(${selectedIds.size} 个素材)`}
open={classifyModalOpen}
onCancel={() => {
setClassifyModalOpen(false);
setBatchCategory("");
}}
onOk={handleBatchClassify}
confirmLoading={batchLoading}
okText="确认修改"
cancelText="取消"
>
<div className="xx-batch-classify-modal">
<p className="xx-batch-classify-hint">
{selectedIds.size}
</p>
<AntSelect
value={batchCategory || undefined}
onChange={(v) => setBatchCategory(v)}
placeholder="请选择分类"
style={{ width: "100%" }}
options={[
{ value: "person", label: "人物" },
{ value: "scenic", label: "风景" },
{ value: "product", label: "产品" },
{ value: "food", label: "美食" },
{ value: "animal", label: "动物" },
{ value: "architecture", label: "建筑" },
{ value: "other", label: "其他" },
]}
/>
</div>
</AntModal>
{/* ─── 批量智能标记弹窗 ─── */}
<AntModal
title={`批量智能标记(${selectedIds.size} 个素材)`}
open={markModalOpen}
onCancel={() => setMarkModalOpen(false)}
onOk={handleBatchMark}
confirmLoading={batchLoading}
okText="确认标记"
cancelText="取消"
>
<div className="xx-batch-mark-modal">
<p className="xx-batch-mark-hint">
{selectedIds.size}
</p>
<Radio.Group
value={batchSmartView}
onChange={(e) => setBatchSmartView(e.target.value)}
className="xx-batch-mark-options"
>
<div className="xx-batch-mark-option">
<Radio value="recommended">
<Tag color="success"></Tag>
<span className="xx-batch-mark-desc">
</span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="caution">
<Tag color="warning"></Tag>
<span className="xx-batch-mark-desc">
使
</span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="high_risk">
<Tag color="error"></Tag>
<span className="xx-batch-mark-desc">
使
</span>
</Radio>
</div>
</Radio.Group>
</div>
</AntModal>
{/* ─── 操作结果 Drawer ─── */}
<Drawer
title={`${operationTitle} — 操作结果`}
open={resultDrawerOpen}
onClose={() => {
setResultDrawerOpen(false);
setOperationResult(null);
}}
width={420}
>
{operationResult && (
<div className="xx-batch-result">
<div className="xx-batch-result-summary">
<div className="xx-batch-result-stat">
<span className="xx-batch-result-total">
{operationResult.total}
</span>
</div>
<div className="xx-batch-result-stat success">
<CheckCircleOutlined />
<span> {operationResult.success_count} </span>
</div>
{operationResult.failure_count > 0 && (
<div className="xx-batch-result-stat fail">
<CloseCircleOutlined />
<span> {operationResult.failure_count} </span>
</div>
)}
</div>
{operationResult.succeeded.length > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title success">
<CheckCircleOutlined />
</h4>
<div className="xx-batch-result-ids">
{operationResult.succeeded.map((id) => (
<div key={id} className="xx-batch-result-id">
{id}
</div>
))}
</div>
</div>
)}
{operationResult.failed.length > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title fail">
<CloseCircleOutlined />
</h4>
<div className="xx-batch-result-ids">
{operationResult.failed.map((id) => (
<div key={id} className="xx-batch-result-id fail">
{id}
</div>
))}
</div>
</div>
)}
</div>
)}
</Drawer>
</div>
);
};
+172
View File
@@ -653,3 +653,175 @@
font-size: 13px;
color: var(--text-secondary, #6b7280);
}
/* ─── 批量打标签弹窗 ─── */
.xx-batch-tag-modal {
display: flex;
flex-direction: column;
gap: 16px;
}
.xx-batch-tag-mode {
display: flex;
align-items: center;
gap: 8px;
}
.xx-batch-tag-mode-label {
font-size: 14px;
color: var(--text-primary, #111827);
font-weight: 500;
}
.xx-batch-tag-input-row {
display: flex;
gap: 8px;
}
.xx-batch-tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.xx-batch-tag-warning {
padding: 10px 12px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: var(--radius-md, 8px);
color: #c2410c;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
/* ─── 批量改分类弹窗 ─── */
.xx-batch-classify-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-batch-classify-hint {
font-size: 14px;
color: var(--text-secondary, #6b7280);
margin: 0;
}
/* ─── 批量智能标记弹窗 ─── */
.xx-batch-mark-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-batch-mark-hint {
font-size: 14px;
color: var(--text-secondary, #6b7280);
margin: 0;
}
.xx-batch-mark-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-batch-mark-option {
display: flex;
flex-direction: column;
}
.xx-batch-mark-desc {
margin-left: 8px;
font-size: 13px;
color: var(--text-secondary, #6b7280);
}
/* ─── 操作结果 Drawer ─── */
.xx-batch-result {
display: flex;
flex-direction: column;
gap: 20px;
}
.xx-batch-result-summary {
display: flex;
gap: 16px;
padding: 16px;
background: var(--bg-secondary, #f9fafb);
border-radius: var(--radius-md, 8px);
}
.xx-batch-result-stat {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--text-primary, #111827);
}
.xx-batch-result-stat.success {
color: #059669;
}
.xx-batch-result-stat.fail {
color: #dc2626;
}
.xx-batch-result-total {
font-weight: 600;
}
.xx-batch-result-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-batch-result-section-title {
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
margin: 0;
}
.xx-batch-result-section-title.success {
color: #059669;
}
.xx-batch-result-section-title.fail {
color: #dc2626;
}
.xx-batch-result-ids {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 300px;
overflow-y: auto;
}
.xx-batch-result-id {
padding: 6px 10px;
background: var(--bg-surface, #fff);
border: 1px solid var(--border-primary, #e5e7eb);
border-radius: var(--radius-sm, 4px);
font-size: 12px;
font-family: monospace;
color: var(--text-secondary, #6b7280);
word-break: break-all;
}
.xx-batch-result-id.fail {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
@@ -33,8 +33,9 @@ const formatSize = (bytes: number) => {
/** 格式化时长 */
const formatDuration = (seconds?: number) => {
if (!seconds) return "-";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const totalSec = Math.round(seconds);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return m > 0 ? `${m}${s}` : `${s}`;
};
@@ -59,8 +59,9 @@ const formatSize = (bytes: number) => {
/** 格式化时长 */
const formatDuration = (seconds?: number) => {
if (!seconds) return "-";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const totalSec = Math.round(seconds);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return m > 0 ? `${m}${s}` : `${s}`;
};
+444
View File
@@ -0,0 +1,444 @@
/**
*
*
*/
import { useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Table,
Tabs,
Select,
Tag,
Button,
message,
Popconfirm,
Tooltip,
} from "antd";
import {
CheckCircleOutlined,
ClockCircleOutlined,
SyncOutlined,
CloseCircleOutlined,
EditOutlined,
DeleteOutlined,
FileTextOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getEditPlans,
deleteEditPlan,
generateEditPlan,
type EditPlan,
type EditPlanStatus,
type EditPlanListParams,
} from "@/api/editPlans";
import { getTemplatesList, type TemplateItem } from "@/api/templates";
import "./edit-plans.css";
/* ──────────── 常量 ──────────── */
/** 状态 Tab 配置 */
const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "draft", label: "草稿" },
{ key: "editing", label: "编辑中" },
{ key: "rendering", label: "渲染中" },
{ key: "completed", label: "已完成" },
{ key: "failed", label: "失败" },
];
/** 状态标签配置 */
const STATUS_CONFIG: Record<
EditPlanStatus,
{ label: string; color: string; icon: React.ReactNode }
> = {
draft: {
label: "草稿",
color: "default",
icon: <FileTextOutlined />,
},
editing: {
label: "编辑中",
color: "processing",
icon: <EditOutlined />,
},
rendering: {
label: "渲染中",
color: "warning",
icon: <SyncOutlined spin />,
},
completed: {
label: "已完成",
color: "success",
icon: <CheckCircleOutlined />,
},
failed: {
label: "失败",
color: "error",
icon: <CloseCircleOutlined />,
},
};
/* ──────────── 工具函数 ──────────── */
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
if (seconds <= 0) return "-";
const totalSec = Math.round(seconds);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
if (m === 0) return `${s}`;
return `${m}${s > 0 ? `${s}` : ""}`;
};
/** 格式化时间 */
const formatTime = (dateStr?: string | null): string => {
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
/* ──────────── 主组件 ──────────── */
export default function EditPlans() {
const navigate = useNavigate();
const queryClient = useQueryClient();
// 筛选状态
const [statusFilter, setStatusFilter] = useState<EditPlanStatus | "all">(
"all",
);
const [templateFilter, setTemplateFilter] = useState<string>("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
// 查询参数
const queryParams: EditPlanListParams = {
page,
page_size: pageSize,
...(statusFilter !== "all" && { status: statusFilter }),
...(templateFilter !== "all" && { template_id: templateFilter }),
};
// 获取剪辑计划列表
const {
data: planData,
isLoading,
error,
} = useQuery({
queryKey: ["edit-plans", queryParams],
queryFn: () => getEditPlans(queryParams),
refetchInterval: (query) => {
// 有进行中的计划时自动刷新
const plans = query.state.data?.items ?? [];
const hasRunning = plans.some(
(p) => p.status === "rendering" || p.status === "editing",
);
return hasRunning ? 5000 : false;
},
});
// 获取模板列表(用于筛选下拉)
const { data: templates } = useQuery({
queryKey: ["templates-list-simple"],
queryFn: getTemplatesList,
});
const plans = planData?.items ?? [];
const total = planData?.total ?? 0;
// 模板名称映射
const templateNameMap = new Map<string, string>();
(templates ?? []).forEach((t: TemplateItem) => {
templateNameMap.set(t.id, t.name);
});
// 删除计划
const deleteMutation = useMutation({
mutationFn: deleteEditPlan,
onSuccess: () => {
message.success("剪辑计划已删除");
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
},
onError: () => {
message.error("删除失败,请稍后重试");
},
});
// 重新生成
const regenerateMutation = useMutation({
mutationFn: generateEditPlan,
onSuccess: () => {
message.success("已重新提交生成");
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
},
onError: () => {
message.error("重新生成失败,请稍后重试");
},
});
// 跳转到剪辑编辑器
const handleEdit = useCallback(
(plan: EditPlan) => {
navigate(`/app/editing-planner?planId=${plan.id}`);
},
[navigate],
);
// 表格列定义
const columns: ColumnsType<EditPlan> = [
{
title: "计划名称",
dataIndex: "name",
key: "name",
width: 240,
ellipsis: true,
render: (name: string, record: EditPlan) => (
<Tooltip title={name}>
<span className="plan-name" onClick={() => handleEdit(record)}>
{name}
</span>
</Tooltip>
),
},
{
title: "模板",
dataIndex: "template_id",
key: "template_id",
width: 140,
ellipsis: true,
render: (templateId: string) => {
const name = templateNameMap.get(templateId);
return (
<Tag color="blue" className="plan-template-tag">
{name || templateId.slice(0, 8)}
</Tag>
);
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: EditPlanStatus) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
};
return (
<Tag
color={config.color}
icon={config.icon}
className="plan-status-tag"
>
{config.label}
</Tag>
);
},
},
{
title: "时长",
dataIndex: "total_duration",
key: "total_duration",
width: 100,
render: (seconds: number) => (
<span className="plan-duration">{formatDuration(seconds)}</span>
),
},
{
title: "视频数",
dataIndex: "result_count",
key: "result_count",
width: 80,
align: "center",
render: (count: number) => (
<span className="plan-result-count">{count > 0 ? count : "—"}</span>
),
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 130,
render: (time: string) => (
<span className="plan-time">{formatTime(time)}</span>
),
},
{
title: "更新时间",
dataIndex: "updated_at",
key: "updated_at",
width: 130,
render: (time: string) => (
<span className="plan-time">{formatTime(time)}</span>
),
},
{
title: "操作",
key: "action",
width: 180,
fixed: "right",
render: (_: unknown, record: EditPlan) => (
<div className="plan-actions">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
className="plan-action-btn"
>
</Button>
{(record.status === "failed" || record.status === "completed") && (
<Popconfirm
title="确认重新生成"
description="确定要重新生成这个剪辑计划吗?"
onConfirm={() => regenerateMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<ThunderboltOutlined />}
loading={regenerateMutation.isPending}
className="plan-action-btn plan-regenerate-btn"
>
</Button>
</Popconfirm>
)}
<Popconfirm
title="确认删除"
description="确定要删除这个剪辑计划吗?此操作不可恢复。"
onConfirm={() => deleteMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
loading={deleteMutation.isPending}
className="plan-action-btn"
>
</Button>
</Popconfirm>
</div>
),
},
];
// 错误处理
if (error) {
return (
<div className="edit-plans-page">
<div className="edit-plans-error">
<CloseCircleOutlined />
<p></p>
<Button onClick={() => window.location.reload()}></Button>
</div>
</div>
);
}
return (
<div className="edit-plans-page">
{/* 页面标题 */}
<div className="edit-plans-header">
<div className="edit-plans-header-text">
<h2></h2>
<p></p>
</div>
<Button type="primary" onClick={() => navigate("/app/templates")}>
</Button>
</div>
{/* 筛选栏 */}
<div className="edit-plans-filters">
{/* 状态 Tab */}
<Tabs
activeKey={statusFilter}
onChange={(key) => {
setStatusFilter(key as EditPlanStatus | "all");
setPage(1);
}}
items={STATUS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
}))}
className="edit-plans-status-tabs"
/>
{/* 模板筛选 */}
<Select
value={templateFilter}
onChange={(value) => {
setTemplateFilter(value);
setPage(1);
}}
options={[
{ value: "all", label: "全部模板" },
...(templates ?? []).map((t: TemplateItem) => ({
value: t.id,
label: t.name,
})),
]}
style={{ minWidth: 180 }}
placeholder="选择模板"
className="edit-plans-template-filter"
/>
</div>
{/* 计划表格 */}
<Table
columns={columns}
dataSource={plans}
rowKey="id"
loading={isLoading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
scroll={{ x: 900 }}
className="edit-plans-table"
locale={{
emptyText: (
<div className="edit-plans-empty">
<ClockCircleOutlined />
<p></p>
<Button
type="primary"
style={{ marginTop: 12 }}
onClick={() => navigate("/app/templates")}
>
</Button>
</div>
),
}}
/>
</div>
);
}
@@ -0,0 +1,260 @@
/**
* 剪辑计划管理页面样式
*/
/* ── 页面容器 ──────────────────────────────────────────── */
.edit-plans-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
/* ── 页面头部 ──────────────────────────────────────────── */
.edit-plans-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
}
.edit-plans-header-text h2 {
margin: 0 0 4px;
font-size: 22px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.edit-plans-header-text p {
margin: 0;
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── 筛选栏 ────────────────────────────────────────────── */
.edit-plans-filters {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.edit-plans-status-tabs {
flex: 1;
}
.edit-plans-status-tabs .ant-tabs-nav {
margin-bottom: 0 !important;
}
.edit-plans-status-tabs .ant-tabs-tab {
padding: 8px 16px !important;
font-size: 14px;
}
.edit-plans-status-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--primary-500, #6366f1) !important;
font-weight: 500;
}
.edit-plans-status-tabs .ant-tabs-ink-bar {
background: var(--primary-500, #6366f1) !important;
}
.edit-plans-template-filter {
min-width: 180px;
}
/* ── 表格 ──────────────────────────────────────────────── */
.edit-plans-table {
background: var(--bg-surface, #fff);
border-radius: var(--radius-lg, 12px);
overflow: hidden;
border: 1px solid var(--border-primary, #e2e8f0);
}
.edit-plans-table .ant-table {
font-size: 14px;
}
.edit-plans-table .ant-table-thead > tr > th {
background: var(--bg-tertiary, #f8fafc) !important;
border-bottom: 1px solid var(--border-primary, #e2e8f0);
font-weight: 500;
color: var(--text-secondary, #64748b);
font-size: 13px;
padding: 12px 16px;
}
.edit-plans-table .ant-table-tbody > tr > td {
padding: 14px 16px;
border-bottom: 1px solid var(--border-light, #f1f5f9);
}
.edit-plans-table .ant-table-tbody > tr:hover > td {
background: var(--bg-hover, #f8fafc) !important;
}
/* ── 计划名称 ──────────────────────────────────────────── */
.plan-name {
font-weight: 500;
color: var(--text-primary, #1e293b);
cursor: pointer;
transition: color 0.2s;
}
.plan-name:hover {
color: var(--primary-500, #6366f1);
}
/* ── 状态标签 ──────────────────────────────────────────── */
.plan-status-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
}
.plan-status-tag.ant-tag-default {
background: #f1f5f9;
color: #64748b;
border-color: transparent;
}
.plan-status-tag.ant-tag-processing {
background: #eff6ff;
color: #2563eb;
border-color: transparent;
}
.plan-status-tag.ant-tag-success {
background: #f0fdf4;
color: #16a34a;
border-color: transparent;
}
.plan-status-tag.ant-tag-error {
background: #fef2f2;
color: #dc2626;
border-color: transparent;
}
.plan-status-tag.ant-tag-warning {
background: #fffbeb;
color: #d97706;
border-color: transparent;
}
/* ── 时长 ──────────────────────────────────────────────── */
.plan-duration {
font-variant-numeric: tabular-nums;
color: var(--text-secondary, #64748b);
}
/* ── 时间 ──────────────────────────────────────────────── */
.plan-time {
color: var(--text-secondary, #64748b);
font-size: 13px;
}
/* ── 操作按钮 ──────────────────────────────────────────── */
.plan-actions {
display: flex;
gap: 4px;
}
.plan-action-btn {
padding: 4px 8px !important;
font-size: 13px !important;
}
.plan-action-btn.ant-btn-link {
color: var(--primary-500, #6366f1);
}
.plan-action-btn.ant-btn-link:hover {
color: var(--primary-600, #4f46e5);
}
.plan-regenerate-btn {
color: var(--primary-500, #6366f1) !important;
}
.plan-regenerate-btn:hover {
color: var(--primary-600, #4f46e5) !important;
}
/* ── 空状态 ────────────────────────────────────────────── */
.edit-plans-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.edit-plans-empty .anticon {
font-size: 48px;
color: var(--text-disabled, #cbd5e1);
margin-bottom: 16px;
}
.edit-plans-empty p {
margin: 0;
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── 错误状态 ──────────────────────────────────────────── */
.edit-plans-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
background: var(--bg-surface, #fff);
border-radius: var(--radius-lg, 12px);
border: 1px solid var(--border-primary, #e2e8f0);
}
.edit-plans-error .anticon {
font-size: 48px;
color: #ef4444;
margin-bottom: 16px;
}
.edit-plans-error p {
margin: 0 0 16px;
font-size: 14px;
color: var(--text-secondary, #64748b);
}
/* ── 响应式 ────────────────────────────────────────────── */
@media (max-width: 768px) {
.edit-plans-page {
padding: 16px;
}
.edit-plans-header {
flex-direction: column;
gap: 12px;
}
.edit-plans-filters {
flex-direction: column;
align-items: stretch;
}
.edit-plans-status-tabs {
width: 100%;
}
.edit-plans-template-filter {
width: 100%;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,292 @@
/**
* BGM Drawer
* BGM //
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import { Drawer, Slider, Input, Tag, message } from "antd";
import {
getBgmPresets,
type BgmPreset,
type BgmCategory,
type BgmMixConfig,
DEFAULT_BGM_MIX_CONFIG,
} from "@/api/bgm";
const { Search } = Input;
/* ──────────── 分类标签 ──────────── */
const CATEGORY_LIST: {
key: BgmCategory | "all";
label: string;
icon: string;
}[] = [
{ key: "all", label: "全部", icon: "🎶" },
{ key: "轻快", label: "轻快", icon: "🎉" },
{ key: "治愈", label: "治愈", icon: "🌿" },
{ key: "科技", label: "科技", icon: "🔬" },
{ key: "电商", label: "电商", icon: "🛒" },
];
/* ──────────── Props ──────────── */
interface BgmSelectorProps {
open: boolean;
onClose: () => void;
config: BgmMixConfig;
onChange: (config: BgmMixConfig) => void;
}
const BgmSelector: React.FC<BgmSelectorProps> = ({
open,
onClose,
config,
onChange,
}) => {
const [presets, setPresets] = useState<BgmPreset[]>([]);
const [loading, setLoading] = useState(false);
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">(
"all",
);
const [keyword, setKeyword] = useState("");
const [previewingId, setPreviewingId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
/* ── 加载 BGM 列表 ── */
const loadPresets = useCallback(async () => {
setLoading(true);
try {
const params: { category?: string; keyword?: string } = {};
if (activeCategory !== "all") params.category = activeCategory;
if (keyword.trim()) params.keyword = keyword.trim();
const data = await getBgmPresets(params);
setPresets(data);
} catch {
message.error("加载 BGM 列表失败");
} finally {
setLoading(false);
}
}, [activeCategory, keyword]);
useEffect(() => {
if (open) loadPresets();
}, [open, loadPresets]);
/* ── 试听 ── */
const handlePreview = useCallback(
(bgm: BgmPreset) => {
if (previewingId === bgm.id) {
audioRef.current?.pause();
setPreviewingId(null);
return;
}
audioRef.current?.pause();
const audio = new Audio(bgm.url);
audioRef.current = audio;
audio.play().catch(() => {});
audio.onended = () => setPreviewingId(null);
setPreviewingId(bgm.id);
},
[previewingId],
);
/* ── 选中 BGM ── */
const handleSelect = useCallback(
(bgm: BgmPreset) => {
onChange({
...config,
enabled: true,
music_id: bgm.id,
});
},
[config, onChange],
);
/* ── 关闭时停止播放 ── */
const handleClose = useCallback(() => {
audioRef.current?.pause();
setPreviewingId(null);
onClose();
}, [onClose]);
/* ── 移除 BGM ── */
const handleClear = useCallback(() => {
audioRef.current?.pause();
setPreviewingId(null);
onChange({ ...DEFAULT_BGM_MIX_CONFIG });
}, [onChange]);
/* ── 当前选中的 BGM ── */
const selectedBgm = presets.find((p) => p.id === config.music_id);
return (
<Drawer
title="🎵 BGM 音乐选择"
placement="right"
width={420}
open={open}
onClose={handleClose}
className="bgm-selector-drawer"
>
{/* ── 搜索框 ── */}
<div className="bgm-search-row">
<Search
placeholder="搜索 BGM 名称..."
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadPresets()}
/>
</div>
{/* ── 分类标签 ── */}
<div className="bgm-category-bar">
{CATEGORY_LIST.map((cat) => (
<Tag
key={cat.key}
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
onClick={() => setActiveCategory(cat.key)}
>
{cat.icon} {cat.label}
</Tag>
))}
</div>
{/* ── BGM 列表 ── */}
<div className="bgm-list">
{loading && <div className="bgm-loading">...</div>}
{!loading && presets.length === 0 && (
<div className="bgm-empty"> BGM </div>
)}
{presets.map((bgm) => {
const isSelected = config.music_id === bgm.id;
const isPlaying = previewingId === bgm.id;
return (
<div
key={bgm.id}
className={`bgm-item${isSelected ? " selected" : ""}`}
onClick={() => handleSelect(bgm)}
>
<div className="bgm-item-cover">
{bgm.cover_url ? (
<img src={bgm.cover_url} alt={bgm.name} />
) : (
<span className="bgm-item-cover-icon">🎵</span>
)}
</div>
<div className="bgm-item-info">
<div className="bgm-item-name">{bgm.name}</div>
<div className="bgm-item-meta">
<span className="bgm-item-category">{bgm.category}</span>
<span className="bgm-item-duration">
{Math.floor(bgm.duration / 60)}:
{String(Math.floor(bgm.duration % 60)).padStart(2, "0")}
</span>
</div>
{bgm.tags.length > 0 && (
<div className="bgm-item-tags">
{bgm.tags.slice(0, 3).map((t) => (
<span key={t} className="bgm-item-tag">
{t}
</span>
))}
</div>
)}
</div>
<button
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
onClick={(e) => {
e.stopPropagation();
handlePreview(bgm);
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? "⏸" : "▶️"}
</button>
{isSelected && <span className="bgm-item-check"></span>}
</div>
);
})}
</div>
{/* ── 混音配置 ── */}
{config.enabled && config.music_id && (
<div className="bgm-mix-config">
<div className="bgm-mix-header">
<span></span>
<button className="bgm-mix-clear" onClick={handleClear}>
BGM
</button>
</div>
<div className="bgm-mix-selected">
{selectedBgm
? `当前:${selectedBgm.name}`
: `当前:${config.music_id}`}
</div>
{/* 音量 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.volume}%</span>
</label>
<Slider
min={0}
max={100}
value={config.volume}
onChange={(v) => onChange({ ...config, volume: v })}
/>
</div>
{/* 淡入 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
{" "}
<span className="bgm-mix-value">
{config.fade_in.toFixed(1)}s
</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_in}
onChange={(v) => onChange({ ...config, fade_in: v })}
/>
</div>
{/* 淡出 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
{" "}
<span className="bgm-mix-value">
{config.fade_out.toFixed(1)}s
</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_out}
onChange={(v) => onChange({ ...config, fade_out: v })}
/>
</div>
{/* 人声闪避 */}
<div className="bgm-mix-field bgm-mix-toggle-row">
<label className="bgm-mix-label">sidechain</label>
<div
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
onClick={() =>
onChange({ ...config, voice_dodge: !config.voice_dodge })
}
>
<div className="ep-toggle-knob" />
</div>
</div>
</div>
)}
</Drawer>
);
};
export default BgmSelector;
@@ -5,32 +5,30 @@
import React, { useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import type { TemplateMode } from "@/api/editingPlanner";
import type { ClipData, ClipType } from "../types";
import type { ClipData, ClipType, TitleSettings } from "../types";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
import type { AssetItem } from "@/api/assets";
interface TitleSettings {
aiAutoSelect: boolean;
title: string;
position: string;
font: string;
size: number;
bold: boolean;
italic: boolean;
stroke: boolean;
shadow: boolean;
color: string;
}
interface SubtitleSettings {
enabled: boolean;
position: string;
font: string;
size: number;
fontSize: number;
fontColor: string;
animation: string;
mode?: string;
stroke?: boolean;
shadow?: boolean;
asrLanguage?: string;
}
interface BgmSettings {
music: string;
enabled: boolean;
music_id: string;
volume?: number;
fade_in?: number;
fade_out?: number;
voice_dodge?: boolean;
}
interface ClipPropertiesPanelProps {
@@ -45,6 +43,10 @@ interface ClipPropertiesPanelProps {
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
/** 打开 BGM 选择器 Drawer */
onOpenBgmDrawer?: () => void;
/** 打开字幕样式配置 Drawer */
onOpenSubtitleDrawer?: () => void;
/** 配音素材列表(从配音素材库 API 获取) */
voiceMaterials?: AssetItem[];
/** 配音素材加载中 */
@@ -53,6 +55,26 @@ interface ClipPropertiesPanelProps {
onRefreshVoiceMaterials?: () => void;
/** 为片段选择配音素材 */
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void;
/** 打开转场特效选择器 Drawer */
onOpenTransitionDrawer?: (clipId: string) => void;
/** 打开片段调速面板 Drawer */
onOpenSpeedDrawer?: (clipId: string) => void;
/** 打开 TTS 配音面板 Drawer */
onOpenTtsDrawer?: (clipId: string) => void;
/** 打开水印设置面板 Drawer */
onOpenWatermarkDrawer?: () => void;
/** 打开片头片尾设置面板 Drawer */
onOpenIntroOutroDrawer?: () => void;
/** 打开画中画设置面板 Drawer */
onOpenPipDrawer?: () => void;
/** 打开滤镜调色面板 Drawer */
onOpenFilterDrawer?: () => void;
/** 打开绿幕抠像面板 Drawer */
onOpenGreenScreenDrawer?: () => void;
/** 打开贴纸面板 Drawer */
onOpenStickerDrawer?: () => void;
/** 打开封面选择器 Drawer */
onOpenCoverDrawer?: () => void;
}
const POSITION_OPTIONS = [
@@ -78,14 +100,6 @@ const ANIMATION_OPTIONS = [
{ value: "typewriter", label: "打字机" },
];
const BGM_OPTIONS = [
{ value: "none", label: "无背景音乐" },
{ value: "bgm_01", label: "🎵 轻快节奏" },
{ value: "bgm_02", label: "🎵 温馨舒缓" },
{ value: "bgm_03", label: "🎵 动感活力" },
{ value: "bgm_04", label: "🎵 科技感" },
];
/**
* ++++
*
@@ -272,12 +286,24 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
currentMode,
onTitleSettingsChange,
onSubtitleSettingsChange,
onBgmSettingsChange,
onBgmSettingsChange: _onBgmSettingsChange,
onClipUpdate,
onOpenBgmDrawer,
onOpenSubtitleDrawer,
voiceMaterials = [],
voiceMaterialsLoading = false,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
onOpenWatermarkDrawer,
onOpenIntroOutroDrawer,
onOpenPipDrawer,
onOpenFilterDrawer,
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
onOpenCoverDrawer,
}) => {
const navigate = useNavigate();
@@ -534,15 +560,17 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
<input
className="ep-slider"
type="range"
min={10}
max={32}
value={subtitleSettings.size}
min={12}
max={48}
value={subtitleSettings.fontSize}
onChange={(e) =>
onSubtitleSettingsChange({ size: Number(e.target.value) })
onSubtitleSettingsChange({
fontSize: Number(e.target.value),
})
}
/>
<span className="ep-slider-value">
{subtitleSettings.size}px
{subtitleSettings.fontSize}px
</span>
</div>
</div>
@@ -563,6 +591,16 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
))}
</select>
</div>
{/* 高级配置按钮 */}
{onOpenSubtitleDrawer && (
<button
className="ep-advanced-btn"
onClick={onOpenSubtitleDrawer}
>
🎨
</button>
)}
</>
)}
</div>
@@ -574,20 +612,130 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
BGM
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={bgmSettings.music}
onChange={(e) => onBgmSettingsChange({ music: e.target.value })}
>
{BGM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{bgmSettings.enabled && bgmSettings.music_id ? (
<div className="ep-bgm-current">
<span className="ep-bgm-current-label">🎵 BGM</span>
<span className="ep-bgm-current-id">{bgmSettings.music_id}</span>
{bgmSettings.volume !== undefined && (
<span className="ep-bgm-current-vol">
{bgmSettings.volume}%
</span>
)}
</div>
) : (
<div className="ep-bgm-empty"></div>
)}
{onOpenBgmDrawer && (
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
🎵 {bgmSettings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
</button>
)}
</div>
{/* ═══ 水印设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🔖</span>
</div>
{onOpenWatermarkDrawer && (
<button className="ep-advanced-btn" onClick={onOpenWatermarkDrawer}>
<span className="ep-advanced-btn-icon">🔖</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 片头片尾设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎬</span>
</div>
{onOpenIntroOutroDrawer && (
<button className="ep-advanced-btn" onClick={onOpenIntroOutroDrawer}>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 画中画 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🖼</span>
</div>
{onOpenPipDrawer && (
<button className="ep-advanced-btn" onClick={onOpenPipDrawer}>
<span className="ep-advanced-btn-icon">🖼</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 滤镜调色 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎨</span>
</div>
{onOpenFilterDrawer && (
<button className="ep-advanced-btn" onClick={onOpenFilterDrawer}>
<span className="ep-advanced-btn-icon">🎨</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 绿幕抠像 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🟩</span>
绿
</div>
{onOpenGreenScreenDrawer && (
<button className="ep-advanced-btn" onClick={onOpenGreenScreenDrawer}>
<span className="ep-advanced-btn-icon">🟩</span>
<span className="ep-advanced-btn-label">绿</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 贴纸 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🏷</span>
</div>
{onOpenStickerDrawer && (
<button className="ep-advanced-btn" onClick={onOpenStickerDrawer}>
<span className="ep-advanced-btn-icon">🏷</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 封面 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🖼</span>
</div>
{onOpenCoverDrawer && (
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
<span className="ep-advanced-btn-icon">🖼</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 片段详情(选中时显示) ═══ */}
@@ -649,6 +797,71 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
</div>
</div>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">
{(() => {
const t = selectedClip.transition;
if (!t || t.type === "none") return "无转场";
const opt = TRANSITION_OPTIONS.find(
(o) => o.value === t.type,
);
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`;
})()}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">
{selectedClip.speed
? `${selectedClip.speed.rate.toFixed(2)}x`
: "1.00x"}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">
{(() => {
const tts = selectedClip.tts_config;
if (!tts || tts.mode === "none") return "无配音";
if (tts.mode === "upload") return "上传配音";
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`;
})()}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 素材起始时间 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
@@ -0,0 +1,301 @@
/**
*
* + +
*/
import React, { useCallback, useRef, useState } from "react";
import { Drawer } from "antd";
import type { CoverConfig, CoverMode } from "../types";
import { DEFAULT_COVER_CONFIG } from "../types";
interface CoverSelectorProps {
open: boolean;
onClose: () => void;
config: CoverConfig;
onChange: (config: CoverConfig) => void;
totalDuration: number;
}
/** 封面模式标签 */
const MODE_LABELS: Record<CoverMode, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
};
/** 封面模式图标 */
const MODE_ICONS: Record<CoverMode, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
};
const CoverSelector: React.FC<CoverSelectorProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const update = useCallback(
(partial: Partial<CoverConfig>) => {
onChange({ ...config, ...partial });
},
[config, onChange],
);
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled });
}, [config.enabled, onChange]);
/** 切换模式 */
const handleModeChange = useCallback(
(mode: CoverMode) => {
update({ mode });
},
[update],
);
/** 处理文件上传 */
const handleFileUpload = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = (e) => {
const url = e.target?.result as string;
update({ upload_url: url, thumbnail_url: url, mode: "upload" });
};
reader.readAsDataURL(file);
},
[update],
);
/** 拖拽上传 */
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFileUpload(file);
},
[handleFileUpload],
);
/** 使用 AI 推荐时间 */
const handleUseAiSuggestion = useCallback(() => {
if (config.ai_suggested_time !== null) {
update({ frame_time: config.ai_suggested_time, mode: "frame" });
}
}, [config.ai_suggested_time, update]);
/** 格式化时间 */
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 10);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`;
};
return (
<Drawer
title="封面选择"
placement="right"
width={440}
open={open}
onClose={onClose}
className="cover-selector-drawer"
>
{/* 顶部开关 */}
<div className="cover-header">
<span className="cover-header-label"></span>
<label className="cover-switch">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
/>
<span className="cover-switch-slider" />
</label>
</div>
{/* 模式选择 */}
<div className="cover-mode-section">
<div className="cover-section-title"></div>
<div className="cover-mode-tabs">
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
<button
key={m}
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
onClick={() => handleModeChange(m)}
>
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
</button>
))}
</div>
</div>
{/* 模式内容区 */}
<div className="cover-mode-content">
{/* 智能封面 */}
{config.mode === "auto" && (
<div className="cover-auto-section">
<div className="cover-auto-desc">
AI
</div>
{config.ai_suggested_time !== null ? (
<div className="cover-auto-suggestion">
<div className="cover-auto-badge">AI </div>
<div className="cover-auto-time">
{formatTime(config.ai_suggested_time)}
</div>
<button
className="cover-auto-use-btn"
onClick={handleUseAiSuggestion}
>
使
</button>
</div>
) : (
<div className="cover-auto-pending">
<div className="cover-auto-spinner" />
<span>AI ...</span>
</div>
)}
</div>
)}
{/* 抽帧选封面 */}
{config.mode === "frame" && (
<div className="cover-frame-section">
<div className="cover-frame-preview">
<div className="cover-frame-placeholder">
<span className="cover-frame-icon">🎞</span>
<span className="cover-frame-time">
{formatTime(config.frame_time)}
</span>
</div>
</div>
<div className="cover-frame-timeline">
<div className="cover-frame-slider-header">
<span className="cover-frame-slider-label"></span>
<span className="cover-frame-slider-value">
{formatTime(config.frame_time)}
</span>
</div>
<input
type="range"
className="cover-frame-slider"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={config.frame_time}
onChange={(e) => update({ frame_time: Number(e.target.value) })}
/>
<div className="cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
{/* 快捷时间点 */}
<div className="cover-frame-quick">
<span className="cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio;
return (
<button
key={ratio}
className="cover-quick-btn"
onClick={() => update({ frame_time: t })}
>
{formatTime(t)}
</button>
);
})}
</div>
</div>
)}
{/* 上传封面 */}
{config.mode === "upload" && (
<div className="cover-upload-section">
<div
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
{config.upload_url ? (
<div className="cover-upload-preview">
<img src={config.upload_url} alt="封面预览" />
<div className="cover-upload-overlay"></div>
</div>
) : (
<div className="cover-upload-placeholder">
<span className="cover-upload-icon">📤</span>
<span className="cover-upload-text">
</span>
<span className="cover-upload-hint">
JPG / PNG 16:9
</span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
}}
/>
</div>
</div>
)}
</div>
{/* 封面预览 */}
<div className="cover-preview-section">
<div className="cover-section-title"></div>
<div className="cover-preview-box">
{config.upload_url ? (
<img
src={config.upload_url}
alt="封面预览"
className="cover-preview-img"
/>
) : (
<div className="cover-preview-placeholder">
<span className="cover-preview-icon">🖼</span>
<span className="cover-preview-text">
{config.mode === "auto"
? "AI 智能选择"
: config.mode === "frame"
? `${formatTime(config.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="cover-preview-ratio">16:9</div>
</div>
</div>
{/* 底部 */}
<div className="cover-footer">
<button className="cover-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default CoverSelector;
@@ -0,0 +1,234 @@
/**
*
* + /////
*/
import React, { useCallback } from "react";
import { Drawer, Switch } from "antd";
import type { FilterConfig, FilterPreset } from "../types";
import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types";
interface FilterPanelProps {
open: boolean;
onClose: () => void;
config: FilterConfig;
onChange: (config: FilterConfig) => void;
}
/** 所有预设列表 */
const PRESET_LIST: FilterPreset[] = [
"none",
"original",
"fresh",
"warm",
"cool",
"vintage",
"cinema",
"bw",
"sunshine",
"film",
];
/** 预设对应的示例渐变色(用于视觉预览) */
const PRESET_GRADIENTS: Record<FilterPreset, string> = {
none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)",
warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)",
cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)",
bw: "linear-gradient(135deg, #434343 0%, #000000 100%)",
sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)",
film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)",
};
const FilterPanel: React.FC<FilterPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
const update = useCallback(
(partial: Partial<FilterConfig>) => {
onChange({ ...config, ...partial });
},
[config, onChange],
);
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
}, [config.enabled, onChange]);
/** 选择预设时重置手动参数 */
const handlePresetSelect = useCallback(
(preset: FilterPreset) => {
if (preset === "none") {
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
} else {
onChange({
...DEFAULT_FILTER_CONFIG,
enabled: config.enabled,
preset,
});
}
},
[config.enabled, onChange],
);
return (
<Drawer
title="滤镜调色"
placement="right"
width={420}
open={open}
onClose={onClose}
className="filter-panel-drawer"
>
{/* 顶部开关 */}
<div className="filter-header">
<span className="filter-header-label"></span>
<Switch
size="small"
checked={config.enabled}
onChange={(checked) => update({ enabled: checked })}
/>
</div>
{/* 预设滤镜选择 */}
<div className="filter-section">
<div className="filter-section-title"></div>
<div className="filter-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`filter-preset-item${config.preset === p ? " active" : ""}`}
onClick={() => handlePresetSelect(p)}
>
<div
className="filter-preset-preview"
style={{ background: PRESET_GRADIENTS[p] }}
/>
<span className="filter-preset-label">
{FILTER_PRESET_LABELS[p]}
</span>
</button>
))}
</div>
</div>
{/* 手动调节 */}
<div className="filter-section">
<div className="filter-section-title"></div>
{/* 亮度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.brightness}
onChange={(e) => update({ brightness: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.brightness}</span>
</div>
{/* 对比度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.contrast}
onChange={(e) => update({ contrast: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.contrast}</span>
</div>
{/* 饱和度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.saturation}
onChange={(e) => update({ saturation: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.saturation}</span>
</div>
{/* 色温 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.temperature}
onChange={(e) => update({ temperature: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.temperature}</span>
</div>
{/* 色调 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.tint}
onChange={(e) => update({ tint: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.tint}</span>
</div>
{/* 锐度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={0}
max={100}
value={config.sharpness}
onChange={(e) => update({ sharpness: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.sharpness}</span>
</div>
</div>
{/* 预览色块 */}
<div className="filter-section">
<div className="filter-section-title"></div>
<div
className="filter-preview-block"
style={{
background: PRESET_GRADIENTS[config.preset],
filter: [
`brightness(${100 + config.brightness}%)`,
`contrast(${100 + config.contrast}%)`,
`saturate(${100 + config.saturation}%)`,
].join(" "),
}}
/>
</div>
{/* 底部重置 */}
<div className="filter-footer">
<button className="filter-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default FilterPanel;
@@ -65,7 +65,7 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
return (
<tr key={gen.id} className="ep-gh-table-row">
<td className="ep-gh-td ep-gh-td-id">
{gen.generation_task_id.slice(0, 8)}...
{gen.id ? `${gen.id.slice(0, 8)}...` : "—"}
</td>
<td className="ep-gh-td">
<span className={`ep-gh-status-tag ${statusClass}`}>
@@ -0,0 +1,219 @@
/**
* 绿
* 5 + + //
*/
import React, { useCallback } from "react";
import { Drawer, Switch } from "antd";
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types";
import {
DEFAULT_CHROMA_KEY_CONFIG,
CHROMA_KEY_PRESET_LABELS,
CHROMA_KEY_PRESET_COLORS,
} from "../types";
interface GreenScreenPanelProps {
open: boolean;
onClose: () => void;
config: ChromaKeyConfig;
onChange: (config: ChromaKeyConfig) => void;
}
/** 预设列表 */
const PRESET_LIST: ChromaKeyColorPreset[] = [
"green",
"blue",
"red",
"pure_green",
"soft_green",
];
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
const update = useCallback(
(partial: Partial<ChromaKeyConfig>) => {
onChange({ ...config, ...partial });
},
[config, onChange],
);
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled });
}, [config.enabled, onChange]);
/** 选择颜色预设时同步更新 color 字段 */
const handlePresetSelect = useCallback(
(preset: ChromaKeyColorPreset) => {
update({
color_preset: preset,
color: CHROMA_KEY_PRESET_COLORS[preset],
});
},
[update],
);
/** 自定义颜色变化时清除预设标记 */
const handleColorChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
update({ color: e.target.value });
},
[update],
);
return (
<Drawer
title="绿幕抠像"
placement="right"
width={420}
open={open}
onClose={onClose}
className="green-screen-panel-drawer"
>
{/* 顶部开关 */}
<div className="green-header">
<span className="green-header-label">绿</span>
<Switch
size="small"
checked={config.enabled}
onChange={(checked) => update({ enabled: checked })}
/>
</div>
{/* 颜色预设 */}
<div className="green-section">
<div className="green-section-title"></div>
<div className="green-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`green-preset-btn${config.color_preset === p ? " active" : ""}`}
onClick={() => handlePresetSelect(p)}
>
<span
className="green-preset-dot"
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
/>
<span className="green-preset-label">
{CHROMA_KEY_PRESET_LABELS[p]}
</span>
</button>
))}
</div>
</div>
{/* 自定义颜色 */}
<div className="green-section">
<div className="green-section-title"></div>
<div className="green-color-row">
<input
type="color"
className="green-color-picker"
value={config.color}
onChange={handleColorChange}
/>
<input
type="text"
className="green-color-hex"
value={config.color}
onChange={handleColorChange}
placeholder="#00FF00"
/>
<div
className="green-color-swatch"
style={{ background: config.color }}
/>
</div>
</div>
{/* 参数调节 */}
<div className="green-section">
<div className="green-section-title"></div>
{/* 相似度 */}
<div className="green-slider-row">
<div className="green-slider-header">
<span className="green-slider-label"></span>
<span className="green-slider-value">{config.similarity}%</span>
</div>
<input
type="range"
className="green-slider"
min={0}
max={100}
value={config.similarity}
onChange={(e) => update({ similarity: Number(e.target.value) })}
/>
<div className="green-slider-desc">广</div>
</div>
{/* 边缘平滑 */}
<div className="green-slider-row">
<div className="green-slider-header">
<span className="green-slider-label"></span>
<span className="green-slider-value">{config.blend}%</span>
</div>
<input
type="range"
className="green-slider"
min={0}
max={100}
value={config.blend}
onChange={(e) => update({ blend: Number(e.target.value) })}
/>
<div className="green-slider-desc"></div>
</div>
{/* 溢色抑制 */}
<div className="green-slider-row">
<div className="green-slider-header">
<span className="green-slider-label"></span>
<span className="green-slider-value">{config.spill}%</span>
</div>
<input
type="range"
className="green-slider"
min={0}
max={100}
value={config.spill}
onChange={(e) => update({ spill: Number(e.target.value) })}
/>
<div className="green-slider-desc"></div>
</div>
</div>
{/* 预览 */}
<div className="green-section">
<div className="green-section-title"></div>
<div className="green-preview-box">
<div
className="green-preview-bg"
style={{ background: config.color, opacity: 0.3 }}
/>
<div className="green-preview-subject">
<div className="green-preview-circle" />
<div className="green-preview-text"></div>
</div>
<div
className="green-preview-edge"
style={{
borderColor: config.color,
filter: `blur(${config.blend / 10}px)`,
}}
/>
</div>
</div>
{/* 底部重置 */}
<div className="green-footer">
<button className="green-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default GreenScreenPanel;
@@ -0,0 +1,309 @@
/**
* Drawer
* Intro/ Outro
* // URL
*/
import React, { useCallback } from "react";
import { Drawer } from "antd";
import type {
IntroOutroConfig,
IntroOutroItem,
IntroOutroKind,
TransitionType,
} from "../types";
import { DEFAULT_INTRO_OUTRO } from "../types";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
/* ──────────── 常量 ──────────── */
const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
{ value: "none", label: "无", icon: "🚫" },
{ value: "video", label: "视频", icon: "🎬" },
{ value: "image", label: "图片", icon: "🖼️" },
];
/* ──────────── Props ──────────── */
interface IntroOutroPanelProps {
open: boolean;
onClose: () => void;
config: IntroOutroConfig;
onChange: (config: IntroOutroConfig) => void;
}
const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
/* ── 更新片头 ── */
const handleIntroChange = useCallback(
(partial: Partial<IntroOutroItem>) => {
onChange({ ...config, intro: { ...config.intro, ...partial } });
},
[config, onChange],
);
/* ── 更新片尾 ── */
const handleOutroChange = useCallback(
(partial: Partial<IntroOutroItem>) => {
onChange({ ...config, outro: { ...config.outro, ...partial } });
},
[config, onChange],
);
/* ── 切换片头类型 ── */
const handleIntroKindChange = useCallback(
(kind: IntroOutroKind) => {
handleIntroChange({ kind, url: kind === "none" ? undefined : "" });
},
[handleIntroChange],
);
/* ── 切换片尾类型 ── */
const handleOutroKindChange = useCallback(
(kind: IntroOutroKind) => {
handleOutroChange({ kind, url: kind === "none" ? undefined : "" });
},
[handleOutroChange],
);
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_INTRO_OUTRO });
}, [onChange]);
return (
<Drawer
title="🎬 片头片尾设置"
placement="right"
width={420}
open={open}
onClose={onClose}
className="intro-outro-panel-drawer"
>
{/* ═══ 片头区块 ═══ */}
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🎞</span>
<span className="iop-block-title"></span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.intro.kind === opt.value ? " active" : ""}`}
onClick={() => handleIntroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.intro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.intro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.intro.kind === "video"
? "https://example.com/intro.mp4"
: "https://example.com/intro.png"
}
value={config.intro.url ?? ""}
onChange={(e) => handleIntroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.intro.duration}
onChange={(e) =>
handleIntroChange({ duration: Number(e.target.value) })
}
/>
<span className="iop-slider-value">
{config.intro.duration}s
</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<select
className="iop-select"
value={config.intro.transition ?? "none"}
onChange={(e) =>
handleIntroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.intro.transition && config.intro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.intro.transition_duration ?? 0.5}
onChange={(e) =>
handleIntroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.intro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
{/* ═══ 片尾区块 ═══ */}
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🏁</span>
<span className="iop-block-title"></span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.outro.kind === opt.value ? " active" : ""}`}
onClick={() => handleOutroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.outro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.outro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.outro.kind === "video"
? "https://example.com/outro.mp4"
: "https://example.com/outro.png"
}
value={config.outro.url ?? ""}
onChange={(e) => handleOutroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.outro.duration}
onChange={(e) =>
handleOutroChange({ duration: Number(e.target.value) })
}
/>
<span className="iop-slider-value">
{config.outro.duration}s
</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label">退</label>
<select
className="iop-select"
value={config.outro.transition ?? "none"}
onChange={(e) =>
handleOutroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.outro.transition && config.outro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.outro.transition_duration ?? 0.5}
onChange={(e) =>
handleOutroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.outro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
{/* ── 底部操作 ── */}
<div className="iop-footer">
<button className="iop-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default IntroOutroPanel;
@@ -0,0 +1,582 @@
/**
* Drawer
* + +
*/
import React, { useCallback, useMemo } from "react";
import { Drawer, Switch } from "antd";
import type {
PipConfig,
PipLayer,
PipGridPosition,
PipAnimType,
PipSlideDirection,
} from "../types";
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types";
/* ──────────── 常量 ──────────── */
/** 九宫格位置 → 百分比坐标映射 */
const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
top_left: { x: 5, y: 5 },
top_center: { x: 37.5, y: 5 },
top_right: { x: 70, y: 5 },
center_left: { x: 5, y: 37.5 },
center: { x: 37.5, y: 37.5 },
center_right: { x: 70, y: 37.5 },
bottom_left: { x: 5, y: 70 },
bottom_center: { x: 37.5, y: 70 },
bottom_right: { x: 70, y: 70 },
};
/** 九宫格位置选项 */
const GRID_POSITIONS: PipGridPosition[] = [
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
];
/** 入场动画选项 */
const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
{ value: "none", label: "无" },
{ value: "fade_in", label: "淡入" },
{ value: "slide_in", label: "滑入" },
];
/** 滑入方向选项 */
const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
{ value: "left", label: "← 左" },
{ value: "right", label: "→ 右" },
{ value: "up", label: "↑ 上" },
{ value: "down", label: "↓ 下" },
];
/** 预览图层颜色池 */
const LAYER_COLORS = [
"rgba(22,119,255,0.5)",
"rgba(82,196,26,0.5)",
"rgba(250,173,20,0.5)",
"rgba(255,77,79,0.5)",
"rgba(114,46,209,0.5)",
"rgba(19,194,194,0.5)",
];
/* ──────────── Props ──────────── */
interface PipConfigPanelProps {
open: boolean;
onClose: () => void;
config: PipConfig;
onChange: (config: PipConfig) => void;
totalDuration: number;
}
/* ──────────── 辅助函数 ──────────── */
let layerIdCounter = 0;
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`;
/* ──────────── 组件 ──────────── */
const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
/** 当前选中图层 ID */
const [selectedId, setSelectedId] = React.useState<string>("");
/** 当前选中图层 */
const selectedLayer = useMemo(
() => config.layers.find((l) => l.id === selectedId) ?? null,
[config.layers, selectedId],
);
/* ── 添加图层 ── */
const handleAddLayer = useCallback(() => {
const newLayer: PipLayer = {
...DEFAULT_PIP_LAYER,
id: genLayerId(),
name: `图层 ${config.layers.length + 1}`,
z_index: config.layers.length + 1,
};
onChange({
...config,
layers: [...config.layers, newLayer],
});
setSelectedId(newLayer.id);
}, [config, onChange]);
/* ── 删除图层 ── */
const handleDeleteLayer = useCallback(
(id: string) => {
const newLayers = config.layers.filter((l) => l.id !== id);
onChange({ ...config, layers: newLayers });
if (selectedId === id) {
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "");
}
},
[config, onChange, selectedId],
);
/* ── 更新图层 ── */
const updateLayer = useCallback(
(id: string, partial: Partial<PipLayer>) => {
onChange({
...config,
layers: config.layers.map((l) =>
l.id === id ? { ...l, ...partial } : l,
),
});
},
[config, onChange],
);
/* ── 切换启用 ── */
const handleEnableToggle = useCallback(
(checked: boolean) => {
onChange({ ...config, enabled: checked });
},
[config, onChange],
);
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_PIP_CONFIG });
setSelectedId("");
}, [onChange]);
/* ── 九宫格点击 ── */
const handleGridClick = useCallback(
(pos: PipGridPosition) => {
if (!selectedLayer) return;
const coords = GRID_POSITION_MAP[pos];
updateLayer(selectedLayer.id, {
grid_position: pos,
x: coords.x,
y: coords.y,
});
},
[selectedLayer, updateLayer],
);
/* ── 宽高比锁定 ── */
const handleWidthChange = useCallback(
(val: number) => {
if (!selectedLayer) return;
const partial: Partial<PipLayer> = { width: val };
if (selectedLayer.aspect_lock) {
// 保持宽高比 1:1(百分比相同)
partial.height = val;
}
updateLayer(selectedLayer.id, partial);
},
[selectedLayer, updateLayer],
);
const handleHeightChange = useCallback(
(val: number) => {
if (!selectedLayer) return;
const partial: Partial<PipLayer> = { height: val };
if (selectedLayer.aspect_lock) {
partial.width = val;
}
updateLayer(selectedLayer.id, partial);
},
[selectedLayer, updateLayer],
);
return (
<Drawer
title="🖼️ 画中画设置"
placement="right"
width={520}
open={open}
onClose={onClose}
className="pip-config-panel-drawer"
>
{/* ═══ 顶部工具栏 ═══ */}
<div className="pip-toolbar">
<div className="pip-toolbar-left">
<button className="pip-add-btn" onClick={handleAddLayer}>
+
</button>
</div>
<div className="pip-enable-switch">
<span></span>
<Switch
size="small"
checked={config.enabled}
onChange={handleEnableToggle}
/>
</div>
</div>
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
<div className="pip-body">
{/* 左侧图层列表 */}
<div className="pip-layer-list">
{config.layers.length === 0 ? (
<div className="pip-layer-empty"></div>
) : (
config.layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
onClick={() => setSelectedId(layer.id)}
>
{layer.thumbnail_url || layer.material_url ? (
<img
className="pip-layer-thumb"
src={layer.thumbnail_url || layer.material_url}
alt={layer.name}
/>
) : (
<div
className="pip-layer-thumb"
style={{
background: LAYER_COLORS[idx % LAYER_COLORS.length],
}}
/>
)}
<span className="pip-layer-name">{layer.name}</span>
<button
className="pip-layer-delete"
onClick={(e) => {
e.stopPropagation();
handleDeleteLayer(layer.id);
}}
title="删除图层"
>
</button>
</div>
))
)}
</div>
{/* 右侧配置区 */}
<div className="pip-config-area">
{!selectedLayer ? (
<div className="pip-config-empty"></div>
) : (
<>
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{config.layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
style={{
left: `${layer.x}%`,
top: `${layer.y}%`,
width: `${layer.width}%`,
height: `${layer.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: layer.opacity / 100,
borderRadius: `${layer.border_radius}%`,
}}
>
<span className="pip-preview-label">{layer.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${selectedLayer.material_type === "image" ? " active" : ""}`}
onClick={() =>
updateLayer(selectedLayer.id, { material_type: "image" })
}
>
🖼
</button>
<button
className={`pip-type-btn${selectedLayer.material_type === "video" ? " active" : ""}`}
onClick={() =>
updateLayer(selectedLayer.id, { material_type: "video" })
}
>
🎬
</button>
</div>
</div>
{/* ── 素材 URL ── */}
<div className="pip-field">
<label className="pip-field-label">
{selectedLayer.material_type === "image" ? "图片" : "视频"}{" "}
URL
</label>
<input
className="pip-input"
type="text"
placeholder={
selectedLayer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={selectedLayer.material_url}
onChange={(e) =>
updateLayer(selectedLayer.id, {
material_url: e.target.value,
})
}
/>
</div>
{/* ── 位置:九宫格 + 坐标 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div
style={{ display: "flex", gap: 16, alignItems: "flex-start" }}
>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${selectedLayer.grid_position === pos ? " active" : ""}`}
onClick={() => handleGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={selectedLayer.x}
onChange={(e) =>
updateLayer(selectedLayer.id, {
x: Number(e.target.value),
})
}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={selectedLayer.y}
onChange={(e) =>
updateLayer(selectedLayer.id, {
y: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
</div>
{/* ── 尺寸 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}>
</span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={selectedLayer.width}
onChange={(e) => handleWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">
{selectedLayer.width}%
</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}>
</span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={selectedLayer.height}
onChange={(e) => handleHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">
{selectedLayer.height}%
</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() =>
updateLayer(selectedLayer.id, {
aspect_lock: !selectedLayer.aspect_lock,
})
}
>
<span className="pip-lock-icon">
{selectedLayer.aspect_lock ? "🔒" : "🔓"}
</span>
<span>
{selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"}
</span>
</div>
</div>
{/* ── 圆角 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={selectedLayer.border_radius}
onChange={(e) =>
updateLayer(selectedLayer.id, {
border_radius: Number(e.target.value),
})
}
/>
<span className="pip-slider-value">
{selectedLayer.border_radius}%
</span>
</div>
</div>
{/* ── 透明度 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={selectedLayer.opacity}
onChange={(e) =>
updateLayer(selectedLayer.id, {
opacity: Number(e.target.value),
})
}
/>
<span className="pip-slider-value">
{selectedLayer.opacity}%
</span>
</div>
</div>
{/* ── 时间 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={selectedLayer.start_time}
onChange={(e) =>
updateLayer(selectedLayer.id, {
start_time: Number(e.target.value),
})
}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={selectedLayer.duration}
onChange={(e) =>
updateLayer(selectedLayer.id, {
duration: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
{/* ── 入场动画 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={selectedLayer.animation}
onChange={(e) =>
updateLayer(selectedLayer.id, {
animation: e.target.value as PipAnimType,
})
}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{selectedLayer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={selectedLayer.slide_direction}
onChange={(e) =>
updateLayer(selectedLayer.id, {
slide_direction: e.target.value as PipSlideDirection,
})
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</>
)}
</div>
</div>
{/* ── 底部操作 ── */}
<div className="pip-footer">
<button className="pip-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default PipConfigPanel;
@@ -4,25 +4,13 @@
* 4
*/
import React from "react";
import type { ClipData, ClipType } from "../types";
import type { ClipData, ClipType, TitleSettings } from "../types";
interface CoverScheme {
key: string;
label: string;
}
interface TitleSettings {
aiAutoSelect: boolean;
title: string;
position: string;
font: string;
size: number;
bold: boolean;
italic: boolean;
stroke: boolean;
shadow: boolean;
}
interface SubtitleSettings {
enabled: boolean;
position: string;
@@ -0,0 +1,157 @@
/**
* Drawer
* 0.25x ~ 4x+ +
* /
*/
import React, { useCallback } from "react";
import { Drawer, Slider } from "antd";
import type { SpeedConfig } from "../types";
import { DEFAULT_SPEED } from "../types";
/* ──────────── 预设速度 ──────────── */
const SPEED_PRESETS: { rate: number; label: string }[] = [
{ rate: 0.5, label: "0.5x" },
{ rate: 1.0, label: "1x" },
{ rate: 1.5, label: "1.5x" },
{ rate: 2.0, label: "2x" },
];
/* ──────────── Props ──────────── */
interface SpeedPanelProps {
open: boolean;
onClose: () => void;
/** 当前片段调速配置 */
config: SpeedConfig;
onChange: (config: SpeedConfig) => void;
/** 应用到所有片段 */
onApplyAll?: (config: SpeedConfig) => void;
}
const SpeedPanel: React.FC<SpeedPanelProps> = ({
open,
onClose,
config,
onChange,
onApplyAll,
}) => {
/* ── 修改速度 ── */
const handleChangeRate = useCallback(
(rate: number) => {
onChange({ ...config, rate });
},
[config, onChange],
);
/* ── 切换音调修正 ── */
const handleTogglePitch = useCallback(() => {
onChange({ ...config, pitchCorrection: !config.pitchCorrection });
}, [config, onChange]);
/* ── 选择预设 ── */
const handlePreset = useCallback(
(rate: number) => {
onChange({ ...config, rate });
},
[config, onChange],
);
/* ── 应用到所有片段 ── */
const handleApplyAll = useCallback(() => {
onApplyAll?.(config);
}, [config, onApplyAll]);
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_SPEED });
}, [onChange]);
/* ── 速度描述文字 ── */
const speedLabel =
config.rate < 1
? "慢速(慢动作)"
: config.rate === 1
? "原速"
: config.rate < 2
? "快速"
: "极速";
return (
<Drawer
title="⚡ 片段调速"
placement="right"
width={380}
open={open}
onClose={onClose}
className="speed-panel-drawer"
>
{/* ── 速度滑块 ── */}
<div className="sp-speed-section">
<div className="sp-speed-header">
<span className="sp-speed-label"></span>
<span className="sp-speed-value">{config.rate.toFixed(2)}x</span>
</div>
<Slider
min={0.25}
max={4.0}
step={0.05}
value={config.rate}
onChange={handleChangeRate}
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
/>
<div className="sp-speed-marks">
<span>0.25x</span>
<span>1x</span>
<span>2x</span>
<span>4x</span>
</div>
<div className="sp-speed-desc">{speedLabel}</div>
</div>
{/* ── 预设快捷按钮 ── */}
<div className="sp-presets">
<div className="sp-presets-label"></div>
<div className="sp-presets-row">
{SPEED_PRESETS.map((p) => (
<button
key={p.rate}
className={`sp-preset-btn${Math.abs(config.rate - p.rate) < 0.01 ? " active" : ""}`}
onClick={() => handlePreset(p.rate)}
>
{p.label}
</button>
))}
</div>
</div>
{/* ── 音调修正开关 ── */}
<div className="sp-pitch-section">
<div className="sp-pitch-info">
<span className="sp-pitch-label"></span>
<span className="sp-pitch-desc">
{config.pitchCorrection ? "变速不变调(推荐)" : "变速同时变调"}
</span>
</div>
<div
className={`ep-toggle${config.pitchCorrection ? " active" : ""}`}
onClick={handleTogglePitch}
>
<div className="ep-toggle-knob" />
</div>
</div>
{/* ── 底部操作 ── */}
<div className="sp-footer">
<button className="sp-reset-btn" onClick={handleReset}>
</button>
{onApplyAll && (
<button className="sp-apply-all-btn" onClick={handleApplyAll}>
</button>
)}
</div>
</Drawer>
);
};
export default SpeedPanel;
@@ -0,0 +1,561 @@
/**
*
* emoji / + +
*/
import React, { useCallback, useState } from "react";
import { Drawer, Switch } from "antd";
import type {
StickerConfig,
StickerItem,
StickerType,
TextStickerPreset,
} from "../types";
import {
DEFAULT_STICKER_CONFIG,
DEFAULT_STICKER_ITEM,
TEXT_STICKER_PRESET_LABELS,
} from "../types";
interface StickerPanelProps {
open: boolean;
onClose: () => void;
config: StickerConfig;
onChange: (config: StickerConfig) => void;
totalDuration: number;
}
/** 常用 emoji 素材 */
const EMOJI_LIST = [
"😀",
"😂",
"🥰",
"😎",
"🤩",
"😱",
"🤔",
"😴",
"🥳",
"😍",
"❤️",
"🔥",
"⭐",
"✨",
"💯",
"👍",
"👏",
"🎉",
"🎵",
"💪",
"📌",
"💡",
"🎯",
"✅",
"❌",
"⬆️",
"⬇️",
"➡️",
"⭕",
"🔔",
];
/** 文字花字预设对应的 CSS 样式预览 */
const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
normal: { color: "#fff", textShadow: "none" },
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
gradient: {
color: "transparent",
background: "linear-gradient(90deg,#f093fb,#f5576c)",
WebkitBackgroundClip: "text",
},
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
};
/** 生成唯一 ID */
const genId = () =>
`sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const StickerPanel: React.FC<StickerPanelProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<StickerType>("emoji");
const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null;
/** 更新单个贴纸 */
const updateItem = useCallback(
(id: string, partial: Partial<StickerItem>) => {
onChange({
...config,
items: config.items.map((s) =>
s.id === id ? { ...s, ...partial } : s,
),
});
},
[config, onChange],
);
/** 添加贴纸 */
const addSticker = useCallback(
(type: StickerType, content: string) => {
const newItem: StickerItem = {
...DEFAULT_STICKER_ITEM,
id: genId(),
type,
content,
duration: totalDuration > 0 ? totalDuration : 5,
z_index: config.items.length + 1,
};
onChange({
...config,
enabled: true,
items: [...config.items, newItem],
});
setSelectedId(newItem.id);
},
[config, onChange, totalDuration],
);
/** 删除贴纸 */
const removeSticker = useCallback(
(id: string) => {
onChange({
...config,
items: config.items.filter((s) => s.id !== id),
});
if (selectedId === id) setSelectedId(null);
},
[config, onChange, selectedId],
);
/** 重置所有 */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled });
setSelectedId(null);
}, [config.enabled, onChange]);
/** 文字花字输入 */
const [textInput, setTextInput] = useState("");
return (
<Drawer
title="贴纸"
placement="right"
width={460}
open={open}
onClose={onClose}
className="sticker-panel-drawer"
>
{/* 顶部开关 */}
<div className="sticker-header">
<span className="sticker-header-label"></span>
<Switch
size="small"
checked={config.enabled}
onChange={(checked) => onChange({ ...config, enabled: checked })}
/>
</div>
{/* 类型 Tab */}
<div className="sticker-tabs">
{(["emoji", "image", "text"] as StickerType[]).map((t) => (
<button
key={t}
className={`sticker-tab${activeTab === t ? " active" : ""}`}
onClick={() => setActiveTab(t)}
>
{t === "emoji"
? "表情贴纸"
: t === "image"
? "图片贴纸"
: "文字花字"}
</button>
))}
</div>
{/* Tab 内容区 */}
<div className="sticker-tab-content">
{/* Emoji 素材库 */}
{activeTab === "emoji" && (
<div className="sticker-emoji-grid">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
className="sticker-emoji-btn"
onClick={() => addSticker("emoji", emoji)}
>
{emoji}
</button>
))}
</div>
)}
{/* 图片贴纸 */}
{activeTab === "image" && (
<div className="sticker-image-input">
<input
type="text"
className="sticker-url-input"
placeholder="输入图片 URL 添加贴纸..."
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
addSticker("image", e.currentTarget.value.trim());
e.currentTarget.value = "";
}
}}
/>
<button
className="sticker-url-add-btn"
onClick={() => {
const input =
document.querySelector<HTMLInputElement>(
".sticker-url-input",
);
if (input?.value.trim()) {
addSticker("image", input.value.trim());
input.value = "";
}
}}
>
</button>
</div>
)}
{/* 文字花字 */}
{activeTab === "text" && (
<div className="sticker-text-section">
<div className="sticker-text-input-row">
<input
type="text"
className="sticker-text-input"
placeholder="输入文字内容..."
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
/>
<button
className="sticker-text-add-btn"
disabled={!textInput.trim()}
onClick={() => {
if (textInput.trim()) {
addSticker("text", textInput.trim());
setTextInput("");
}
}}
>
</button>
</div>
<div className="sticker-text-presets">
<div className="sticker-preset-title"></div>
<div className="sticker-preset-grid">
{(
Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]
).map((p) => (
<div
key={p}
className="sticker-preset-preview"
style={{
...TEXT_PRESET_STYLES[p],
background:
p === "bubble"
? "rgba(0,0,0,0.5)"
: p === "gradient"
? "linear-gradient(90deg,#f093fb,#f5576c)"
: "#1a1a2e",
}}
>
<span style={TEXT_PRESET_STYLES[p]}></span>
<div className="sticker-preset-name">
{TEXT_STICKER_PRESET_LABELS[p]}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
{/* 已添加贴纸列表 */}
{config.items.length > 0 && (
<div className="sticker-list-section">
<div className="sticker-section-title">
({config.items.length})
</div>
<div className="sticker-list">
{config.items.map((item) => (
<div
key={item.id}
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
onClick={() => setSelectedId(item.id)}
>
<span className="sticker-list-icon">
{item.type === "emoji"
? item.content
: item.type === "text"
? "T"
: "🖼"}
</span>
<span className="sticker-list-name">
{item.type === "text"
? item.content.slice(0, 10)
: item.type === "emoji"
? "表情贴纸"
: "图片贴纸"}
</span>
<button
className="sticker-list-delete"
onClick={(e) => {
e.stopPropagation();
removeSticker(item.id);
}}
>
</button>
</div>
))}
</div>
</div>
)}
{/* 选中贴纸的属性编辑 */}
{selectedSticker && (
<div className="sticker-props-section">
<div className="sticker-section-title"></div>
{/* 位置 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"> X</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.x}
onChange={(e) =>
updateItem(selectedSticker.id, { x: Number(e.target.value) })
}
/>
<span className="sticker-prop-value">{selectedSticker.x}%</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"> Y</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.y}
onChange={(e) =>
updateItem(selectedSticker.id, { y: Number(e.target.value) })
}
/>
<span className="sticker-prop-value">{selectedSticker.y}%</span>
</div>
{/* 尺寸 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={5}
max={50}
value={selectedSticker.width}
onChange={(e) =>
updateItem(selectedSticker.id, {
width: Number(e.target.value),
height: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.width}%</span>
</div>
{/* 旋转 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={-180}
max={180}
value={selectedSticker.rotation}
onChange={(e) =>
updateItem(selectedSticker.id, {
rotation: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">
{selectedSticker.rotation}°
</span>
</div>
{/* 透明度 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.opacity}
onChange={(e) =>
updateItem(selectedSticker.id, {
opacity: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">
{selectedSticker.opacity}%
</span>
</div>
{/* 时间 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={selectedSticker.start_time}
onChange={(e) =>
updateItem(selectedSticker.id, {
start_time: Number(e.target.value),
})
}
/>
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={selectedSticker.duration}
onChange={(e) =>
updateItem(selectedSticker.id, {
duration: Number(e.target.value),
})
}
/>
</div>
{/* 文字贴纸特有属性 */}
{selectedSticker.type === "text" && (
<>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<select
className="sticker-prop-select"
value={selectedSticker.text_preset}
onChange={(e) =>
updateItem(selectedSticker.id, {
text_preset: e.target.value as TextStickerPreset,
})
}
>
{(
Object.keys(
TEXT_STICKER_PRESET_LABELS,
) as TextStickerPreset[]
).map((p) => (
<option key={p} value={p}>
{TEXT_STICKER_PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={12}
max={72}
value={selectedSticker.font_size}
onChange={(e) =>
updateItem(selectedSticker.id, {
font_size: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">
{selectedSticker.font_size}px
</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="color"
className="sticker-prop-color"
value={selectedSticker.text_color}
onChange={(e) =>
updateItem(selectedSticker.id, {
text_color: e.target.value,
})
}
/>
</div>
</>
)}
{/* 预览 */}
<div className="sticker-preview-box">
<div
className="sticker-preview-item"
style={{
left: `${selectedSticker.x}%`,
top: `${selectedSticker.y}%`,
width: `${selectedSticker.width}%`,
height: `${selectedSticker.width}%`,
transform: `translate(-50%, -50%) rotate(${selectedSticker.rotation}deg)`,
opacity: selectedSticker.opacity / 100,
fontSize:
selectedSticker.type === "text"
? `${selectedSticker.font_size}px`
: undefined,
...TEXT_PRESET_STYLES[selectedSticker.text_preset],
}}
>
{selectedSticker.type === "emoji" && selectedSticker.content}
{selectedSticker.type === "text" && selectedSticker.content}
{selectedSticker.type === "image" && (
<img
src={selectedSticker.content}
alt="sticker"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
)}
</div>
</div>
</div>
)}
{/* 底部重置 */}
<div className="sticker-footer">
<button className="sticker-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default StickerPanel;
@@ -0,0 +1,274 @@
/**
* Drawer
* / ASR /ASR
*/
import React from "react";
import { Drawer, Slider, ColorPicker, Select } from "antd";
import type { Color } from "antd/es/color-picker";
/* ──────────── 类型 ──────────── */
export type SubtitleMode = "manual" | "asr";
export interface SubtitleStyleConfig {
/** 是否启用字幕 */
enabled: boolean;
/** 字幕模式:手动输入 / ASR 自动识别 */
mode: SubtitleMode;
/** 字体大小 px */
fontSize: number;
/** 字体颜色 */
fontColor: string;
/** 描边 */
stroke: boolean;
/** 阴影 */
shadow: boolean;
/** 字幕位置 */
position: "top" | "center" | "bottom";
/** 字体 */
font: string;
/** 动画效果 */
animation: string;
/** ASR 语言(仅 ASR 模式) */
asrLanguage: "zh" | "en";
}
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
enabled: true,
mode: "asr",
fontSize: 16,
fontColor: "#ffffff",
stroke: true,
shadow: false,
position: "bottom",
font: "思源黑体",
animation: "none",
asrLanguage: "zh",
};
/* ──────────── 选项常量 ──────────── */
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
];
const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
];
const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
];
const ASR_LANGUAGE_OPTIONS = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
];
/* ──────────── Props ──────────── */
interface SubtitleStylePanelProps {
open: boolean;
onClose: () => void;
config: SubtitleStyleConfig;
onChange: (config: SubtitleStyleConfig) => void;
}
const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
const update = (partial: Partial<SubtitleStyleConfig>) => {
onChange({ ...config, ...partial });
};
return (
<Drawer
title="💬 字幕样式配置"
placement="right"
width={380}
open={open}
onClose={onClose}
className="subtitle-style-drawer"
>
{/* ── 字幕开关 ── */}
<div className="sub-field">
<div className="sub-toggle-row">
<span className="sub-label"></span>
<div
className={`ep-toggle${config.enabled ? " active" : ""}`}
onClick={() => update({ enabled: !config.enabled })}
>
<div className="ep-toggle-knob" />
</div>
</div>
</div>
{config.enabled && (
<>
{/* ── 模式切换 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-mode-switch">
<button
className={`sub-mode-btn${config.mode === "manual" ? " active" : ""}`}
onClick={() => update({ mode: "manual" })}
>
</button>
<button
className={`sub-mode-btn${config.mode === "asr" ? " active" : ""}`}
onClick={() => update({ mode: "asr" })}
>
🤖 ASR
</button>
</div>
</div>
{/* ── ASR 语言(仅 ASR 模式) ── */}
{config.mode === "asr" && (
<div className="sub-field">
<label className="sub-label"></label>
<Select
className="sub-select"
value={config.asrLanguage}
onChange={(v) => update({ asrLanguage: v })}
options={ASR_LANGUAGE_OPTIONS}
popupMatchSelectWidth={false}
/>
</div>
)}
{/* ── 字体大小 ── */}
<div className="sub-field">
<label className="sub-label">
<span className="sub-value">{config.fontSize}px</span>
</label>
<Slider
min={12}
max={48}
value={config.fontSize}
onChange={(v) => update({ fontSize: v })}
/>
</div>
{/* ── 字体颜色 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-color-row">
<ColorPicker
value={config.fontColor}
onChange={(_color: Color, hex: string) =>
update({ fontColor: hex })
}
showText
/>
</div>
</div>
{/* ── 字体 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<Select
className="sub-select"
value={config.font}
onChange={(v) => update({ font: v })}
options={FONT_OPTIONS.map((f) => ({ value: f, label: f }))}
popupMatchSelectWidth={false}
/>
</div>
{/* ── 字幕位置 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-position-group">
{POSITION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`sub-position-btn${config.position === opt.value ? " active" : ""}`}
onClick={() =>
update({
position: opt.value as SubtitleStyleConfig["position"],
})
}
>
{opt.label}
</button>
))}
</div>
</div>
{/* ── 描边 / 阴影 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-effect-btns">
<button
className={`sub-effect-btn${config.stroke ? " active" : ""}`}
onClick={() => update({ stroke: !config.stroke })}
>
S
</button>
<button
className={`sub-effect-btn${config.shadow ? " active" : ""}`}
onClick={() => update({ shadow: !config.shadow })}
>
</button>
</div>
</div>
{/* ── 动画 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<Select
className="sub-select"
value={config.animation}
onChange={(v) => update({ animation: v })}
options={ANIMATION_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
popupMatchSelectWidth={false}
/>
</div>
{/* ── 预览 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-preview-box">
<span
className="sub-preview-text"
style={{
fontSize: `${Math.min(config.fontSize, 28)}px`,
color: config.fontColor,
fontFamily: config.font,
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
textShadow: config.shadow
? "2px 2px 4px rgba(0,0,0,0.8)"
: undefined,
}}
>
</span>
</div>
</div>
</>
)}
</Drawer>
);
};
export default SubtitleStylePanel;
@@ -1,7 +1,12 @@
/**
* 线 = +
* 线
* (20px) + (100x100) + HTML5拖拽排序
* "+" +
*
*
* - /
* - //
* - / /
*/
import React, {
useState,
@@ -11,7 +16,8 @@ import React, {
useLayoutEffect,
useMemo,
} from "react";
import type { ClipData, ClipType } from "../types";
import type { ClipData, ClipType, TrimConfig } from "../types";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
interface TimelinePanelProps {
clips: ClipData[];
@@ -21,6 +27,26 @@ interface TimelinePanelProps {
onClipReorder: (fromIdx: number, toIdx: number) => void;
onClipRemove: (clipId: string) => void;
onAddClip: (type: ClipType, duration: number) => void;
/** 裁剪更新:调整片段的 trim_config 和 duration */
onClipTrim?: (
clipId: string,
trimConfig: TrimConfig,
newDuration: number,
) => void;
/** 在指定位置分割片段 */
onClipSplit?: (clipId: string, splitRatio: number) => void;
/** 恢复片段原始长度 */
onClipResetTrim?: (clipId: string) => void;
/** 当前播放时间(秒) */
currentTime?: number;
/** 缩放:每秒像素数 */
pixelsPerSecond?: number;
/** 缩放变更回调 */
onZoomChange?: (pps: number) => void;
/** 播放头跳转回调 */
onSeek?: (time: number) => void;
/** 总时长(秒),可选(默认由 clips 计算) */
totalDuration?: number;
}
/** 片段类型图标 */
@@ -35,6 +61,25 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
pip: "画中画",
};
/** 裁剪拖拽方向 */
type TrimDirection = "left" | "right";
/** 裁剪拖拽状态 */
interface TrimDragState {
clipId: string;
direction: TrimDirection;
startX: number;
originalTrim: TrimConfig;
originalDuration: number;
}
/** 右键菜单状态 */
interface ContextMenuState {
x: number;
y: number;
clipId: string;
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
clips,
selectedClipId,
@@ -43,6 +88,14 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onClipReorder,
onClipRemove,
onAddClip,
onClipTrim,
onClipSplit,
onClipResetTrim,
currentTime = 0,
pixelsPerSecond = 40,
onZoomChange,
onSeek,
totalDuration: totalDurationProp,
}) => {
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
@@ -55,6 +108,28 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
right: 0,
});
/* ── 裁剪拖拽状态 ── */
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null);
const [trimPreview, setTrimPreview] = useState<{
clipId: string;
startTime: number;
endTime: number;
duration: number;
x: number;
y: number;
} | null>(null);
/* ── 右键菜单 ── */
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null);
/* ── 播放头拖拽状态 ── */
const [playheadDragging, setPlayheadDragging] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
@@ -66,7 +141,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
[currentMode],
);
/* ── 默认添加类型:跟随模式(纯单类型模式直接用该类型,混合模式默认 voice) ── */
/* ── 默认添加类型:跟随模式 ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice";
if (currentMode === "pip") return "pip";
@@ -83,33 +158,28 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setAddType(defaultAddType);
}
}, [currentMode, addType, availableTypes, defaultAddType]);
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
const GAP = 6; // 面板与"+"卡片的间距
/* ── 计算 picker 初始位置(默认从"+"按钮上方弹出) ── */
/* ── 面板尺寸 ── */
const PICKER_W = 240;
const GAP = 6;
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return;
const rect = addCardRef.current.getBoundingClientRect();
const vw = window.innerWidth;
/* 垂直方向:默认向上弹出(上方空间永远比下方大) */
const roughHeight = 180; // 粗略估算,useLayoutEffect 会用实际高度校正
const roughHeight = 180;
let top = rect.top - GAP - roughHeight;
if (top < 8) top = 8;
/* 水平方向:右对齐"+"卡片;太靠右超出视口则左移 */
let right = vw - rect.right;
if (rect.right - PICKER_W < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, []);
const handleTogglePicker = () => {
if (!showAddPicker) {
// 打开面板时,默认选中当前模式下的第一个可用类型
const defaultType =
currentMode === "pip"
? "pip"
@@ -122,35 +192,27 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setShowAddPicker((v) => !v);
};
/* ── 渲染后用实际 offsetHeight 做精确边界校正(useLayoutEffect 确保 paint 前完成) ── */
/* ── 渲染后精确边界校正 ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return;
const pickerEl = pickerRef.current;
const addRect = addCardRef.current.getBoundingClientRect();
const pickerH = pickerEl.offsetHeight; // 实际高度,不用硬编码
const pickerH = pickerEl.offsetHeight;
const vh = window.innerHeight;
const vw = window.innerWidth;
/* 默认:面板在"+"按钮上方 */
let top = addRect.top - GAP - pickerH;
/* 上方空间也不够(极端情况)→ 翻转到下方 */
if (top < 8) {
top = addRect.bottom + GAP;
/* 下方也溢出 → clamp */
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH;
if (top < 8) top = 8;
}
}
/* 水平方向:右对齐"+"卡片;左侧溢出保护 */
let right = vw - addRect.right;
const pickerRect = pickerEl.getBoundingClientRect();
if (pickerRect.left < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, [showAddPicker]);
@@ -167,6 +229,70 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAddPicker]);
/* ── 点击外部关闭右键菜单 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(e.target as Node)
) {
setContextMenu(null);
}
};
if (contextMenu) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [contextMenu]);
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? 40;
const totalDuration =
totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0);
/* ── 播放头拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!playheadDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const trackEl = trackRef.current;
if (!trackEl) return;
const rect = trackEl.getBoundingClientRect();
const x = e.clientX - rect.left + trackEl.scrollLeft;
const time = Math.max(0, Math.min(x / pps, totalDuration));
onSeek?.(Math.round(time * 10) / 10);
};
const handleMouseUp = () => {
setPlayheadDragging(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [playheadDragging, pps, totalDuration, onSeek]);
/* ── 标尺点击跳转播放头 ── */
const handleRulerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const time = Math.max(0, Math.min(x / pps, totalDuration));
onSeek?.(Math.round(time * 10) / 10);
},
[pps, totalDuration, onSeek],
);
/* ── 播放头拖拽开始 ── */
const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setPlayheadDragging(true);
}, []);
/* ── 确认添加片段 ── */
const handleConfirmAdd = () => {
onAddClip(addType, addDuration);
@@ -175,6 +301,8 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
/* ── 片段拖拽排序 ── */
const handleDragStart = (e: React.DragEvent, idx: number) => {
// 如果正在裁剪拖拽,不允许排序拖拽
if (trimDrag) return;
dragRef.current = idx;
setDragIdx(idx);
e.dataTransfer.setData("application/x-clip-drag", String(idx));
@@ -210,8 +338,132 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
e.preventDefault();
};
/* ── 裁剪手柄拖拽 ── */
const handleTrimHandleMouseDown = useCallback(
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
e.preventDefault();
e.stopPropagation();
const clip = clips.find((c) => c.id === clipId);
if (!clip) return;
const trim: TrimConfig = clip.trim_config ?? {
start_time: 0,
end_time: clip.duration,
original_duration: clip.duration,
};
setTrimDrag({
clipId,
direction,
startX: e.clientX,
originalTrim: { ...trim },
originalDuration: clip.duration,
});
},
[clips],
);
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!trimDrag) return;
const PX_PER_SECOND = pixelsPerSecond ?? 40; // 与缩放级别同步
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX;
const dtSec = dx / PX_PER_SECOND;
const clip = clips.find((c) => c.id === trimDrag.clipId);
if (!clip) return;
const origTrim = trimDrag.originalTrim;
const origDur = origTrim.original_duration ?? trimDrag.originalDuration;
let newStart = origTrim.start_time;
let newEnd = origTrim.end_time;
if (trimDrag.direction === "left") {
// 左手柄:调整入点
newStart = Math.max(
0,
Math.min(origTrim.start_time + dtSec, newEnd - 1),
);
} else {
// 右手柄:调整出点
newEnd = Math.max(
origTrim.start_time + 1,
Math.min(origTrim.end_time + dtSec, origDur),
);
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10;
setTrimPreview({
clipId: trimDrag.clipId,
startTime: Math.round(newStart * 10) / 10,
endTime: Math.round(newEnd * 10) / 10,
duration: newDuration,
x: e.clientX,
y: e.clientY,
});
};
const handleMouseUp = () => {
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
const newTrim: TrimConfig = {
start_time: trimPreview.startTime,
end_time: trimPreview.endTime,
original_duration:
trimDrag.originalTrim.original_duration ??
trimDrag.originalDuration,
};
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration);
}
setTrimDrag(null);
setTrimPreview(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond]);
/* ── 右键菜单 ── */
const handleContextMenu = useCallback(
(e: React.MouseEvent, clipId: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, clipId });
},
[],
);
/* ── 右键菜单操作 ── */
const handleContextSplit = useCallback(() => {
if (!contextMenu) return;
if (onClipSplit) {
onClipSplit(contextMenu.clipId, 0.5); // 在中间分割
}
setContextMenu(null);
}, [contextMenu, onClipSplit]);
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return;
if (onClipResetTrim) {
onClipResetTrim(contextMenu.clipId);
}
setContextMenu(null);
}, [contextMenu, onClipResetTrim]);
const handleContextDelete = useCallback(() => {
if (!contextMenu) return;
onClipRemove(contextMenu.clipId);
setContextMenu(null);
}, [contextMenu, onClipRemove]);
/* ── 时间标尺 ── */
const totalDuration = clips.reduce((s, c) => s + c.duration, 0);
const trackWidth = Math.max(totalDuration * pps, 300);
const rulerMarks: number[] = [];
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15;
for (let t = 0; t <= totalDuration + step; t += step) {
@@ -224,6 +476,11 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
return `${m}:${s.toString().padStart(2, "0")}`;
};
/** 格式化裁剪时间(精确到0.1秒) */
const formatTrimTime = (sec: number) => {
return `${sec.toFixed(1)}s`;
};
return (
<div className="ep-timeline-area">
{/* 时间线头部 */}
@@ -235,6 +492,33 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
</span>
</div>
<div className="ep-timeline-actions">
{/* 缩放控件 */}
<div className="ep-timeline-zoom">
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
title="缩小"
>
</button>
<input
type="range"
className="ep-zoom-slider"
min={10}
max={120}
step={5}
value={pps}
onChange={(e) => onZoomChange?.(Number(e.target.value))}
/>
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
title="放大"
>
+
</button>
<span className="ep-zoom-label">{pps}px/s</span>
</div>
<button
className="ep-timeline-action-btn"
onClick={() => {
@@ -260,21 +544,13 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 时间标尺 */}
{currentMode !== "one_take" && (
<div className="ep-time-ruler">
<div
className="ep-time-ruler-inner"
style={{ width: Math.max(clips.length * 108, 300) }}
>
<div className="ep-time-ruler" onClick={handleRulerClick}>
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
{rulerMarks.map((t) => (
<span
key={t}
className="ep-time-mark"
style={{
left:
totalDuration > 0
? `${(t / totalDuration) * clips.length * 108}px`
: `${t * 20}px`,
}}
style={{ left: `${t * pps}px` }}
>
{t}s
</span>
@@ -285,52 +561,151 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 水平片段轨道 */}
{currentMode !== "one_take" && (
<div className="ep-clip-track" onDragOver={handleEmptyDragOver}>
<div
className="ep-clip-track"
ref={trackRef}
onDragOver={handleEmptyDragOver}
>
{/* 播放头 */}
{currentMode !== "one_take" && totalDuration > 0 && (
<div
className="ep-playhead"
style={{ left: currentTime * pps }}
onMouseDown={handlePlayheadMouseDown}
>
<div className="ep-playhead-handle" />
</div>
)}
{clips.length === 0 ? (
<div className="ep-track-empty">
<div className="ep-track-empty-icon">🎬</div>
<div className="ep-track-empty-text"> + </div>
</div>
) : (
clips.map((clip, idx) => (
<div
key={clip.id}
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
>
{/* 类型图标 */}
<div className="ep-clip-thumbnail">
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
</div>
clips.map((clip, idx) => {
/* 转场指示器 */
const trans = clip.transition;
const showTransition = idx > 0 && trans && trans.type !== "none";
const transOpt = showTransition
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
: undefined;
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">{clip.duration}s</span>
</div>
/* 速度徽章 */
const speed = clip.speed;
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01;
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
}}
>
</button>
</div>
))
/* 裁剪状态 */
const hasTrim = !!clip.trim_config;
const isHovered = hoveredClipId === clip.id;
return (
<React.Fragment key={clip.id}>
{/* 转场指示器 */}
{showTransition && transOpt && (
<div
className="ep-transition-indicator"
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
>
<span className="ep-trans-icon">{transOpt.icon}</span>
<span className="ep-trans-duration">
{trans!.duration.toFixed(1)}s
</span>
</div>
)}
<div
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
style={{ width: Math.max(clip.duration * pps, 60) }}
draggable={!trimDrag}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
onContextMenu={(e) => handleContextMenu(e, clip.id)}
onMouseEnter={() => setHoveredClipId(clip.id)}
onMouseLeave={() => setHoveredClipId(null)}
>
{/* 左裁剪手柄 */}
{isHovered && onClipTrim && (
<div
className="ep-trim-handle ep-trim-handle-left"
onMouseDown={(e) =>
handleTrimHandleMouseDown(e, clip.id, "left")
}
title="拖动调整入点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 类型图标 */}
<div className="ep-clip-thumbnail">
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">
{clip.duration}s
{hasTrim && (
<span className="ep-trim-indicator" title="已裁剪">
</span>
)}
</span>
</div>
{/* 速度徽章 */}
{showSpeed && (
<span className="ep-speed-badge">
{speed!.rate.toFixed(1)}x
</span>
)}
{/* 裁剪徽章 */}
{hasTrim && (
<span
className="ep-trim-badge"
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
>
</span>
)}
{/* 右裁剪手柄 */}
{isHovered && onClipTrim && (
<div
className="ep-trim-handle ep-trim-handle-right"
onMouseDown={(e) =>
handleTrimHandleMouseDown(e, clip.id, "right")
}
title="拖动调整出点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
}}
>
</button>
</div>
</React.Fragment>
);
})
)}
{/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */}
{/* ── 轨道末尾 "+" 添加卡片 ── */}
<div className="ep-track-add-card-wrapper">
<div
ref={addCardRef}
@@ -344,7 +719,73 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
</div>
)}
{/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */}
{/* 裁剪预览 tooltip */}
{trimPreview && (
<div
className="ep-trim-preview"
style={{
position: "fixed",
left: trimPreview.x + 12,
top: trimPreview.y - 40,
}}
>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">
{formatTrimTime(trimPreview.startTime)}
</span>
</div>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">
{formatTrimTime(trimPreview.endTime)}
</span>
</div>
<div className="ep-trim-preview-row ep-trim-preview-duration">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">
{formatTrimTime(trimPreview.duration)}
</span>
</div>
</div>
)}
{/* 右键菜单 */}
{contextMenu && (
<div
ref={contextMenuRef}
className="ep-context-menu"
style={{
position: "fixed",
left: contextMenu.x,
top: contextMenu.y,
}}
>
<div className="ep-context-menu-item" onClick={handleContextSplit}>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && (
<div
className="ep-context-menu-item"
onClick={handleContextResetTrim}
>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
)}
<div className="ep-context-menu-divider" />
<div
className="ep-context-menu-item ep-context-menu-item-danger"
onClick={handleContextDelete}
>
<span className="ep-context-menu-icon">🗑</span>
<span></span>
</div>
</div>
)}
{/* 类型+时长选择面板 */}
{showAddPicker && (
<div
ref={pickerRef}
@@ -0,0 +1,118 @@
/**
* Drawer
* 14 +
* +
*/
import React, { useCallback } from "react";
import { Drawer, Slider } from "antd";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
import type { TransitionConfig, TransitionType } from "../types";
import { DEFAULT_TRANSITION } from "../types";
/* ──────────── Props ──────────── */
interface TransitionSelectorProps {
open: boolean;
onClose: () => void;
/** 当前转场配置 */
config: TransitionConfig;
onChange: (config: TransitionConfig) => void;
/** 标题提示(区分全局 / 片段间) */
title?: string;
}
const TransitionSelector: React.FC<TransitionSelectorProps> = ({
open,
onClose,
config,
onChange,
title = "转场特效",
}) => {
/* ── 选择转场类型 ── */
const handleSelectType = useCallback(
(type: TransitionType) => {
onChange({ ...config, type });
},
[config, onChange],
);
/* ── 修改时长 ── */
const handleChangeDuration = useCallback(
(duration: number) => {
onChange({ ...config, duration });
},
[config, onChange],
);
/* ── 重置为无转场 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_TRANSITION });
}, [onChange]);
return (
<Drawer
title={`🎬 ${title}`}
placement="right"
width={480}
open={open}
onClose={onClose}
className="transition-selector-drawer"
>
{/* ── 时长滑块 ── */}
<div className="ts-duration-section">
<div className="ts-duration-header">
<span className="ts-duration-label"></span>
<span className="ts-duration-value">
{config.duration.toFixed(1)}s
</span>
</div>
<Slider
min={0.3}
max={2.0}
step={0.1}
value={config.duration}
onChange={handleChangeDuration}
tooltip={{ formatter: (v) => `${(v as number).toFixed(1)}s` }}
/>
<div className="ts-duration-marks">
<span>0.3s</span>
<span>1.0s</span>
<span>2.0s</span>
</div>
</div>
{/* ── 转场类型卡片网格 ── */}
<div className="ts-grid">
{TRANSITION_OPTIONS.map((opt) => {
const isActive = config.type === opt.value;
return (
<div
key={opt.value}
className={`ts-card${isActive ? " active" : ""}`}
onClick={() => handleSelectType(opt.value)}
>
<div className="ts-card-icon">{opt.icon}</div>
<div className="ts-card-name">{opt.label}</div>
{isActive && <span className="ts-card-check"></span>}
</div>
);
})}
</div>
{/* ── 底部操作 ── */}
<div className="ts-footer">
<button className="ts-reset-btn" onClick={handleReset}>
</button>
<div className="ts-current">
{TRANSITION_OPTIONS.find((o) => o.value === config.type)?.label ??
"无转场"}
{" · "}
{config.duration.toFixed(1)}s
</div>
</div>
</Drawer>
);
};
export default TransitionSelector;
@@ -0,0 +1,349 @@
/**
* TTS Drawer
* + + + // + +
*/
import React, { useState, useCallback, useEffect, useRef } from "react";
import { Drawer, Slider, message } from "antd";
import type { TtsConfig, TtsMode } from "../types";
import { DEFAULT_TTS_CONFIG } from "../types";
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts";
/* ──────────── 音色卡片分类图标 ──────────── */
const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
male: { icon: "👨", label: "男声" },
female: { icon: "👩", label: "女声" },
young: { icon: "🧑", label: "少年" },
service: { icon: "🎧", label: "客服" },
news: { icon: "📰", label: "新闻" },
emotion: { icon: "🎭", label: "情感" },
};
/* ──────────── Props ──────────── */
interface TtsPanelProps {
open: boolean;
onClose: () => void;
/** 当前片段 TTS 配置 */
config: TtsConfig;
onChange: (config: TtsConfig) => void;
}
const TtsPanel: React.FC<TtsPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
/* ── 音色列表 ── */
const [voices, setVoices] = useState<TTSVoice[]>([]);
const [voicesLoading, setVoicesLoading] = useState(false);
/* ── 试听状态 ── */
const [previewLoading, setPreviewLoading] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
/* ── 加载音色列表 ── */
useEffect(() => {
if (!open) return;
setVoicesLoading(true);
getTtsVoices()
.then((v) => setVoices(v))
.catch(() => message.error("加载音色列表失败"))
.finally(() => setVoicesLoading(false));
}, [open]);
/* ── 切换配音模式 ── */
const handleModeChange = useCallback(
(mode: TtsMode) => {
onChange({ ...config, mode });
},
[config, onChange],
);
/* ── 文本输入 ── */
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value.slice(0, 5000);
onChange({ ...config, text });
},
[config, onChange],
);
/* ── 选择音色 ── */
const handleVoiceSelect = useCallback(
(voiceId: string) => {
onChange({ ...config, voice_id: voiceId });
},
[config, onChange],
);
/* ── 语速 ── */
const handleSpeedChange = useCallback(
(speed: number) => {
onChange({ ...config, speed });
},
[config, onChange],
);
/* ── 语调 ── */
const handlePitchChange = useCallback(
(pitch: number) => {
onChange({ ...config, pitch });
},
[config, onChange],
);
/* ── 音量 ── */
const handleVolumeChange = useCallback(
(volume: number) => {
onChange({ ...config, volume });
},
[config, onChange],
);
/* ── 字幕联动 ── */
const handleSubtitleSyncToggle = useCallback(() => {
onChange({ ...config, subtitle_sync: !config.subtitle_sync });
}, [config, onChange]);
/* ── 试听 ── */
const handlePreview = useCallback(async () => {
if (!config.text.trim()) {
message.warning("请先输入合成文本");
return;
}
if (!config.voice_id) {
message.warning("请先选择音色");
return;
}
setPreviewLoading(true);
try {
const res = await previewTts({
text: config.text.slice(0, 200), // 试听截取前200字
voice_id: config.voice_id,
speed: config.speed,
pitch: config.pitch,
});
// 停止上一个
audioRef.current?.pause();
const audio = new Audio(res.audio_url);
audioRef.current = audio;
audio.play().catch(() => message.error("播放失败"));
audio.onended = () => {
audioRef.current = null;
};
message.success("试听播放中");
} catch {
message.error("试听生成失败");
} finally {
setPreviewLoading(false);
}
}, [config]);
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_TTS_CONFIG });
}, [onChange]);
/* ── 关闭时停止音频 ── */
const handleClose = useCallback(() => {
audioRef.current?.pause();
audioRef.current = null;
onClose();
}, [onClose]);
/* ── 音色分类分组 ── */
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP);
return (
<Drawer
title="🎙️ TTS 配音"
placement="right"
width={400}
open={open}
onClose={handleClose}
className="tts-panel-drawer"
>
{/* ── 配音模式切换 ── */}
<div className="tts-mode-section">
<div className="tts-mode-label"></div>
<div className="tts-mode-group">
{[
{ mode: "none" as TtsMode, icon: "🔇", label: "无配音" },
{ mode: "upload" as TtsMode, icon: "📁", label: "上传配音" },
{ mode: "tts" as TtsMode, icon: "🤖", label: "TTS 合成" },
].map((m) => (
<button
key={m.mode}
className={`tts-mode-btn${config.mode === m.mode ? " active" : ""}`}
onClick={() => handleModeChange(m.mode)}
>
<span className="tts-mode-icon">{m.icon}</span>
<span className="tts-mode-text">{m.label}</span>
</button>
))}
</div>
</div>
{/* ── TTS 配置(仅 tts 模式显示) ── */}
{config.mode === "tts" && (
<>
{/* 文本输入 */}
<div className="tts-text-section">
<div className="tts-text-header">
<span className="tts-text-label"></span>
<span className="tts-text-count">{config.text.length}/5000</span>
</div>
<textarea
className="tts-text-input"
placeholder="请输入需要合成的文本内容..."
value={config.text}
onChange={handleTextChange}
maxLength={5000}
rows={5}
/>
</div>
{/* 音色选择 */}
<div className="tts-voice-section">
<div className="tts-voice-label">
{voicesLoading && (
<span className="tts-voice-loading">...</span>
)}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat);
const isSelected = voice && config.voice_id === voice.id;
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && handleVoiceSelect(voice.id)}
disabled={!voice || voicesLoading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">
{voice?.name || info.label}
</span>
</button>
);
})}
</div>
</div>
{/* 语速滑块 */}
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">
{config.speed.toFixed(2)}x
</span>
</div>
<Slider
min={0.5}
max={2.0}
step={0.05}
value={config.speed}
onChange={handleSpeedChange}
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
/>
<div className="tts-slider-marks">
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 语调滑块 */}
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">
{config.pitch > 0 ? "+" : ""}
{config.pitch}
</span>
</div>
<Slider
min={-12}
max={12}
step={1}
value={config.pitch}
onChange={handlePitchChange}
tooltip={{ formatter: (v) => `${v}半音` }}
/>
<div className="tts-slider-marks">
<span>-12</span>
<span>0</span>
<span>+12</span>
</div>
</div>
{/* 音量滑块 */}
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">{config.volume}%</span>
</div>
<Slider
min={0}
max={100}
step={1}
value={config.volume}
onChange={handleVolumeChange}
tooltip={{ formatter: (v) => `${v}%` }}
/>
</div>
{/* 试听按钮 */}
<div className="tts-preview-section">
<button
className="tts-preview-btn"
onClick={handlePreview}
disabled={previewLoading}
>
{previewLoading ? "⏳ 生成中..." : "🔊 试听"}
</button>
</div>
{/* 字幕联动 */}
<div className="tts-subtitle-section">
<div className="tts-subtitle-info">
<span className="tts-subtitle-label"></span>
<span className="tts-subtitle-desc">
{config.subtitle_sync
? "TTS 文本自动同步到字幕"
: "字幕需手动编辑"}
</span>
</div>
<div
className={`ep-toggle${config.subtitle_sync ? " active" : ""}`}
onClick={handleSubtitleSyncToggle}
>
<div className="ep-toggle-knob" />
</div>
</div>
</>
)}
{/* ── 上传配音模式提示 ── */}
{config.mode === "upload" && (
<div className="tts-upload-hint">
<p></p>
<p></p>
</div>
)}
{/* ── 底部操作 ── */}
{config.mode === "tts" && (
<div className="tts-footer">
<button className="tts-reset-btn" onClick={handleReset}>
</button>
</div>
)}
</Drawer>
);
};
export default TtsPanel;
@@ -0,0 +1,419 @@
/**
* Drawer
* Tab / /
*
*/
import React, { useCallback, useState } from "react";
import { Drawer } from "antd";
import type {
WatermarkConfig,
WatermarkType,
WatermarkPosition,
ScrollDirection,
} from "../types";
import { DEFAULT_WATERMARK } from "../types";
/* ──────────── 常量 ──────────── */
const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [
{ key: "none", label: "无水印", icon: "🚫" },
{ key: "image", label: "图片水印", icon: "🖼️" },
{ key: "text", label: "文字水印", icon: "📝" },
{ key: "scroll", label: "滚动水印", icon: "📜" },
];
const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [
{ value: "top_left", label: "左上角" },
{ value: "top_right", label: "右上角" },
{ value: "bottom_left", label: "左下角" },
{ value: "bottom_right", label: "右下角" },
{ value: "center", label: "居中" },
];
const SCROLL_DIRECTION_OPTIONS: {
value: ScrollDirection;
label: string;
}[] = [
{ value: "horizontal", label: "水平滚动" },
{ value: "vertical", label: "垂直滚动" },
{ value: "diagonal", label: "对角滚动" },
];
/* ──────────── Props ──────────── */
interface WatermarkPanelProps {
open: boolean;
onClose: () => void;
config: WatermarkConfig;
onChange: (config: WatermarkConfig) => void;
}
const WatermarkPanel: React.FC<WatermarkPanelProps> = ({
open,
onClose,
config,
onChange,
}) => {
/* ── 图片上传预览 URL(本地预览用) ── */
const [localImageUrl, setLocalImageUrl] = useState<string>("");
/* ── 切换水印类型 ── */
const handleTypeChange = useCallback(
(type: WatermarkType) => {
onChange({ ...DEFAULT_WATERMARK, type });
},
[onChange],
);
/* ── 通用设置变更 ── */
const handlePositionChange = useCallback(
(position: WatermarkPosition) => {
onChange({ ...config, position });
},
[config, onChange],
);
const handleOpacityChange = useCallback(
(opacity: number) => {
onChange({ ...config, opacity });
},
[config, onChange],
);
/* ── 图片水印设置 ── */
const handleImageUrlChange = useCallback(
(url: string) => {
setLocalImageUrl(url);
onChange({ ...config, image_url: url });
},
[config, onChange],
);
const handleImageWidthChange = useCallback(
(width: number) => {
onChange({ ...config, width });
},
[config, onChange],
);
const handleImageHeightChange = useCallback(
(height: number) => {
onChange({ ...config, height });
},
[config, onChange],
);
/* ── 文字水印设置 ── */
const handleTextChange = useCallback(
(text: string) => {
onChange({ ...config, text });
},
[config, onChange],
);
const handleFontSizeChange = useCallback(
(font_size: number) => {
onChange({ ...config, font_size });
},
[config, onChange],
);
const handleColorChange = useCallback(
(color: string) => {
onChange({ ...config, color });
},
[config, onChange],
);
/* ── 滚动水印设置 ── */
const handleScrollDirectionChange = useCallback(
(scroll_direction: ScrollDirection) => {
onChange({ ...config, scroll_direction });
},
[config, onChange],
);
const handleScrollSpeedChange = useCallback(
(scroll_speed: number) => {
onChange({ ...config, scroll_speed });
},
[config, onChange],
);
/* ── 重置 ── */
const handleReset = useCallback(() => {
setLocalImageUrl("");
onChange({ ...DEFAULT_WATERMARK });
}, [onChange]);
/* ── 当前激活的 Tab ── */
const activeTab = config.type;
return (
<Drawer
title="🔖 水印设置"
placement="right"
width={400}
open={open}
onClose={onClose}
className="watermark-panel-drawer"
>
{/* ── Tab 切换 ── */}
<div className="wp-tabs">
{WATERMARK_TABS.map((tab) => (
<button
key={tab.key}
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => handleTypeChange(tab.key)}
>
<span className="wp-tab-icon">{tab.icon}</span>
<span className="wp-tab-label">{tab.label}</span>
</button>
))}
</div>
{/* ── 无水印提示 ── */}
{config.type === "none" && (
<div className="wp-empty-hint">
<span className="wp-empty-icon">🚫</span>
<p></p>
<p className="wp-empty-desc"></p>
</div>
)}
{/* ── 图片水印配置 ── */}
{config.type === "image" && (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"> URL</label>
<input
className="wp-input"
type="text"
placeholder="https://example.com/logo.png"
value={localImageUrl || config.image_url || ""}
onChange={(e) => handleImageUrlChange(e.target.value)}
/>
{(localImageUrl || config.image_url) && (
<div className="wp-image-preview">
<img
src={localImageUrl || config.image_url}
alt="水印预览"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
)}
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={config.width ?? 0}
onChange={(e) => handleImageWidthChange(Number(e.target.value))}
/>
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={config.height ?? 0}
onChange={(e) => handleImageHeightChange(Number(e.target.value))}
/>
</div>
</div>
)}
{/* ── 文字水印配置 ── */}
{config.type === "text" && (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入水印文字内容"
rows={3}
value={config.text ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={config.font_size ?? 24}
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">
{config.font_size ?? 24}px
</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={config.color ?? "#ffffff"}
onChange={(e) => handleColorChange(e.target.value)}
/>
<span className="wp-color-value">
{config.color ?? "#ffffff"}
</span>
</div>
</div>
</div>
)}
{/* ── 滚动水印配置 ── */}
{config.type === "scroll" && (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入滚动水印文字"
rows={2}
value={config.text ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={config.scroll_direction ?? "horizontal"}
onChange={(e) =>
handleScrollDirectionChange(e.target.value as ScrollDirection)
}
>
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={10}
max={200}
value={config.scroll_speed ?? 50}
onChange={(e) =>
handleScrollSpeedChange(Number(e.target.value))
}
/>
<span className="wp-slider-value">
{config.scroll_speed ?? 50}px/s
</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={config.font_size ?? 24}
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">
{config.font_size ?? 24}px
</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={config.color ?? "#ffffff"}
onChange={(e) => handleColorChange(e.target.value)}
/>
<span className="wp-color-value">
{config.color ?? "#ffffff"}
</span>
</div>
</div>
</div>
)}
{/* ── 通用设置(非 none 时显示) ── */}
{config.type !== "none" && (
<div className="wp-section wp-common-section">
<div className="wp-section-divider" />
<div className="wp-common-title"></div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={config.position}
onChange={(e) =>
handlePositionChange(e.target.value as WatermarkPosition)
}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={0}
max={1}
step={0.05}
value={config.opacity}
onChange={(e) => handleOpacityChange(Number(e.target.value))}
/>
<span className="wp-slider-value">
{Math.round(config.opacity * 100)}%
</span>
</div>
</div>
</div>
)}
{/* ── 底部操作 ── */}
<div className="wp-footer">
<button className="wp-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
);
};
export default WatermarkPanel;
+529
View File
@@ -5,6 +5,508 @@
export type ClipType = "voice" | "pip";
/* ──────── 转场特效 ──────── */
/** 14 种转场类型 */
export type TransitionType =
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop";
/** 片段间转场配置 */
export interface TransitionConfig {
/** 转场类型 */
type: TransitionType;
/** 转场时长(秒),0.3 ~ 2.0 */
duration: number;
}
/** 默认转场配置 */
export const DEFAULT_TRANSITION: TransitionConfig = {
type: "none",
duration: 0.5,
};
/* ──────── 片段调速 ──────── */
/** 片段调速配置 */
export interface SpeedConfig {
/** 播放速度,0.25 ~ 4.0 */
rate: number;
/** 音调修正(变速不变调) */
pitchCorrection: boolean;
}
/** 默认调速配置 */
export const DEFAULT_SPEED: SpeedConfig = {
rate: 1.0,
pitchCorrection: true,
};
/* ──────── TTS 配音 ──────── */
/** 配音模式 */
export type TtsMode = "none" | "upload" | "tts";
/** TTS 配音配置 */
export interface TtsConfig {
/** 配音模式 */
mode: TtsMode;
/** TTS 合成文本 */
text: string;
/** 音色 ID */
voice_id: string;
/** 语速 0.5 ~ 2.0 */
speed: number;
/** 语调(半音)-12 ~ +12 */
pitch: number;
/** 音量 0 ~ 100 */
volume: number;
/** 字幕联动 */
subtitle_sync: boolean;
}
/** 默认 TTS 配置 */
export const DEFAULT_TTS_CONFIG: TtsConfig = {
mode: "none",
text: "",
voice_id: "",
speed: 1.0,
pitch: 0,
volume: 100,
subtitle_sync: true,
};
/* ──────── 裁剪配置 ──────── */
/** 片段裁剪配置 — 定义素材的入点/出点 */
export interface TrimConfig {
/** 入点(秒),素材原始时间轴上的起始位置 */
start_time: number;
/** 出点(秒),素材原始时间轴上的结束位置 */
end_time: number;
/** 素材原始总时长(秒),用于"恢复原始长度" */
original_duration?: number;
}
/* ──────── 水印配置 ──────── */
/** 水印类型 */
export type WatermarkType = "none" | "image" | "text" | "scroll";
/** 水印位置 */
export type WatermarkPosition =
"top_left" | "top_right" | "bottom_left" | "bottom_right" | "center";
/** 滚动水印方向 */
export type ScrollDirection = "horizontal" | "vertical" | "diagonal";
/** 水印配置 */
export interface WatermarkConfig {
/** 水印类型 */
type: WatermarkType;
/** 图片水印 URL */
image_url?: string;
/** 水印宽度(像素或百分比 0~1) */
width?: number;
/** 水印高度(像素或百分比 0~1) */
height?: number;
/** 水印位置 */
position: WatermarkPosition;
/** 水印不透明度 0~1 */
opacity: number;
/** 文字水印内容 */
text?: string;
/** 文字水印字号 */
font_size?: number;
/** 文字水印颜色 */
color?: string;
/** 滚动水印方向 */
scroll_direction?: ScrollDirection;
/** 滚动水印速度(像素/秒) */
scroll_speed?: number;
}
/** 默认水印配置 */
export const DEFAULT_WATERMARK: WatermarkConfig = {
type: "none",
position: "bottom_right",
opacity: 0.7,
};
/* ──────── 片头片尾配置 ──────── */
/** 片头片尾素材类型 */
export type IntroOutroKind = "none" | "video" | "image";
/** 片头/片尾单项配置 */
export interface IntroOutroItem {
/** 素材类型 */
kind: IntroOutroKind;
/** 素材 URL */
url?: string;
/** 显示时长(秒) */
duration: number;
/** 过渡动画 */
transition?: TransitionType;
/** 过渡时长(秒) */
transition_duration?: number;
}
/** 片头片尾完整配置 */
export interface IntroOutroConfig {
intro: IntroOutroItem;
outro: IntroOutroItem;
}
/** 默认片头片尾配置 */
export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
intro: { kind: "none", duration: 3 },
outro: { kind: "none", duration: 3 },
};
/* ──────── 画中画配置 ──────── */
/** 九宫格位置 */
export type PipGridPosition =
| "top_left"
| "top_center"
| "top_right"
| "center_left"
| "center"
| "center_right"
| "bottom_left"
| "bottom_center"
| "bottom_right";
/** 入场动画类型 */
export type PipAnimType = "none" | "fade_in" | "slide_in";
/** 入场方向 */
export type PipSlideDirection = "left" | "right" | "up" | "down";
/** 画中画图层 */
export interface PipLayer {
id: string;
/** 图层名称(用户可编辑) */
name: string;
/** 素材类型 */
material_type: "image" | "video";
/** 素材 URL */
material_url: string;
/** 素材缩略图 */
thumbnail_url?: string;
/** 九宫格快捷位置 */
grid_position: PipGridPosition;
/** 精确 X 坐标(百分比 0~100 */
x: number;
/** 精确 Y 坐标(百分比 0~100 */
y: number;
/** 宽度(百分比 0~100,相对主画面) */
width: number;
/** 高度(百分比 0~100,相对主画面) */
height: number;
/** 锁定宽高比 */
aspect_lock: boolean;
/** 圆角(百分比 0~50 */
border_radius: number;
/** 不透明度(0~100 */
opacity: number;
/** 开始时间(秒) */
start_time: number;
/** 持续时长(秒) */
duration: number;
/** 入场动画 */
animation: PipAnimType;
/** 入场方向 */
slide_direction: PipSlideDirection;
/** 图层顺序(z-index */
z_index: number;
}
/** 画中画配置 */
export interface PipConfig {
/** 是否启用画中画 */
enabled: boolean;
/** 图层列表 */
layers: PipLayer[];
}
/** 默认 PiP 图层 */
export const DEFAULT_PIP_LAYER: PipLayer = {
id: "",
name: "图层",
material_type: "image",
material_url: "",
grid_position: "top_right",
x: 70,
y: 5,
width: 25,
height: 25,
aspect_lock: true,
border_radius: 0,
opacity: 100,
start_time: 0,
duration: 5,
animation: "none",
slide_direction: "right",
z_index: 1,
};
/** 默认 PiP 配置 */
export const DEFAULT_PIP_CONFIG: PipConfig = {
enabled: false,
layers: [],
};
/* ──────── 滤镜调色 ──────── */
/** 预设滤镜 */
export type FilterPreset =
| "none"
| "original"
| "fresh"
| "warm"
| "cool"
| "vintage"
| "cinema"
| "bw"
| "sunshine"
| "film";
/** 预设滤镜标签 */
export const FILTER_PRESET_LABELS: Record<FilterPreset, string> = {
none: "无",
original: "原片",
fresh: "清新",
warm: "暖调",
cool: "冷色",
vintage: "复古",
cinema: "电影",
bw: "黑白",
sunshine: "暖阳",
film: "胶片",
};
/** 滤镜调色配置 */
export interface FilterConfig {
/** 是否启用滤镜 */
enabled: boolean;
/** 预设滤镜 */
preset: FilterPreset;
/** 亮度(-100 ~ 100 */
brightness: number;
/** 对比度(-100 ~ 100 */
contrast: number;
/** 饱和度(-100 ~ 100 */
saturation: number;
/** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */
temperature: number;
/** 色调(-100 ~ 100,负值偏绿,正值偏品红) */
tint: number;
/** 锐度(0 ~ 100 */
sharpness: number;
}
/** 默认滤镜调色配置 */
export const DEFAULT_FILTER_CONFIG: FilterConfig = {
enabled: false,
preset: "none",
brightness: 0,
contrast: 0,
saturation: 0,
temperature: 0,
tint: 0,
sharpness: 0,
};
/* ──────── 绿幕抠像 ──────── */
/** 绿幕抠像颜色预设 */
export type ChromaKeyColorPreset =
"green" | "blue" | "red" | "pure_green" | "soft_green";
/** 颜色预设标签 */
export const CHROMA_KEY_PRESET_LABELS: Record<ChromaKeyColorPreset, string> = {
green: "绿",
blue: "蓝",
red: "红",
pure_green: "精绿",
soft_green: "柔绿",
};
/** 颜色预设对应的默认色值 */
export const CHROMA_KEY_PRESET_COLORS: Record<ChromaKeyColorPreset, string> = {
green: "#00FF00",
blue: "#0000FF",
red: "#FF0000",
pure_green: "#00C800",
soft_green: "#40E040",
};
/** 绿幕抠像配置 */
export interface ChromaKeyConfig {
/** 是否启用绿幕抠像 */
enabled: boolean;
/** 颜色预设 */
color_preset: ChromaKeyColorPreset;
/** 抠像目标颜色(HEX */
color: string;
/** 相似度(0 ~ 100,越大容忍的色差范围越广) */
similarity: number;
/** 边缘平滑(0 ~ 100,越大边缘越柔和) */
blend: number;
/** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */
spill: number;
}
/** 默认绿幕抠像配置 */
export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = {
enabled: false,
color_preset: "green",
color: "#00FF00",
similarity: 30,
blend: 10,
spill: 20,
};
/* ──────── 贴纸配置 ──────── */
/** 贴纸类型 */
export type StickerType = "emoji" | "image" | "text";
/** 文字花字预设 */
export type TextStickerPreset =
| "normal" // 普通
| "highlight" // 高亮
| "bubble" // 气泡
| "neon" // 霓虹
| "shadow" // 投影
| "outline" // 描边
| "gradient" // 渐变
| "handwrite"; // 手写
/** 贴纸项 */
export interface StickerItem {
id: string;
/** 贴纸类型 */
type: StickerType;
/** 内容(emoji 字符 / 图片 URL / 文字内容) */
content: string;
/** X 坐标(百分比 0~100 */
x: number;
/** Y 坐标(百分比 0~100 */
y: number;
/** 宽度(百分比 0~100 */
width: number;
/** 高度(百分比 0~100 */
height: number;
/** 旋转角度(度 -180~180 */
rotation: number;
/** 不透明度(0~100 */
opacity: number;
/** 开始时间(秒) */
start_time: number;
/** 持续时长(秒,0 表示全程显示) */
duration: number;
/** 图层顺序 */
z_index: number;
/** 文字花字预设(仅 type=text 时有效) */
text_preset: TextStickerPreset;
/** 文字颜色(仅 type=text 时有效) */
text_color: string;
/** 文字大小(px,仅 type=text 时有效) */
font_size: number;
}
/** 贴纸配置 */
export interface StickerConfig {
enabled: boolean;
items: StickerItem[];
}
/** 默认贴纸项 */
export const DEFAULT_STICKER_ITEM: StickerItem = {
id: "",
type: "emoji",
content: "😀",
x: 50,
y: 50,
width: 15,
height: 15,
rotation: 0,
opacity: 100,
start_time: 0,
duration: 0,
z_index: 1,
text_preset: "normal",
text_color: "#FFFFFF",
font_size: 24,
};
/** 默认贴纸配置 */
export const DEFAULT_STICKER_CONFIG: StickerConfig = {
enabled: false,
items: [],
};
/** 文字花字预设标签 */
export const TEXT_STICKER_PRESET_LABELS: Record<TextStickerPreset, string> = {
normal: "普通",
highlight: "高亮",
bubble: "气泡",
neon: "霓虹",
shadow: "投影",
outline: "描边",
gradient: "渐变",
handwrite: "手写",
};
/* ──────── 封面配置 ──────── */
/** 封面来源模式 */
export type CoverMode = "auto" | "frame" | "upload";
/** 封面配置 */
export interface CoverConfig {
/** 是否启用自定义封面 */
enabled: boolean;
/** 封面来源模式 */
mode: CoverMode;
/** 抽帧时间点(秒,mode=frame 时使用) */
frame_time: number;
/** 上传的封面 URLmode=upload 时使用) */
upload_url: string;
/** AI 智能推荐的抽帧时间(由后端分析得出) */
ai_suggested_time: number | null;
/** 封面缩略图 URL */
thumbnail_url: string;
}
/** 默认封面配置 */
export const DEFAULT_COVER_CONFIG: CoverConfig = {
enabled: false,
mode: "auto",
frame_time: 0,
upload_url: "",
ai_suggested_time: null,
thumbnail_url: "",
};
/* ──────── 片段数据 ──────── */
export interface ClipData {
id: string;
type: ClipType; // 片段类型:voice(口播)或 pip(画中画)
@@ -18,4 +520,31 @@ export interface ClipData {
voice_asset_id?: string;
/** 配音素材文件 URL(voice 类型片段使用) */
voice_file_url?: string;
/** 与前一片段之间的转场效果 */
transition?: TransitionConfig;
/** 播放速度配置 */
speed?: SpeedConfig;
/** TTS 配音配置 */
tts_config?: TtsConfig;
/** 裁剪配置 — 定义素材入点/出点 */
trim_config?: TrimConfig;
}
/* ──────── 标题设置 ──────── */
/**
* title_config
* UI 使 camelCase snake_case
*/
export interface TitleSettings {
aiAutoSelect: boolean;
title: string;
position: string;
font: string;
size: number;
bold: boolean;
italic: boolean;
stroke: boolean;
shadow: boolean;
color: string;
}
+102 -10
View File
@@ -29,7 +29,9 @@ import {
createEditPlan,
generateEditPlan,
updateEditPlan,
getGenerationTaskResults,
} from "@/api/editPlans";
import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans";
import { getEditingTemplates } from "@/api/editingPlanner";
import { getTitles } from "@/api/titles";
import apiClient from "@/api/client";
@@ -152,6 +154,9 @@ const GeneratePage: React.FC = () => {
const [progress, setProgress] = useState(0);
const [generated, setGenerated] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [videoUrl, setVideoUrl] = useState<string>("");
const [thumbnailUrl, setThumbnailUrl] = useState<string>("");
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -197,8 +202,8 @@ const GeneratePage: React.FC = () => {
try {
const plan = await getEditPlan(editPlanId);
if (plan.name) setTitle(plan.name);
const cfg = plan.config as Record<string, unknown>;
if (cfg && Array.isArray(cfg.asset_ids)) {
const cfg = plan.config;
if (cfg?.asset_ids) {
setSelectedMaterials(
cfg.asset_ids.filter((v): v is string => typeof v === "string"),
);
@@ -472,7 +477,13 @@ const GeneratePage: React.FC = () => {
setGenerateError(null);
try {
const voiceConfig: Record<string, unknown> = {};
const voiceConfig: Pick<
EditPlanConfig,
| "voice_id"
| "voice_clone_profile_id"
| "custom_audio_url"
| "custom_text"
> = {};
if (voiceMode === "preset") {
voiceConfig.voice_id = selectedVoice || undefined;
} else if (voiceMode === "clone") {
@@ -518,6 +529,25 @@ const GeneratePage: React.FC = () => {
setProgress(100);
setGenerating(false);
setGenerated(true);
// 获取生成的视频结果
if (data.generation_task_id) {
try {
const videos = await getGenerationTaskResults(
data.generation_task_id,
);
setGeneratedVideos(videos);
if (videos.length > 0) {
setVideoUrl(
videos[0].file_url || videos[0].download_url || "",
);
setThumbnailUrl(videos[0].thumbnail_url || "");
}
} catch (err) {
console.error("[获取生成结果失败]", err);
}
}
message.success("视频生成完成!");
return;
}
@@ -537,7 +567,8 @@ const GeneratePage: React.FC = () => {
const safeExtract = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -597,7 +628,8 @@ const GeneratePage: React.FC = () => {
const extractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -627,7 +659,8 @@ const GeneratePage: React.FC = () => {
const safeExtractErr = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -708,6 +741,42 @@ const GeneratePage: React.FC = () => {
materialMode,
]);
/* ── 下载视频 ── */
const handleDownload = useCallback(async () => {
if (!generatedVideos.length) return;
const video = generatedVideos[0];
try {
// 优先使用 download_url(签名 URL),回退到 file_url
const url = video.download_url || video.file_url;
if (url) {
const a = document.createElement("a");
a.href = url;
a.download = video.name || "generated-video.mp4";
a.target = "_blank";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
} catch (err) {
console.error("[下载失败]", err);
message.error("下载失败,请重试");
}
}, [generatedVideos]);
/* ── 分享视频 ── */
const handleShare = useCallback(async () => {
if (!generatedVideos.length) return;
const video = generatedVideos[0];
const shareUrl = video.file_url || window.location.href;
try {
await navigator.clipboard.writeText(shareUrl);
message.success("视频链接已复制到剪贴板");
} catch {
// fallback: 显示 URL 让用户手动复制
message.info(`视频链接: ${shareUrl}`);
}
}, [generatedVideos]);
/* ── 步骤导航 ── */
const goNext = useCallback(() => {
if (currentStep === 1 && !selectedTemplate) {
@@ -1724,8 +1793,25 @@ const GeneratePage: React.FC = () => {
<div className="xx-generate-preview">
{/* 视频预览 */}
<div className="xx-preview-video">
{generated ? (
<video src="" controls preload="none" />
{generated && videoUrl ? (
<video
src={videoUrl}
controls
preload="metadata"
poster={thumbnailUrl || undefined}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
) : generated ? (
<div
style={{
textAlign: "center",
padding: 24,
color: "var(--text-secondary)",
}}
>
<LoadingOutlined style={{ fontSize: 24, marginBottom: 8 }} />
<div></div>
</div>
) : (
<button className="xx-play-btn" type="button">
<PlayCircleOutlined />
@@ -1778,12 +1864,18 @@ const GeneratePage: React.FC = () => {
{/* 生成完成后显示下载/分享 */}
{generated && (
<div className="xx-generate-actions" style={{ marginTop: 8 }}>
<button className="xx-btn xx-btn-ghost">
<button className="xx-btn xx-btn-ghost" onClick={handleDownload}>
<DownloadOutlined />
</button>
<button className="xx-btn xx-btn-ghost">
<button className="xx-btn xx-btn-ghost" onClick={handleShare}>
<ShareAltOutlined />
</button>
<button
className="xx-btn xx-btn-ghost"
onClick={() => navigate("/app/products")}
>
</button>
</div>
)}
</div>
+185 -18
View File
@@ -31,7 +31,11 @@ import {
getProducts,
deleteProduct,
getProductDownloadUrl,
updateReviewStatus,
batchDownload,
getBatchDownloadStatus,
type ProductItem as ApiProductItem,
type ReviewStatus,
} from "@/api/products";
import "./products.css";
@@ -53,6 +57,12 @@ interface ProductItem {
fileSize: number; // MB
videoUrl?: string;
thumbnailUrl?: string;
/** 复核状态 */
reviewStatus?: ReviewStatus;
/** 所属项目 ID */
projectId?: string;
/** 所属项目名称 */
projectName?: string;
}
/** 将后端 status 映射为前端 ProductStatus */
@@ -108,6 +118,9 @@ const mapApiProduct = (item: ApiProductItem): ProductItem => ({
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
videoUrl: item.video_url,
thumbnailUrl: item.thumbnail_url,
reviewStatus: item.review_status,
projectId: item.project_id,
projectName: item.project_name,
});
/* ============================================================
@@ -135,6 +148,30 @@ const formatSize = (mb: number): string => {
return `${mb.toFixed(1)} MB`;
};
/** 复核状态配置 */
const reviewStatusConfig: Record<
ReviewStatus,
{ text: string; className: string }
> = {
pending_review: { text: "待复核", className: "review-pending" },
approved: { text: "已通过", className: "review-approved" },
rejected: { text: "需修改", className: "review-rejected" },
};
/** 复核状态循环顺序 */
const REVIEW_STATUS_CYCLE: ReviewStatus[] = [
"pending_review",
"approved",
"rejected",
];
/** 获取下一个复核状态 */
const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
if (!current) return "approved";
const idx = REVIEW_STATUS_CYCLE.indexOf(current);
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length];
};
/* ============================================================
* ProductCard
* ============================================================ */
@@ -148,6 +185,7 @@ const ProductCard: React.FC<{
onShare: (product: ProductItem) => void;
onDelete: (id: string) => void;
onPublish: (product: ProductItem) => void;
onReviewStatusChange: (id: string) => void;
}> = ({
product,
isSelected,
@@ -158,6 +196,7 @@ const ProductCard: React.FC<{
onShare,
onDelete,
onPublish,
onReviewStatusChange,
}) => {
const st = statusConfig[product.status];
@@ -201,6 +240,33 @@ const ProductCard: React.FC<{
{/* 已发布徽章(右上角) */}
{product.isPublished && <div className="xx-product-badge"> </div>}
{/* 复核状态标签(右上角) */}
{product.reviewStatus && (
<div
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
onClick={(e) => {
e.stopPropagation();
onReviewStatusChange(product.id);
}}
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text}`}
>
{reviewStatusConfig[product.reviewStatus].text}
</div>
)}
{/* 无复核状态时显示"待复核"入口 */}
{!product.reviewStatus && (
<div
className="xx-product-review-tag review-pending"
onClick={(e) => {
e.stopPropagation();
onReviewStatusChange(product.id);
}}
title="点击设置复核状态"
>
</div>
)}
{/* 缩略图 */}
<div className="xx-product-thumb">
{product.thumbnailUrl ? (
@@ -517,7 +583,7 @@ const ProductLibrary: React.FC = () => {
refetch,
} = useQuery<ApiProductItem[], Error>({
queryKey: ["products"],
queryFn: getProducts,
queryFn: () => getProducts(),
staleTime: 30_000,
});
@@ -536,11 +602,26 @@ const ProductLibrary: React.FC = () => {
},
});
/* ── 复核状态 mutation ── */
const reviewMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
updateReviewStatus(id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
message.success("复核状态已更新");
},
onError: () => {
message.error("更新复核状态失败");
},
});
/* 筛选 */
const [searchText, setSearchText] = useState("");
const [filterStatus, setFilterStatus] = useState<string>("all");
const [filterTime, setFilterTime] = useState<string>("all");
const [filterDuration, setFilterDuration] = useState<string>("all");
const [filterProject, setFilterProject] = useState<string>("all");
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all");
/* 批量操作 */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -596,6 +677,20 @@ const ProductLibrary: React.FC = () => {
});
}
/* 项目筛选 */
if (filterProject !== "all") {
list = list.filter((p) => p.projectId === filterProject);
}
/* 复核状态筛选 */
if (filterReviewStatus !== "all") {
if (filterReviewStatus === "none") {
list = list.filter((p) => !p.reviewStatus);
} else {
list = list.filter((p) => p.reviewStatus === filterReviewStatus);
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase();
@@ -603,7 +698,15 @@ const ProductLibrary: React.FC = () => {
}
return list;
}, [products, filterStatus, filterTime, filterDuration, searchText]);
}, [
products,
filterStatus,
filterTime,
filterDuration,
filterProject,
filterReviewStatus,
searchText,
]);
/* 全选 */
const allSelected =
@@ -679,24 +782,56 @@ const ProductLibrary: React.FC = () => {
message.info("发布功能待后端 API 补齐");
};
/* 批量下载 */
/* 切换复核状态 */
const handleReviewStatusChange = (id: string) => {
const current = products.find((p) => p.id === id)?.reviewStatus;
const nextStatus = getNextReviewStatus(current);
reviewMutation.mutate({ id, status: nextStatus });
};
/* 批量下载 — 使用 batch-download API + 轮询 */
const [batchDownloading, setBatchDownloading] = useState(false);
const handleBatchDownload = async () => {
const ids = Array.from(selectedIds);
let successCount = 0;
for (const id of ids) {
try {
const { url } = await getProductDownloadUrl(id);
const a = document.createElement("a");
a.href = url;
a.download = "";
a.click();
successCount++;
} catch {
// 忽略单个失败
}
if (ids.length === 0) return;
setBatchDownloading(true);
try {
// 发起批量下载任务
const { job_id } = await batchDownload(ids);
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`);
// 轮询下载状态(最多 60 次,每次 2 秒)
let attempts = 0;
const maxAttempts = 60;
const poll = async (): Promise<void> => {
if (attempts >= maxAttempts) {
message.warning("打包超时,请稍后在消息中心查看");
return;
}
attempts++;
await new Promise((r) => setTimeout(r, 2000));
const status = await getBatchDownloadStatus(job_id);
if (status.status === "completed" && status.download_url) {
const a = document.createElement("a");
a.href = status.download_url;
a.download = "";
a.click();
message.success(`已打包下载 ${ids.length} 个视频`);
setSelectedIds(new Set());
} else if (status.status === "failed") {
message.error("批量下载失败,请重试");
} else {
// 继续轮询
await poll();
}
};
await poll();
} catch {
message.error("发起批量下载失败");
} finally {
setBatchDownloading(false);
}
message.success(`已下载 ${successCount}/${ids.length} 个视频`);
setSelectedIds(new Set());
};
/* 批量删除 */
@@ -823,8 +958,9 @@ const ProductLibrary: React.FC = () => {
buttonSize="sm"
icon={<DownloadOutlined />}
onClick={handleBatchDownload}
disabled={batchDownloading}
>
{batchDownloading ? "打包中..." : "批量下载"}
</Button>
<Button
buttonType="primary"
@@ -904,6 +1040,36 @@ const ProductLibrary: React.FC = () => {
{ value: "long", label: ">3分钟" },
]}
/>
<Select
value={filterProject}
onChange={setFilterProject}
style={{ width: 140 }}
options={[
{ value: "all", label: "全部项目" },
...Array.from(
new Map(
products
.filter((p) => p.projectId && p.projectName)
.map((p) => [p.projectId!, p.projectName!] as const),
),
).map(([id, name]) => ({
value: id as string,
label: name as string,
})),
]}
/>
<Select
value={filterReviewStatus}
onChange={setFilterReviewStatus}
style={{ width: 130 }}
options={[
{ value: "all", label: "全部复核" },
{ value: "none", label: "未设置" },
{ value: "pending_review", label: "待复核" },
{ value: "approved", label: "已通过" },
{ value: "rejected", label: "需修改" },
]}
/>
</div>
<div className="xx-products-filters-right">
<span
@@ -932,6 +1098,7 @@ const ProductLibrary: React.FC = () => {
onShare={handleShare}
onDelete={handleDelete}
onPublish={handlePublish}
onReviewStatusChange={handleReviewStatusChange}
/>
))}
</div>
+36
View File
@@ -222,6 +222,42 @@
backdrop-filter: blur(4px);
}
/* 复核状态标签(右上角,位于已发布徽章下方) */
.xx-product-review-tag {
position: absolute;
top: 34px;
right: 10px;
z-index: 2;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
backdrop-filter: blur(4px);
cursor: pointer;
transition: var(--transition-all);
user-select: none;
}
.xx-product-review-tag:hover {
transform: scale(1.05);
filter: brightness(1.1);
}
.xx-product-review-tag.review-pending {
background: rgba(156, 163, 175, 0.9);
color: var(--text-inverse);
}
.xx-product-review-tag.review-approved {
background: rgba(16, 185, 129, 0.9);
color: var(--text-inverse);
}
.xx-product-review-tag.review-rejected {
background: rgba(239, 68, 68, 0.9);
color: var(--text-inverse);
}
/* 缩略图区域 */
.xx-product-thumb {
aspect-ratio: 9 / 16;
+450
View File
@@ -0,0 +1,450 @@
/**
*
*
*/
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Table,
Tabs,
Select,
Tag,
Button,
message,
Popconfirm,
Tooltip,
} from "antd";
import {
CheckCircleOutlined,
ClockCircleOutlined,
SyncOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
MinusCircleOutlined,
RedoOutlined,
InfoCircleOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getTasks,
retryTask,
type TaskItem,
type TaskStatus,
type TaskListParams,
} from "@/api/tasks";
import "./tasks.css";
/* ──────────── 常量 ──────────── */
/** 状态 Tab 配置 */
const STATUS_TABS: { key: TaskStatus | "all"; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "waiting", label: "等待中" },
{ key: "running", label: "进行中" },
{ key: "completed", label: "已完成" },
{ key: "failed", label: "失败" },
{ key: "cancelled", label: "已取消" },
];
/** 类型筛选选项 */
const TYPE_OPTIONS = [
{ value: "all", label: "全部类型" },
{ value: "generation", label: "生成任务" },
{ value: "ingest", label: "素材导入" },
];
/** 状态标签配置 */
const STATUS_CONFIG: Record<
TaskStatus,
{ label: string; color: string; icon: React.ReactNode }
> = {
pending: {
label: "等待中",
color: "default",
icon: <ClockCircleOutlined />,
},
waiting: {
label: "排队中",
color: "processing",
icon: <SyncOutlined spin />,
},
running: {
label: "进行中",
color: "processing",
icon: <SyncOutlined spin />,
},
completed: {
label: "已完成",
color: "success",
icon: <CheckCircleOutlined />,
},
failed: {
label: "失败",
color: "error",
icon: <CloseCircleOutlined />,
},
cancelled: {
label: "已取消",
color: "default",
icon: <MinusCircleOutlined />,
},
};
/** 任务类型标签 */
const TYPE_LABELS: Record<string, { label: string; color: string }> = {
generation: { label: "生成任务", color: "blue" },
ingest: { label: "素材导入", color: "green" },
};
/* ──────────── 工具函数 ──────────── */
/** 格式化耗时 */
const formatDuration = (seconds?: number): string => {
if (!seconds) return "-";
if (seconds < 60) return `${Math.round(seconds)}`;
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
if (minutes < 60) return `${minutes}${secs}`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}小时${mins}`;
};
/** 格式化时间 */
const formatTime = (dateStr?: string | null): string => {
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
/* ──────────── 主组件 ──────────── */
export default function TaskCenter() {
const queryClient = useQueryClient();
// 筛选状态
const [statusFilter, setStatusFilter] = useState<TaskStatus | "all">("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
const [expandedTaskDetail, setExpandedTaskDetail] = useState<TaskItem | null>(
null,
);
// 查询参数
const queryParams: TaskListParams = {
page,
page_size: pageSize,
...(statusFilter !== "all" && { status: statusFilter }),
...(typeFilter !== "all" && { task_type: typeFilter }),
};
// 获取任务列表
const { data, isLoading, error } = useQuery({
queryKey: ["tasks", queryParams],
queryFn: () => getTasks(queryParams),
refetchInterval: (query) => {
// 有进行中的任务时自动刷新
const tasks = query.state.data?.items ?? [];
const hasRunning = tasks.some(
(t) => t.status === "running" || t.status === "waiting",
);
return hasRunning ? 5000 : false;
},
});
// 重试任务
const retryMutation = useMutation({
mutationFn: retryTask,
onSuccess: () => {
message.success("任务已重新提交");
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
onError: () => {
message.error("重试失败,请检查任务状态");
},
});
// 展开查看详情
const handleExpand = async (expanded: boolean, record: TaskItem) => {
if (!expanded) {
setExpandedTaskId(null);
setExpandedTaskDetail(null);
return;
}
setExpandedTaskId(record.id);
// 如果是失败任务,获取详情(含 error_info
if (record.status === "failed" && record.error_info) {
setExpandedTaskDetail(record);
}
};
// 表格列定义
const columns: ColumnsType<TaskItem> = [
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 120,
ellipsis: true,
render: (id: string) => (
<Tooltip title={id}>
<span className="task-id">{id.slice(0, 8)}...</span>
</Tooltip>
),
},
{
title: "类型",
dataIndex: "task_type",
key: "task_type",
width: 100,
render: (type: string) => {
const config = TYPE_LABELS[type] || { label: type, color: "default" };
return <Tag color={config.color}>{config.label}</Tag>;
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: TaskStatus, record: TaskItem) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
};
return (
<Tag
color={config.color}
icon={config.icon}
className="task-status-tag"
>
{config.label}
{status === "running" && record.progress > 0 && (
<span className="task-progress"> {record.progress}%</span>
)}
</Tag>
);
},
},
{
title: "当前步骤",
dataIndex: "current_step",
key: "current_step",
width: 150,
ellipsis: true,
render: (step: string) => (
<span className="task-step">{step || "-"}</span>
),
},
{
title: "耗时",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 100,
render: (seconds: number) => (
<span className="task-duration">{formatDuration(seconds)}</span>
),
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 120,
render: (time: string) => (
<span className="task-time">{formatTime(time)}</span>
),
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right",
render: (_: unknown, record: TaskItem) => {
if (record.status === "failed" && record.retryable) {
return (
<Popconfirm
title="确认重试"
description="确定要重试这个失败的任务吗?"
onConfirm={() => retryMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryMutation.isPending}
className="task-retry-btn"
>
</Button>
</Popconfirm>
);
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => {
setExpandedTaskId(record.id);
setExpandedTaskDetail(record);
}}
>
</Button>
);
}
return <span className="task-action-placeholder">-</span>;
},
},
];
// 展开行渲染(错误详情)
const expandedRowRender = (record: TaskItem) => {
const detail = expandedTaskDetail || record;
const errorInfo = detail.error_info;
if (!errorInfo && !detail.error_message) {
return <div className="task-expand-empty"></div>;
}
return (
<div className="task-error-detail">
<div className="task-error-header">
<ExclamationCircleOutlined className="task-error-icon" />
<span></span>
</div>
<div className="task-error-body">
{errorInfo?.error_type && (
<div className="task-error-row">
<span className="task-error-label"></span>
<Tag color="error">{errorInfo.error_type}</Tag>
</div>
)}
{(errorInfo?.error_message || detail.error_message) && (
<div className="task-error-row">
<span className="task-error-label"></span>
<span className="task-error-message">
{errorInfo?.error_message || detail.error_message}
</span>
</div>
)}
{errorInfo?.failed_step && (
<div className="task-error-row">
<span className="task-error-label"></span>
<span>{errorInfo.failed_step}</span>
</div>
)}
{errorInfo?.stack_trace && (
<div className="task-error-row task-error-stack">
<span className="task-error-label"></span>
<pre>{errorInfo.stack_trace}</pre>
</div>
)}
</div>
</div>
);
};
// 错误处理
if (error) {
return (
<div className="task-center">
<div className="task-error">
<CloseCircleOutlined />
<p></p>
<Button onClick={() => window.location.reload()}></Button>
</div>
</div>
);
}
return (
<div className="task-center">
{/* 页面标题 */}
<div className="task-header">
<h1 className="task-title"></h1>
<p className="task-subtitle"></p>
</div>
{/* 筛选栏 */}
<div className="task-filters">
{/* 状态 Tab */}
<Tabs
activeKey={statusFilter}
onChange={(key) => {
setStatusFilter(key as TaskStatus | "all");
setPage(1);
}}
items={STATUS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
}))}
className="task-status-tabs"
/>
{/* 类型筛选 */}
<div className="task-type-filter">
<Select
value={typeFilter}
onChange={(value) => {
setTypeFilter(value);
setPage(1);
}}
options={TYPE_OPTIONS}
style={{ width: 140 }}
placeholder="选择类型"
/>
</div>
</div>
{/* 任务表格 */}
<Table
columns={columns}
dataSource={data?.items || []}
rowKey="id"
loading={isLoading}
pagination={{
current: page,
pageSize,
total: data?.total || 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
expandable={{
expandedRowRender,
expandedRowKeys: expandedTaskId ? [expandedTaskId] : [],
onExpand: handleExpand,
rowExpandable: (record) =>
record.status === "failed" &&
(!!record.error_info || !!record.error_message),
}}
scroll={{ x: 800 }}
className="task-table"
locale={{
emptyText: (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
),
}}
/>
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
/* ──────────── 任务中心 ──────────── */
.task-center {
padding: var(--space-lg);
max-width: 1400px;
margin: 0 auto;
}
/* 页面标题 */
.task-header {
margin-bottom: var(--space-lg);
}
.task-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin: 0 0 var(--space-xs) 0;
}
.task-subtitle {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin: 0;
}
/* 筛选栏 */
.task-filters {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-lg);
padding: var(--space-md);
background: var(--bg-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--border-primary);
}
.task-status-tabs {
flex: 1;
}
.task-status-tabs .ant-tabs-nav {
margin-bottom: 0;
}
.task-status-tabs .ant-tabs-tab {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
}
.task-type-filter {
flex-shrink: 0;
margin-left: var(--space-md);
}
/* 表格 */
.task-table {
background: var(--bg-surface);
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--border-primary);
}
.task-table .ant-table {
background: transparent;
}
.task-table .ant-table-thead > tr > th {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.task-table .ant-table-tbody > tr > td {
border-bottom: 1px solid var(--border-light);
}
.task-table .ant-table-tbody > tr:hover > td {
background: var(--bg-hover);
}
/* 任务 ID */
.task-id {
font-family: var(--font-mono, monospace);
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
/* 状态标签 */
.task-status-tag {
display: inline-flex;
align-items: center;
gap: 4px;
}
.task-progress {
font-size: var(--font-size-xs);
opacity: 0.9;
}
/* 当前步骤 */
.task-step {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* 耗时 */
.task-duration {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
/* 时间 */
.task-time {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* 操作按钮 */
.task-retry-btn {
color: var(--primary-500);
}
.task-retry-btn:hover {
color: var(--primary-600);
}
.task-action-placeholder {
color: var(--text-disabled, var(--text-secondary));
font-size: var(--font-size-sm);
}
/* 展开行 - 错误详情 */
.task-error-detail {
padding: var(--space-md);
background: var(--bg-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--border-primary);
}
.task-error-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
font-weight: var(--font-weight-semibold);
color: var(--error-500, #ef4444);
}
.task-error-icon {
font-size: var(--font-size-lg);
}
.task-error-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.task-error-row {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
font-size: var(--font-size-sm);
}
.task-error-label {
color: var(--text-secondary);
flex-shrink: 0;
min-width: 80px;
}
.task-error-message {
color: var(--error-500, #ef4444);
word-break: break-all;
}
.task-error-stack {
flex-direction: column;
}
.task-error-stack pre {
margin: var(--space-xs) 0 0 0;
padding: var(--space-sm);
background: var(--bg-surface);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
color: var(--text-secondary);
overflow-x: auto;
max-height: 200px;
font-family: var(--font-mono, monospace);
}
.task-expand-empty {
padding: var(--space-md);
text-align: center;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* 空状态 */
.task-empty {
padding: var(--space-2xl) 0;
text-align: center;
color: var(--text-secondary);
}
.task-empty .anticon {
font-size: 48px;
margin-bottom: var(--space-md);
opacity: 0.5;
}
.task-empty p {
margin: 0;
font-size: var(--font-size-base);
}
/* 错误状态 */
.task-error {
padding: var(--space-2xl);
text-align: center;
color: var(--error-500, #ef4444);
}
.task-error .anticon {
font-size: 48px;
margin-bottom: var(--space-md);
}
.task-error p {
margin: 0 0 var(--space-md) 0;
font-size: var(--font-size-base);
}
/* 响应式 */
@media (max-width: 768px) {
.task-center {
padding: var(--space-md);
}
.task-filters {
flex-direction: column;
gap: var(--space-md);
}
.task-type-filter {
margin-left: 0;
width: 100%;
}
.task-type-filter .ant-select {
width: 100% !important;
}
.task-status-tabs .ant-tabs-nav {
overflow-x: auto;
}
.task-status-tabs .ant-tabs-tab {
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-xs);
}
}
+414 -323
View File
@@ -1,106 +1,41 @@
/**
* V21
* 2.13 EditPlan
*
* - EditTemplate
* - +
* - EditPlan UI
* - 使 useQuery APIapi/templates.ts, api/editPlans.ts
* API
* - page/page_size/category/keyword/duration_range
* - BGM
* - /
* - + + +
*/
import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui";
import { Button, message, Pagination, Tooltip, Tag, Descriptions } from "antd";
import {
LoadingOutlined,
ExclamationCircleOutlined,
InboxOutlined,
SearchOutlined,
CopyOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import {
getTemplates,
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
type TemplateItem,
type TemplateListParams,
type TemplateSegment,
} from "@/api/templates";
import { createEditPlan } from "@/api/editPlans";
import "./templates.css";
/* ============================================================
* EditTemplate / EditPlan / TemplateClipConfig
*
* ============================================================ */
/** 模板片段配置 */
interface TemplateClipConfig {
id: string;
order: number;
clipType: string;
description: string;
duration: number; // 秒
}
/** 模板类型 */
type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog";
/** 模板数据(UI 层,映射自后端 TemplateItem */
interface EditTemplate {
id: string;
name: string;
type: EditTemplateType;
description: string;
usageCount: number;
isFavorite: boolean;
thumbnailGradient: string;
scriptContent: string;
clipConfigs: TemplateClipConfig[];
recommendedDuration: number; // 秒
tags: string[];
}
/* ============================================================
* TemplateItem EditTemplate
* ============================================================ */
/** 根据 category 推断模板类型 */
const inferTemplateType = (category: string): EditTemplateType => {
const map: Record<string, EditTemplateType> = {
: "口播",
: "种草",
: "产品",
: "品牌",
: "混剪",
Vlog: "Vlog",
};
return map[category] ?? "口播";
};
/** 根据 category 生成占位渐变色 */
const gradientForCategory = (category: string): string => {
const gradients: Record<string, string> = {
: "linear-gradient(135deg, #6366f1, #8b5cf6)",
: "linear-gradient(135deg, #10b981, #059669)",
: "linear-gradient(135deg, #0ea5e9, #0284c7)",
: "linear-gradient(135deg, #f59e0b, #d97706)",
: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
};
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
};
/** 将后端 TemplateItem 映射为前端 EditTemplate */
const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
id: item.id,
name: item.name,
type: inferTemplateType(item.category),
description: item.description ?? "",
usageCount: 0,
isFavorite: item.is_favorite ?? false,
thumbnailGradient: gradientForCategory(item.category),
scriptContent: "",
clipConfigs: [],
recommendedDuration: item.target_duration ?? 0,
tags: [item.category],
});
/* ============================================================
*
* ============================================================ */
@@ -120,98 +55,117 @@ const TEMPLATE_TYPES: Array<{
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
];
/** 时长筛选选项 */
const DURATION_OPTIONS: Array<{
value: "" | "short" | "medium" | "long";
label: string;
}> = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
];
/* ============================================================
*
* ============================================================ */
/** 获取类型对应颜色 */
const getTypeColor = (type: EditTemplateType): string => {
const getTypeColor = (type: string): string => {
const found = TEMPLATE_TYPES.find((t) => t.type === type);
return found?.color ?? "#6366f1";
};
/** 获取片段类型标签 */
const getClipTypeLabel = (clipType: string): string => clipType;
/** 获取片段类型颜色 */
const getClipTypeColor = (clipType: string): string => {
const colorMap: Record<string, string> = {
: "#6366f1",
: "#0ea5e9",
: "#10b981",
: "#f59e0b",
: "#8b5cf6",
: "#ec4899",
: "#14b8a6",
: "#f59e0b",
: "#6366f1",
: "#0ea5e9",
: "#10b981",
: "#64748b",
: "#f59e0b",
: "#0ea5e9",
: "#8b5cf6",
: "#10b981",
1: "#6366f1",
2: "#8b5cf6",
: "#ef4444",
: "#10b981",
使: "#0ea5e9",
: "#f59e0b",
: "#6366f1",
: "#0ea5e9",
: "#10b981",
: "#f59e0b",
: "#6366f1",
: "#10b981",
: "#f59e0b",
: "#0ea5e9",
: "#ef4444",
: "#64748b",
: "#ec4899",
: "#f59e0b",
: "#10b981",
: "#0ea5e9",
: "#8b5cf6",
/** 根据 category 生成占位渐变色 */
const gradientForCategory = (category: string): string => {
const gradients: Record<string, string> = {
: "linear-gradient(135deg, #6366f1, #8b5cf6)",
: "linear-gradient(135deg, #10b981, #059669)",
: "linear-gradient(135deg, #0ea5e9, #0284c7)",
: "linear-gradient(135deg, #f59e0b, #d97706)",
: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
};
return colorMap[clipType] ?? "#6366f1";
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
};
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const formatDuration = (seconds: number | undefined | null): string => {
if (!seconds || seconds <= 0) return "0秒";
const totalSec = Math.round(seconds);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
if (m === 0) return `${s}`;
return `${m}${s > 0 ? `${s}` : ""}`;
};
/** 配置展示字段(formatConfig 提取通用配置的可读属性) */
interface ConfigDisplayFields {
font_size?: string | number;
font_family?: string;
color?: string;
position?: string;
volume?: string | number;
name?: string;
}
/** 格式化配置对象为可读文本 */
const formatConfig = (config?: object): string => {
if (!config || Object.keys(config).length === 0) return "默认";
const c = config as ConfigDisplayFields;
const parts: string[] = [];
if (c.font_size) parts.push(`字号: ${c.font_size}`);
if (c.font_family) parts.push(`字体: ${c.font_family}`);
if (c.color) parts.push(`颜色: ${c.color}`);
if (c.position) parts.push(`位置: ${c.position}`);
if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`);
if (c.name) parts.push(String(c.name));
return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config);
};
/** 素材类型标签 */
const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
subtitle: "字幕",
null: "不限",
};
/* ============================================================
*
*
* ============================================================ */
interface TemplatePreviewModalProps {
template: EditTemplate;
interface TemplateDetailModalProps {
template: TemplateItem;
isFavorite: boolean;
onClose: () => void;
onToggleFavorite: (id: string) => void;
onUse: (template: EditTemplate) => void;
onUse: (template: TemplateItem) => void;
onCopy: (template: TemplateItem) => void;
}
const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
template,
isFavorite,
onClose,
onToggleFavorite,
onUse,
onCopy,
}) => {
const totalDuration = template.clipConfigs.reduce(
(sum, c) => sum + c.duration,
const segments = template.segments ?? [];
const totalSegmentDuration = segments.reduce(
(sum, s) => sum + (s.duration_min + s.duration_max) / 2,
0,
);
return (
<div className="xx-template-modal-overlay" onClick={onClose}>
<div className="xx-template-modal" onClick={(e) => e.stopPropagation()}>
<div
className="xx-template-modal xx-template-modal-wide"
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button
className="xx-template-modal-close"
@@ -224,15 +178,23 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
{/* 预览区域 */}
<div
className="xx-template-modal-preview"
style={{ background: template.thumbnailGradient }}
style={{ background: gradientForCategory(template.category) }}
>
<div className="xx-template-modal-preview-content">
<span className="xx-template-preview-icon">
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon ??
"📋"}
</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
{template.thumbnail_url ? (
<img
src={template.thumbnail_url}
alt={template.name}
className="xx-template-modal-thumb-img"
/>
) : (
<div className="xx-template-modal-preview-content">
<span className="xx-template-preview-icon">
{TEMPLATE_TYPES.find((t) => t.type === template.category)
?.icon ?? "📋"}
</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
)}
</div>
{/* 内容区域 */}
@@ -243,12 +205,12 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
<span
className="xx-template-modal-type-badge"
style={{
color: getTypeColor(template.type),
background: `${getTypeColor(template.type)}18`,
color: getTypeColor(template.category),
background: `${getTypeColor(template.category)}18`,
}}
>
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon}{" "}
{template.type}
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon}{" "}
{template.category}
</span>
</div>
@@ -256,66 +218,130 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
<p className="xx-template-modal-desc">{template.description}</p>
{/* 标签 */}
<div className="xx-template-modal-tags">
{template.tags.map((tag) => (
<span key={tag} className="xx-template-modal-tag">
#{tag}
</span>
))}
</div>
{/* 脚本内容 */}
{template.scriptContent && (
<div className="xx-template-modal-section">
<h4>📝 </h4>
<pre className="xx-template-modal-script">
{template.scriptContent}
</pre>
{(template.tags?.length ?? 0) > 0 && (
<div className="xx-template-modal-tags">
{template.tags!.map((tag) => (
<span key={tag} className="xx-template-modal-tag">
#{tag}
</span>
))}
</div>
)}
{/* 视频结构 */}
{template.clipConfigs.length > 0 && (
{/* 基本信息 */}
<Descriptions
column={2}
size="small"
className="xx-template-modal-desc-table"
items={[
{
key: "duration",
label: "目标时长",
children: formatDuration(
template.estimated_duration ?? template.target_duration,
),
},
{
key: "clips",
label: "片段数量",
children: `${template.clip_count}`,
},
{
key: "ratio",
label: "视频比例",
children: template.aspect_ratio ?? "16:9",
},
{
key: "usage",
label: "使用次数",
children: `${template.usage_count ?? 0}`,
},
]}
/>
{/* 素材规则(片段配置) */}
{segments.length > 0 && (
<div className="xx-template-modal-section">
<h4>🎬 </h4>
<h4>🎬 </h4>
<div className="xx-template-modal-clip-list">
{template.clipConfigs
.sort((a, b) => a.order - b.order)
.map((clip) => (
<div key={clip.id} className="xx-template-modal-clip-item">
{segments
.sort((a, b) => a.segment_order - b.segment_order)
.map((seg: TemplateSegment, idx: number) => (
<div
key={seg.id ?? idx}
className="xx-template-modal-clip-item"
>
<span className="xx-template-modal-clip-order">
#{seg.segment_order}
</span>
<span
className="xx-template-modal-clip-badge"
style={{
color: getClipTypeColor(clip.clipType),
background: `${getClipTypeColor(clip.clipType)}18`,
color: seg.material_type
? getTypeColor(seg.material_type)
: "#64748b",
background: seg.material_type
? `${getTypeColor(seg.material_type)}18`
: "#f1f5f9",
}}
>
{getClipTypeLabel(clip.clipType)}
{MATERIAL_TYPE_LABELS[seg.material_type ?? "null"] ??
seg.material_type ??
"不限"}
</span>
<span className="xx-template-modal-clip-desc">
{clip.description}
</span>
<span className="xx-template-modal-clip-duration">
{clip.duration}
{seg.description || `片段 ${seg.segment_order}`}
</span>
<Tooltip
title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}`}
>
<span className="xx-template-modal-clip-duration">
{seg.duration_min}-{seg.duration_max}
</span>
</Tooltip>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(totalDuration)}
{template.recommendedDuration !== totalDuration && (
<span>
{" "}
· {formatDuration(template.recommendedDuration)}
</span>
)}
{formatDuration(Math.round(totalSegmentDuration))}
</div>
</div>
)}
{/* 样式配置 */}
<div className="xx-template-modal-section">
<h4>🎨 </h4>
<div className="xx-template-modal-style-grid">
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{formatConfig(template.subtitle_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{formatConfig(template.title_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label">BGM </span>
<span className="xx-template-modal-style-value">
{formatConfig(template.bgm_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{template.aspect_ratio ?? "16:9"}
</span>
</div>
</div>
</div>
{/* 统计信息 */}
<div className="xx-template-modal-stats">
<span>使 {template.usageCount} </span>
<span>使 {template.usage_count ?? 0} </span>
<button
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={() => onToggleFavorite(template.id)}
@@ -326,15 +352,15 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
{/* 操作按钮 */}
<div className="xx-template-modal-actions">
<Button buttonType="ghost" buttonSize="md" onClick={onClose}>
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
</Button>
<Button
buttonType="primary"
buttonSize="md"
type="primary"
icon={<ThunderboltOutlined />}
onClick={() => onUse(template)}
>
使
使
</Button>
</div>
</div>
@@ -348,11 +374,11 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
* ============================================================ */
interface TemplateCardProps {
template: EditTemplate;
template: TemplateItem;
isFavorite: boolean;
onPreview: (template: EditTemplate) => void;
onPreview: (template: TemplateItem) => void;
onToggleFavorite: (id: string, e: React.MouseEvent) => void;
onUse: (template: EditTemplate) => void;
onUse: (template: TemplateItem) => void;
}
const TemplateCard: React.FC<TemplateCardProps> = ({
@@ -366,15 +392,31 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="xx-template-card" onClick={() => onPreview(template)}>
{/* 缩略图 */}
<div className="xx-template-thumb">
<div
className="xx-template-thumb-bg"
style={{ background: template.thumbnailGradient }}
>
{(template.description ?? "").slice(0, 80)}...
</div>
{template.thumbnail_url ? (
<img
src={template.thumbnail_url}
alt={template.name}
className="xx-template-thumb-img"
/>
) : (
<div
className="xx-template-thumb-bg"
style={{ background: gradientForCategory(template.category) }}
>
{(template.description ?? "").slice(0, 80)}
{(template.description ?? "").length > 80 ? "..." : ""}
</div>
)}
<div className="xx-template-thumb-overlay" />
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-preview-hint"></div>
<div className="xx-template-thumb-meta">
<span className="xx-template-thumb-duration">
{formatDuration(
template.estimated_duration ?? template.target_duration,
)}
</span>
</div>
<div className="xx-template-preview-hint"></div>
<button
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={(e) => onToggleFavorite(template.id, e)}
@@ -390,17 +432,22 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
<span
className="xx-template-category-pill"
style={{
color: getTypeColor(template.type),
background: `${getTypeColor(template.type)}18`,
color: getTypeColor(template.category),
background: `${getTypeColor(template.category)}18`,
}}
>
{template.type}
{template.category}
</span>
{(template.tags ?? []).slice(0, 2).map((tag) => (
<Tag key={tag} className="xx-template-tag-pill" bordered={false}>
{tag}
</Tag>
))}
</div>
<p className="xx-template-desc">{template.description ?? ""}</p>
<div className="xx-template-meta">
<span className="xx-template-usage">
使 {template.usageCount}
使 {template.usage_count ?? 0}
</span>
<button
className="xx-template-use-btn"
@@ -424,98 +471,142 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
const TemplateLibrary: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
// 筛选状态
const [searchText, setSearchText] = useState("");
const [activeType, setActiveType] = useState<EditTemplateType | "全部">(
"全部",
);
const [previewTemplate, setPreviewTemplate] = useState<EditTemplate | null>(
const [durationRange, setDurationRange] = useState<
"" | "short" | "medium" | "long"
>("");
const [page, setPage] = useState(1);
const [pageSize] = useState(12);
// 弹窗状态
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(
null,
);
const [detailLoading, setDetailLoading] = useState(false);
// ── 获取模板列表 ──
// ── 构建查询参数 ──
const queryParams: TemplateListParams = useMemo(() => {
const params: TemplateListParams = {
page,
page_size: pageSize,
};
if (activeType !== "全部") params.category = activeType;
if (searchText.trim()) params.keyword = searchText.trim();
if (durationRange) params.duration_range = durationRange;
return params;
}, [page, pageSize, activeType, searchText, durationRange]);
// ── 获取模板列表(后端分页 + 筛选) ──
const {
data: apiTemplates = [],
data: templateData,
isLoading,
isError,
error,
} = useQuery<TemplateItem[], Error>({
queryKey: ["templates"],
queryFn: getTemplates,
staleTime: 60_000,
} = useQuery({
queryKey: ["templates", queryParams],
queryFn: () => getTemplates(queryParams),
staleTime: 30_000,
});
// 将后端数据映射为前端 EditTemplate
const templates = useMemo(
() => apiTemplates.map(mapTemplateItemToEditTemplate),
[apiTemplates],
);
const templates = templateData?.items ?? [];
const totalTemplates = templateData?.total ?? 0;
// ── 收藏 mutation ──
const favMutation = useMutation({
mutationFn: toggleFavoriteTemplate,
onSuccess: (_data, templateId) => {
// 乐观更新:刷新模板列表
queryClient.invalidateQueries({ queryKey: ["templates"] });
// 同时更新当前预览(如果有)
if (previewTemplate && previewTemplate.id === templateId) {
setPreviewTemplate((prev) =>
prev ? { ...prev, isFavorite: !prev.isFavorite } : prev,
prev ? { ...prev, is_favorite: !prev.is_favorite } : prev,
);
}
},
});
// ── 创建 EditPlan mutation ──
const createPlanMutation = useMutation({
mutationFn: (params: { template_id: string; name: string }) =>
createEditPlan(params),
// ── 复制模板 mutation ──
const copyMutation = useMutation({
mutationFn: copyTemplate,
onSuccess: (data) => {
message.success(`模板「${data.name}」已复制到「我的模板」`);
queryClient.invalidateQueries({ queryKey: ["templates"] });
},
onError: () => {
message.error("复制模板失败,请稍后重试");
},
});
/** 切换收藏 */
const toggleFavorite = (id: string, e?: React.MouseEvent) => {
e?.stopPropagation();
favMutation.mutate(id);
};
const toggleFavorite = useCallback(
(id: string, e?: React.MouseEvent) => {
e?.stopPropagation();
favMutation.mutate(id);
},
[favMutation],
);
/** 过滤模板 */
const filtered = useMemo(() => {
return templates.filter((t) => {
const matchSearch =
!searchText ||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
(t.description ?? "")
.toLowerCase()
.includes(searchText.toLowerCase()) ||
t.tags.some((tag) =>
tag.toLowerCase().includes(searchText.toLowerCase()),
);
const matchType = activeType === "全部" || t.type === activeType;
return matchSearch && matchType;
});
}, [searchText, activeType, templates]);
/** 按类型分组 */
const groupedTemplates = useMemo(() => {
const groups: Record<string, EditTemplate[]> = {};
for (const tpl of filtered) {
if (!groups[tpl.type]) groups[tpl.type] = [];
groups[tpl.type].push(tpl);
}
return groups;
}, [filtered]);
/** 使用模板 → 创建 EditPlan */
const handleUseTemplate = async (template: EditTemplate) => {
/** 点击卡片 → 获取详情并展示弹窗 */
const handlePreview = useCallback(async (template: TemplateItem) => {
setDetailLoading(true);
setPreviewTemplate(template);
try {
await createPlanMutation.mutateAsync({
template_id: template.id,
name: `基于「${template.name}」的剪辑计划`,
});
navigate("/app/editing-planner");
} catch (err) {
console.error("[TemplateLibrary] createEditPlan failed:", err);
const detail = await getTemplate(template.id);
setPreviewTemplate(detail);
} catch {
// 详情加载失败时使用列表数据
message.warning("模板详情加载失败,显示摘要信息");
} finally {
setDetailLoading(false);
}
};
}, []);
/** 复制模板 */
const handleCopy = useCallback(
(template: TemplateItem) => {
copyMutation.mutate(template.id);
},
[copyMutation],
);
/** 使用模板 → 进入剪辑编辑器配置 */
const handleUse = useCallback(
(template: TemplateItem) => {
navigate(`/app/editing-planner?templateId=${template.id}`);
},
[navigate],
);
/** 搜索防抖处理 */
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value);
setPage(1);
},
[],
);
/** 切换分类 */
const handleCategoryChange = useCallback(
(type: EditTemplateType | "全部") => {
setActiveType(type);
setPage(1);
},
[],
);
/** 切换时长筛选 */
const handleDurationChange = useCallback(
(value: "" | "short" | "medium" | "long") => {
setDurationRange(value);
setPage(1);
},
[],
);
// ── Loading 状态 ──
if (isLoading) {
@@ -554,16 +645,12 @@ const TemplateLibrary: React.FC = () => {
<h2></h2>
<p></p>
</div>
<Button
buttonType="primary"
buttonSize="md"
onClick={() => navigate("/app/editing-planner")}
>
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
+
</Button>
</div>
{/* ── 工具栏:搜索 + 类型按钮组 ─────────────────────────── */}
{/* ── 工具栏:搜索 + 类型按钮组 + 时长筛选 ─────────────── */}
<div className="xx-templates-toolbar">
<div className="xx-templates-search">
<span className="xx-templates-search-icon">
@@ -574,7 +661,7 @@ const TemplateLibrary: React.FC = () => {
type="text"
placeholder="搜索模板名称、描述或标签..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onChange={handleSearchChange}
/>
</div>
<div className="xx-templates-categories">
@@ -582,90 +669,94 @@ const TemplateLibrary: React.FC = () => {
<button
key={cat.type}
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
onClick={() => setActiveType(cat.type)}
onClick={() => handleCategoryChange(cat.type)}
>
<span className="xx-templates-cat-icon">{cat.icon}</span>
{cat.label}
</button>
))}
</div>
{/* 时长筛选 */}
<div className="xx-templates-duration-filter">
{DURATION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
onClick={() => handleDurationChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* ── 模板展示区 ────────────────────────────────────────── */}
{filtered.length === 0 ? (
{templates.length === 0 ? (
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon">
<InboxOutlined />
</div>
<h3>
{searchText || activeType !== "全部"
{searchText || activeType !== "全部" || durationRange
? "未找到匹配的模板"
: "暂无模板"}
</h3>
<p>
{searchText || activeType !== "全部"
{searchText || activeType !== "全部" || durationRange
? "试试调整搜索条件或切换类型"
: "点击上方「创建模板」开始创作"}
</p>
</div>
) : activeType === "全部" ? (
/* 全部类型 → 按类型分组展示 */
<div className="xx-templates-grouped">
{Object.entries(groupedTemplates).map(([type, tpls]) => {
const typeConfig = TEMPLATE_TYPES.find((t) => t.type === type);
return (
<div key={type} className="xx-templates-group">
<div className="xx-templates-group-header">
<span className="xx-templates-group-icon">
{typeConfig?.icon ?? "📋"}
</span>
<h3>{type}</h3>
<span className="xx-templates-group-count">
{tpls.length}
</span>
</div>
<div className="xx-templates-grid">
{tpls.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={tpl.isFavorite}
onPreview={setPreviewTemplate}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
/>
))}
</div>
</div>
);
})}
</div>
) : (
/* 单类型 → 平铺网格 */
<div className="xx-templates-grid">
{filtered.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={tpl.isFavorite}
onPreview={setPreviewTemplate}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
/>
))}
</div>
<>
<div className="xx-templates-grid">
{templates.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={tpl.is_favorite ?? false}
onPreview={handlePreview}
onToggleFavorite={toggleFavorite}
onUse={handleUse}
/>
))}
</div>
{/* 分页 */}
{totalTemplates > pageSize && (
<div className="xx-templates-pagination">
<Pagination
current={page}
pageSize={pageSize}
total={totalTemplates}
showSizeChanger={false}
showQuickJumper
showTotal={(total) => `${total} 个模板`}
onChange={(p) => setPage(p)}
/>
</div>
)}
</>
)}
{/* ── 预览弹窗 ──────────────────────────────────────────── */}
{/* ── 详情弹窗 ──────────────────────────────────────────── */}
{previewTemplate && (
<TemplatePreviewModal
<TemplateDetailModal
template={previewTemplate}
isFavorite={previewTemplate.isFavorite}
isFavorite={previewTemplate.is_favorite ?? false}
onClose={() => setPreviewTemplate(null)}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
onUse={handleUse}
onCopy={handleCopy}
/>
)}
{/* 详情加载中的提示(可选覆盖层) */}
{detailLoading && previewTemplate && (
<div className="xx-template-detail-loading">
<LoadingOutlined /> ...
</div>
)}
</div>
);
};
@@ -130,12 +130,20 @@ const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
};
};
/** 配音素材上传元数据(传递给 createAsset 的 metadata */
interface VoiceAssetMetadata {
gender: VoiceGender;
description: string;
duration: number;
[key: string]: unknown;
}
/** 前端表单数据 → 后端 metadata(标签走独立 API,不再写 metadata.style */
const buildMetadata = (data: {
gender: VoiceGender;
description: string;
duration?: number;
}): Record<string, unknown> => ({
}): VoiceAssetMetadata => ({
gender: data.gender,
description: data.description,
duration: data.duration || 0,
+10 -2
View File
@@ -623,12 +623,20 @@ const getAudioDuration = (file: File): Promise<number> =>
audio.src = url;
});
/** 音色上传元数据(传递给 createAsset 的 metadata */
interface VoiceUploadMetadata {
gender?: string;
description?: string;
duration?: number;
[key: string]: unknown;
}
const buildVoiceMetadata = (data: {
gender?: string;
description?: string;
duration?: number;
}): Record<string, unknown> => {
const metadata: Record<string, unknown> = {};
}): VoiceUploadMetadata => {
const metadata: VoiceUploadMetadata = {};
if (data.gender) metadata.gender = data.gender;
if (data.description) metadata.description = data.description;
if (data.duration) metadata.duration = Math.round(data.duration);
+14
View File
@@ -135,6 +135,13 @@ export const router = createBrowserRouter([
Component: m.default,
})),
},
{
path: "tasks",
lazy: () =>
import("@/pages/tasks/TaskCenter").then((m) => ({
Component: m.default,
})),
},
{
path: "editing-planner",
lazy: () =>
@@ -149,6 +156,13 @@ export const router = createBrowserRouter([
Component: m.default,
})),
},
{
path: "edit-plans",
lazy: () =>
import("@/pages/edit-plans/EditPlans").then((m) => ({
Component: m.default,
})),
},
{
path: "voice-clone",
lazy: () =>
+1
View File
@@ -37,6 +37,7 @@ export default defineConfig({
},
},
build: {
cache: true,
rollupOptions: {
output: {
manualChunks: {
+697
View File
@@ -0,0 +1,697 @@
"""视频拼接/合并引擎 — 多段视频按顺序拼接成一个成片.
基于 FFmpeg 实现两种拼接模式
1. **concat demuxerstream copy**最快所有视频编码参数必须一致
2. **concat filter重新编码**更灵活支持不同分辨率/编码/帧率的视频
使用场景
- 多段素材按顺序合并成一个视频
- 视频分割后重新拼接
- 片头 + 正片 + 片尾拼接
降级策略
- 优先尝试 stream copy速度快无质量损失
- 参数不一致时自动降级到 concat filter
- 某段视频失败时跳过不阻断整体拼接
"""
from __future__ import annotations
import logging
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
MAX_CONCAT_SEGMENTS = 50 # 最大拼接段数(安全上限,防止OOM)
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"}
# concat demuxer 要求一致的参数列表
CONCAT_DEMUXER_REQUIRED_PARAMS = [
"codec_name", # 视频编码
"width", # 宽度
"height", # 高度
"r_frame_rate", # 帧率
"pix_fmt", # 像素格式
"sample_rate", # 音频采样率
"channels", # 音频声道数
"audio_codec", # 音频编码
]
# ── 拼接片段配置 ──────────────────────────────────────────────────────────────
@dataclass
class ConcatSegment:
"""单个拼接片段."""
video_path: str # 视频文件路径
start_time: float = 0.0 # 开始时间(秒),从视频的哪个位置开始取
duration: float = 0.0 # 持续时长(秒),0表示取到末尾
has_audio: bool = True # 是否包含音频
@classmethod
def from_dict(cls, seg: dict) -> "ConcatSegment":
"""从字典创建拼接片段,带安全类型转换."""
try:
start_time = max(0.0, float(seg.get("start_time", 0.0)))
except (TypeError, ValueError):
start_time = 0.0
try:
duration = max(0.0, float(seg.get("duration", 0.0)))
except (TypeError, ValueError):
duration = 0.0
return cls(
video_path=str(seg.get("video_path", "")),
start_time=start_time,
duration=duration,
has_audio=bool(seg.get("has_audio", True)),
)
@dataclass
class ConcatConfig:
"""视频拼接配置."""
segments: list[ConcatSegment] = field(default_factory=list)
output_width: int = 0 # 输出宽度(0=自动取第一段)
output_height: int = 0 # 输出高度(0=自动取第一段)
output_fps: float = 0.0 # 输出帧率(0=自动取第一段)
force_reencode: bool = False # 强制重新编码(不用 stream copy
transition: str = "none" # 转场效果(none/crossfade- 预留
transition_duration: float = 0.3 # 转场时长
@classmethod
def from_config_dict(cls, config: dict | None) -> "ConcatConfig":
"""从配置字典创建 ConcatConfig."""
if not config or not isinstance(config, dict):
return cls()
segments_raw = config.get("segments", [])
segments: list[ConcatSegment] = []
if isinstance(segments_raw, list):
for s in segments_raw:
if isinstance(s, dict) and s.get("video_path"):
try:
seg = ConcatSegment.from_dict(s)
if seg.video_path:
segments.append(seg)
except Exception:
logger.warning("[concat] skip invalid segment: %s", s)
continue
try:
output_width = max(0, int(config.get("output_width", 0)))
except (TypeError, ValueError):
output_width = 0
try:
output_height = max(0, int(config.get("output_height", 0)))
except (TypeError, ValueError):
output_height = 0
try:
output_fps = max(0.0, float(config.get("output_fps", 0.0)))
except (TypeError, ValueError):
output_fps = 0.0
return cls(
segments=segments,
output_width=output_width,
output_height=output_height,
output_fps=output_fps,
force_reencode=bool(config.get("force_reencode", False)),
transition=str(config.get("transition", "none")),
transition_duration=max(0.1, float(config.get("transition_duration", 0.3))),
)
@property
def has_effect(self) -> bool:
"""是否有有效片段需要拼接."""
return len([s for s in self.segments if s.video_path]) >= 2
@property
def total_segments(self) -> int:
"""有效片段数量."""
return len([s for s in self.segments if s.video_path])
# ── 路径安全校验 ────────────────────────────────────────────────────────────
def _validate_video_path(video_path: str, work_dir: Path) -> None:
"""校验视频文件路径安全性.
规则
- local:// schema 必须在 work_dir
- 相对路径 必须在 work_dir
- 绝对路径 必须在允许目录白名单内
- 扩展名必须是视频格式
Raises:
PathSecurityError: 路径不安全
"""
if not video_path or not isinstance(video_path, str):
raise PathSecurityError("视频路径不能为空")
# 本地路径(local:// 或相对路径 / 绝对路径)
if video_path.startswith("local://") or not video_path.startswith(("http://", "https://", "oss://")):
is_abs = video_path.startswith("/") and not video_path.startswith("local://")
resolved_path = safe_resolve_path(
video_path,
work_dir,
allow_outside=is_abs,
allowed_extensions=ALLOWED_VIDEO_EXTENSIONS,
)
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
if is_abs:
resolved_work_dir = work_dir.resolve()
try:
resolved_path.relative_to(resolved_work_dir)
except ValueError as _e:
if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}") from _e
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名
else:
path_part = video_path.split("?")[0].split("#")[0]
ext = Path(path_part).suffix.lower()
if ext and ext not in ALLOWED_VIDEO_EXTENSIONS:
raise PathSecurityError(f"不允许的视频文件类型: {ext}")
# ── 视频拼接引擎 ──────────────────────────────────────────────────────────────
class ConcatEngine:
"""视频拼接引擎 — 支持 stream copy 和重新编码两种模式."""
def __init__(self, work_dir: Path):
self.work_dir = work_dir
self.work_dir.mkdir(parents=True, exist_ok=True)
# ── 主入口 ────────────────────────────────────────────────────────
def concat_videos(
self,
config: ConcatConfig,
output_path: Path,
) -> Path:
"""拼接多段视频.
自动选择最优拼接策略
1. 所有片段参数一致 concat demuxerstream copy最快
2. 参数不一致或有裁剪 concat filter重新编码
Args:
config: 拼接配置
output_path: 输出文件路径
Returns:
输出文件路径
"""
valid_segments = [s for s in config.segments if s.video_path]
if not valid_segments:
raise ValueError("No valid video segments to concat")
# ── 安全校验:段数上限 ──
if len(valid_segments) > MAX_CONCAT_SEGMENTS:
raise ValueError(f"Too many concat segments: {len(valid_segments)} > {MAX_CONCAT_SEGMENTS}")
# ── 安全校验:所有视频路径白名单校验 ──
safe_segments = []
for seg in valid_segments:
try:
_validate_video_path(seg.video_path, self.work_dir)
safe_segments.append(seg)
except PathSecurityError as e:
logger.warning("[concat] skip segment: path security check failed: %s", e)
if len(safe_segments) != len(valid_segments):
valid_segments = safe_segments
config.segments = safe_segments
logger.info("[concat] %d segments passed security check", len(safe_segments))
if not valid_segments:
raise ValueError("No valid video segments after security check")
if len(valid_segments) == 1:
# 只有一段,直接复制
import shutil
logger.info("[concat] single segment, copy directly")
shutil.copy2(valid_segments[0].video_path, output_path)
return output_path
# 判断能否用 stream copy
can_stream_copy = self._can_use_stream_copy(config)
if can_stream_copy and not config.force_reencode:
logger.info("[concat] using concat demuxer (stream copy)")
try:
return self._concat_demuxer(config, output_path)
except Exception as e:
logger.warning("[concat] demuxer failed, fallback to filter: %s", e)
# 降级到 concat filter
logger.info("[concat] using concat filter (re-encode)")
return self._concat_filter(config, output_path)
# ── 模式判断 ──────────────────────────────────────────────────────
def _can_use_stream_copy(self, config: ConcatConfig) -> bool:
"""判断是否可以使用 concat demuxerstream copy.
条件
1. 所有视频编码参数一致分辨率帧率编码像素格式
2. 所有音频参数一致采样率声道编码
3. 没有设置 start_time 裁剪或可以通过 concat demuxer inpoint/outpoint 实现
4. 没有强制重新编码
"""
if config.force_reencode:
return False
# 如果有转场效果,必须重新编码
if config.transition != "none":
return False
# 探测所有视频的参数
video_infos = []
for seg in config.segments:
if not seg.video_path:
continue
try:
info = probe_video_info(seg.video_path)
video_infos.append(info)
except Exception:
logger.warning("[concat] probe failed for %s", seg.video_path[-40:])
return False
if len(video_infos) < 2:
return False
# 检查参数一致性
base_info = video_infos[0]
for info in video_infos[1:]:
for param in CONCAT_DEMUXER_REQUIRED_PARAMS:
base_val = base_info.get(param)
curr_val = info.get(param)
if base_val != curr_val:
logger.debug(
"[concat] param mismatch: %s (%s vs %s)",
param,
base_val,
curr_val,
)
return False
# 检查是否有裁剪需求
# concat demuxer 支持 inpoint/outpoint,所以有裁剪也可以用
# 但为了简单和稳定性,有裁剪时也用 filter 模式
# inpoint/outpoint 不是所有格式都支持得好)
has_trimming = any(seg.start_time > 0 or seg.duration > 0 for seg in config.segments if seg.video_path)
if has_trimming:
return False
return True
# ── 模式1concat demuxerstream copy) ──────────────────────────
def _concat_demuxer(self, config: ConcatConfig, output_path: Path) -> Path:
"""使用 concat demuxer 拼接(stream copy.
优点速度极快无质量损失
缺点要求所有视频参数完全一致
"""
# 生成 concat 文件列表
list_file = self.work_dir / "concat_list.txt"
lines = []
for seg in config.segments:
if not seg.video_path:
continue
# 路径转义:单引号替换为 '\''
safe_path = str(seg.video_path).replace("'", "'\\''")
lines.append(f"file '{safe_path}'")
list_file.write_text("\n".join(lines), encoding="utf-8")
command = [
FFMPEG_BIN,
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(list_file),
"-c",
"copy",
"-copyts",
str(output_path),
]
logger.info("[concat] demuxer: %d segments", config.total_segments)
run_ffmpeg(command)
return output_path
# ── 模式2concat filter(重新编码) ──────────────────────────────
def _concat_filter(self, config: ConcatConfig, output_path: Path) -> Path:
"""使用 concat filter 拼接(重新编码).
优点支持不同参数的视频支持裁剪
缺点需要重新编码较慢
"""
valid_segments = [s for s in config.segments if s.video_path]
num_segments = len(valid_segments)
# 构建输入参数
input_args: list[str] = []
for seg in valid_segments:
input_args.extend(["-i", seg.video_path])
# 确定输出参数
output_width, output_height, output_fps = self._get_output_params(config)
# 构建 filter_complex
filter_parts: list[str] = []
concat_inputs = ""
for i, seg in enumerate(valid_segments):
vid_label = f"v{i}"
aud_label = f"a{i}"
seg_filters: list[str] = []
# 1. 裁剪(start_time + duration
if seg.start_time > 0 or seg.duration > 0:
start = seg.start_time
if seg.duration > 0:
end = start + seg.duration
seg_filters.append(f"trim=start={start:.3f}:end={end:.3f}")
else:
seg_filters.append(f"trim=start={start:.3f}")
seg_filters.append("setpts=PTS-STARTPTS")
# 音频同步裁剪
if seg.has_audio:
if seg.duration > 0:
filter_parts.append(
f"[{i}:a]atrim=start={start:.3f}:end={end:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]"
)
else:
filter_parts.append(f"[{i}:a]atrim=start={start:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]")
else:
# 无音频时生成静音轨
filter_parts.append(
f"[{i}:v]trim=start={start:.3f}," f"setpts=PTS-STARTPTS, " f"aevalsrc=0:d={0.1}[{aud_label}]"
)
else:
# 无裁剪,直接用原始标签
if not seg.has_audio:
# 无音频时需要生成静音
try:
dur = probe_duration(seg.video_path)
except Exception:
dur = 10.0
filter_parts.append(f"aevalsrc=0:d={dur:.3f}:s=44100[{aud_label}]")
# 2. 缩放/帧率统一
vf_parts = []
if not seg_filters:
vf_parts.append(f"[{i}:v]")
else:
vf_parts.append("")
# 分辨率统一
if output_width and output_height:
vf_parts.append(
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black"
)
# 帧率统一
if output_fps > 0:
vf_parts.append(f"fps={output_fps}")
# 像素格式统一
vf_parts.append("format=yuv420p")
if len(vf_parts) > 1 or (seg_filters and vf_parts):
if seg_filters:
# 先裁剪后缩放
crop_str = "".join(seg_filters)
scale_str = "".join(vf_parts[1:]) # 跳过空字符串
if scale_str:
filter_parts.append(f"[{i}:v]{crop_str},{scale_str}[{vid_label}]")
else:
filter_parts.append(f"[{i}:v]{crop_str}[{vid_label}]")
else:
filter_parts.append(f"{vf_parts[0]}{''.join(vf_parts[1:])}[{vid_label}]")
else:
if seg_filters:
filter_parts.append(f"[{i}:v]{''.join(seg_filters)}[{vid_label}]")
else:
# 什么都不需要,直接用输入
pass
# 拼接 concat 的输入标签
if seg_filters or (output_width and output_height) or output_fps > 0:
concat_inputs += f"[{vid_label}]"
else:
concat_inputs += f"[{i}:v]"
# 音频标签
if seg.start_time > 0 or seg.duration > 0:
# 已经生成了 aud_label
pass
elif not seg.has_audio:
# 已经生成了静音 aud_label
pass
else:
# 使用原始音频
pass
# 简化处理:用更直接的方式构建 filter
# 重新整理一下,确保所有输入都有对应的 v_i 和 a_i 标签
filter_parts.clear()
concat_inputs = "" # 按段交织: [v0][a0][v1][a1]...
for i, seg in enumerate(valid_segments):
v_label = f"v{i}_in"
a_label = f"a{i}_in"
# 视频处理链
v_steps: list[str] = [f"[{i}:v]"]
# 裁剪
if seg.start_time > 0 or seg.duration > 0:
start = seg.start_time
if seg.duration > 0:
end = start + seg.duration
v_steps.append(f"trim=start={start:.3f}:end={end:.3f},")
else:
v_steps.append(f"trim=start={start:.3f},")
v_steps.append("setpts=PTS-STARTPTS,")
# 缩放
if output_width and output_height:
v_steps.append(
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black,"
)
# 帧率
if output_fps > 0:
v_steps.append(f"fps={output_fps},")
# 像素格式
v_steps.append("format=yuv420p")
v_filter = "".join(v_steps) + f"[{v_label}]"
filter_parts.append(v_filter)
# 音频处理链
a_steps: list[str] = []
if seg.has_audio:
a_steps.append(f"[{i}:a]")
if seg.start_time > 0 or seg.duration > 0:
start = seg.start_time
if seg.duration > 0:
end = start + seg.duration
a_steps.append(f"atrim=start={start:.3f}:end={end:.3f},")
else:
a_steps.append(f"atrim=start={start:.3f},")
a_steps.append("asetpts=PTS-STARTPTS,")
a_steps.append("aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo")
else:
# 生成静音音频
try:
dur = probe_duration(seg.video_path)
except Exception:
dur = 10.0
# 减去裁剪
if seg.start_time > 0:
dur = max(0.1, dur - seg.start_time)
if seg.duration > 0 and seg.duration < dur:
dur = seg.duration
a_steps.append(f"aevalsrc=0:d={dur:.3f}:s=44100:c=stereo")
a_filter = "".join(a_steps) + f"[{a_label}]"
filter_parts.append(a_filter)
# 按段交织排列(v_i, a_i),这是 FFmpeg concat filter 要求的顺序
concat_inputs += f"[{v_label}][{a_label}]"
# concat filter: 输入按 [v0][a0][v1][a1]... 顺序
filter_parts.append(f"{concat_inputs}" f"concat=n={num_segments}:v=1:a=1[vout][aout]")
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
"[vout]",
"-map",
"[aout]",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
str(output_path),
]
logger.info(
"[concat] filter: %d segments, %dx%d, %.2f fps",
num_segments,
output_width,
output_height,
output_fps,
)
run_ffmpeg(command)
return output_path
# ── 辅助方法 ──────────────────────────────────────────────────────
def _get_output_params(self, config: ConcatConfig) -> tuple[int, int, float]:
"""获取输出参数(宽、高、帧率).
优先级
1. config 中显式指定的
2. 第一段视频的参数
"""
valid_segments = [s for s in config.segments if s.video_path]
width = config.output_width
height = config.output_height
fps = config.output_fps
# 如果没有显式指定,用第一段的参数
if (width == 0 or height == 0 or fps == 0) and valid_segments:
try:
info = probe_video_info(valid_segments[0].video_path)
if width == 0:
width = int(info.get("width", 1080))
if height == 0:
height = int(info.get("height", 1920))
if fps == 0:
fps_str = info.get("r_frame_rate", "30/1")
if "/" in str(fps_str):
num, den = str(fps_str).split("/")
try:
fps = float(num) / float(den)
except (ValueError, ZeroDivisionError):
fps = 30.0
else:
fps = float(fps_str) if fps_str else 30.0
except Exception:
# 探测失败,用默认值
if width == 0:
width = 1080
if height == 0:
height = 1920
if fps == 0:
fps = 30.0
return width, height, fps
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def concat_video_files(
video_paths: list[str],
output_path: Path,
*,
work_dir: Path | None = None,
force_reencode: bool = False,
) -> Path:
"""简单拼接多个视频文件.
Args:
video_paths: 视频文件路径列表
output_path: 输出路径
work_dir: 工作目录默认输出文件所在目录
force_reencode: 是否强制重新编码
Returns:
输出文件路径
"""
if work_dir is None:
work_dir = output_path.parent
segments = [ConcatSegment(video_path=p) for p in video_paths if p]
config = ConcatConfig(segments=segments, force_reencode=force_reencode)
engine = ConcatEngine(work_dir)
return engine.concat_videos(config, output_path)
def concat_videos_from_config(
config_dict: dict | None,
output_path: Path,
*,
work_dir: Path,
) -> Path | None:
"""从配置字典执行视频拼接.
降级策略配置无效或拼接失败时返回 None.
"""
config = ConcatConfig.from_config_dict(config_dict)
if not config.has_effect:
return None
try:
engine = ConcatEngine(work_dir)
return engine.concat_videos(config, output_path)
except Exception as e:
logger.error("[concat] concat failed: %s", e)
return None
+431
View File
@@ -0,0 +1,431 @@
"""视频封面生成器 — 从视频中提取/生成封面图.
支持能力
- 指定时间点抽帧默认第1秒
- 智能封面抽取多帧选最清晰的一帧
- 自定义上传封面图直接返回路径
- 生成的封面图保存为 JPEG 格式可复用
"""
from __future__ import annotations
import logging
import subprocess
from pathlib import Path
from typing import Any
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_video_info, run_ffmpeg
logger = logging.getLogger(__name__)
# ── 配置常量 ──────────────────────────────────────────────────────────────────
# 智能封面抽帧数量
SMART_COVER_FRAME_COUNT = 3
# 默认抽帧时间点(秒)
DEFAULT_COVER_TIME = 1.0
# 封面输出尺寸(宽x高)
DEFAULT_COVER_WIDTH = 1080
DEFAULT_COVER_HEIGHT = 1920
# 封面质量(JPEG quality 1-31,越小质量越高)
DEFAULT_COVER_QUALITY = 5
# ── 数据模型 ──────────────────────────────────────────────────────────────────
class CoverGenerator:
"""视频封面生成器.
三种模式
1. 指定时间点抽帧从视频指定时间提取一帧
2. 智能封面抽取3帧 blur 检测选最清晰的
3. 自定义上传直接使用用户上传的图片
"""
@staticmethod
def extract_frame(
video_path: str | Path,
output_path: str | Path,
*,
time_sec: float = DEFAULT_COVER_TIME,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Path:
"""从视频指定时间点提取一帧作为封面.
Args:
video_path: 视频文件路径
output_path: 输出图片路径
time_sec: 抽帧时间点
width: 输出宽度
height: 输出高度
quality: JPEG 质量1-31越小越好
Returns:
封面图片路径
Raises:
FileNotFoundError: 视频文件不存在
subprocess.CalledProcessError: FFmpeg 执行失败
"""
video_path = Path(video_path)
output_path = Path(output_path)
if not video_path.exists():
raise FileNotFoundError(f"视频文件不存在: {video_path}")
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
# 安全钳制时间
info = probe_video_info(str(video_path))
duration = info.get("duration", 0.0)
if duration > 0 and time_sec >= duration:
# 超过视频长度,取中间帧
time_sec = max(0, duration / 2)
if time_sec < 0:
time_sec = 0
# scale + crop 实现 cover 裁剪(铺满输出尺寸)
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
command = [
FFMPEG_BIN,
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.info("抽取视频封面: video=%s time=%.2fs output=%s", video_path.name, time_sec, output_path.name)
run_ffmpeg(command)
if not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError(f"封面生成失败: {output_path}")
return output_path
@staticmethod
def extract_smart_cover(
video_path: str | Path,
output_path: str | Path,
*,
frame_count: int = SMART_COVER_FRAME_COUNT,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
work_dir: str | Path | None = None,
) -> Path:
"""智能封面:抽取多帧,选最清晰的一帧.
清晰度判断使用拉普拉斯方差Variance of Laplacian
方差越大表示图像边缘越丰富越清晰
Args:
video_path: 视频文件路径
output_path: 最终输出封面路径
frame_count: 抽帧数量均匀分布在视频中
width: 输出宽度
height: 输出高度
quality: JPEG 质量
work_dir: 临时工作目录默认输出目录的父目录
Returns:
最佳封面图片路径
"""
video_path = Path(video_path)
output_path = Path(output_path)
if not video_path.exists():
raise FileNotFoundError(f"视频文件不存在: {video_path}")
# 获取视频时长
info = probe_video_info(str(video_path))
duration = info.get("duration", 0.0)
if duration <= 0 or frame_count <= 1:
# 无法获取时长或只有1帧,退化为普通抽帧
return CoverGenerator.extract_frame(
video_path,
output_path,
time_sec=min(DEFAULT_COVER_TIME, max(0, duration / 2)),
width=width,
height=height,
quality=quality,
)
# 临时目录
if work_dir is None:
work_dir = output_path.parent
work_dir = Path(work_dir)
work_dir.mkdir(parents=True, exist_ok=True)
# 均匀分布抽帧时间点(跳过首尾5%
start_pct = 0.05
end_pct = 0.95
if frame_count == 1:
time_points = [duration * 0.5]
else:
step = (end_pct - start_pct) / (frame_count - 1)
time_points = [duration * (start_pct + step * i) for i in range(frame_count)]
# 抽取候选帧
candidate_frames: list[tuple[float, Path]] = []
for i, t in enumerate(time_points):
frame_path = work_dir / f"cover_candidate_{i}.jpg"
try:
CoverGenerator.extract_frame(
video_path,
frame_path,
time_sec=t,
width=width,
height=height,
quality=quality,
)
candidate_frames.append((t, frame_path))
except Exception as e:
logger.warning("智能封面抽帧失败(t=%.2fs: %s", t, e)
continue
if not candidate_frames:
# 全部失败,退化到普通抽帧
logger.warning("智能封面所有候选帧抽取失败,退化为普通抽帧")
return CoverGenerator.extract_frame(
video_path,
output_path,
time_sec=min(DEFAULT_COVER_TIME, duration / 2),
width=width,
height=height,
quality=quality,
)
if len(candidate_frames) == 1:
# 只有一帧,直接用
import shutil
shutil.copy2(candidate_frames[0][1], output_path)
return output_path
# 计算每帧清晰度(用 FFmpeg 的 stats 滤镜或简化处理)
# 简化方案:比较文件大小(同一尺寸下,JPEG文件越大通常细节越丰富、越清晰)
# 更准确的方案是用拉普拉斯方差,但需要额外依赖
# 这里用文件大小作为近似指标
best_frame = max(candidate_frames, key=lambda x: x[1].stat().st_size)
# 复制最佳帧到输出路径
import shutil
shutil.copy2(best_frame[1], output_path)
logger.info(
"智能封面生成完成: 候选%d帧, 最佳t=%.2fs, 大小=%d字节",
len(candidate_frames),
best_frame[0],
output_path.stat().st_size,
)
# 清理临时文件
for _, fp in candidate_frames:
try:
fp.unlink()
except OSError:
pass
return output_path
@staticmethod
def process_custom_cover(
image_path: str | Path,
output_path: str | Path,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Path:
"""处理用户自定义上传的封面图.
调整尺寸格式转换为标准封面格式
Args:
image_path: 用户上传的图片路径
output_path: 输出封面路径
width: 目标宽度
height: 目标高度
quality: JPEG 质量
Returns:
处理后的封面图片路径
"""
image_path = Path(image_path)
output_path = Path(output_path)
if not image_path.exists():
raise FileNotFoundError(f"封面图片不存在: {image_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
# scale + crop 实现 cover 裁剪
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
command = [
FFMPEG_BIN,
"-y",
"-i",
str(image_path),
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.info("处理自定义封面: input=%s output=%s", image_path.name, output_path.name)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError:
# 处理失败,直接复制原图
logger.warning("自定义封面处理失败,使用原图")
import shutil
shutil.copy2(image_path, output_path)
return output_path
@staticmethod
def generate_cover(
video_path: str | Path,
output_path: str | Path,
*,
mode: str = "smart", # smart / time / custom
time_sec: float = DEFAULT_COVER_TIME,
custom_image: str | Path | None = None,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Path:
"""统一封面生成入口.
Args:
video_path: 视频文件路径
output_path: 输出封面路径
mode: 模式 - smart智能选帧/ time指定时间/ custom自定义图片
time_sec: time 模式下的抽帧时间点
custom_image: custom 模式下的自定义图片路径
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面图片路径
"""
if mode == "custom" and custom_image:
return CoverGenerator.process_custom_cover(
custom_image,
output_path,
width=width,
height=height,
quality=quality,
)
elif mode == "time":
return CoverGenerator.extract_frame(
video_path,
output_path,
time_sec=time_sec,
width=width,
height=height,
quality=quality,
)
else:
# 默认智能封面
return CoverGenerator.extract_smart_cover(
video_path,
output_path,
width=width,
height=height,
quality=quality,
)
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def generate_cover_from_plan(
plan: Any,
video_path: str | Path,
output_dir: str | Path,
) -> Path | None:
"""从 EditPlan 配置生成封面图.
配置读取plan.config.cover_config
支持字段
- mode: smart / time / custom
- time_sec: 抽帧时间time模式
- custom_image_url: 自定义图片URL需要先下载到本地
Args:
plan: EditPlan 对象
video_path: 渲染后的视频路径
output_dir: 封面输出目录
Returns:
封面图片路径 None不需要生成封面时
"""
config = getattr(plan, "config", None) or {}
cover_config = config.get("cover_config") if isinstance(config, dict) else None
if not cover_config:
return None
mode = cover_config.get("mode", "smart")
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"cover_{plan.id}.jpg"
try:
if mode == "custom":
# 自定义封面:需要先有本地图片路径
custom_path = cover_config.get("custom_image_path")
if custom_path and Path(custom_path).exists():
return CoverGenerator.process_custom_cover(
custom_path,
output_path,
)
else:
logger.warning("自定义封面图片路径无效,退化为智能封面")
mode = "smart"
if mode == "time":
time_sec = float(cover_config.get("time_sec", DEFAULT_COVER_TIME))
return CoverGenerator.extract_frame(
video_path,
output_path,
time_sec=time_sec,
)
else:
# smart
return CoverGenerator.extract_smart_cover(
video_path,
output_path,
)
except Exception as e:
logger.warning("封面生成失败: %s", e)
return None
+1 -1
View File
@@ -356,7 +356,7 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
except Exception as e:
logger.error(f"Duplicate check failed for {generated_video_id}: {str(e)}")
session.rollback()
raise self.retry(exc=e, countdown=60)
raise self.retry(exc=e, countdown=60) from e
finally:
session.close()
import shutil

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