Compare commits

...

88 Commits

Author SHA1 Message Date
xiaoxia 28a2322f73 test(wave210): 验证码服务单测补全 +70测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 38s
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 / Validate - Type Check (mypy) (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m43s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m50s
AI Code Review / AI Code Review (pull_request) Successful in 3m46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m49s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m7s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m31s
CI/CD Pipeline / CI Gate (pull_request) Successful in 17s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 40s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 39s
覆盖范围:
- generate: 成功/自定义code/自定义TTL/参数校验/频控(冷却+每日上限)
- verify: 成功/错误/不存在/过期/已使用/尝试次数/consume开关
- validate_phone: 各种合法/非法号码格式
- normalize_phone: +86前缀/空格处理
- validate_email: 各种合法/非法邮箱格式
- VerificationCode实体: is_expired/is_used/is_valid/生命周期

70 test cases, 8 test classes
2026-07-30 07:20:16 +08:00
xiaoxia 0f27bfa999 refactor(titles): 拆分 useTitleLibrary 为4个子Hook(174→58行, -67%) (#1156)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m41s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m33s
CI/CD Pipeline / Unit Tests (push) Successful in 9m54s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m14s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / Staging E2E Tests (push) Waiting to run
CI/CD Pipeline / Staging API Integration Tests (push) Waiting to run
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 / ACR Image Cleanup (push) Waiting to run
CI/CD Pipeline / Canary Release to Production (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-30 01:19:17 +08:00
xiaoxia f15bc2f2a2 refactor(products): 拆分 useProductList 为3个子Hook(202→91行, -55%) (#1155)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-30 01:15:19 +08:00
xiaoxia 2273fb329f test(wave208): 密码哈希与验证模块单测 +50测 (#1175)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 01:13:58 +08:00
xiaoxia 4bd3da73e3 fix(ci): pr_auto_scan修复崩溃bug + 统一merge门禁为CI Gate
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m17s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m24s
CI/CD Pipeline / Unit Tests (push) Successful in 9m36s
CI/CD Pipeline / Integration Tests (push) Successful in 3m35s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m17s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m35s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m7s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
- api_request: 修复HTTPError空响应体导致JSON解析崩溃
- REQUIRED_CONTEXTS_FULL: 改为只检查CI Gate,与pr-automation/分支保护保持一致
- FRONTEND_ONLY_CONTEXT: 改为CI Gate,内部自动处理跳过逻辑
2026-07-30 00:41:43 +08:00
xiaoxia 7674a04a33 fix(ci): CI Gate添加integration-tests必填检查,防止集成测试失败仍被合并
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:40:52 +08:00
xiaoxia 9d97e8aacc refactor(assets): 拆分 batchOperations 为4个子Hook(238→8行, -97%) (#1168)
CI/CD Pipeline / Check if frontend-only change (push) Waiting to run
CI/CD Pipeline / Validate - Code Quality (push) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (push) Waiting to run
CI/CD Pipeline / Validate - Migration (alembic) (push) Waiting to run
CI/CD Pipeline / Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Blocked by required conditions
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Web Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging API Image (push) Waiting to run
CI/CD Pipeline / Build Staging Web Image (push) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (push) Waiting to run
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-30 00:27:41 +08:00
xiaoxia 89639a6d3e test(wave206): InMemory仓储单测补全 +72测 (#1173)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:26:11 +08:00
xiaoxia c4144d9e3f test(wave205): path_security 单测重构与补全 +46测 (#1172)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:26:05 +08:00
xiaoxia de201436ea test(wave204): text_splitter + pagination 单测补全 +37测 (#1171)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:26:00 +08:00
xiaoxia 77a49e3365 test(wave201): concat_engine_pure 单测补全 +85测 (#1167)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:42 +08:00
xiaoxia d7e362a637 test(wave199): sticker_engine_pure 单测补全 +141测 (#1165)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:30 +08:00
xiaoxia 028c6613ce test(shared): wave198 ai_service 单测 +41 (#1164)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:24 +08:00
xiaoxia 696cdda87b refactor(products): VideoPlayer 改用 useVideoPlayer Hook 消除重复代码(199→123行, -38%) (#1163)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:18 +08:00
xiaoxia a85167a529 refactor(generate): 拆分 useGenerateVideo 核心逻辑(211→154行, -27%) (#1162)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:12 +08:00
xiaoxia e0b95e69d4 test(domain): wave197 asset/asset_library 兼容层 +12 (#1161)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:07 +08:00
xiaoxia 63d4048354 test(domain): wave196 小模块批量单测 +74 (#1160)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:01 +08:00
xiaoxia e811516c6e test(domain): wave195 verification_code 单测 +27 (#1159)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:55 +08:00
xiaoxia f9a106f36b test(domain): wave194 tts_config 单测 +52 (#1158)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:49 +08:00
xiaoxia e902fbd65e test(domain): wave193 video_share 单测 +43 (#1157)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:43 +08:00
xiaoxia e8352227af test(wave192): media_validation 媒体校验 +56测 (#1154)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:27 +08:00
xiaoxia ef7596739c test(wave190): preset_voices 预置音色 +35测 (#1152)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:16 +08:00
xiaoxia c2329f3f98 test(wave189): preset_bgm 预设BGM库 +44测 (#1151)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:10 +08:00
xiaoxia 0a0914322c test(wave188): video_concat 视频拼接配置 +58测 (#1150)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:04 +08:00
xiaoxia 042cae7a61 test(wave187): speed_config 调速配置 +77测 (#1148)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:59 +08:00
xiaoxia be885eb8f0 test(wave186): subtitle 字幕时间轴模型 +52测 (#1147)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:53 +08:00
xiaoxia fd61800be9 test(wave185): audio_track_config 多轨道音频配置 +80测 (#1146)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:47 +08:00
xiaoxia 183681c07d test(wave184): intro_outro_config 片头片尾配置 +59测 (#1145)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:41 +08:00
xiaoxia 8d906bac72 test(wave183): voice_presets 配音音色预设 +59测 (#1143)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:30 +08:00
xiaoxia 8f925e6c65 test(wave182): config_schemas 配置Schema +110测 (#1142)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:23 +08:00
xiaoxia 9732cc51ee refactor(generate): 拆分 useGenerateFormState 为4个子Hook(309→196行, -37%) (#1141)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:17 +08:00
xiaoxia 815b3758a7 refactor(tasks): 拆分 TaskTable 列定义和空状态到子模块(190→72行, -62%) (#1140)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m25s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m37s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m25s
CI/CD Pipeline / Unit Tests (push) Successful in 9m11s
CI/CD Pipeline / Integration Tests (push) Successful in 2m40s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m7s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m58s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-07-30 00:22:45 +08:00
xiaoxia eb73eeab69 refactor(assets): 拆分 batchOperations 为4个独立Hook(238→4行, -98%) (#1149)
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m47s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m14s
CI/CD Pipeline / Unit Tests (push) Successful in 8m34s
CI/CD Pipeline / Integration Tests (push) Successful in 3m7s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 54s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 19m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m31s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m42s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 35s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 25s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m15s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 9m16s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-29 22:21:52 +08:00
xiaoxia 2325ffbc57 fix(ci): 修复pr_auto_scan.py的typo - base_ref字段名应为ref而非re
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 33s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m36s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m42s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m11s
CI/CD Pipeline / Unit Tests (push) Successful in 9m14s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m35s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m57s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m33s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m14s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 28s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m59s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
- 修复base_ref获取字段名错误(re->ref),导致所有PR被误判为目标分支不匹配而跳过
- 修复dry-run模式打印缺少f前缀的小bug
2026-07-29 22:20:24 +08:00
xiaoxia faeed6f014 refactor(editing-planner): 拆分 SubtitleStylePanel 为子组件(190→146行, -23%) (#1139)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m4s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m6s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m3s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m4s
CI/CD Pipeline / Unit Tests (push) Successful in 12m32s
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 4m36s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m31s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m37s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-29 17:30:42 +08:00
xiaoxia 6dc388a794 fix(cosyvoice): 修复prefix过滤中文失效导致400错误 (#1138)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m33s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m33s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m29s
CI/CD Pipeline / Unit Tests (push) Successful in 11m50s
CI/CD Pipeline / Integration Tests (push) Successful in 4m26s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m18s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m31s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 31s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m32s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m42s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
修复prefix过滤中文失效导致400错误
2026-07-29 14:40:58 +08:00
xiaoxia 7ca5b3732f refactor(voice-materials): 拆分 VoiceMaterialRow 进度条和标签组件 (#1087)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 拆分 VoiceMaterialRow 为独立子组件
- 进度条逻辑抽取到 useRowProgress hook
- 标签展示组件独立为 TagDisplay
- 代码行数从 186 行减少到 138 行 (-26%)
2026-07-29 14:35:45 +08:00
xiaoxia 7cdf56a1ac test(wave175): template_clip_converter 模板片段转换 +64测 (#1137)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 新增 template_clip_converter 模块单元测试
- 覆盖模板解析、片段转换、边界条件等场景
2026-07-29 14:34:37 +08:00
xiaoxia fac825b60b test(wave181): noise_reduction_config 降噪配置 +65测 (#1135)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-29 14:29:37 +08:00
xiaoxia 409efe141a test(wave174): tts_job TTS任务配置 +60测 (#1136) 2026-07-29 14:29:37 +08:00
xiaoxia 6e676a07f7 test(wave180): render_layer_utils 渲染层工具 +78测 (#1134)
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m15s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m30s
CI/CD Pipeline / Unit Tests (push) Successful in 10m51s
CI/CD Pipeline / Integration Tests (push) Successful in 4m28s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m23s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 12m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m37s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m8s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 6m17s
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 / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-07-29 14:28:17 +08:00
xiaoxia e629cf0e69 test(wave179): transition_config 转场配置 +79测 (#1133) 2026-07-29 14:28:17 +08:00
xiaoxia dd17312181 test(wave178): ai_parsing AI解析配置 +53测 (#1132) 2026-07-29 14:28:17 +08:00
xiaoxia 05fbf64697 test(wave177): voice_clone_profile 音色克隆配置 +58测 (#1131) 2026-07-29 14:28:17 +08:00
xiaoxia 096dc66e2c test(wave176): clip_operations 片段操作工具 +56测 (#1130) 2026-07-29 14:28:17 +08:00
CI Bot 1230eaa24b style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m11s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m4s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m5s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m5s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 31s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m18s
CI/CD Pipeline / Unit Tests (push) Successful in 10m16s
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 5m8s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m48s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-29 05:07:44 +00:00
xiaoxia 0d7effe88f fix(ci): 修复两个外围失败 - E2E容器残留清理 + ACR pr-days参数支持
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m21s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m57s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m4s
CI/CD Pipeline / Unit Tests (push) Successful in 8m22s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m48s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m20s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m5s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 3m42s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m12s
2026-07-29 13:04:50 +08:00
xiaoxia 76fdab4f63 fix(ci): 修复develop分支CI核心链路失败(Code Quality + Unit Tests + Frontend) (#1129)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m2s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m56s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m27s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m33s
CI/CD Pipeline / Integration Tests (push) Successful in 2m51s
CI/CD Pipeline / Unit Tests (push) Successful in 10m13s
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 / CI Gate (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 12m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 29s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m31s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-29 12:12:37 +08:00
CI Bot 4c31026f81 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m38s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m48s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m29s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 4m7s
CI/CD Pipeline / Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m34s
CI/CD Pipeline / Unit Tests (push) Failing after 8m43s
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 / CI Gate (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 15m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 17s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m22s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-29 02:18:43 +00:00
xiaoxia 44ee898aa0 fix(ci): 修复CI Gate中python3 -c多行代码的YAML缩进错误\n\nPR #1113引入的AI门禁代码中,python3 -c的多行代码缩进为0,\n导致YAML run: | 块标量提前结束,整个ci-pipeline.yml解析失败,\ndevelop分支push完全不触发CI。\n\n修复方式:将python代码整体右移到与run块基准缩进对齐(10空格),\n保留python内部相对缩进结构,YAML解析时自动去除基准缩进。
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m39s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m59s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m11s
CI/CD Pipeline / Unit Tests (push) Failing after 7m39s
CI/CD Pipeline / Integration Tests (push) Successful in 2m22s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m41s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m38s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 43s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m17s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m42s
2026-07-29 10:16:06 +08:00
xiaoxia 049dd028c0 test(wave173): pip_config画中画配置 +67测 2026-07-29 01:40:02 +08:00
xiaoxia e64e1c2938 chore(ci): 删除意外提交的bak文件 2026-07-29 00:46:33 +08:00
xiaoxia 93e59df866 fix(ci): develop分支pr-automation优化 - concurrency + 短作业模式 2026-07-29 00:45:11 +08:00
xiaoxia 577f744912 fix(ci): 修复生产构建shell和preview-deploy npm install 2026-07-28 21:01:37 +08:00
xiaoxia d727220562 fix: 修复 url_security 测试中 _is_trusted_domain 和 _check_ssrf_ip 的 mock 兼容性
- _is_trusted_domain 改为 wrapper 函数,自动使用模块级 TRUSTED_DOMAINS
- validate_url_safety 对 IP 地址显式调用本地 _check_ssrf_ip 别名,
  使测试 mock 能正确拦截

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:31:50 +08:00
xiaoxia 0ce9a22b91 fix: 补全 shared/worker 层对 domain 常量的 re-export
domain 层重构后,部分常量未从 shared/worker 层 re-export,
导致 10 个测试模块因 ImportError 无法收集。

修复:
- packages/shared/url_security.py: 补全 ALLOWED_AUDIO_MIME_TYPES,
  ALLOWED_IMAGE_MIME_TYPES, MAX_URL_LENGTH 的 re-export,
  以及 _check_internal_hostnames, _check_ssrf_ip, _is_trusted_domain
  私有别名
- apps/worker/video_processing/speed_engine.py: 补全 MAX_SPEED,
  MIN_SPEED 的 re-export

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:29:14 +08:00
xiaoxia 28173f1713 test(wave158): edit_plan剪辑计划单测 +32 (#1088) 2026-07-28 16:20:20 +08:00
xiaoxia 23edd869c6 refactor(editing-planner): 拆分 useEditPlanClipMutations 抽出重排序和素材导入(183→134行, -27%) (#1101) 2026-07-28 16:20:00 +08:00
xiaoxia a863324942 test(wave139): url_security domain 层 +86 单测 (#1056) 2026-07-28 16:19:09 +08:00
xiaoxia 3859ce1750 test(wave152): duplication查重记录单测 +40 (#1080) 2026-07-28 16:19:06 +08:00
xiaoxia ba35e3f518 test(wave153): edit_plan_clip剪辑计划片段单测 +44 (#1081) 2026-07-28 16:19:04 +08:00
xiaoxia ec231ac88f test(wave154): classification素材分类单测 +34 (#1082) 2026-07-28 16:19:02 +08:00
xiaoxia ccfaf9aa7e test(wave155): generated_video生成视频实体单测 +16 (#1084) 2026-07-28 16:19:01 +08:00
xiaoxia fe97f016b2 test(wave156): generation_task生成任务实体单测 +24 (#1085) 2026-07-28 16:18:59 +08:00
xiaoxia 8a0b9b8878 test(wave157): domain层7个小模块批量单测 +44 (#1086) 2026-07-28 16:18:57 +08:00
xiaoxia 15f12ead3f refactor(generate): 拆分 useStep5Voice 模式选择逻辑到子Hook(184→165行, -10%) (#1089) 2026-07-28 16:18:55 +08:00
xiaoxia cfabbd3d61 test(wave159): template_clip_config模板片段配置单测 +41 (#1090) 2026-07-28 16:18:52 +08:00
xiaoxia 19ca924087 refactor(generate): 拆分 Step7ConfirmGenerate 为子组件(194→88行, -55%) (#1091) 2026-07-28 16:18:51 +08:00
xiaoxia 0830b3ac45 test(wave160): job领域模型 +69测 (#1092) 2026-07-28 16:18:49 +08:00
xiaoxia cb7daa0674 refactor(editing-planner): 拆分 useTimelineMenus 为子Hook(192→67行, -65%) (#1093) 2026-07-28 16:18:47 +08:00
xiaoxia 33fd762eea test(wave161): domain entities领域模型 +64测 (#1094) 2026-07-28 16:18:46 +08:00
xiaoxia 3eb541ca39 refactor: split useProductActions into product-actions sub-hooks (#1095) 2026-07-28 16:18:44 +08:00
xiaoxia b353ac0328 test(wave162): asset_scoring素材评分 +66测 (#1096) 2026-07-28 16:18:42 +08:00
xiaoxia 0638c685b6 refactor(generate): 拆分 Step6CoverSettings 为子组件(187→109行, -42%) (#1097) 2026-07-28 16:18:41 +08:00
xiaoxia fdcdd4c6ef test(wave163): watermark_config水印配置 +88测 (#1098) 2026-07-28 16:18:39 +08:00
xiaoxia 8378c2e8ff refactor(generate): 拆分 GenerateResultPanel 为子组件(187→99行, -47%) (#1099) 2026-07-28 16:18:38 +08:00
xiaoxia 575a83fcaf test(wave164): filter_presets滤镜预设 +53测 (#1100) 2026-07-28 16:18:36 +08:00
xiaoxia 17ad2235b6 test(wave165): trim_config裁剪配置 +78测 (#1102) 2026-07-28 16:18:34 +08:00
xiaoxia 7c47856671 refactor(editing-planner): 拆分 GreenScreenPanel 为子组件(200→94行, -53%) (#1103) 2026-07-28 16:18:32 +08:00
xiaoxia 8fa161cfb8 test(wave166): plan_generator_utils计划生成工具 +54测 (#1104) 2026-07-28 16:18:30 +08:00
xiaoxia cbf481a7fb test(wave167): sticker_config贴纸配置 +91测 (#1105) 2026-07-28 16:18:29 +08:00
xiaoxia fe485cf0fc test(wave168): transition_presets转场预设 +35测 (#1106) 2026-07-28 16:18:27 +08:00
xiaoxia 04886e0b30 test(wave169): ass_subtitle_builder ASS字幕构建 +76测 (#1108) 2026-07-28 16:18:25 +08:00
xiaoxia cbc7b48a05 test(wave170): chroma_key_config绿幕抠像配置 +70测 (#1109) 2026-07-28 16:18:24 +08:00
xiaoxia f10f691e40 test(wave171): subtitle_style字幕样式 +106测 (#1110) 2026-07-28 16:18:22 +08:00
xiaoxia 4c2509648e test(wave172): color_grade_config色彩调色 +72测 (#1111) 2026-07-28 16:18:20 +08:00
xiaoxia e6006e849b ci: P1+P2优化 - AI门禁/跳过逻辑/生产构建门禁/daily-check修复 (#1113) 2026-07-28 16:18:19 +08:00
xiaoxia 1076ce4216 fix: 克隆音色上传修复 422 错误 (#1107)
CI/CD Pipeline / Check if frontend-only change (push) Waiting to run
CI/CD Pipeline / Validate - Code Quality (push) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (push) Waiting to run
CI/CD Pipeline / Validate - Migration (alembic) (push) Waiting to run
CI/CD Pipeline / Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Waiting to run
CI/CD Pipeline / PR Build Web Image (push) Waiting to run
CI/CD Pipeline / PR Build Worker Image (push) Waiting to run
CI/CD Pipeline / Build Staging API Image (push) Waiting to run
CI/CD Pipeline / Build Staging Web Image (push) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (push) Waiting to run
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Blocked by required conditions
Squash merge
2026-07-28 16:18:00 +08:00
157 changed files with 27721 additions and 5468 deletions
+47 -2
View File
@@ -458,6 +458,8 @@ jobs:
name: Frontend Lint
runs-on: ci-l2
timeout-minutes: 10
needs: check-frontend-only
if: needs.check-frontend-only.outputs.skip_frontend != 'true'
steps:
- name: Checkout code
shell: sh
@@ -576,7 +578,12 @@ jobs:
name: PR Build ${{ matrix.service_display }} Image
runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }}
if: github.event_name == 'pull_request'
needs: check-frontend-only
if: |
github.event_name == 'pull_request' && (
(matrix.service == 'web' && needs.check-frontend-only.outputs.skip_frontend != 'true') ||
(matrix.service != 'web' && needs.check-frontend-only.outputs.skip_backend != 'true')
)
strategy:
fail-fast: false
matrix:
@@ -1084,6 +1091,8 @@ jobs:
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
# 改用 docker create + docker cp 方式把代码拷进容器
CONTAINER_NAME="staging-e2e-$$"
# 强制清理可能残留的同名容器(上一次异常退出时未清理)
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
docker create --name "$CONTAINER_NAME" --ipc=host \
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
@@ -1192,6 +1201,11 @@ jobs:
runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }}
needs:
- validate-code-quality
- validate-type-check
- unit-tests
- frontend-lint
- frontend-unit-test
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
strategy:
fail-fast: false
@@ -1265,7 +1279,7 @@ jobs:
docker buildx inspect --bootstrap
- name: Build and push production ${{ matrix.service_display }} image (with retry)
shell: sh
shell: bash
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
@@ -1718,6 +1732,30 @@ jobs:
echo " frontend-lint: $RESULT_FRONTEND_LINT"
echo " frontend-unit-test: $RESULT_FRONTEND_UNIT"
echo " build-pr: $RESULT_BUILD_PR"
# 查询 AI Code Review 状态(跨workflow,读commit status
AI_REVIEW_STATUS="pending"
AI_REVIEW_DESC=""
STATUS_JSON=$(curl -sH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits/${PR_HEAD_SHA}/status" 2>/dev/null || true)
if [ -n "$STATUS_JSON" ]; then
AI_STATUS=$(echo "$STATUS_JSON" | python3 -c "
import json,sys
try:
data=json.load(sys.stdin)
for s in data.get('statuses',[]):
if 'AI Code Review' in s.get('context',''):
print(s['state']+'|'+s.get('description',''))
break
except: pass
" 2>/dev/null)
if [ -n "$AI_STATUS" ]; then
AI_REVIEW_STATUS="${AI_STATUS%%|*}"
AI_REVIEW_DESC="${AI_STATUS#*|}"
fi
fi
echo " ai-code-review: $AI_REVIEW_STATUS ($AI_REVIEW_DESC)"
echo ""
# 判断PR类型
@@ -1733,11 +1771,13 @@ jobs:
"validate-migration:$RESULT_MIGRATION"
"frontend-lint:$RESULT_FRONTEND_LINT"
"build-pr:$RESULT_BUILD_PR"
"ai-code-review:$AI_REVIEW_STATUS"
)
# 后端检查
REQUIRED_BACKEND=(
"unit-tests:$RESULT_UNIT_TESTS"
"integration-tests:$RESULT_INTEGRATION"
)
# 前端检查
@@ -1767,6 +1807,11 @@ jobs:
for item in "${REQUIRED_GENERAL[@]}"; do
name="${item%%:*}"
result="${item##*:}"
# AI Code Review pending时不阻塞(可能还在跑),等它跑完自然会重跑Gate
if [ "$name" = "ai-code-review" ] && [ "$result" = "pending" ]; then
echo " ⏳ $name: pending(审查中,暂不阻塞)"
continue
fi
check_job "$name" "$result"
done
+2 -2
View File
@@ -87,7 +87,7 @@ jobs:
runs-on: ci-l2
timeout-minutes: 10
outputs:
report: ${{ steps.smoke.outputs.report }}
report: ${{ steps.report.outputs.report }}
steps:
- name: Checkout code
@@ -201,7 +201,7 @@ jobs:
runs-on: ci-l2
timeout-minutes: 15
outputs:
report: ${{ steps.smoke.outputs.report }}
report: ${{ steps.e2e.outputs.report }}
steps:
- name: Checkout code
+6 -1
View File
@@ -8,6 +8,11 @@ on:
permissions:
contents: read
concurrency:
group: pr-automation-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
auto-approve:
name: Auto Approve on CI Green
@@ -56,7 +61,7 @@ jobs:
name: Auto Merge on CI Green + Approved
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底
steps:
- name: Checkout code
shell: sh
+1 -1
View File
@@ -120,7 +120,7 @@ jobs:
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
preview_user="${PREVIEW_SSH_USER:-deploy}"
preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
+1 -1
View File
@@ -101,7 +101,7 @@ jobs:
# Install dependencies with retry
for i in 1 2 3; do
npm ci --no-audit --no-fund && break
echo "npm ci failed, retry $i/3..."
echo "npm install failed, retry $i/3..."
[ $i -eq 3 ] && exit 1
rm -rf node_modules
sleep 5
@@ -1,6 +1,7 @@
import { useRef, useCallback, useEffect } from "react"
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import { uploadAsset } from "@/api/assets"
import { uploadAsset, ensureDefaultLibrary } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { VoiceClone } from "@/api/voice-clone"
interface UseCloneSubmitOptions {
@@ -61,8 +62,14 @@ export function useCloneSubmit({
})
}
// 获取默认项目和素材库(后端 /upload 接口必填)
const project = await getOrCreateDefaultProject()
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
const formData = new FormData()
formData.append("file", fileToUpload)
formData.append("project_id", project.id)
formData.append("library_id", library.id)
const uploadResult = await uploadAsset(formData)
// 阶段 2:克隆
@@ -0,0 +1,4 @@
export { useBatchDelete } from "./useBatchDelete"
export { useBatchTag } from "./useBatchTag"
export { useBatchClassify } from "./useBatchClassify"
export { useBatchMark } from "./useBatchMark"
@@ -0,0 +1,58 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
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)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
@@ -0,0 +1,40 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
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)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
@@ -0,0 +1,53 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "../constants"
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
@@ -0,0 +1,86 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(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,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
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)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
@@ -0,0 +1,4 @@
export { useBatchDelete } from "./useBatchDelete"
export { useBatchTag } from "./useBatchTag"
export { useBatchClassify } from "./useBatchClassify"
export { useBatchMark } from "./useBatchMark"
@@ -0,0 +1,58 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
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)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
@@ -0,0 +1,40 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
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)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
@@ -0,0 +1,53 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "../constants"
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
@@ -0,0 +1,86 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(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,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
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)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
@@ -1,238 +1,8 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import {
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type BatchOperationResult,
} from "@/api/assets"
import type { SmartViewType } from "../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "./constants"
/* ── 批量删除 ── */
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
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)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
/* ── 批量打标签 ── */
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(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,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
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)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
/* ── 批量改分类 ── */
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
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)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
/* ── 批量智能标记 ── */
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
/**
* @deprecated 请从 ./batch/ 目录导入子模块
* 保持向后兼容,re-export 所有批量操作 Hook
*/
export { useBatchDelete } from "./batch/useBatchDelete"
export { useBatchTag } from "./batch/useBatchTag"
export { useBatchClassify } from "./batch/useBatchClassify"
export { useBatchMark } from "./batch/useBatchMark"
@@ -12,7 +12,7 @@ import {
useBatchTag,
useBatchClassify,
useBatchMark,
} from "./asset-operations/batchOperations"
} from "./asset-operations/batch-operations"
import type { BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../components/BatchMarkModal"
+21 -130
View File
@@ -1,15 +1,11 @@
/**
* 绿幕抠像配置面板
* 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"
import { DEFAULT_CHROMA_KEY_CONFIG, CHROMA_KEY_PRESET_COLORS } from "../types"
import { GreenScreenPresets } from "./green-screen/GreenScreenPresets"
import { GreenScreenCustomColor } from "./green-screen/GreenScreenCustomColor"
import { GreenScreenSliders } from "./green-screen/GreenScreenSliders"
import { GreenScreenPreview } from "./green-screen/GreenScreenPreview"
interface GreenScreenPanelProps {
open: boolean
@@ -18,9 +14,6 @@ interface GreenScreenPanelProps {
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>) => {
@@ -33,7 +26,6 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
/** 选择颜色预设时同步更新 color 字段 */
const handlePresetSelect = useCallback(
(preset: ChromaKeyColorPreset) => {
update({
@@ -44,10 +36,9 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
[update],
)
/** 自定义颜色变化时清除预设标记 */
const handleColorChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
update({ color: e.target.value })
(color: string) => {
update({ color })
},
[update],
)
@@ -61,7 +52,6 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
onClose={onClose}
className="green-screen-panel-drawer"
>
{/* 顶部开关 */}
<div className="green-header">
<span className="green-header-label">绿</span>
<Switch
@@ -71,123 +61,24 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
/>
</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>
<GreenScreenPresets
selectedPreset={config.color_preset}
onPresetSelect={handlePresetSelect}
/>
{/* 自定义颜色 */}
<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>
<GreenScreenCustomColor color={config.color} onColorChange={handleColorChange} />
{/* 参数调节 */}
<div className="green-section">
<div className="green-section-title"></div>
<GreenScreenSliders
similarity={config.similarity}
blend={config.blend}
spill={config.spill}
onSimilarityChange={(v) => update({ similarity: v })}
onBlendChange={(v) => update({ blend: v })}
onSpillChange={(v) => update({ spill: v })}
/>
{/* 相似度 */}
<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>
<GreenScreenPreview color={config.color} blend={config.blend} />
{/* 边缘平滑 */}
<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}>
+14 -58
View File
@@ -1,18 +1,16 @@
/**
* 字幕样式配置面板 — Drawer 形式
* 字幕开关(手动 / ASR 自动识别)、字体大小、颜色、描边/阴影、位置、ASR 语言
*/
import React from "react"
import { Drawer, Slider, ColorPicker, Select } from "antd"
import type { Color } from "antd/es/color-picker"
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
import {
POSITION_OPTIONS,
FONT_OPTIONS,
ANIMATION_OPTIONS,
ASR_LANGUAGE_OPTIONS,
} from "@/pages/editing-planner/constants/subtitleStyle"
import SubtitlePreview from "./subtitle-style/SubtitlePreview"
import { SubtitleModeSwitch } from "./subtitle-style/SubtitleModeSwitch"
import { SubtitlePositionSelector } from "./subtitle-style/SubtitlePositionSelector"
import { SubtitleEffectButtons } from "./subtitle-style/SubtitleEffectButtons"
interface SubtitleStylePanelProps {
open: boolean
@@ -40,7 +38,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
onClose={onClose}
className="subtitle-style-drawer"
>
{/* ── 字幕开关 ── */}
<div className="sub-field">
<div className="sub-toggle-row">
<span className="sub-label"></span>
@@ -55,26 +52,11 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
{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>
<SubtitleModeSwitch mode={config.mode} onModeChange={(mode) => update({ mode })} />
</div>
{/* ── ASR 语言(仅 ASR 模式) ── */}
{config.mode === "asr" && (
<div className="sub-field">
<label className="sub-label"></label>
@@ -88,7 +70,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
</div>
)}
{/* ── 字体大小 ── */}
<div className="sub-field">
<label className="sub-label">
<span className="sub-value">{config.fontSize}px</span>
@@ -101,7 +82,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
/>
</div>
{/* ── 字体颜色 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-color-row">
@@ -113,7 +93,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
</div>
</div>
{/* ── 字体 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<Select
@@ -125,46 +104,24 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
/>
</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>
<SubtitlePositionSelector
position={config.position}
onPositionChange={(position) => update({ position })}
/>
</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>
<SubtitleEffectButtons
stroke={config.stroke}
shadow={config.shadow}
onStrokeChange={(stroke) => update({ stroke })}
onShadowChange={(shadow) => update({ shadow })}
/>
</div>
{/* ── 动画 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<Select
@@ -179,7 +136,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
/>
</div>
{/* ── 预览 ── */}
<SubtitlePreview config={config} />
</>
)}
@@ -0,0 +1,37 @@
import React from "react"
interface GreenScreenCustomColorProps {
color: string
onColorChange: (color: string) => void
}
export const GreenScreenCustomColor: React.FC<GreenScreenCustomColorProps> = ({
color,
onColorChange,
}) => {
const handleColorInput = (e: React.ChangeEvent<HTMLInputElement>) => {
onColorChange(e.target.value)
}
return (
<div className="green-section">
<div className="green-section-title"></div>
<div className="green-color-row">
<input
type="color"
className="green-color-picker"
value={color}
onChange={handleColorInput}
/>
<input
type="text"
className="green-color-hex"
value={color}
onChange={handleColorInput}
placeholder="#00FF00"
/>
<div className="green-color-swatch" style={{ background: color }} />
</div>
</div>
)
}
@@ -0,0 +1,36 @@
import React from "react"
import type { ChromaKeyColorPreset } from "../../types"
import { CHROMA_KEY_PRESET_LABELS, CHROMA_KEY_PRESET_COLORS } from "../../types"
interface GreenScreenPresetsProps {
selectedPreset: ChromaKeyColorPreset | null
onPresetSelect: (preset: ChromaKeyColorPreset) => void
}
const PRESET_LIST: ChromaKeyColorPreset[] = ["green", "blue", "red", "pure_green", "soft_green"]
export const GreenScreenPresets: React.FC<GreenScreenPresetsProps> = ({
selectedPreset,
onPresetSelect,
}) => {
return (
<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${selectedPreset === p ? " active" : ""}`}
onClick={() => onPresetSelect(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>
)
}
@@ -0,0 +1,28 @@
import React from "react"
interface GreenScreenPreviewProps {
color: string
blend: number
}
export const GreenScreenPreview: React.FC<GreenScreenPreviewProps> = ({ color, blend }) => {
return (
<div className="green-section">
<div className="green-section-title"></div>
<div className="green-preview-box">
<div className="green-preview-bg" style={{ background: 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: color,
filter: `blur(${blend / 10}px)`,
}}
/>
</div>
</div>
)
}
@@ -0,0 +1,68 @@
import React from "react"
interface SliderConfig {
label: string
value: number
description: string
onChange: (value: number) => void
}
interface GreenScreenSlidersProps {
similarity: number
blend: number
spill: number
onSimilarityChange: (value: number) => void
onBlendChange: (value: number) => void
onSpillChange: (value: number) => void
}
const SliderRow: React.FC<SliderConfig> = ({ label, value, description, onChange }) => (
<div className="green-slider-row">
<div className="green-slider-header">
<span className="green-slider-label">{label}</span>
<span className="green-slider-value">{value}%</span>
</div>
<input
type="range"
className="green-slider"
min={0}
max={100}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
<div className="green-slider-desc">{description}</div>
</div>
)
export const GreenScreenSliders: React.FC<GreenScreenSlidersProps> = ({
similarity,
blend,
spill,
onSimilarityChange,
onBlendChange,
onSpillChange,
}) => {
return (
<div className="green-section">
<div className="green-section-title"></div>
<SliderRow
label="相似度"
value={similarity}
description="越大容忍的色差范围越广"
onChange={onSimilarityChange}
/>
<SliderRow
label="边缘平滑"
value={blend}
description="越大边缘越柔和自然"
onChange={onBlendChange}
/>
<SliderRow
label="溢色抑制"
value={spill}
description="去除边缘颜色溢出"
onChange={onSpillChange}
/>
</div>
)
}
@@ -0,0 +1,32 @@
import React from "react"
interface SubtitleEffectButtonsProps {
stroke: boolean
shadow: boolean
onStrokeChange: (enabled: boolean) => void
onShadowChange: (enabled: boolean) => void
}
export const SubtitleEffectButtons: React.FC<SubtitleEffectButtonsProps> = ({
stroke,
shadow,
onStrokeChange,
onShadowChange,
}) => {
return (
<div className="sub-effect-btns">
<button
className={`sub-effect-btn${stroke ? " active" : ""}`}
onClick={() => onStrokeChange(!stroke)}
>
S
</button>
<button
className={`sub-effect-btn${shadow ? " active" : ""}`}
onClick={() => onShadowChange(!shadow)}
>
</button>
</div>
)
}
@@ -0,0 +1,25 @@
import React from "react"
interface SubtitleModeSwitchProps {
mode: "manual" | "asr"
onModeChange: (mode: "manual" | "asr") => void
}
export const SubtitleModeSwitch: React.FC<SubtitleModeSwitchProps> = ({ mode, onModeChange }) => {
return (
<div className="sub-mode-switch">
<button
className={`sub-mode-btn${mode === "manual" ? " active" : ""}`}
onClick={() => onModeChange("manual")}
>
</button>
<button
className={`sub-mode-btn${mode === "asr" ? " active" : ""}`}
onClick={() => onModeChange("asr")}
>
🤖 ASR
</button>
</div>
)
}
@@ -0,0 +1,27 @@
import React from "react"
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
import { POSITION_OPTIONS } from "@/pages/editing-planner/constants/subtitleStyle"
interface SubtitlePositionSelectorProps {
position: SubtitleStyleConfig["position"]
onPositionChange: (position: SubtitleStyleConfig["position"]) => void
}
export const SubtitlePositionSelector: React.FC<SubtitlePositionSelectorProps> = ({
position,
onPositionChange,
}) => {
return (
<div className="sub-position-group">
{POSITION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`sub-position-btn${position === opt.value ? " active" : ""}`}
onClick={() => onPositionChange(opt.value as SubtitleStyleConfig["position"])}
>
{opt.label}
</button>
))}
</div>
)
}
@@ -0,0 +1,116 @@
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
import type { ClipType } from "../../types"
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../../constants/timeline"
interface UseAddPickerOptions {
currentMode: string
onAddClip: (type: ClipType, duration: number) => void
}
export function useAddPicker({ currentMode, onAddClip }: UseAddPickerOptions) {
const [showAddPicker, setShowAddPicker] = useState(false)
const pickerRef = useRef<HTMLDivElement>(null)
const addCardRef = useRef<HTMLDivElement>(null)
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
const availableTypes: ClipType[] = useMemo(
() =>
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
[currentMode],
)
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice"
if (currentMode === "pip") return "pip"
return "voice"
}, [currentMode])
const [addType, setAddType] = useState<ClipType>(defaultAddType)
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
useEffect(() => {
if (!availableTypes.includes(addType)) {
setAddType(defaultAddType)
}
}, [currentMode, addType, availableTypes, defaultAddType])
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - TRACK_GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - ADD_PICKER_WIDTH < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [])
const handleTogglePicker = useCallback(() => {
if (!showAddPicker) {
setAddType(defaultAddType)
updatePickerPosition()
}
setShowAddPicker((v) => !v)
}, [showAddPicker, defaultAddType, updatePickerPosition])
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
const pickerEl = pickerRef.current
const addRect = addCardRef.current.getBoundingClientRect()
const pickerH = pickerEl.offsetHeight
const vh = window.innerHeight
const vw = window.innerWidth
let top = addRect.top - TRACK_GAP - pickerH
if (top < 8) {
top = addRect.bottom + TRACK_GAP
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 - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [showAddPicker])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowAddPicker(false)
}
}
if (showAddPicker) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [showAddPicker])
const handleConfirmAdd = useCallback(() => {
onAddClip(addType, addDuration)
setShowAddPicker(false)
}, [onAddClip, addType, addDuration])
return {
showAddPicker,
setShowAddPicker,
pickerRef,
addCardRef,
pickerPos,
availableTypes,
addType,
addDuration,
setAddType,
setAddDuration,
handleTogglePicker,
handleConfirmAdd,
}
}
@@ -0,0 +1,68 @@
import { useState, useRef, useCallback, useEffect } from "react"
interface ContextMenuState {
x: number
y: number
clipId: string
}
interface UseContextMenuOptions {
onClipSplit?: (clipId: string, splitRatio: number) => void
onClipResetTrim?: (clipId: string) => void
onClipRemove?: (clipId: string) => void
}
export function useContextMenu({
onClipSplit,
onClipResetTrim,
onClipRemove,
}: UseContextMenuOptions) {
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
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
onClipSplit?.(contextMenu.clipId, 0.5)
setContextMenu(null)
}, [contextMenu, onClipSplit])
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return
onClipResetTrim?.(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipResetTrim])
const handleContextDelete = useCallback(() => {
if (!contextMenu) return
onClipRemove?.(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipRemove])
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])
return {
contextMenu,
setContextMenu,
contextMenuRef,
handleContextMenu,
handleContextSplit,
handleContextResetTrim,
handleContextDelete,
}
}
@@ -0,0 +1,38 @@
import { useCallback } from "react"
import { message } from "antd"
import { useQueryClient, useMutation } from "@tanstack/react-query"
import { createClipsFromAssets } from "@/api/template-editor"
const QUERY_KEY = "editPlanClips"
export function useClipImport(planId: string | undefined) {
const queryClient = useQueryClient()
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
}
const importFromAssetsMutation = useMutation({
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
onSuccess: (res) => {
invalidate()
message.success(`已导入 ${res.created_count} 个素材片段`)
},
onError: () => {
message.error("导入素材失败")
},
})
const importFromAssets = useCallback(
(assetIds: string[]) => {
if (!planId || assetIds.length === 0) return
importFromAssetsMutation.mutate(assetIds)
},
[planId, importFromAssetsMutation],
)
return {
importFromAssets,
isImporting: importFromAssetsMutation.isPending,
}
}
@@ -0,0 +1,39 @@
import { useCallback } from "react"
import { message } from "antd"
import { useQueryClient, useMutation } from "@tanstack/react-query"
import type { ClipReorderItem } from "@/api/template-editor"
import { reorderEditPlanClips } from "@/api/template-editor"
const QUERY_KEY = "editPlanClips"
export function useClipReorder(planId: string | undefined) {
const queryClient = useQueryClient()
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
}
const reorderMutation = useMutation({
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
onSuccess: () => {
invalidate()
},
onError: () => {
message.error("排序失败")
invalidate()
},
})
const reorderClips = useCallback(
(items: ClipReorderItem[]) => {
if (!planId || items.length === 0) return
reorderMutation.mutate(items)
},
[planId, reorderMutation],
)
return {
reorderClips,
isReordering: reorderMutation.isPending,
}
}
@@ -1,19 +1,15 @@
import { useCallback } from "react"
import { message } from "antd"
import { useQueryClient, useMutation } from "@tanstack/react-query"
import type {
CreateEditPlanClipRequest,
UpdateEditPlanClipRequest,
ClipReorderItem,
} from "@/api/template-editor"
import type { CreateEditPlanClipRequest, UpdateEditPlanClipRequest } from "@/api/template-editor"
import {
createEditPlanClip,
updateEditPlanClip,
deleteEditPlanClip,
reorderEditPlanClips,
batchDeleteEditPlanClips,
createClipsFromAssets,
} from "@/api/template-editor"
import { useClipReorder } from "./useClipReorder"
import { useClipImport } from "./useClipImport"
const QUERY_KEY = "editPlanClips"
@@ -24,10 +20,6 @@ interface UseEditPlanClipMutationsOptions {
clipsLength: number
}
/**
* 编辑计划片段 CRUD Hook
* 封装创建、更新、删除、批量删除、重排序、素材导入等操作
*/
export function useEditPlanClipMutations({
planId,
selectedClipId,
@@ -40,7 +32,6 @@ export function useEditPlanClipMutations({
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
}
/* ── 创建片段 ── */
const createMutation = useMutation({
mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data),
onSuccess: () => {
@@ -61,7 +52,6 @@ export function useEditPlanClipMutations({
[planId, clipsLength, createMutation],
)
/* ── 更新片段 ── */
const updateMutation = useMutation({
mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) =>
updateEditPlanClip(planId!, clipId, data),
@@ -81,7 +71,6 @@ export function useEditPlanClipMutations({
[planId, updateMutation],
)
/* ── 删除片段 ── */
const deleteMutation = useMutation({
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
onSuccess: () => {
@@ -104,7 +93,6 @@ export function useEditPlanClipMutations({
[planId, selectedClipId, setSelectedClipId, deleteMutation],
)
/* ── 批量删除 ── */
const batchDeleteMutation = useMutation({
mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds),
onSuccess: (res) => {
@@ -127,45 +115,8 @@ export function useEditPlanClipMutations({
[planId, selectedClipId, setSelectedClipId, batchDeleteMutation],
)
/* ── 重排序 ── */
const reorderMutation = useMutation({
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
onSuccess: () => {
invalidate()
},
onError: () => {
message.error("排序失败")
invalidate()
},
})
const reorderClips = useCallback(
(items: ClipReorderItem[]) => {
if (!planId || items.length === 0) return
reorderMutation.mutate(items)
},
[planId, reorderMutation],
)
/* ── 从素材批量导入 ── */
const importFromAssetsMutation = useMutation({
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
onSuccess: (res) => {
invalidate()
message.success(`已导入 ${res.created_count} 个素材片段`)
},
onError: () => {
message.error("导入素材失败")
},
})
const importFromAssets = useCallback(
(assetIds: string[]) => {
if (!planId || assetIds.length === 0) return
importFromAssetsMutation.mutate(assetIds)
},
[planId, importFromAssetsMutation],
)
const { reorderClips, isReordering } = useClipReorder(planId)
const { importFromAssets, isImporting } = useClipImport(planId)
return {
addClip,
@@ -177,7 +128,7 @@ export function useEditPlanClipMutations({
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
isReordering: reorderMutation.isPending,
isImporting: importFromAssetsMutation.isPending,
isReordering,
isImporting,
}
}
+24 -149
View File
@@ -1,12 +1,7 @@
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
import { useState } from "react"
import type { ClipData, ClipType } from "../types"
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../constants/timeline"
interface ContextMenuState {
x: number
y: number
clipId: string
}
import { useContextMenu } from "./timeline-menus/useContextMenu"
import { useAddPicker } from "./timeline-menus/useAddPicker"
/**
* 时间线菜单 Hook
@@ -20,150 +15,30 @@ export const useTimelineMenus = (
onClipResetTrim?: (clipId: string) => void,
onClipRemove?: (clipId: string) => void,
) => {
/* ── 右键菜单 ── */
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
/* ── 添加片段面板 ── */
const [showAddPicker, setShowAddPicker] = useState(false)
const pickerRef = useRef<HTMLDivElement>(null)
const addCardRef = useRef<HTMLDivElement>(null)
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
[currentMode],
)
const {
contextMenu,
contextMenuRef,
handleContextMenu,
handleContextSplit,
handleContextResetTrim,
handleContextDelete,
} = useContextMenu({ onClipSplit, onClipResetTrim, onClipRemove })
/* ── 默认添加类型:跟随模式 ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice"
if (currentMode === "pip") return "pip"
return "voice"
}, [currentMode])
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>(defaultAddType)
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
if (!availableTypes.includes(addType)) {
setAddType(defaultAddType)
}
}, [currentMode, addType, availableTypes, defaultAddType])
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - TRACK_GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - ADD_PICKER_WIDTH < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [])
const handleTogglePicker = useCallback(() => {
if (!showAddPicker) {
setAddType(defaultAddType)
updatePickerPosition()
}
setShowAddPicker((v) => !v)
}, [showAddPicker, defaultAddType, updatePickerPosition])
/* ── 渲染后精确边界校正 ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
const pickerEl = pickerRef.current
const addRect = addCardRef.current.getBoundingClientRect()
const pickerH = pickerEl.offsetHeight
const vh = window.innerHeight
const vw = window.innerWidth
let top = addRect.top - TRACK_GAP - pickerH
if (top < 8) {
top = addRect.bottom + TRACK_GAP
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 - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [showAddPicker])
/* ── 点击外部关闭添加面板 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowAddPicker(false)
}
}
if (showAddPicker) {
document.addEventListener("mousedown", handleClickOutside)
}
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 handleConfirmAdd = useCallback(() => {
onAddClip(addType, addDuration)
setShowAddPicker(false)
}, [onAddClip, addType, addDuration])
/* ── 右键菜单操作 ── */
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
onClipSplit?.(contextMenu.clipId, 0.5)
setContextMenu(null)
}, [contextMenu, onClipSplit])
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return
onClipResetTrim?.(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipResetTrim])
const handleContextDelete = useCallback(() => {
if (!contextMenu) return
onClipRemove?.(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipRemove])
const {
showAddPicker,
pickerRef,
addCardRef,
pickerPos,
availableTypes,
addType,
addDuration,
setAddType,
setAddDuration,
handleTogglePicker,
handleConfirmAdd,
} = useAddPicker({ currentMode, onAddClip })
return {
// 右键菜单
+11 -102
View File
@@ -1,37 +1,21 @@
/**
* 智能剪辑右侧生成结果面板
*/
import React from "react"
import { Typography } from "antd"
import {
PlayCircleOutlined,
CloseCircleOutlined,
DownloadOutlined,
ShareAltOutlined,
} from "@ant-design/icons"
import { PlayCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
import { formatDuration } from "@/api/voice-clone"
import { ProgressIndicator } from "./result-panel/ProgressIndicator"
import { ResultVideoCard } from "./result-panel/ResultVideoCard"
const { Text } = Typography
interface GenerateResultPanelProps {
/** 是否已生成完成 */
generated: boolean
/** 是否正在生成中 */
generating: boolean
/** 生成进度(0-100 */
progress: number
/** 生成错误信息 */
generateError: string | null
/** 生成的视频列表 */
generatedVideos: GeneratedVideo[]
/** 点击视频卡片预览回调 */
onVideoPreview: (video: GeneratedVideo) => void
/** 下载回调 */
onDownload: () => void
/** 分享回调 */
onShare: () => void
/** 前往成片库回调 */
onGoToLibrary: () => void
}
@@ -55,45 +39,8 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
)}
</div>
{/* 生成中进度 */}
{generating && (
<div className="xx-result-progress">
<div className="xx-progress-circle">
<svg viewBox="0 0 80 80">
<circle
cx="40"
cy="40"
r="36"
fill="none"
stroke="var(--border-color)"
strokeWidth="6"
/>
<circle
cx="40"
cy="40"
r="36"
fill="none"
stroke="var(--primary-color)"
strokeWidth="6"
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
strokeLinecap="round"
transform="rotate(-90 40 40)"
/>
</svg>
<span className="xx-progress-percent">{Math.round(progress)}%</span>
</div>
<div className="xx-progress-text">
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
</Text>
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
AI
</Text>
</div>
</div>
)}
{generating && <ProgressIndicator progress={progress} />}
{/* 生成失败 */}
{generateError && !generating && (
<div className="xx-result-empty">
<CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} />
@@ -106,7 +53,6 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
</div>
)}
{/* 空状态 */}
{!generated && !generating && !generateError && (
<div className="xx-result-empty">
<PlayCircleOutlined
@@ -121,54 +67,17 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
</div>
)}
{/* 生成结果卡片列表 */}
{generated && generatedVideos.length > 0 && (
<div className="xx-video-grid">
{generatedVideos.map((video, idx) => (
<div
<ResultVideoCard
key={video.id || idx}
className="xx-video-card"
onClick={() => onVideoPreview(video)}
>
<div className="xx-video-thumb">
{video.thumbnail_url ? (
<img src={video.thumbnail_url} alt="" />
) : (
<div className="xx-video-thumb-placeholder">
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
</div>
)}
<div className="xx-video-play-overlay">
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
</div>
{video.duration && (
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
)}
</div>
<div className="xx-video-info">
<div className="xx-video-title"> {idx + 1}</div>
<div className="xx-video-actions">
<button
className="xx-video-action-btn"
onClick={(e) => {
e.stopPropagation()
onDownload()
}}
>
<DownloadOutlined />
</button>
<button
className="xx-video-action-btn"
onClick={(e) => {
e.stopPropagation()
onShare()
}}
>
<ShareAltOutlined />
</button>
</div>
</div>
</div>
video={video}
index={idx}
onPreview={onVideoPreview}
onDownload={onDownload}
onShare={onShare}
/>
))}
</div>
)}
+16 -101
View File
@@ -1,9 +1,9 @@
/**
* Step 6 封面设置组件
*/
import React from "react"
import type { CoverConfig } from "../../editing-planner/types"
import { useStep6Cover } from "../hooks/useStep6Cover"
import { CoverModeSelector } from "./cover-settings/CoverModeSelector"
import { FrameCoverPicker } from "./cover-settings/FrameCoverPicker"
import { UploadCoverPicker } from "./cover-settings/UploadCoverPicker"
interface Step6CoverSettingsProps {
coverSettings: CoverConfig
@@ -28,7 +28,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
<div className="xx-form-section">
<h3>🖼 </h3>
{/* 启用开关 */}
<div className="xx-cover-header">
<span className="xx-cover-header-label"></span>
<label className="xx-switch">
@@ -43,22 +42,14 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
{coverSettings.enabled && (
<>
{/* 模式选择 */}
<div className="xx-section-title"></div>
<div className="xx-cover-mode-tabs">
{(["auto", "frame", "upload"] as const).map((m) => (
<button
key={m}
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
onClick={() => setMode(m)}
>
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
</button>
))}
</div>
<CoverModeSelector
mode={coverSettings.mode}
onModeChange={setMode}
modeLabels={COVER_MODE_LABELS}
modeIcons={COVER_MODE_ICONS}
/>
{/* 智能封面 */}
{coverSettings.mode === "auto" && (
<div className="xx-cover-auto">
<div className="xx-cover-auto-desc">
@@ -71,95 +62,19 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
</div>
)}
{/* 抽帧选封面 */}
{coverSettings.mode === "frame" && (
<div className="xx-cover-frame">
<div className="xx-cover-frame-preview">
<div className="xx-cover-frame-placeholder">
<span className="xx-cover-frame-icon">🎞</span>
<span className="xx-cover-frame-time">
{formatTime(coverSettings.frame_time)}
</span>
</div>
</div>
<div className="xx-cover-frame-slider">
<div className="xx-cover-frame-slider-header">
<span></span>
<span className="xx-cover-frame-value">
{formatTime(coverSettings.frame_time)}
</span>
</div>
<input
type="range"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={coverSettings.frame_time}
onChange={(e) => setFrameTime(Number(e.target.value))}
className="xx-cover-range"
/>
<div className="xx-cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="xx-cover-frame-quick">
<span className="xx-cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button
key={ratio}
className="xx-cover-quick-btn"
onClick={() => setFrameTime(t)}
>
{formatTime(t)}
</button>
)
})}
</div>
</div>
<FrameCoverPicker
frameTime={coverSettings.frame_time}
totalDuration={totalDuration}
formatTime={formatTime}
onFrameTimeChange={setFrameTime}
/>
)}
{/* 上传封面 */}
{coverSettings.mode === "upload" && (
<div className="xx-cover-upload">
<div
className="xx-cover-upload-area"
onClick={() => {
const input = document.getElementById("cover-upload-input")
input?.click()
}}
>
{coverSettings.upload_url ? (
<div className="xx-cover-upload-preview">
<img src={coverSettings.upload_url} alt="封面预览" />
<div className="xx-cover-upload-overlay"></div>
</div>
) : (
<div className="xx-cover-upload-placeholder">
<span style={{ fontSize: 32 }}>📤</span>
<span className="xx-cover-upload-text"></span>
<span className="xx-cover-upload-hint"> JPG / PNG 16:9 </span>
</div>
)}
<input
id="cover-upload-input"
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
}
}}
/>
</div>
</div>
<UploadCoverPicker uploadUrl={coverSettings.upload_url} onUpload={handleUpload} />
)}
{/* 封面预览 */}
<div className="xx-section-title"></div>
<div className="xx-cover-preview-box">
{coverSettings.upload_url ? (
+24 -130
View File
@@ -2,19 +2,14 @@
* Step 7 确认生成组件
*/
import React from "react"
import {
LoadingOutlined,
CheckCircleFilled,
CloseCircleOutlined,
MinusOutlined,
PlusOutlined,
} from "@ant-design/icons"
import type { EditingTemplate } from "@/api/editing-planner"
import type { GeneratedVideo } from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import type { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices"
import { useStep7Generate } from "../hooks/useStep7Generate"
import SummaryCard from "./step7-confirm/SummaryCard"
import GenerationStatus from "./step7-confirm/GenerationStatus"
interface Step7ConfirmGenerateProps {
templates: EditingTemplate[]
@@ -64,129 +59,28 @@ const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
return (
<div className="xx-form-section">
<h3> </h3>
<div className="xx-summary-card">
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{templateName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{materialSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{title || "未选择"}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{voiceName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{coverSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
<div className="xx-count-stepper">
<button
className="xx-count-stepper-btn"
disabled={generateCount <= 1 || generating}
onClick={handleDecrement}
>
<MinusOutlined />
</button>
<span className="xx-count-stepper-value">{generateCount}</span>
<button
className="xx-count-stepper-btn"
disabled={generateCount >= 10 || generating}
onClick={handleIncrement}
>
<PlusOutlined />
</button>
<span className="xx-count-stepper-hint"></span>
</div>
</span>
</div>
</div>
{/* 生成进度 / 结果反馈 */}
{(generating || generated || generateError) && (
<div style={{ marginTop: 16 }}>
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">
<LoadingOutlined />
</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
</div>
<div className="xx-gen-progress-sub"> 1-2 </div>
</div>
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<div className="xx-gen-progress-tip">
💡
</div>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-icon">
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
</div>
<div className="xx-gen-success-info">
<div className="xx-gen-success-title"></div>
<div className="xx-gen-success-sub">
{generatedVideos.length}
</div>
</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={handleScrollToPreview}
>
</button>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card">
<div className="xx-gen-error-icon">
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
</div>
<div className="xx-gen-error-info">
<div className="xx-gen-error-title"></div>
<div className="xx-gen-error-msg">
{typeof generateError === "string"
? generateError
: JSON.stringify(generateError)}
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
🔄
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={onDismissError}
>
</button>
</div>
</div>
)}
</div>
)}
<SummaryCard
templateName={templateName}
materialSummary={materialSummary}
title={title}
voiceName={voiceName}
coverSummary={coverSummary}
generateCount={generateCount}
generating={generating}
onDecrement={handleDecrement}
onIncrement={handleIncrement}
/>
<GenerationStatus
generating={generating}
generated={generated}
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
getGenerationPhase={getGenerationPhase}
onScrollToPreview={handleScrollToPreview}
onRetry={onRetry}
onDismissError={onDismissError}
/>
</div>
)
}
@@ -0,0 +1,32 @@
import React from "react"
import type { CoverMode } from "../../../editing-planner/types"
interface CoverModeSelectorProps {
mode: CoverMode
onModeChange: (mode: CoverMode) => void
modeLabels: Record<CoverMode, string>
modeIcons: Record<CoverMode, string>
}
export const CoverModeSelector: React.FC<CoverModeSelectorProps> = ({
mode,
onModeChange,
modeLabels,
modeIcons,
}) => {
const modes: CoverMode[] = ["auto", "frame", "upload"]
return (
<div className="xx-cover-mode-tabs">
{modes.map((m) => (
<button
key={m}
className={`xx-cover-mode-tab${mode === m ? " active" : ""}`}
onClick={() => onModeChange(m)}
>
<span className="xx-cover-mode-icon">{modeIcons[m]}</span>
<span className="xx-cover-mode-label">{modeLabels[m]}</span>
</button>
))}
</div>
)
}
@@ -0,0 +1,58 @@
import React from "react"
interface FrameCoverPickerProps {
frameTime: number
totalDuration: number
formatTime: (seconds: number) => string
onFrameTimeChange: (time: number) => void
}
export const FrameCoverPicker: React.FC<FrameCoverPickerProps> = ({
frameTime,
totalDuration,
formatTime,
onFrameTimeChange,
}) => {
const quickRatios = [0, 0.25, 0.5, 0.75]
return (
<div className="xx-cover-frame">
<div className="xx-cover-frame-preview">
<div className="xx-cover-frame-placeholder">
<span className="xx-cover-frame-icon">🎞</span>
<span className="xx-cover-frame-time">{formatTime(frameTime)}</span>
</div>
</div>
<div className="xx-cover-frame-slider">
<div className="xx-cover-frame-slider-header">
<span></span>
<span className="xx-cover-frame-value">{formatTime(frameTime)}</span>
</div>
<input
type="range"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={frameTime}
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
className="xx-cover-range"
/>
<div className="xx-cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="xx-cover-frame-quick">
<span className="xx-cover-quick-label"></span>
{quickRatios.map((ratio) => {
const t = totalDuration * ratio
return (
<button key={ratio} className="xx-cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,46 @@
import React from "react"
interface UploadCoverPickerProps {
uploadUrl: string
onUpload: (file: File) => void
}
export const UploadCoverPicker: React.FC<UploadCoverPickerProps> = ({ uploadUrl, onUpload }) => {
const handleClick = () => {
const input = document.getElementById("cover-upload-input")
input?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
onUpload(file)
}
}
return (
<div className="xx-cover-upload">
<div className="xx-cover-upload-area" onClick={handleClick}>
{uploadUrl ? (
<div className="xx-cover-upload-preview">
<img src={uploadUrl} alt="封面预览" />
<div className="xx-cover-upload-overlay"></div>
</div>
) : (
<div className="xx-cover-upload-placeholder">
<span style={{ fontSize: 32 }}>📤</span>
<span className="xx-cover-upload-text"></span>
<span className="xx-cover-upload-hint"> JPG / PNG 16:9 </span>
</div>
)}
<input
id="cover-upload-input"
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
)
}
@@ -0,0 +1,40 @@
import React from "react"
import { Typography } from "antd"
const { Text } = Typography
interface ProgressIndicatorProps {
progress: number
}
export const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({ progress }) => {
return (
<div className="xx-result-progress">
<div className="xx-progress-circle">
<svg viewBox="0 0 80 80">
<circle cx="40" cy="40" r="36" fill="none" stroke="var(--border-color)" strokeWidth="6" />
<circle
cx="40"
cy="40"
r="36"
fill="none"
stroke="var(--primary-color)"
strokeWidth="6"
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
strokeLinecap="round"
transform="rotate(-90 40 40)"
/>
</svg>
<span className="xx-progress-percent">{Math.round(progress)}%</span>
</div>
<div className="xx-progress-text">
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
</Text>
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
AI
</Text>
</div>
</div>
)
}
@@ -0,0 +1,63 @@
import React from "react"
import { PlayCircleOutlined, DownloadOutlined, ShareAltOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
import { formatDuration } from "@/api/voice-clone"
interface ResultVideoCardProps {
video: GeneratedVideo
index: number
onPreview: (video: GeneratedVideo) => void
onDownload: () => void
onShare: () => void
}
export const ResultVideoCard: React.FC<ResultVideoCardProps> = ({
video,
index,
onPreview,
onDownload,
onShare,
}) => {
return (
<div className="xx-video-card" onClick={() => onPreview(video)}>
<div className="xx-video-thumb">
{video.thumbnail_url ? (
<img src={video.thumbnail_url} alt="" />
) : (
<div className="xx-video-thumb-placeholder">
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
</div>
)}
<div className="xx-video-play-overlay">
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
</div>
{video.duration && (
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
)}
</div>
<div className="xx-video-info">
<div className="xx-video-title"> {index + 1}</div>
<div className="xx-video-actions">
<button
className="xx-video-action-btn"
onClick={(e) => {
e.stopPropagation()
onDownload()
}}
>
<DownloadOutlined />
</button>
<button
className="xx-video-action-btn"
onClick={(e) => {
e.stopPropagation()
onShare()
}}
>
<ShareAltOutlined />
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,106 @@
import React from "react"
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
interface GenerationStatusProps {
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
getGenerationPhase: (progress: number) => { icon: string; label: string }
onScrollToPreview: () => void
onRetry: () => void
onDismissError: () => void
}
const GenerationStatus: React.FC<GenerationStatusProps> = ({
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
onScrollToPreview,
onRetry,
onDismissError,
}) => {
if (!generating && !generated && !generateError) return null
return (
<div style={{ marginTop: 16 }}>
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">
<LoadingOutlined />
</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
</div>
<div className="xx-gen-progress-sub"> 1-2 </div>
</div>
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<div className="xx-gen-progress-tip">
💡
</div>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-icon">
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
</div>
<div className="xx-gen-success-info">
<div className="xx-gen-success-title"></div>
<div className="xx-gen-success-sub">
{generatedVideos.length}
</div>
</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={onScrollToPreview}
>
</button>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card">
<div className="xx-gen-error-icon">
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
</div>
<div className="xx-gen-error-info">
<div className="xx-gen-error-title"></div>
<div className="xx-gen-error-msg">
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)}
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
🔄
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={onDismissError}
>
</button>
</div>
</div>
)}
</div>
)
}
export default GenerationStatus
@@ -0,0 +1,76 @@
import React from "react"
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"
interface SummaryCardProps {
templateName: string
materialSummary: string
title: string
voiceName: string
coverSummary: string
generateCount: number
generating: boolean
onDecrement: () => void
onIncrement: () => void
}
const SummaryCard: React.FC<SummaryCardProps> = ({
templateName,
materialSummary,
title,
voiceName,
coverSummary,
generateCount,
generating,
onDecrement,
onIncrement,
}) => {
return (
<div className="xx-summary-card">
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{templateName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{materialSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{title || "未选择"}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{voiceName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{coverSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
<div className="xx-count-stepper">
<button
className="xx-count-stepper-btn"
disabled={generateCount <= 1 || generating}
onClick={onDecrement}
>
<MinusOutlined />
</button>
<span className="xx-count-stepper-value">{generateCount}</span>
<button
className="xx-count-stepper-btn"
disabled={generateCount >= 10 || generating}
onClick={onIncrement}
>
<PlusOutlined />
</button>
<span className="xx-count-stepper-hint"></span>
</div>
</span>
</div>
</div>
)
}
export default SummaryCard
@@ -0,0 +1,76 @@
import type { UseGenerateVideoProps } from "./types"
import { buildVoiceConfig } from "./voiceConfig"
/**
* 构建 updateEditPlan 的 payload
* 从 props 中提取需要的字段,组装成 API 所需的 config 结构
*/
export const buildEditPlanPayload = (props: UseGenerateVideoProps) => {
const {
titleSettings,
selectedMaterials,
materialMode,
smartSelectedIds,
voiceMode,
selectedVoice,
selectedClonedVoice,
coverSettings,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
generateCount,
} = props
const voiceConfig = buildVoiceConfig({
voiceMode,
selectedVoice,
selectedClonedVoice,
})
return {
name: titleSettings.title.trim(),
config: {
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
},
cover_config: coverSettings,
...voiceConfig,
ratio: videoRatio,
style,
duration,
auto_subtitles: autoSubtitles,
bgm,
generate_count: generateCount,
material_mode: materialMode,
},
total_duration: duration,
status: "editing" as const,
}
}
/**
* 生成前置校验
* 返回错误信息,通过则返回 null
*/
export const validateGenerateInputs = (props: UseGenerateVideoProps): string | null => {
const { titleSettings, materialMode, selectedMaterials, voiceMode, selectedClonedVoice } = props
if (!titleSettings.title.trim()) {
return "请先选择或输入标题"
}
if (materialMode === "manual" && selectedMaterials.length === 0) {
return "请至少选择一个素材"
}
if (voiceMode === "clone" && !selectedClonedVoice) {
return "请先选择一个克隆音色"
}
return null
}
@@ -0,0 +1,70 @@
import { useCallback } from "react"
import type { VoiceClone } from "@/api/voice-clone"
interface UseVoiceModeSelectionOptions {
selectedVoice: string
onSelectedVoiceChange: (voiceId: string) => void
voiceMode: "preset" | "custom" | "clone"
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
onSelectedClonedVoiceChange: (voiceId: string) => void
addClone: (voice: VoiceClone) => void
cloneModalOpen: boolean
onCloneModalOpenChange: (open: boolean) => void
}
export function useVoiceModeSelection({
onSelectedVoiceChange,
onVoiceModeChange,
onSelectedClonedVoiceChange,
addClone,
onCloneModalOpenChange,
}: UseVoiceModeSelectionOptions) {
const handleSelectRecommendedVoice = useCallback(
(voiceId: string) => {
onVoiceModeChange("preset")
onSelectedVoiceChange(voiceId)
},
[onVoiceModeChange, onSelectedVoiceChange],
)
const handleSelectPresetVoice = useCallback(
(voiceId: string) => {
onVoiceModeChange("preset")
onSelectedVoiceChange(voiceId)
},
[onVoiceModeChange, onSelectedVoiceChange],
)
const handleSelectCloneVoice = useCallback(() => {
onVoiceModeChange("clone")
}, [onVoiceModeChange])
const handleSelectClonedVoice = useCallback(
(voiceId: string) => {
onSelectedClonedVoiceChange(voiceId)
},
[onSelectedClonedVoiceChange],
)
const handleOpenCloneModal = useCallback(() => {
onCloneModalOpenChange(true)
}, [onCloneModalOpenChange])
const handleCloneSuccess = useCallback(
(voice: VoiceClone) => {
addClone(voice)
onCloneModalOpenChange(false)
},
[addClone, onCloneModalOpenChange],
)
return {
handleSelectRecommendedVoice,
handleSelectPresetVoice,
handleSelectCloneVoice,
handleSelectClonedVoice,
handleOpenCloneModal,
handleCloneSuccess,
}
}
@@ -1,309 +0,0 @@
/**
* GeneratePage 表单状态管理
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
*/
import { useState, useEffect, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { useSearchParams } from "react-router-dom"
import type { GeneratedVideo, TitleConfig } from "@/api/template-editor"
import type { EditingTemplate } from "@/api/editing-planner"
import { getEditPlan } from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import { getEditingTemplates } from "@/api/editing-planner"
import type { PresetVoiceItem } from "@/api/voices"
import { fetchPresetVoices } from "@/api/voices"
import { DEFAULT_COVER_SETTINGS } from "../constants"
import type { TitleSettings } from "../types"
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
}
export interface GenerateFormState {
/* 步骤 */
currentStep: number
setCurrentStep: (step: number | ((prev: number) => number)) => void
/* 模板 */
selectedTemplate: string
setSelectedTemplate: (id: string) => void
userTemplates: EditingTemplate[]
/* 素材 */
selectedMaterials: string[]
setSelectedMaterials: (ids: string[]) => void
materialMode: "manual" | "auto"
setMaterialMode: (mode: "manual" | "auto") => void
smartSelectedIds: string[]
setSmartSelectedIds: (ids: string[]) => void
/* 标题 */
titleSettings: TitleSettings
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
/* 封面 */
coverSettings: CoverConfig
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
/* 配音 */
selectedVoice: string
setSelectedVoice: (id: string) => void
voiceMode: "preset" | "custom" | "clone"
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
setSelectedClonedVoice: (id: string) => void
presetVoices: PresetVoiceItem[]
/* 克隆弹窗 */
cloneModalOpen: boolean
setCloneModalOpen: (open: boolean) => void
/* 生成数量 */
generateCount: number
setGenerateCount: (n: number) => void
/* 高级设置 */
videoRatio: string
duration: number
style: string
autoSubtitles: boolean
bgm: boolean
/* URL 参数 */
editPlanId: string | null
planConfigStr: string | null
/* 预览弹窗 */
previewVideo: GeneratedVideo | null
setPreviewVideo: (v: GeneratedVideo | null) => void
previewModalOpen: boolean
setPreviewModalOpen: (open: boolean) => void
}
export const useGenerateFormState = (): GenerateFormState => {
const [searchParams] = useSearchParams()
const editPlanId = searchParams.get("edit_plan_id")
const planConfigStr = searchParams.get("plan_config")
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板(从 API 加载) ── */
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery({
queryKey: ["generate-templates"],
queryFn: () => getEditingTemplates(),
staleTime: 60_000,
})
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [userTemplates, selectedTemplate])
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
/* ── 标题设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
/* ── 封面设置 ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */
useEffect(() => {
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
if (tpl?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: tpl.title_config!.ai_auto_select,
title: tpl.title_config!.content || prev.title,
position: tpl.title_config!.position || prev.position,
font: tpl.title_config!.font_preset || prev.font,
size: tpl.title_config!.font_size || prev.size,
color: tpl.title_config!.font_color || prev.color,
}))
}
if (tpl?.cover_config) {
setCoverSettings((prev) => ({
...prev,
enabled: tpl.cover_config!.enabled ?? prev.enabled,
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
}))
}
}, [selectedTemplate, userTemplates])
/* ── 配音 ── */
const [selectedVoice, setSelectedVoice] = useState("")
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
/* ── 预置音色 API ── */
const { data: presetVoicesData } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
/* ── 克隆声音弹窗 ── */
const [cloneModalOpen, setCloneModalOpen] = useState(false)
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1)
/* ── 高级设置(隐藏但保留) ── */
const [videoRatio] = useState("16:9")
const [duration] = useState(30)
const [style] = useState("business")
const [autoSubtitles] = useState(true)
const [bgm] = useState(true)
/* ── 预览弹窗 ── */
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
const [previewModalOpen, setPreviewModalOpen] = useState(false)
/** 解析 plan_config 并自动填充表单 */
useEffect(() => {
if (!planConfigStr) return
try {
const config = JSON.parse(planConfigStr) as {
title_config?: {
content?: string
ai_auto_select?: boolean
position?: string
font_preset?: string
font_size?: number
font_color?: string
}
subtitle_config?: { enabled?: boolean }
bgm_config?: { enabled?: boolean; music_id?: string }
mode?: string
total_duration?: number
segments?: Array<{ media_asset_id?: string; material_type?: string }>
}
if (config.title_config) {
const tc = config.title_config as TitleConfig
setTitleSettings((prev) => ({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: tc.position || prev.position,
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
}))
}
if (config.segments && config.segments.length > 0) {
const assetIds = config.segments
.map((s) => s.media_asset_id)
.filter((id): id is string => !!id)
if (assetIds.length > 0) {
setSelectedMaterials(assetIds)
}
}
} catch (err) {
console.warn("解析 plan_config 失败:", err)
}
}, [planConfigStr])
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
useEffect(() => {
if (!editPlanId || planConfigStr) return
const loadPlanConfig = async () => {
try {
const plan = await getEditPlan(editPlanId)
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
const cfg = plan.config
if (cfg?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: cfg.title_config!.position || prev.position,
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
}))
}
if (cfg?.cover_config) {
const cc = cfg.cover_config as CoverConfig
setCoverSettings((prev) => ({
...prev,
enabled: cc.enabled ?? prev.enabled,
mode: cc.mode || prev.mode,
frame_time: cc.frame_time ?? prev.frame_time,
upload_url: cc.upload_url || prev.upload_url,
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
}))
}
if (cfg?.asset_ids) {
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
}
} catch (err) {
console.warn("加载模板草稿配置失败:", err)
}
}
loadPlanConfig()
}, [editPlanId, planConfigStr])
return {
currentStep,
setCurrentStep,
selectedTemplate,
setSelectedTemplate,
userTemplates,
selectedMaterials,
setSelectedMaterials,
materialMode,
setMaterialMode,
smartSelectedIds,
setSmartSelectedIds,
titleSettings,
setTitleSettings,
coverSettings,
setCoverSettings,
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
cloneModalOpen,
setCloneModalOpen,
generateCount,
setGenerateCount,
videoRatio,
duration,
style,
autoSubtitles,
bgm,
editPlanId,
planConfigStr,
previewVideo,
setPreviewVideo,
previewModalOpen,
setPreviewModalOpen,
}
}
@@ -0,0 +1,198 @@
/**
* GeneratePage 表单状态管理
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
*/
import { useState } from "react"
import { useSearchParams } from "react-router-dom"
import type { GeneratedVideo } from "@/api/template-editor"
import type { EditingTemplate } from "@/api/editing-planner"
import type { CoverConfig } from "../../../editing-planner/types"
import type { PresetVoiceItem } from "@/api/voices"
import { DEFAULT_COVER_SETTINGS } from "../../constants"
import type { TitleSettings } from "../../types"
import { useTemplateSelection } from "./useTemplateSelection"
import { useTitleCoverSync } from "./useTitleCoverSync"
import { useVoiceState } from "./useVoiceState"
import { usePlanConfigLoader } from "./usePlanConfigLoader"
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
}
export interface GenerateFormState {
/* 步骤 */
currentStep: number
setCurrentStep: (step: number | ((prev: number) => number)) => void
/* 模板 */
selectedTemplate: string
setSelectedTemplate: (id: string) => void
userTemplates: EditingTemplate[]
/* 素材 */
selectedMaterials: string[]
setSelectedMaterials: (ids: string[]) => void
materialMode: "manual" | "auto"
setMaterialMode: (mode: "manual" | "auto") => void
smartSelectedIds: string[]
setSmartSelectedIds: (ids: string[]) => void
/* 标题 */
titleSettings: TitleSettings
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
/* 封面 */
coverSettings: CoverConfig
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
/* 配音 */
selectedVoice: string
setSelectedVoice: (id: string) => void
voiceMode: "preset" | "custom" | "clone"
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
setSelectedClonedVoice: (id: string) => void
presetVoices: PresetVoiceItem[]
/* 克隆弹窗 */
cloneModalOpen: boolean
setCloneModalOpen: (open: boolean) => void
/* 生成数量 */
generateCount: number
setGenerateCount: (n: number) => void
/* 高级设置 */
videoRatio: string
duration: number
style: string
autoSubtitles: boolean
bgm: boolean
/* URL 参数 */
editPlanId: string | null
planConfigStr: string | null
/* 预览弹窗 */
previewVideo: GeneratedVideo | null
setPreviewVideo: (v: GeneratedVideo | null) => void
previewModalOpen: boolean
setPreviewModalOpen: (open: boolean) => void
}
export const useGenerateFormState = (): GenerateFormState => {
const [searchParams] = useSearchParams()
const editPlanId = searchParams.get("edit_plan_id")
const planConfigStr = searchParams.get("plan_config")
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板选择 ── */
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
/* ── 标题设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
/* ── 封面设置 ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
/* ── 模板切换时同步标题/封面 ── */
useTitleCoverSync({
selectedTemplate,
userTemplates,
setTitleSettings,
setCoverSettings,
})
/* ── 配音状态 ── */
const {
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
} = useVoiceState()
/* ── 克隆声音弹窗 ── */
const [cloneModalOpen, setCloneModalOpen] = useState(false)
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1)
/* ── 高级设置(隐藏但保留) ── */
const [videoRatio] = useState("16:9")
const [duration] = useState(30)
const [style] = useState("business")
const [autoSubtitles] = useState(true)
const [bgm] = useState(true)
/* ── 预览弹窗 ── */
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
const [previewModalOpen, setPreviewModalOpen] = useState(false)
/* ── 从 URL / 编辑计划加载配置 ── */
usePlanConfigLoader({
editPlanId,
planConfigStr,
setTitleSettings,
setCoverSettings,
setSelectedMaterials,
})
return {
currentStep,
setCurrentStep,
selectedTemplate,
setSelectedTemplate,
userTemplates,
selectedMaterials,
setSelectedMaterials,
materialMode,
setMaterialMode,
smartSelectedIds,
setSmartSelectedIds,
titleSettings,
setTitleSettings,
coverSettings,
setCoverSettings,
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
cloneModalOpen,
setCloneModalOpen,
generateCount,
setGenerateCount,
videoRatio,
duration,
style,
autoSubtitles,
bgm,
editPlanId,
planConfigStr,
previewVideo,
setPreviewVideo,
previewModalOpen,
setPreviewModalOpen,
}
}
@@ -0,0 +1,110 @@
import { useEffect } from "react"
import type { CoverConfig } from "../../../editing-planner/types"
import type { TitleSettings } from "../../types"
import type { TitleConfig } from "@/api/template-editor"
import { getEditPlan } from "@/api/template-editor"
interface UsePlanConfigLoaderOptions {
editPlanId: string | null
planConfigStr: string | null
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
setSelectedMaterials: (ids: string[]) => void
}
/**
* 从 URL 参数或编辑计划 ID 加载表单配置
*/
export function usePlanConfigLoader({
editPlanId,
planConfigStr,
setTitleSettings,
setCoverSettings,
setSelectedMaterials,
}: UsePlanConfigLoaderOptions) {
/** 解析 plan_config 并自动填充表单 */
useEffect(() => {
if (!planConfigStr) return
try {
const config = JSON.parse(planConfigStr) as {
title_config?: {
content?: string
ai_auto_select?: boolean
position?: string
font_preset?: string
font_size?: number
font_color?: string
}
subtitle_config?: { enabled?: boolean }
bgm_config?: { enabled?: boolean; music_id?: string }
mode?: string
total_duration?: number
segments?: Array<{ media_asset_id?: string; material_type?: string }>
}
if (config.title_config) {
const tc = config.title_config as TitleConfig
setTitleSettings((prev: TitleSettings) => ({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: tc.position || prev.position,
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
}))
}
if (config.segments && config.segments.length > 0) {
const assetIds = config.segments
.map((s) => s.media_asset_id)
.filter((id): id is string => !!id)
if (assetIds.length > 0) {
setSelectedMaterials(assetIds)
}
}
} catch (err) {
console.warn("解析 plan_config 失败:", err)
}
}, [planConfigStr, setTitleSettings, setSelectedMaterials])
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
useEffect(() => {
if (!editPlanId || planConfigStr) return
const loadPlanConfig = async () => {
try {
const plan = await getEditPlan(editPlanId)
if (plan.name) setTitleSettings((prev: TitleSettings) => ({ ...prev, title: plan.name }))
const cfg = plan.config
if (cfg?.title_config) {
setTitleSettings((prev: TitleSettings) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: cfg.title_config!.position || prev.position,
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
}))
}
if (cfg?.cover_config) {
const cc = cfg.cover_config as CoverConfig
setCoverSettings((prev: CoverConfig) => ({
...prev,
enabled: cc.enabled ?? prev.enabled,
mode: cc.mode || prev.mode,
frame_time: cc.frame_time ?? prev.frame_time,
upload_url: cc.upload_url || prev.upload_url,
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
}))
}
if (cfg?.asset_ids) {
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
}
} catch (err) {
console.warn("加载模板草稿配置失败:", err)
}
}
loadPlanConfig()
}, [editPlanId, planConfigStr, setTitleSettings, setCoverSettings, setSelectedMaterials])
}
@@ -0,0 +1,22 @@
import { useState, useEffect } from "react"
import { useQuery } from "@tanstack/react-query"
import { getEditingTemplates } from "@/api/editing-planner"
import type { EditingTemplate } from "@/api/editing-planner"
export function useTemplateSelection() {
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery<EditingTemplate[]>({
queryKey: ["generate-templates"],
queryFn: () => getEditingTemplates(),
staleTime: 60_000,
})
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [userTemplates, selectedTemplate])
return { selectedTemplate, setSelectedTemplate, userTemplates }
}
@@ -0,0 +1,47 @@
import { useEffect } from "react"
import type { TitleSettings } from "../../types"
import type { CoverConfig } from "../../../editing-planner/types"
import type { EditingTemplate } from "@/api/editing-planner"
interface UseTitleCoverSyncOptions {
selectedTemplate: string
userTemplates: EditingTemplate[]
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
}
/**
* 当选中模板变化时,自动同步标题和封面配置
*/
export function useTitleCoverSync({
selectedTemplate,
userTemplates,
setTitleSettings,
setCoverSettings,
}: UseTitleCoverSyncOptions) {
useEffect(() => {
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
if (tpl?.title_config) {
setTitleSettings((prev: TitleSettings) => ({
...prev,
aiAutoSelect: tpl.title_config!.ai_auto_select,
title: tpl.title_config!.content || prev.title,
position: tpl.title_config!.position || prev.position,
font: tpl.title_config!.font_preset || prev.font,
size: tpl.title_config!.font_size || prev.size,
color: tpl.title_config!.font_color || prev.color,
}))
}
if (tpl?.cover_config) {
setCoverSettings((prev: CoverConfig) => ({
...prev,
enabled: tpl.cover_config!.enabled ?? prev.enabled,
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
}))
}
}, [selectedTemplate, userTemplates, setTitleSettings, setCoverSettings])
}
@@ -0,0 +1,30 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import type { PresetVoiceItem } from "@/api/voices"
import { fetchPresetVoices } from "@/api/voices"
export function useVoiceState() {
const [selectedVoice, setSelectedVoice] = useState("")
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
/* 预置音色 API */
const { data: presetVoicesData } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
return {
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
}
}
@@ -9,27 +9,11 @@ import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-ed
import type { UseGenerateVideoProps } from "./generate-video/types"
import { getGenerationPhase } from "./generate-video/phase"
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
import { buildVoiceConfig } from "./generate-video/voiceConfig"
import { buildEditPlanPayload, validateGenerateInputs } from "./generate-video/buildPayload"
import { extractBackendError, translateError } from "./generate-video/errorUtils"
export function useGenerateVideo(props: UseGenerateVideoProps) {
const {
titleSettings,
selectedTemplate,
selectedMaterials,
materialMode,
smartSelectedIds,
voiceMode,
selectedVoice,
selectedClonedVoice,
coverSettings,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
generateCount,
} = props
const { selectedTemplate } = props
/* ── 生成状态 ── */
const [generating, setGenerating] = useState(false)
@@ -58,16 +42,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
/* ── 生成视频 ── */
const generate = useCallback(async () => {
if (!titleSettings.title.trim()) {
message.warning("请先选择或输入标题")
return
}
if (materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材")
return
}
if (voiceMode === "clone" && !selectedClonedVoice) {
message.warning("请先选择一个克隆音色")
const errorMsg = validateGenerateInputs(props)
if (errorMsg) {
message.warning(errorMsg)
return
}
@@ -78,41 +55,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
clearTimer()
try {
const voiceConfig = buildVoiceConfig({
voiceMode,
selectedVoice,
selectedClonedVoice,
})
const payload = buildEditPlanPayload(props)
// 获取或创建草稿
await getEditPlan(selectedTemplate)
// 更新草稿内容 + 切换到 editing 状态
await updateEditPlan(selectedTemplate, {
name: titleSettings.title.trim(),
config: {
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
},
cover_config: coverSettings,
...voiceConfig,
ratio: videoRatio,
style,
duration,
auto_subtitles: autoSubtitles,
bgm,
generate_count: generateCount,
material_mode: materialMode,
},
total_duration: duration,
status: "editing",
})
await updateEditPlan(selectedTemplate, payload)
await generateEditPlan(selectedTemplate)
startPolling()
@@ -125,25 +74,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerateError(finalMsg)
message.error(finalMsg)
}
}, [
titleSettings,
selectedMaterials,
selectedVoice,
voiceMode,
selectedClonedVoice,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
selectedTemplate,
generateCount,
materialMode,
coverSettings,
smartSelectedIds,
clearTimer,
startPolling,
])
}, [props, selectedTemplate, clearTimer, startPolling])
/* 重新生成(失败后重试) */
const retry = useCallback(() => {
+24 -43
View File
@@ -2,15 +2,15 @@
* Step 5 配音选择 Hook
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
*/
import { useCallback } from "react"
import type { VoiceClone } from "@/api/voice-clone"
import { message } from "antd"
import type { VoiceClone } from "@/api/voice-clone"
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
import { formatDuration } from "../utils/formatDuration"
import { useVoiceAudio } from "./step5-voice/useVoiceAudio"
import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend"
import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis"
import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary"
import { useVoiceModeSelection } from "./step5-voice/useVoiceModeSelection"
interface UseStep5VoiceProps {
selectedVoice: string
@@ -81,48 +81,29 @@ export function useStep5Voice({
handleAddTagInModal,
} = useSaveToLibrary(completedTtsJobId, resetTtsState)
/* ── 推荐音色选择 ── */
const handleSelectRecommendedVoice = useCallback(
(voiceId: string) => {
onVoiceModeChange("preset")
onSelectedVoiceChange(voiceId)
},
[onVoiceModeChange, onSelectedVoiceChange],
)
const {
handleSelectRecommendedVoice,
handleSelectPresetVoice,
handleSelectCloneVoice,
handleSelectClonedVoice,
handleOpenCloneModal,
handleCloneSuccess: rawCloneSuccess,
} = useVoiceModeSelection({
selectedVoice,
onSelectedVoiceChange,
voiceMode,
onVoiceModeChange,
selectedClonedVoice,
onSelectedClonedVoiceChange,
addClone,
cloneModalOpen,
onCloneModalOpenChange,
})
/* ── 克隆成功回调 ── */
const handleCloneSuccess = useCallback(
(voice: VoiceClone) => {
addClone(voice)
onCloneModalOpenChange(false)
message.success("音色克隆成功!")
},
[addClone, onCloneModalOpenChange],
)
/* ── 预设音色选择操作 ── */
const handleSelectPresetVoice = useCallback(
(voiceId: string) => {
onVoiceModeChange("preset")
onSelectedVoiceChange(voiceId)
},
[onVoiceModeChange, onSelectedVoiceChange],
)
const handleSelectCloneVoice = useCallback(() => {
onVoiceModeChange("clone")
}, [onVoiceModeChange])
const handleSelectClonedVoice = useCallback(
(voiceId: string) => {
onSelectedClonedVoiceChange(voiceId)
},
[onSelectedClonedVoiceChange],
)
const handleOpenCloneModal = useCallback(() => {
onCloneModalOpenChange(true)
}, [onCloneModalOpenChange])
const handleCloneSuccess = (voice: VoiceClone) => {
rawCloneSuccess(voice)
message.success("音色克隆成功!")
}
return {
// 数据
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useCallback } from "react"
import React, { useEffect } from "react"
import {
VideoCameraOutlined,
PlayCircleOutlined,
@@ -11,6 +11,7 @@ import {
import { Button } from "@/components/ui"
import type { ProductItem } from "../types"
import { formatTime, formatSize } from "../utils"
import { useVideoPlayer } from "../hooks/useVideoPlayer"
interface VideoPlayerProps {
product: ProductItem
@@ -27,52 +28,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onShare,
onViewDetail,
}) => {
const videoRef = useRef<HTMLVideoElement>(null)
const progressRef = useRef<HTMLDivElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(product.duration)
const {
videoRef,
progressRef,
isPlaying,
currentTime,
duration,
progress,
togglePlay,
handleSeek,
} = useVideoPlayer()
const hasVideo = !!product.videoUrl
/** 播放/暂停 */
const handlePlayPause = useCallback(() => {
const video = videoRef.current
if (!video) return
if (isPlaying) {
video.pause()
} else {
video.play().catch(() => {})
}
setIsPlaying(!isPlaying)
}, [isPlaying])
/** 视频事件监听 */
useEffect(() => {
const video = videoRef.current
if (!video) return
const onTime = () => setCurrentTime(video.currentTime)
const onDur = () => setDuration(video.duration || product.duration)
const onEnd = () => setIsPlaying(false)
video.addEventListener("timeupdate", onTime)
video.addEventListener("loadedmetadata", onDur)
video.addEventListener("ended", onEnd)
return () => {
video.removeEventListener("timeupdate", onTime)
video.removeEventListener("loadedmetadata", onDur)
video.removeEventListener("ended", onEnd)
}
}, [product.duration])
/** 进度条点击 */
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const newTime = percent * duration
setCurrentTime(newTime)
if (videoRef.current) videoRef.current.currentTime = newTime
}
const displayDuration = duration || product.duration
/** ESC 关闭 */
useEffect(() => {
@@ -83,8 +51,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
return () => window.removeEventListener("keydown", handleKey)
}, [onClose])
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="xx-player-overlay" onClick={onClose}>
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
@@ -114,7 +80,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
{/* 播放/暂停按钮 */}
<button className="xx-player-play-btn" onClick={handlePlayPause}>
<button className="xx-player-play-btn" onClick={togglePlay}>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
@@ -125,12 +91,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
{/* 进度条 */}
<div className="xx-player-progress-wrap">
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
<div ref={progressRef} className="xx-player-progress" onClick={handleSeek}>
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="xx-player-time">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
<span>{formatTime(displayDuration)}</span>
</div>
</div>
</div>
@@ -0,0 +1,30 @@
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { deleteProduct } from "@/api/products"
interface UseBatchDeleteOptions {
selectedIds: Set<string>
clearSelection: () => void
}
export function useBatchDelete({ selectedIds, clearSelection }: UseBatchDeleteOptions) {
const queryClient = useQueryClient()
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds)
let successCount = 0
for (const id of ids) {
try {
await deleteProduct(id)
successCount++
} catch {
// 忽略单个失败
}
}
queryClient.invalidateQueries({ queryKey: ["products"] })
clearSelection()
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
}
return { handleBatchDelete }
}
@@ -0,0 +1,53 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchDownload, getBatchDownloadStatus } from "@/api/products"
interface UseBatchDownloadOptions {
selectedIds: Set<string>
clearSelection: () => void
}
export function useBatchDownload({ selectedIds, clearSelection }: UseBatchDownloadOptions) {
const [batchDownloading, setBatchDownloading] = useState(false)
const handleBatchDownload = useCallback(async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
setBatchDownloading(true)
try {
const { job_id } = await batchDownload(ids)
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
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} 个视频`)
clearSelection()
} else if (status.status === "failed") {
message.error("批量下载失败,请重试")
} else {
await poll()
}
}
await poll()
} catch {
message.error("发起批量下载失败")
} finally {
setBatchDownloading(false)
}
}, [selectedIds, clearSelection])
return { batchDownloading, handleBatchDownload }
}
@@ -0,0 +1,37 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { deleteProduct, updateReviewStatus, type ReviewStatus } from "@/api/products"
export function useProductMutations() {
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: deleteProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
message.success("已删除")
},
onError: () => {
message.error("删除失败")
},
})
const reviewMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
updateReviewStatus(id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
message.success("复核状态已更新")
},
onError: () => {
message.error("更新复核状态失败")
},
})
return {
deleteMutation,
reviewMutation,
isDeleting: deleteMutation.isPending,
isUpdatingReview: reviewMutation.isPending,
}
}
+12 -104
View File
@@ -1,17 +1,11 @@
import { useState } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "react-router-dom"
import { message } from "antd"
import {
deleteProduct,
getProductDownloadUrl,
updateReviewStatus,
batchDownload,
getBatchDownloadStatus,
type ReviewStatus,
} from "@/api/products"
import { getProductDownloadUrl } from "@/api/products"
import type { ProductItem } from "../types"
import { getNextReviewStatus } from "../utils"
import { useBatchDownload } from "./product-actions/useBatchDownload"
import { useBatchDelete } from "./product-actions/useBatchDelete"
import { useProductMutations } from "./product-actions/useProductMutations"
interface UseProductActionsOptions {
selectedIds: Set<string>
@@ -26,35 +20,17 @@ export const useProductActions = ({
products,
setPlayingProduct,
}: UseProductActionsOptions) => {
const queryClient = useQueryClient()
const navigate = useNavigate()
/* ── 删除 mutation ── */
const deleteMutation = useMutation({
mutationFn: deleteProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
message.success("已删除")
},
onError: () => {
message.error("删除失败")
},
const { deleteMutation, reviewMutation, isDeleting, isUpdatingReview } = useProductMutations()
const { batchDownloading, handleBatchDownload } = useBatchDownload({
selectedIds,
clearSelection,
})
/* ── 复核状态 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 { handleBatchDelete } = useBatchDelete({ selectedIds, clearSelection })
/* 下载 — 调用真实 API 获取下载链接 */
const handleDownload = async (product: ProductItem) => {
if (product.status !== "completed") return
try {
@@ -69,7 +45,6 @@ export const useProductActions = ({
}
}
/* 分享 */
const handleShare = (product: ProductItem) => {
if (product.status !== "completed") return
const link = `${window.location.origin}/share/${product.id}`
@@ -79,109 +54,42 @@ export const useProductActions = ({
)
}
/* 查看详情 — 跳转到产品详情页 */
const handleViewDetail = (product: ProductItem) => {
setPlayingProduct(null)
navigate(`/app/products/${product.id}`)
}
/* 删除 */
const handleDelete = (id: string) => {
deleteMutation.mutate(id)
}
/* 发布 — TODO: 后端发布 API 待实现 */
const handlePublish = (_product: ProductItem) => {
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 })
}
/* 批量下载 */
const [batchDownloading, setBatchDownloading] = useState(false)
const handleBatchDownload = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
setBatchDownloading(true)
try {
const { job_id } = await batchDownload(ids)
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
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} 个视频`)
clearSelection()
} else if (status.status === "failed") {
message.error("批量下载失败,请重试")
} else {
await poll()
}
}
await poll()
} catch {
message.error("发起批量下载失败")
} finally {
setBatchDownloading(false)
}
}
/* 批量删除 */
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds)
let successCount = 0
for (const id of ids) {
try {
await deleteProduct(id)
successCount++
} catch {
// 忽略单个失败
}
}
queryClient.invalidateQueries({ queryKey: ["products"] })
clearSelection()
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
}
/* 批量发布 */
const handleBatchPublish = () => {
message.info("批量发布功能待后端 API 补齐")
clearSelection()
}
return {
// 单个操作
handleDownload,
handleShare,
handleViewDetail,
handleDelete,
handlePublish,
handleReviewStatusChange,
// 批量操作
handleBatchDownload,
handleBatchDelete,
handleBatchPublish,
batchDownloading,
// mutation 状态
isDeleting: deleteMutation.isPending,
isUpdatingReview: reviewMutation.isPending,
isDeleting,
isUpdatingReview,
}
}
@@ -0,0 +1,93 @@
import { useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
import { mapApiProduct } from "../../utils"
import { useProductFiltering } from "./useProductFiltering"
import { useBatchSelection } from "./useBatchSelection"
export type { Filters } from "./useProductFiltering"
export const useProductList = () => {
/* ── 获取成品列表 ── */
const {
data: apiProducts = [],
isLoading,
isError,
error,
refetch,
} = useQuery<ApiProductItem[], Error>({
queryKey: ["products"],
queryFn: () => getProducts(),
staleTime: 30_000,
})
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
const products = useMemo(
() =>
(Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct).sort((a, b) => {
if (!a.date || a.date === "—") return 1
if (!b.date || b.date === "—") return -1
return new Date(b.date).getTime() - new Date(a.date).getTime()
}),
[apiProducts],
)
/* 筛选 */
const {
searchText,
setSearchText,
filterStatus,
setFilterStatus,
filterTime,
setFilterTime,
filterDuration,
setFilterDuration,
filterProject,
setFilterProject,
filterReviewStatus,
setFilterReviewStatus,
filteredProducts,
projectOptions,
} = useProductFiltering(products)
/* 批量选择 */
const {
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
} = useBatchSelection(filteredProducts)
return {
// 数据
products,
filteredProducts,
isLoading,
isError,
error,
refetch,
// 筛选
searchText,
setSearchText,
filterStatus,
setFilterStatus,
filterTime,
setFilterTime,
filterDuration,
setFilterDuration,
filterProject,
setFilterProject,
filterReviewStatus,
setFilterReviewStatus,
projectOptions,
// 批量选择
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
}
}
@@ -0,0 +1,52 @@
import { useState, useCallback } from "react"
import type { ProductItem } from "../../types"
export const useBatchSelection = (filteredProducts: ProductItem[]) => {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const batchMode = selectedIds.size > 0
const allSelected =
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
const handleSelectAll = useCallback(() => {
if (allSelected) {
// 仅取消选中当前可见的项,保留筛选外的选中状态
setSelectedIds((prev) => {
const next = new Set(prev)
filteredProducts.forEach((p) => next.delete(p.id))
return next
})
} else {
// 选中所有当前可见项
setSelectedIds((prev) => {
const next = new Set(prev)
filteredProducts.forEach((p) => next.add(p.id))
return next
})
}
}, [allSelected, filteredProducts])
const handleToggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
return {
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
}
}
@@ -1,8 +1,5 @@
import { useMemo, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
import type { ProductItem } from "../types"
import { mapApiProduct } from "../utils"
import type { ProductItem } from "../../types"
/** 筛选选项类型 */
export interface Filters {
@@ -27,32 +24,7 @@ const getProjectOptions = (products: ProductItem[]) =>
label: name as string,
}))
export const useProductList = () => {
/* ── 获取成品列表 ── */
const {
data: apiProducts = [],
isLoading,
isError,
error,
refetch,
} = useQuery<ApiProductItem[], Error>({
queryKey: ["products"],
queryFn: () => getProducts(),
staleTime: 30_000,
})
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
const products = useMemo(() => {
const list = (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct)
// 按创建时间倒序(最新的在最前面),无时间的排最后
return list.sort((a, b) => {
if (!a.date || a.date === "—") return 1
if (!b.date || b.date === "—") return -1
return new Date(b.date).getTime() - new Date(a.date).getTime()
})
}, [apiProducts])
/* 筛选 */
export const useProductFiltering = (products: ProductItem[]) => {
const [searchText, setSearchText] = useState("")
const [filterStatus, setFilterStatus] = useState<string>("all")
const [filterTime, setFilterTime] = useState<string>("all")
@@ -60,12 +32,6 @@ export const useProductList = () => {
const [filterProject, setFilterProject] = useState<string>("all")
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all")
/* 批量选择 */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
/* 派生数据 */
const batchMode = selectedIds.size > 0
const filteredProducts = useMemo(() => {
let list = products
@@ -142,42 +108,7 @@ export const useProductList = () => {
const projectOptions = useMemo(() => getProjectOptions(products), [products])
/* 全选 */
const allSelected =
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
const handleSelectAll = () => {
if (allSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredProducts.map((p) => p.id)))
}
}
/* 切换单个选择 */
const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const clearSelection = () => setSelectedIds(new Set())
return {
// 数据
products,
filteredProducts,
isLoading,
isError,
error,
refetch,
// 筛选
searchText,
setSearchText,
filterStatus,
@@ -190,13 +121,7 @@ export const useProductList = () => {
setFilterProject,
filterReviewStatus,
setFilterReviewStatus,
filteredProducts,
projectOptions,
// 批量选择
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
}
}
+5 -123
View File
@@ -1,11 +1,8 @@
import React from "react"
import { Table, Tag, Button, Popconfirm, Tooltip } from "antd"
import { RedoOutlined, InfoCircleOutlined, ClockCircleOutlined } from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import type { TaskItem, TaskStatus } from "@/api/tasks"
import { STATUS_CONFIG, TYPE_LABELS } from "../constants"
import { formatDuration, formatTime } from "../utils"
import { Table } from "antd"
import type { TaskItem } from "@/api/tasks"
import { TaskErrorDetail } from "./TaskErrorDetail"
import { useTaskTableColumns, TaskEmptyState } from "./task-table"
interface TaskTableProps {
dataSource: TaskItem[]
@@ -24,7 +21,6 @@ interface TaskTableProps {
/**
* 任务列表表格
* 含列定义、分页、展开行
*/
export const TaskTable: React.FC<TaskTableProps> = ({
dataSource,
@@ -40,116 +36,7 @@ export const TaskTable: React.FC<TaskTableProps> = ({
onRetry,
onViewDetail,
}) => {
// 表格列定义
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={() => onRetry(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryLoading}
className="task-retry-btn"
>
</Button>
</Popconfirm>
)
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => onViewDetail(record)}
>
</Button>
)
}
return <span className="task-action-placeholder">-</span>
},
},
]
const columns = useTaskTableColumns({ retryLoading, onRetry, onViewDetail })
return (
<Table
@@ -178,12 +65,7 @@ export const TaskTable: React.FC<TaskTableProps> = ({
scroll={{ x: 800 }}
className="task-table"
locale={{
emptyText: (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
),
emptyText: <TaskEmptyState />,
}}
/>
)
@@ -0,0 +1,9 @@
import React from "react"
import { ClockCircleOutlined } from "@ant-design/icons"
export const TaskEmptyState: React.FC = () => (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
)
+2
View File
@@ -0,0 +1,2 @@
export { useTaskTableColumns } from "./useTaskTableColumns"
export { TaskEmptyState } from "./TaskEmptyState"
@@ -0,0 +1,128 @@
import { Tag, Button, Popconfirm, Tooltip } from "antd"
import { RedoOutlined, InfoCircleOutlined } from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import type { TaskItem, TaskStatus } from "@/api/tasks"
import { STATUS_CONFIG, TYPE_LABELS } from "../../constants"
import { formatDuration, formatTime } from "../../utils"
interface UseTaskTableColumnsOptions {
retryLoading: boolean
onRetry: (id: string) => void
onViewDetail: (record: TaskItem) => void
}
export function useTaskTableColumns({
retryLoading,
onRetry,
onViewDetail,
}: UseTaskTableColumnsOptions): ColumnsType<TaskItem> {
return [
{
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={() => onRetry(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryLoading}
className="task-retry-btn"
>
</Button>
</Popconfirm>
)
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => onViewDetail(record)}
>
</Button>
)
}
return <span className="task-action-placeholder">-</span>
},
},
]
}
@@ -1,174 +0,0 @@
import { useMemo, useState, useCallback } from "react"
import { message } from "antd"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getTitles, createTitle, updateTitle, deleteTitle } from "@/api/titles"
import type { TitleData, CategoryItem, Frequency } from "../types/titleLibrary"
import { toTitleData, copyToClipboard } from "../utils/titleLibrary"
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../constants/titleLibrary"
export const useTitleLibrary = () => {
const queryClient = useQueryClient()
/* 分类 */
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
/* 数据获取 */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
})
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
/* 动态派生分类 */
const categories: CategoryItem[] = useMemo(() => {
const cats = new Map<string, number>()
apiTitles.forEach((t) => {
const cat = t.category || "未分类"
cats.set(cat, (cats.get(cat) || 0) + 1)
})
return [
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
]
}, [apiTitles])
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("创建标题失败"),
})
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("更新标题失败"),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("删除标题失败"),
})
/* 筛选状态 */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterIndustry, setFilterIndustry] = useState<string>("all")
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
/* 派生:筛选后的标题列表 */
const activeCategory = categories.find((c) => c.id === activeCatId)
const filteredTitles = useMemo(() => {
let list = titles
/* 按分类过滤 */
if (activeCatId !== ALL_CATEGORY_ID) {
const catName = activeCategory?.name || ""
if (catName) {
list = list.filter((t) => t.category === catName)
}
}
/* 按类型筛选 */
if (filterType !== "all") {
list = list.filter((t) => t.type === filterType)
}
/* 按行业筛选 */
if (filterIndustry !== "all") {
list = list.filter((t) => t.industry === filterIndustry)
}
/* 按使用频率筛选 */
if (filterFrequency !== "all") {
switch (filterFrequency) {
case "high":
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
break
case "medium":
list = list.filter(
(t) =>
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
t.usageCount < FREQUENCY_THRESHOLDS.high,
)
break
case "low":
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
break
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((t) => t.content.toLowerCase().includes(q))
}
return list
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
/* 操作:收藏 */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线")
}, [])
/* 操作:复制 */
const handleCopy = useCallback(async (title: TitleData) => {
const ok = await copyToClipboard(title.content)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 操作:删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id)
message.success("标题已删除")
},
[deleteMutation],
)
return {
/* 状态 */
titles,
categories,
activeCatId,
activeCategory,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
/* mutations */
createMutation,
updateMutation,
deleteMutation,
/* setters */
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
/* handlers */
handleToggleFavorite,
handleCopy,
handleDelete,
}
}
@@ -0,0 +1,55 @@
import { useTitleFilters } from "./useTitleFilters"
import { useTitleMutations } from "./useTitleMutations"
import { useTitleData } from "./useTitleData"
import { useTitleActions } from "./useTitleActions"
export const useTitleLibrary = () => {
/* 数据获取与派生 */
const { titles, categories, activeCatId, activeCategory, setActiveCatId } = useTitleData()
/* 筛选 */
const {
searchText,
filterType,
filterIndustry,
filterFrequency,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
filteredTitles,
} = useTitleFilters(titles, categories, activeCatId, activeCategory)
/* CRUD mutations */
const { createMutation, updateMutation, deleteMutation } = useTitleMutations()
/* 操作 handlers */
const { handleToggleFavorite, handleCopy, handleDelete } = useTitleActions(deleteMutation)
return {
/* 状态 */
titles,
categories,
activeCatId,
activeCategory,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
/* mutations */
createMutation,
updateMutation,
deleteMutation,
/* setters */
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
/* handlers */
handleToggleFavorite,
handleCopy,
handleDelete,
}
}
@@ -0,0 +1,34 @@
import { useCallback } from "react"
import { message } from "antd"
import type { UseMutationResult } from "@tanstack/react-query"
import type { TitleData } from "../../types/titleLibrary"
import { copyToClipboard } from "../../utils/titleLibrary"
export const useTitleActions = (
deleteMutation: UseMutationResult<void, Error, string, unknown>,
) => {
/* 操作:收藏 */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线")
}, [])
/* 操作:复制 */
const handleCopy = useCallback(async (title: TitleData) => {
const ok = await copyToClipboard(title.content)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 操作:删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id)
},
[deleteMutation],
)
return { handleToggleFavorite, handleCopy, handleDelete }
}
@@ -0,0 +1,47 @@
import { useMemo, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { getTitles } from "@/api/titles"
import type { TitleData, CategoryItem } from "../../types/titleLibrary"
import { toTitleData } from "../../utils/titleLibrary"
import { ALL_CATEGORY_ID } from "../../constants/titleLibrary"
export const useTitleData = () => {
/* 分类 */
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
/* 数据获取 */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
})
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
/* 动态派生分类 */
const categories: CategoryItem[] = useMemo(() => {
const cats = new Map<string, number>()
apiTitles.forEach((t) => {
const cat = t.category || "未分类"
cats.set(cat, (cats.get(cat) || 0) + 1)
})
return [
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
]
}, [apiTitles])
const activeCategory = categories.find((c) => c.id === activeCatId)
return {
titles,
categories,
activeCatId,
activeCategory,
setActiveCatId,
}
}
@@ -0,0 +1,77 @@
import { useMemo, useState } from "react"
import type { TitleData, CategoryItem, Frequency } from "../../types/titleLibrary"
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../../constants/titleLibrary"
export const useTitleFilters = (
titles: TitleData[],
_categories: CategoryItem[],
activeCatId: string,
activeCategory: CategoryItem | undefined,
) => {
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterIndustry, setFilterIndustry] = useState<string>("all")
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
/* 派生:筛选后的标题列表 */
const filteredTitles = useMemo(() => {
let list = titles
/* 按分类过滤 */
if (activeCatId !== ALL_CATEGORY_ID) {
const catName = activeCategory?.name || ""
if (catName) {
list = list.filter((t) => t.category === catName)
}
}
/* 按类型筛选 */
if (filterType !== "all") {
list = list.filter((t) => t.type === filterType)
}
/* 按行业筛选 */
if (filterIndustry !== "all") {
list = list.filter((t) => t.industry === filterIndustry)
}
/* 按使用频率筛选 */
if (filterFrequency !== "all") {
switch (filterFrequency) {
case "high":
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
break
case "medium":
list = list.filter(
(t) =>
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
t.usageCount < FREQUENCY_THRESHOLDS.high,
)
break
case "low":
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
break
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((t) => t.content.toLowerCase().includes(q))
}
return list
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
return {
searchText,
setSearchText,
filterType,
setFilterType,
filterIndustry,
setFilterIndustry,
filterFrequency,
setFilterFrequency,
filteredTitles,
}
}
@@ -0,0 +1,34 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { createTitle, updateTitle, deleteTitle } from "@/api/titles"
export const useTitleMutations = () => {
const queryClient = useQueryClient()
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("创建标题失败"),
})
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("更新标题失败"),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
message.success("标题已删除")
},
onError: () => message.error("删除标题失败"),
})
return { createMutation, updateMutation, deleteMutation }
}
@@ -1,4 +1,4 @@
import { useRef, useCallback } from "react"
import { useRef, useCallback, useEffect } from "react"
interface UseRowProgressOptions {
duration: number
@@ -7,6 +7,22 @@ interface UseRowProgressOptions {
export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
const progressRef = useRef<HTMLDivElement>(null)
const listenersRef = useRef<{ move: ((e: MouseEvent) => void) | null; up: (() => void) | null }>({
move: null,
up: null,
})
const cleanupListeners = useCallback(() => {
const { move, up } = listenersRef.current
if (move) {
document.removeEventListener("mousemove", move)
listenersRef.current.move = null
}
if (up) {
document.removeEventListener("mouseup", up)
listenersRef.current.up = null
}
}, [])
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@@ -16,6 +32,7 @@ export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
if (rect.width <= 0) return
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * duration)
}
@@ -24,15 +41,26 @@ export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
cleanupListeners()
}
// 先清理旧的,再添加新的
cleanupListeners()
listenersRef.current.move = handleMove
listenersRef.current.up = handleUp
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
},
[duration, onSeek],
[duration, onSeek, cleanupListeners],
)
// 组件卸载时清理事件监听器
useEffect(() => {
return () => {
cleanupListeners()
}
}, [cleanupListeners])
return { progressRef, handleMouseDown }
}
@@ -36,6 +36,10 @@ import "@/pages/assets/hooks/useLibraryManagement"
import "@/pages/assets/hooks/useAssetUpload"
import "@/pages/assets/hooks/useAssetSelection"
import "@/pages/assets/hooks/useAssetOperations"
import "@/pages/assets/hooks/asset-operations/batch/useBatchDelete"
import "@/pages/assets/hooks/asset-operations/batch/useBatchTag"
import "@/pages/assets/hooks/asset-operations/batch/useBatchClassify"
import "@/pages/assets/hooks/asset-operations/batch/useBatchMark"
describe("AssetLibrary module smoke test", () => {
it("should load all asset modules", () => {
@@ -65,6 +65,9 @@ import "@/pages/editing-planner/components/filter/FilterPresetGrid"
import "@/pages/editing-planner/components/filter/FilterManualAdjust"
import "@/pages/editing-planner/components/intro-outro/IntroOutroBlock"
import "@/pages/editing-planner/components/subtitle-style/SubtitlePreview"
import "@/pages/editing-planner/components/subtitle-style/SubtitleModeSwitch"
import "@/pages/editing-planner/components/subtitle-style/SubtitlePositionSelector"
import "@/pages/editing-planner/components/subtitle-style/SubtitleEffectButtons"
import "@/pages/editing-planner/components/tts/VoiceSelector"
import "@/pages/editing-planner/components/tts/TtsSlider"
import "@/pages/editing-planner/components/watermark/WatermarkTypeTabs"
@@ -38,7 +38,13 @@ describe("GeneratePage module smoke test", () => {
})
import "@/pages/generate/hooks/useGenerateVideo"
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
import "@/pages/generate/hooks/useGenerateFormState"
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
import "@/pages/generate/hooks/useGenerateFormState/useTitleCoverSync"
import "@/pages/generate/hooks/useGenerateFormState/useVoiceState"
import "@/pages/generate/hooks/useGenerateFormState/usePlanConfigLoader"
import "@/pages/generate/hooks/generate-video/types"
import "@/pages/generate/hooks/generate-video/phase"
import "@/pages/generate/hooks/generate-video/voiceConfig"
import "@/pages/generate/hooks/generate-video/errorUtils"
import "@/pages/generate/hooks/generate-video/buildPayload"
@@ -8,14 +8,14 @@ import type {
UseGenerateVideoProps,
GenerationPhase,
} from "@/pages/generate/hooks/generate-video/types"
import { getNextPhase, PHASE_ORDER } from "@/pages/generate/hooks/generate-video/phase"
import { extractErrorMessage } from "@/pages/generate/hooks/generate-video/errorUtils"
import { getDefaultVoiceConfig } from "@/pages/generate/hooks/generate-video/voiceConfig"
import { getGenerationPhase } from "@/pages/generate/hooks/generate-video/phase"
import { extractBackendError } from "@/pages/generate/hooks/generate-video/errorUtils"
import { buildVoiceConfig } from "@/pages/generate/hooks/generate-video/voiceConfig"
describe("generate-video module smoke test", () => {
it("should load all generate-video modules", () => {
expect(PHASE_ORDER.length).toBeGreaterThan(0)
expect(typeof extractErrorMessage).toBe("function")
expect(typeof getDefaultVoiceConfig).toBe("function")
expect(typeof getGenerationPhase).toBe("function")
expect(typeof extractBackendError).toBe("function")
expect(typeof buildVoiceConfig).toBe("function")
})
})
@@ -21,7 +21,10 @@ import "@/pages/products/components/VideoPlayer"
// Hooks
import "@/pages/products/hooks/useProductList"
import "@/pages/products/hooks/useProductList/useProductFiltering"
import "@/pages/products/hooks/useProductList/useBatchSelection"
import "@/pages/products/hooks/useProductActions"
import "@/pages/products/hooks/useVideoPlayer"
describe("ProductLibrary module smoke test", () => {
it("should load all product modules", () => {
@@ -0,0 +1,17 @@
/**
* Tasks 模块 smoke test
* 建立依赖链,确保 vitest related 能匹配到 tasks 目录下的改动
*/
import { describe, it, expect } from "vitest"
import "@/pages/tasks/components/TaskTable"
import "@/pages/tasks/components/task-table/useTaskTableColumns"
import "@/pages/tasks/components/task-table/TaskEmptyState"
import "@/pages/tasks/components/TaskFilterBar"
import "@/pages/tasks/components/TaskErrorDetail"
describe("Tasks module smoke test", () => {
it("should load all task modules", () => {
expect(true).toBe(true)
})
})
@@ -0,0 +1,29 @@
/**
* TitleLibrary 模块 smoke test
* 建立完整依赖链,确保 vitest related 模式能匹配到
* titles 目录下所有文件的改动
*/
import { describe, it, expect } from "vitest"
// 主组件
import "@/pages/titles/TitleLibrary"
// Hooks
import "@/pages/titles/hooks/useTitleLibrary"
import "@/pages/titles/hooks/useTitleLibrary/useTitleData"
import "@/pages/titles/hooks/useTitleLibrary/useTitleFilters"
import "@/pages/titles/hooks/useTitleLibrary/useTitleMutations"
import "@/pages/titles/hooks/useTitleLibrary/useTitleActions"
// 类型与常量
import "@/pages/titles/types/titleLibrary"
import "@/pages/titles/constants/titleLibrary"
// 工具函数
import "@/pages/titles/utils/titleLibrary"
describe("TitleLibrary module smoke test", () => {
it("should load all title modules", () => {
expect(true).toBe(true)
})
})
@@ -20,6 +20,8 @@ import "@/pages/voice-materials/components/voice-material-card/CardActions"
import "@/pages/voice-materials/components/voice-material-card/BatchCheckbox"
import "@/pages/voice-materials/components/voice-material-card/types"
import "@/pages/voice-materials/components/VoiceMaterialRow"
import "@/pages/voice-materials/components/voice-material-row/useRowProgress"
import "@/pages/voice-materials/components/voice-material-row/TagDisplay"
import "@/pages/voice-materials/components/Toolbar"
import "@/pages/voice-materials/components/TagFilterBar"
import "@/pages/voice-materials/components/BatchBar"
+2 -1
View File
@@ -8,10 +8,11 @@ packages / DB 等重依赖。
"""
# 共享工具模块(零外部依赖,供 editing_modes / generation / edit_plan_generation 等复用)
from . import dedup_helpers, ffmpeg_utils, oss_helpers
from . import dedup_helpers, ffmpeg_utils, oss_helpers, url_security
__all__ = [
"ffmpeg_utils",
"oss_helpers",
"dedup_helpers",
"url_security",
]
@@ -6,7 +6,6 @@
from __future__ import annotations
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
+34 -1
View File
@@ -13,7 +13,8 @@
from __future__ import annotations
from packages.domain.speed_config import MAX_SPEED # noqa: F401 — 向后兼容
from packages.domain.speed_config import MIN_SPEED # noqa: F401 — 向后兼容
from packages.domain.speed_config import (
DEFAULT_SPEED,
SpeedConfig,
@@ -69,3 +70,35 @@ class SpeedEngine:
) -> float:
"""从 clip config 中解析 playback_speed0 或缺失则使用全局速度."""
return _resolve_clip_speed_base(clip_config, global_speed)
# ── 向后兼容:模块级函数(重构前的 API) ─────────────────────────
def build_video_filter(config):
"""向后兼容:模块级 build_video_filter."""
return _build_video_filter_base(config)
def build_audio_filter(config):
"""向后兼容:模块级 build_audio_filter."""
return _build_audio_filter_base(config)
def adjust_duration(original_duration, config):
"""向后兼容:模块级 adjust_duration."""
return _adjust_duration_base(original_duration, config)
def resolve_clip_speed(clip_config, global_speed=DEFAULT_SPEED):
"""向后兼容:模块级 resolve_clip_speed."""
return _resolve_clip_speed_base(clip_config, global_speed)
def build_clip_speed_filter(speed, pitch_correct=True):
"""向后兼容:模块级 build_clip_speed_filter."""
config = SpeedConfig(speed=speed, pitch_correct=pitch_correct)
config.clamp()
return (
build_video_filter(config),
build_audio_filter(config),
config,
)
@@ -23,6 +23,23 @@ class InMemoryUserRepository(UserRepository):
def save(self, user: User) -> None:
"""保存用户"""
# 如果是更新,先清理旧索引
old = self._users.get(user.id)
if old:
self._email_index.pop(old.email.lower(), None)
if old.username:
self._username_index.pop(old.username.lower(), None)
if old.email_verification_token:
self._verification_token_index.pop(old.email_verification_token, None)
if old.password_reset_token:
self._reset_token_index.pop(old.password_reset_token, None)
if old.wechat_openid:
self._wechat_openid_index.pop(old.wechat_openid, None)
if old.wechat_unionid:
self._wechat_unionid_index.pop(old.wechat_unionid, None)
if old.phone:
self._phone_index.pop(old.phone, None)
self._users[user.id] = user
self._email_index[user.email.lower()] = user.id
if user.username:
+3 -1
View File
@@ -569,7 +569,9 @@ class CosyVoiceService:
清洗后的 prefix
"""
# 只保留字母和数字
cleaned = "".join(c for c in name if c.isalnum())
import re
cleaned = re.sub(r"[^a-zA-Z0-9]", "", name)
# 最多10字符
cleaned = cleaned[:10]
# 如果清洗后为空,用默认值
+25 -1
View File
@@ -22,11 +22,17 @@ import urllib.error
import urllib.request
from urllib.parse import urljoin
from packages.domain.url_security import ALLOWED_AUDIO_MIME_TYPES as _allowed_audio_base
from packages.domain.url_security import ALLOWED_IMAGE_MIME_TYPES as _allowed_image_base
from packages.domain.url_security import ALLOWED_PORTS as _allowed_ports_base
from packages.domain.url_security import ALLOWED_SCHEMES as _allowed_schemes_base
from packages.domain.url_security import ALLOWED_VIDEO_MIME_TYPES as _allowed_video_base
from packages.domain.url_security import MAX_URL_LENGTH as _max_url_length_base
from packages.domain.url_security import UrlSecurityError as _UrlSecurityError_base
from packages.domain.url_security import check_internal_hostname as _check_internal_hostname_base
from packages.domain.url_security import check_ssrf_ip as _check_ssrf_ip_base
from packages.domain.url_security import is_ip_address as _is_ip_address_base
from packages.domain.url_security import is_trusted_domain as _is_trusted_domain_base
from packages.domain.url_security import validate_magic_number as _validate_magic_number_base
from packages.domain.url_security import validate_url_basic as _validate_url_basic_base
@@ -35,8 +41,22 @@ logger = logging.getLogger(__name__)
# ── 兼容导出(保持原有变量名供外部引用) ──────────────────────────────────
ALLOWED_SCHEMES = set(_allowed_schemes_base)
ALLOWED_PORTS = set(_allowed_ports_base)
ALLOWED_AUDIO_MIME_TYPES = set(_allowed_audio_base)
ALLOWED_IMAGE_MIME_TYPES = set(_allowed_image_base)
ALLOWED_VIDEO_MIME_TYPES = set(_allowed_video_base)
MAX_URL_LENGTH = _max_url_length_base
UrlSecurityError = _UrlSecurityError_base
# 私有别名(供测试和内部引用)
_check_internal_hostnames = _check_internal_hostname_base
_check_ssrf_ip = _check_ssrf_ip_base
def _is_trusted_domain(hostname: str) -> bool:
"""便捷包装:使用模块级 TRUSTED_DOMAINS 做可信域名检查."""
return _is_trusted_domain_base(hostname, TRUSTED_DOMAINS)
# 可信域名白名单(从环境变量读取)
TRUSTED_DOMAINS: set[str] = set()
_env_trusted = os.environ.get("URL_SECURITY_TRUSTED_DOMAINS", "")
@@ -128,7 +148,11 @@ def validate_url_safety(url: str, *, purpose: str = "download") -> str:
parsed = urlparse(url)
hostname = parsed.hostname
if hostname and not _is_ip_address_base(hostname):
if hostname and _is_ip_address_base(hostname):
# IP 直接访问:通过本地别名调用以便 mock
if ALLOW_DIRECT_IP:
_check_ssrf_ip(hostname)
elif hostname:
try:
_check_ssrf_domain(hostname)
except UrlSecurityError:
+2
View File
@@ -102,3 +102,5 @@ ignore = [
"apps/api/app/middleware/auth.py" = ["ALL"]
"apps/*/migrations/*" = ["ALL"]
"alembic/*" = ["ALL"]
"tests/**" = ["B011"]
+26 -3
View File
@@ -303,7 +303,7 @@ def is_in_protected_list(tag, protected_set):
# ========== 核心清理逻辑 ==========
def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open_set=None):
def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open_set=None, pr_days=0):
"""
清理单个仓库
@@ -441,8 +441,28 @@ def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open
deleted_count += 1
print(f" 打开PR数: {len(open_head_shas)}个head sha")
print(f" 将删除PR镜像: {deleted_count}")
# pr-days兜底:超过指定天数的打开PR镜像也清理
if pr_days > 0:
cutoff = datetime.now(timezone.utc) - timedelta(days=pr_days)
extra_old = []
for tag in pr_tags_list:
sha = extract_sha_from_pr_tag(tag)
is_open_pr = False
for ohs in open_head_shas:
if sha.startswith(ohs) or ohs.startswith(sha):
is_open_pr = True
break
if is_open_pr:
info = get_manifest_info(repo, tag, token_pull)
created = parse_time(info["created"])
if created < cutoff and info["digest"]:
extra_old.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
if extra_old:
pr_to_delete.extend(extra_old)
print(f" pr-days兜底: 额外清理{len(extra_old)}个超期打开PR镜像(>{pr_days}天)")
else:
# 无Gitea token,降级为按7天保留
# 无Gitea token,降级为按pr_days天保留(默认7天)
print(" 模式: 按时间保留7天(无Gitea token降级)")
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
for tag in pr_tags_list:
@@ -561,6 +581,9 @@ def main():
parser.add_argument("--pr-sha", type=str, default="", help="PR关闭模式:删除指定commit sha的PR镜像")
parser.add_argument("--protected-tags", type=str, default="", help="受保护tag列表,逗号分隔(运行中镜像白名单)")
parser.add_argument("--skip-pr-check", action="store_true", help="跳过Gitea PR状态检查(纯按时间清理PR镜像)")
parser.add_argument(
"--pr-days", type=int, default=0, help="PR镜像保留天数(超过天数的PR镜像会被清理,0表示不按天数清理)"
)
args = parser.parse_args()
# 必须指定 --dry-run 或 --execute
@@ -633,7 +656,7 @@ def main():
total_tags = 0
for repo in repos_to_clean:
count, deleted = cleanup_repo(
repo, args.keep, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set
repo, args.keep, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set, pr_days=args.pr_days
)
total_tags += count
total_deleted += deleted
Regular → Executable
+85 -74
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env bash
# 自动合并:CI全绿+已审批后自动squash merge PR到develop
# 短作业模式:只检查一次,不满足条件就退出,由pr-auto-scan定时兜底
# 环境变量:GITHUB_TOKEN, MERGE_TOKEN, PR_NUMBER, PR_HEAD_SHA, BASE_REF, GITHUB_API_URL, GITHUB_REPOSITORY
set -eu
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
echo
echo "模式: 短作业(只检查一次,不满足则退出,由pr-auto-scan定时兜底)"
echo
# 只合develop分支
if [ "$BASE_REF" != "develop" ]; then
@@ -22,6 +23,7 @@ TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
echo
# 使用统一的CI Gate门禁(单一检查点,自动处理前端/后端/全栈跳过逻辑)
CONTEXTS=(
@@ -30,21 +32,20 @@ CONTEXTS=(
echo "检查CI Gate统一门禁"
echo
# 初始等待30秒,给CI启动写status的时间
echo "等待30秒让CI启动..."
sleep 30
# 等待60秒,给CI启动写status的时间
echo "等待60秒让CI启动..."
sleep 60
# 405连续计数器
# 405计数器(单次运行内重试)
MERGE_405_COUNT=0
MAX_405_RETRIES=10
MAX_405_RETRIES=3
# 轮询等待,最多30分钟(180次x10秒)
for attempt in $(seq 1 90); do # 最多等45分钟(90次x30秒),确保等得到Worker构建完成
check_and_merge() {
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- ${attempt}检查 ($(date '+%H:%M:%S')) ---"
echo "--- 检查CI状态 ($(date '+%H:%M:%S')) ---"
# 检查CI状态
for ctx in "${CONTEXTS[@]}"; do
@@ -61,77 +62,87 @@ for attempt in $(seq 1 90); do # 最多等45分钟(90次x30秒),确保等得
fi
done
# CI全绿 → 合并
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "CI全绿,执行自动合并"
echo "等待60秒冷却,给Gitea内部状态同步时间..."
sleep 60
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行squash merge
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
cat /tmp/merge_resp.json 2>/dev/null || true
echo
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 0
fi
sleep 30
continue
else
echo "自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
else
# 本轮不满足合并条件,重置405计数器
MERGE_405_COUNT=0
fi
# CI有失败 → 不合并,直接退出
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "CI有失败项,不自动合并"
echo "CI有失败项,不自动合并"
exit 0
fi
# CI未全绿(pending中)→ 退出,等下次触发
if [ "$ALL_SUCCESS" != "true" ]; then
echo
echo "⏳ CI尚未全绿(仍有pending),退出等待下次触发"
echo " pr-auto-scan每5分钟扫描一次,CI通过后会自动合并)"
exit 0
fi
# CI全绿 → 合并
echo
echo "✅ CI全绿,执行自动合并"
echo "等待30秒冷却,给Gitea内部状态同步时间..."
sleep 30
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行squash merge
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "✅ 自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁"
cat /tmp/merge_resp.json 2>/dev/null || true
echo
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃本次自动合并"
echo " pr-auto-scan会继续尝试,需人工确认是否有冲突或门禁问题)"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 0
fi
echo "30秒后重试..."
sleep 30
return 1 # 重试
else
echo "❌ 自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
}
# 最多重试3次(用于405重试,非CI轮询)
for i in 1 2 3; do
if check_and_merge; then
exit 0
fi
done
echo
echo "快速检查超时(3分钟),CI尚未全绿或无审批,退出等待下次触发"
echo "本次检查未满足合并条件,退出。pr-auto-scan每5分钟会继续扫描。"
exit 0
+13 -10
View File
@@ -32,7 +32,13 @@ def api_request(token, repo, endpoint, method="GET", data=None):
resp = urllib.request.urlopen(req, context=ctx)
return json.loads(resp.read().decode()), resp.status
except urllib.error.HTTPError as e:
return json.loads(e.read().decode()) if e.read() else {"error": str(e)}, e.code
body = e.read().decode()
if body:
try:
return json.loads(body), e.code
except json.JSONDecodeError:
return {"error": body}, e.code
return {"error": str(e)}, e.code
def get_open_prs(token, repo, base="develop"):
@@ -269,13 +275,9 @@ def main():
# required contexts(与分支保护一致)
REQUIRED_CONTEXTS_FULL = [
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
"CI/CD Pipeline / Frontend Lint (pull_request)",
"CI/CD Pipeline / PR Build API Image (pull_request)",
"CI/CD Pipeline / PR Build Worker Image (pull_request)",
"CI/CD Pipeline / PR Build Web Image (pull_request)",
# 统一使用CI Gate作为合并门禁(与pr-automation和分支保护保持一致)
# CI Gate内部已包含: 代码质量/类型检查/迁移检查/单测/集成测试/前端Lint/前端单测/构建/AI审查
"CI/CD Pipeline / CI Gate (pull_request)",
]
REQUIRED_CONTEXTS_APPROVE = [
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
@@ -284,7 +286,8 @@ def main():
"CI/CD Pipeline / Frontend Lint (pull_request)",
]
FRONTEND_ONLY_CONTEXT = [
"CI/CD Pipeline / Frontend Lint (pull_request)",
# 纯前端PR也用CI Gate统一判断,内部自动跳过后端相关检查
"CI/CD Pipeline / CI Gate (pull_request)",
]
# 获取所有open PR
@@ -301,7 +304,7 @@ def main():
pr_num = pr["number"]
pr_title = pr["title"]
head_sha = pr["head"]["sha"]
base_ref = pr.get("base", {}).get("re", "")
base_ref = pr.get("base", {}).get("ref", "")
# 跳过draft
if pr.get("draft"):
+414
View File
@@ -0,0 +1,414 @@
"""AI响应解析纯逻辑单测.
覆盖:标题解析(多格式)、语义匹配解析、
标题降级生成、关键词匹配降级。
"""
from __future__ import annotations
import random
from unittest.mock import patch
from packages.domain.ai_parsing import (
generate_titles_fallback,
keyword_match_fallback,
parse_semantic_match_response,
parse_titles_from_response,
)
class TestParseTitlesFromResponse:
def test_empty_content(self):
assert parse_titles_from_response("") == []
def test_json_array(self):
content = '["标题一", "标题二", "标题三"]'
result = parse_titles_from_response(content)
assert result == ["标题一", "标题二", "标题三"]
def test_json_array_with_whitespace_items(self):
content = '[" 标题一 ", "", "标题二"]'
result = parse_titles_from_response(content)
assert result == ["标题一", "标题二"]
def test_json_dict_with_titles_key(self):
content = '{"titles": ["爆款标题1", "爆款标题2"]}'
result = parse_titles_from_response(content)
assert result == ["爆款标题1", "爆款标题2"]
def test_json_code_block(self):
content = '```json\n["标题A", "标题B"]\n```'
result = parse_titles_from_response(content)
assert result == ["标题A", "标题B"]
def test_json_code_block_with_backticks_only(self):
content = '```\n["X", "Y"]\n```'
result = parse_titles_from_response(content)
assert result == ["X", "Y"]
def test_numbered_list_dot(self):
content = "1. 第一个标题\n2. 第二个标题\n3. 第三个标题"
result = parse_titles_from_response(content)
assert result == ["第一个标题", "第二个标题", "第三个标题"]
def test_numbered_list_chinese_comma(self):
content = "1、标题甲\n2、标题乙"
result = parse_titles_from_response(content)
assert result == ["标题甲", "标题乙"]
def test_numbered_list_parenthesis(self):
"""右括号格式编号能被去掉,左括号保留(实际行为)."""
content = "1) 标题1\n2) 标题2"
result = parse_titles_from_response(content)
assert result == ["标题1", "标题2"]
def test_dash_prefix(self):
content = "- 标题A\n- 标题B\n- 标题C"
result = parse_titles_from_response(content)
assert result == ["标题A", "标题B", "标题C"]
def test_bullet_prefix(self):
content = "• 要点一\n• 要点二"
result = parse_titles_from_response(content)
assert result == ["要点一", "要点二"]
def test_newline_only(self):
content = "标题一\n标题二\n标题三"
result = parse_titles_from_response(content)
assert result == ["标题一", "标题二", "标题三"]
def test_quoted_titles(self):
content = "\"双引号标题\"\n'单引号标题'\n「中文引号」"
result = parse_titles_from_response(content)
assert result == ["双引号标题", "单引号标题", "中文引号"]
def test_skip_empty_lines(self):
content = "标题1\n\n标题2\n\n标题3"
result = parse_titles_from_response(content)
assert result == ["标题1", "标题2", "标题3"]
def test_filter_long_lines(self):
"""超过100字符的行被过滤."""
long_title = "a" * 150
content = f"短标题\n{long_title}\n另一个短标题"
result = parse_titles_from_response(content)
assert len(result) == 2
assert "短标题" in result
assert "另一个短标题" in result
def test_invalid_json_falls_back_to_line_parse(self):
content = '["标题1", "标题2", invalid]' # 非法JSON
result = parse_titles_from_response(content)
# 会走到按行解析
assert len(result) >= 1
def test_mixed_format_numbered_and_dash(self):
content = "1. 第一题\n- 第二题\n2. 第三题"
result = parse_titles_from_response(content)
assert "第一题" in result
assert "第二题" in result
assert "第三题" in result
class TestParseSemanticMatchResponse:
def test_empty_content(self):
assert parse_semantic_match_response("", ["a1", "a2"]) is None
def test_dict_format_asset_id_score(self):
content = '{"asset_1": 0.85, "asset_2": 0.6}'
result = parse_semantic_match_response(content, ["asset_1", "asset_2"])
assert result is not None
assert result["asset_1"] == 0.85
assert result["asset_2"] == 0.6
def test_matches_array_format(self):
content = '{"matches": [{"asset_id": "a1", "score": 0.9}, {"asset_id": "a2", "score": 0.7}]}'
result = parse_semantic_match_response(content, ["a1", "a2"])
assert result is not None
assert result["a1"] == 0.9
assert result["a2"] == 0.7
def test_list_format(self):
content = '[{"asset_id": "x", "score": 0.5}, {"asset_id": "y", "score": 0.8}]'
result = parse_semantic_match_response(content, ["x", "y"])
assert result is not None
assert result["x"] == 0.5
assert result["y"] == 0.8
def test_id_alias_in_matches(self):
"""matches中用id替代asset_id."""
content = '{"matches": [{"id": "a1", "score": 0.75}]}'
result = parse_semantic_match_response(content, ["a1", "a2"])
assert result is not None
assert result["a1"] == 0.75
def test_score_clamped_to_0_1(self):
"""分数超出0-1范围会被截断."""
content = '{"a1": -0.5, "a2": 1.5, "a3": 0.5}'
result = parse_semantic_match_response(content, ["a1", "a2", "a3"])
assert result is not None
assert result["a1"] == 0.0
assert result["a2"] == 1.0
assert result["a3"] == 0.5
def test_score_int_converted_to_float(self):
content = '{"a1": 1, "a2": 0}'
result = parse_semantic_match_response(content, ["a1", "a2"])
assert result is not None
assert result["a1"] == 1.0
assert result["a2"] == 0.0
def test_json_code_block(self):
content = '```json\n{"a1": 0.9, "a2": 0.8}\n```'
result = parse_semantic_match_response(content, ["a1", "a2"])
assert result is not None
assert result["a1"] == 0.9
def test_half_threshold_with_asset_ids(self):
"""提供asset_ids时,至少一半有评分才算成功."""
# 4个assets,只有1个有评分(<2)→ 失败
content = '{"a1": 0.9}'
result = parse_semantic_match_response(content, ["a1", "a2", "a3", "a4"])
assert result is None
def test_half_threshold_passes(self):
# 4个assets,2个有评分(=一半)→ 成功
content = '{"a1": 0.9, "a2": 0.8}'
result = parse_semantic_match_response(content, ["a1", "a2", "a3", "a4"])
assert result is not None
def test_no_asset_ids_returns_any_result(self):
content = '{"x1": 0.7}'
result = parse_semantic_match_response(content, [])
assert result is not None
assert result["x1"] == 0.7
def test_no_asset_ids_empty_result_returns_none(self):
content = "{}"
result = parse_semantic_match_response(content, [])
assert result is None
def test_invalid_json_returns_none(self):
content = "not json at all"
result = parse_semantic_match_response(content, ["a1"])
assert result is None
def test_non_numeric_values_ignored(self):
content = '{"a1": "high", "a2": 0.8}'
result = parse_semantic_match_response(content, ["a1", "a2"])
assert result is not None
assert "a1" not in result
assert result["a2"] == 0.8
def test_single_asset_id_needs_at_least_1(self):
"""1个asset,需要至少max(1, 0)=1个评分."""
content = '{"a1": 0.5}'
result = parse_semantic_match_response(content, ["a1"])
assert result is not None
assert result["a1"] == 0.5
class TestGenerateTitlesFallback:
def test_basic_generation(self):
with patch.object(random, "shuffle", lambda x: None): # 禁用shuffle
result = generate_titles_fallback(
"美食 探店 川菜",
{"examples": ["必看攻略", "绝密技巧"]},
count=3,
)
assert len(result) == 3
assert all(isinstance(t, str) for t in result)
assert all(len(t) > 0 for t in result)
def test_count_limited_by_templates(self):
"""最多10个模板."""
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"测试",
{"examples": ["例1", "例2"]},
count=20,
)
assert len(result) == 10 # 模板总数上限
def test_default_count(self):
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"科技 产品",
{"examples": ["测试标题", "另一个例子"]},
)
assert len(result) == 5
def test_empty_description_uses_default_keyword(self):
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
" ",
{"examples": ["例A", "例B"]},
count=1,
)
assert "精彩内容" in result[0]
def test_keyword_extracted_from_description(self):
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"Python编程入门教程",
{"examples": ["入门", "技巧"]},
count=5,
)
# 第一个关键词应该出现在某些标题中
assert any("Python编程入门教程" in t for t in result)
def test_examples_truncated(self):
"""第一个example超过10字符会被截断."""
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"美食",
{"examples": ["这是一个非常长的例子超过十个字", "第二个例子"]},
count=1,
)
# 第一个标题应该包含截断的example + "..."
assert "..." in result[0]
def test_no_examples_uses_default(self):
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"健身",
{"examples": []},
count=2,
)
assert len(result) == 2
assert "必看" in result[0] # 默认example_0
def test_second_example_default(self):
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"健身",
{"examples": ["只有一个"]},
count=3,
)
# 第二个标题应该包含默认的"你不知道的事"
assert any("你不知道的事" in t for t in result)
def test_single_word_keyword(self):
"""单字会被过滤掉,使用默认关键词."""
with patch.object(random, "shuffle", lambda x: None):
result = generate_titles_fallback(
"a b c",
{"examples": [""]},
count=1,
)
# 所有词都是1个字符,应该用默认关键词
assert "精彩内容" in result[0]
class TestKeywordMatchFallback:
def test_basic_matching(self):
assets = [
{"id": "a1", "name": "美食探店视频", "tags": ["美食", "探店"], "description": "成都美食"},
{"id": "a2", "name": "科技产品评测", "tags": ["科技"], "description": "手机评测"},
{"id": "a3", "name": "旅行Vlog", "tags": ["旅行"], "description": "日本旅行"},
]
result = keyword_match_fallback("美食 探店 成都", assets)
assert len(result) == 3
# 美食相关的应该排第一
assert result[0]["id"] == "a1"
assert 0 < result[0]["match_score"] <= 1.0
def test_score_between_0_and_1(self):
assets = [{"id": "a1", "name": "测试素材", "tags": [], "description": ""}]
result = keyword_match_fallback("完全不相关的关键词", assets)
assert 0 <= result[0]["match_score"] <= 1
def test_no_keywords_default_score(self):
"""描述中没有有效关键词时,所有素材0.5分."""
assets = [
{"id": "a1", "name": "素材1", "tags": [], "description": ""},
{"id": "a2", "name": "素材2", "tags": [], "description": ""},
]
result = keyword_match_fallback(" ", assets) # 空描述
assert len(result) == 2
assert result[0]["match_score"] == 0.5
assert result[0]["match_reason"] == "fallback_default"
def test_sorted_descending(self):
assets = [
{"id": "a_low", "name": "不相关", "tags": [], "description": ""},
{"id": "a_high", "name": "美食推荐", "tags": ["美食"], "description": "美食攻略"},
]
result = keyword_match_fallback("美食 推荐", assets)
assert result[0]["id"] == "a_high"
assert result[0]["match_score"] > result[1]["match_score"]
def test_match_reason_keyword(self):
assets = [{"id": "a1", "name": "测试", "tags": [], "description": ""}]
result = keyword_match_fallback("测试关键词", assets)
assert result[0]["match_reason"] == "fallback_keyword"
def test_name_bonus(self):
"""名称命中应该有额外加分."""
assets = [
{
"id": "a1",
"name": "完全不相关的名字",
"tags": [],
"description": "美食教程", # 描述里有关键词
},
{
"id": "a2",
"name": "美食分享", # 名称里有关键词
"tags": [],
"description": "", # 描述里没有
},
]
result = keyword_match_fallback("美食", assets)
# 名称命中的a2应该分数更高(name bonus
assert result[0]["id"] == "a2"
def test_empty_assets(self):
result = keyword_match_fallback("美食", [])
assert result == []
def test_asset_dict_not_mutated(self):
"""不修改原始asset字典."""
asset = {"id": "a1", "name": "测试", "tags": []}
original = dict(asset)
keyword_match_fallback("测试", [asset])
assert asset == original
def test_chinese_keywords_used(self):
"""中文2-4字片段应该被用作关键词."""
assets = [
{"id": "a1", "name": "编程入门", "tags": [], "description": ""},
{"id": "a2", "name": "美食推荐", "tags": [], "description": ""},
]
result = keyword_match_fallback("编程入门教程", assets)
assert result[0]["id"] == "a1"
assert result[0]["match_score"] > 0
def test_english_keywords_used(self):
"""英文3字符以上单词应该被用作关键词."""
assets = [
{"id": "a1", "name": "Python tutorial", "tags": [], "description": ""},
{"id": "a2", "name": "Java course", "tags": [], "description": ""},
]
result = keyword_match_fallback("python programming", assets)
assert result[0]["id"] == "a1"
assert result[0]["match_score"] > 0
def test_score_is_rounded_to_3_decimals(self):
assets = [{"id": "a1", "name": "测试素材", "tags": [], "description": ""}]
result = keyword_match_fallback("测试关键词", assets)
# 3位小数
assert len(str(result[0]["match_score"]).split(".")[-1]) <= 3
def test_perfect_match_score(self):
assets = [
{
"id": "a1",
"name": "美食探店推荐",
"tags": ["美食", "探店", "推荐"],
"description": "美食探店推荐视频",
}
]
result = keyword_match_fallback("美食 探店 推荐", assets)
assert result[0]["match_score"] <= 1.0
assert result[0]["match_score"] > 0.5 # 应该有较高分数
@@ -0,0 +1,531 @@
"""ass_subtitle_builder 单元测试 - wave169
覆盖:
- hex_to_ass_color 颜色转换
- position_to_ass_alignment 位置对齐映射
- build_ass_style Style行构建
- escape_ass_text 文本转义
- format_ass_time 时间格式化
- build_ass_content 完整ASS内容生成
"""
import pytest
from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_BOTTOM,
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
build_ass_content,
build_ass_style,
escape_ass_text,
format_ass_time,
hex_to_ass_color,
position_to_ass_alignment,
)
# ============================================================
# hex_to_ass_color
# ============================================================
class TestHexToAssColor:
def test_red(self):
# #FF0000 → &H0000FF (BBGGRR)
assert hex_to_ass_color("#FF0000") == "&H0000FF"
def test_blue(self):
# #0000FF → &HFF0000
assert hex_to_ass_color("#0000FF") == "&HFF0000"
def test_green(self):
# #00FF00 → &H00FF00
assert hex_to_ass_color("#00FF00") == "&H00FF00"
def test_white(self):
assert hex_to_ass_color("#FFFFFF") == "&HFFFFFF"
def test_black(self):
assert hex_to_ass_color("#000000") == "&H000000"
def test_without_hash_prefix(self):
assert hex_to_ass_color("FF0000") == "&H0000FF"
def test_mixed_case(self):
assert hex_to_ass_color("#aBcDeF") == "&HEFCDAB"
def test_invalid_length_short(self):
assert hex_to_ass_color("#FFF") == "&H000000"
def test_invalid_length_long(self):
assert hex_to_ass_color("#FF0000FF") == "&H000000"
def test_empty_string(self):
assert hex_to_ass_color("") == "&H000000"
def test_uppercase_output(self):
result = hex_to_ass_color("#abcdef")
assert result == result.upper()
# ============================================================
# position_to_ass_alignment
# ============================================================
class TestPositionToAssAlignment:
def test_top(self):
assert position_to_ass_alignment("top") == 8
def test_center(self):
assert position_to_ass_alignment("center") == 5
def test_bottom(self):
assert position_to_ass_alignment("bottom") == 2
def test_unknown_defaults_top(self):
assert position_to_ass_alignment("unknown") == 8
def test_empty_defaults_top(self):
assert position_to_ass_alignment("") == 8
# ============================================================
# build_ass_style
# ============================================================
class TestBuildAssStyle:
def test_minimal_style(self):
result = build_ass_style("Default")
assert result.startswith("Style: Default,")
def test_contains_font_name(self):
result = build_ass_style("S1", font_name="Arial")
assert "Arial" in result
def test_contains_font_size(self):
result = build_ass_style("S1", font_size=36)
# Style行格式:Name, Fontname, Fontsize, ...
parts = result.split(",")
assert parts[2] == "36"
def test_bold_true(self):
result = build_ass_style("S1", bold=True)
parts = result.split(",")
# Bold 是第7个字段(索引7
assert parts[7] == "-1"
def test_bold_false(self):
result = build_ass_style("S1", bold=False)
parts = result.split(",")
assert parts[7] == "0"
def test_italic_true(self):
result = build_ass_style("S1", italic=True)
parts = result.split(",")
# Italic 是第8个字段(索引8)
assert parts[8] == "-1"
def test_italic_false(self):
result = build_ass_style("S1", italic=False)
parts = result.split(",")
assert parts[8] == "0"
def test_alignment(self):
result = build_ass_style("S1", alignment=5)
parts = result.split(",")
# Alignment 是第18个字段(索引18
assert parts[18] == "5"
def test_outline_width(self):
result = build_ass_style("S1", outline_width=3.0)
parts = result.split(",")
# Outline 是第16个字段(索引16
assert parts[16] == "3.0"
def test_margins(self):
result = build_ass_style("S1", margin_l=10, margin_r=20, margin_v=30)
parts = result.split(",")
assert parts[19] == "10" # MarginL
assert parts[20] == "20" # MarginR
assert parts[21] == "30" # MarginV
def test_shadow_with_blur(self):
result = build_ass_style("S1", shadow_blur=2.0, shadow_offset=(3, 5))
parts = result.split(",")
# Shadow 深度 = shadow_offset[1] when blur > 0
assert parts[17] == "5"
def test_shadow_without_blur(self):
result = build_ass_style("S1", shadow_blur=0.0, shadow_offset=(3, 5))
parts = result.split(",")
assert parts[17] == "0"
def test_primary_color(self):
result = build_ass_style("S1", primary_color="&H00FFFFFF")
parts = result.split(",")
assert parts[3] == "&H00FFFFFF"
def test_outline_color(self):
result = build_ass_style("S1", outline_color="&H000000FF")
parts = result.split(",")
assert parts[5] == "&H000000FF"
def test_22_fields(self):
# ASS Style 行应有23个字段(Style: 前缀 + 22个逗号分隔字段)
result = build_ass_style("Default")
parts = result.split(",")
assert len(parts) >= 22 # 至少22个字段
# ============================================================
# escape_ass_text
# ============================================================
class TestEscapeAssText:
def test_plain_text_unchanged(self):
assert escape_ass_text("Hello World") == "Hello World"
def test_newline_converted(self):
assert escape_ass_text("line1\nline2") == "line1\\Nline2"
def test_crlf_converted(self):
assert escape_ass_text("line1\r\nline2") == "line1\\Nline2"
def test_carriage_return_converted(self):
assert escape_ass_text("line1\rline2") == "line1\\Nline2"
def test_curly_braces_escaped(self):
assert escape_ass_text("{text}") == "(text)"
def test_opening_brace_escaped(self):
assert escape_ass_text("{hello") == "(hello"
def test_closing_brace_escaped(self):
assert escape_ass_text("hello}") == "hello)"
def test_multiple_braces(self):
assert escape_ass_text("{a}{b}") == "(a)(b)"
def test_mixed_newlines_and_braces(self):
result = escape_ass_text("line1\n{tag}line2")
assert result == "line1\\N(tag)line2"
def test_empty_string(self):
assert escape_ass_text("") == ""
def test_chinese_text(self):
assert escape_ass_text("你好世界") == "你好世界"
def test_backslash_n_in_input(self):
# 文本里本身有 \n 字符串(不是换行符)
result = escape_ass_text("\\n")
assert result == "\\n" # 不变,因为不是实际换行符
# ============================================================
# format_ass_time
# ============================================================
class TestFormatAssTime:
def test_zero(self):
assert format_ass_time(0) == "0:00:00.00"
def test_seconds_only(self):
assert format_ass_time(5.5) == "0:00:05.50"
def test_minutes(self):
assert format_ass_time(90.0) == "0:01:30.00"
def test_hours(self):
assert format_ass_time(3661.5) == "1:01:01.50"
def test_multi_hours(self):
assert format_ass_time(7384.25) == "2:03:04.25"
def test_two_decimal_places(self):
result = format_ass_time(1.234)
# 两位小数
assert result.endswith(".23") or result.endswith(".24")
def test_minutes_two_digits(self):
result = format_ass_time(65.0)
parts = result.split(":")
assert len(parts[1]) == 2
assert parts[1] == "01"
def test_seconds_two_digits_before_decimal(self):
result = format_ass_time(5.0)
parts = result.split(":")
sec_part = parts[2]
assert sec_part.startswith("05")
def test_float_input(self):
assert format_ass_time(123.45) == "0:02:03.45"
def test_exactly_one_hour(self):
assert format_ass_time(3600.0) == "1:00:00.00"
# ============================================================
# build_ass_content
# ============================================================
class TestBuildAssContent:
def test_no_subtitles_returns_empty(self):
result = build_ass_content(video_width=1920, video_height=1080, video_duration=10.0)
assert result == ""
def test_title_only(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="Test Title",
)
assert result != ""
assert "[Script Info]" in result
assert "PlayResX: 1920" in result
assert "PlayResY: 1080" in result
assert "[V4+ Styles]" in result
assert "[Events]" in result
assert "TitleStyle" in result
assert "Test Title" in result
def test_subtitle_only(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
subtitle_text="Hello Subtitle",
)
assert result != ""
assert "SubtitleStyle" in result
assert "Hello Subtitle" in result
def test_both_title_and_subtitle(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="Title",
subtitle_text="Subtitle",
)
assert "TitleStyle" in result
assert "SubtitleStyle" in result
assert "Title" in result
assert "Subtitle" in result
def test_whitespace_title_returns_empty(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text=" ",
)
assert result == ""
def test_whitespace_subtitle_returns_empty(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
subtitle_text=" \n ",
)
assert result == ""
def test_title_disabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="Title",
title_config={"enabled": False},
)
assert result == ""
def test_subtitle_disabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
subtitle_text="Sub",
subtitle_config={"enabled": False},
)
assert result == ""
def test_title_color(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="T",
title_config={"color": "#FF0000"},
)
# 红色 → &H0000FF
assert "&H0000FF" in result
def test_title_position_top(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="T",
title_config={"position": "top"},
)
# top alignment = 8
assert "TitleStyle" in result
def test_title_position_bottom(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="T",
title_config={"position": "bottom"},
)
# bottom=2, 检查Style行里有2
assert "TitleStyle" in result
def test_subtitle_position_bottom(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
subtitle_text="S",
subtitle_config={"position": "bottom"},
)
assert "SubtitleStyle" in result
def test_title_font_size(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="T",
title_config={"size": 72},
)
# 在TitleStyle行里查找字体大小
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[2] == "72"
break
def test_title_bold(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="T",
title_config={"bold": True},
)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[7] == "-1"
break
def test_title_stroke_enabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="T",
title_config={"stroke": {"enabled": True, "width": 3, "color": "#000000"}},
)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[16] == "3.0"
break
def test_title_stroke_disabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="T",
title_config={"stroke": {"enabled": False, "width": 3}},
)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[16] == "0.0"
break
def test_title_shadow_enabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="T",
title_config={"shadow": {"enabled": True, "blur": 2, "offset_x": 2, "offset_y": 4}},
)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[17] == "4" # Shadow = offset_y
break
def test_dialogue_has_correct_timing(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=65.5,
title_text="T",
)
# 结束时间应该是 0:01:05.50
assert "0:01:05.50" in result
def test_dialogue_starts_at_zero(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
subtitle_text="S",
)
assert "0:00:00.00" in result
def test_contains_script_info_header(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
title_text="T",
)
assert "[Script Info]" in result
assert "ScriptType: v4.00+" in result
assert "ScaledBorderAndShadow: yes" in result
def test_escaped_text_in_dialogue(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
title_text="line1\nline2",
)
# 换行符应被转义为 \N
assert "\\N" in result
assert "line1" in result
assert "line2" in result
# ============================================================
# 常量验证
# ============================================================
class TestConstants:
def test_margin_values(self):
assert TITLE_MARGIN_TOP > 0
assert TITLE_MARGIN_BOTTOM > 0
assert TITLE_MARGIN_SIDE > 0
def test_margins_are_integers(self):
assert isinstance(TITLE_MARGIN_TOP, int)
assert isinstance(TITLE_MARGIN_BOTTOM, int)
assert isinstance(TITLE_MARGIN_SIDE, int)
+61
View File
@@ -0,0 +1,61 @@
"""asset / asset_library 兼容层单元测试."""
from __future__ import annotations
from domain.asset import Asset, AssetStatus, AssetType, ClassificationStatus
from domain.asset_library import AssetLibrary, AssetLibraryKind, LibraryKind
class TestAssetType:
"""AssetType 常量类测试."""
def test_video_value(self):
assert AssetType.VIDEO == "video"
def test_image_value(self):
assert AssetType.IMAGE == "image"
def test_audio_value(self):
assert AssetType.AUDIO == "audio"
def test_three_types(self):
assert AssetType.VIDEO
assert AssetType.IMAGE
assert AssetType.AUDIO
class TestAssetReexports:
"""asset.py 重导出测试."""
def test_asset_reexported(self):
# Asset 类从 entities 转发,确认可访问
assert Asset is not None
def test_asset_status_reexported(self):
assert AssetStatus is not None
def test_classification_status_reexported(self):
assert ClassificationStatus is not None
class TestLibraryKind:
"""LibraryKind 常量类测试."""
def test_video_value(self):
assert LibraryKind.VIDEO == AssetLibraryKind.VIDEO
def test_voice_value(self):
assert LibraryKind.VOICE == AssetLibraryKind.VOICE
def test_image_value(self):
assert LibraryKind.IMAGE == AssetLibraryKind.IMAGE
class TestAssetLibraryReexports:
"""asset_library.py 重导出测试."""
def test_asset_library_reexported(self):
assert AssetLibrary is not None
def test_asset_library_kind_reexported(self):
assert AssetLibraryKind is not None
+530
View File
@@ -0,0 +1,530 @@
"""asset_scoring 单元测试 - wave162
覆盖:
- 分辨率评分 score_resolution
- 时长评分 score_duration
- 码率评分 score_bitrate
- 加权总分 calculate_total_score
- 单个素材评分 score_asset_detail
- 时长分桶 _bucket_by_duration
- 多样性选择 diverse_selection
- 候选过滤 filter_candidates
"""
from dataclasses import dataclass
import pytest
from packages.domain.asset_scoring import (
AssetScoreDetail,
SmartSelectResult,
_bucket_by_duration,
calculate_total_score,
diverse_selection,
filter_candidates,
score_asset_detail,
score_bitrate,
score_duration,
score_resolution,
)
# ============================================================
# score_resolution
# ============================================================
class TestScoreResolution:
def test_none_width_returns_mid(self):
assert score_resolution(None, 1080) == 0.5
def test_none_height_returns_mid(self):
assert score_resolution(1920, None) == 0.5
def test_zero_dimension_returns_mid(self):
assert score_resolution(0, 1080) == 0.5
assert score_resolution(1920, 0) == 0.5
assert score_resolution(-1, 1080) == 0.5
def test_exact_target_returns_1(self):
assert score_resolution(1920, 1080) == 1.0
def test_higher_than_target_returns_1(self):
assert score_resolution(3840, 2160) == 1.0 # 4K
assert score_resolution(2560, 1440) == 1.0 # 2K
def test_lower_than_target_linear_decay(self):
# 720p = 1280*720 / 1920*1080 = 0.444 ratio
# score = 0.3 + 0.7 * 0.444 = 0.611
score = score_resolution(1280, 720)
assert 0.55 < score < 0.7
def test_very_low_has_floor(self):
# 最低不低于 0.1
score = score_resolution(100, 100)
assert score >= 0.1
def test_480p_still_reasonable(self):
score = score_resolution(640, 480)
assert 0.3 < score < 0.5
def test_custom_target(self):
score = score_resolution(1280, 720, target_width=1280, target_height=720)
assert score == 1.0
def test_between_0_and_1(self):
for w, h in [(1920, 1080), (1280, 720), (640, 480), (3840, 2160)]:
s = score_resolution(w, h)
assert 0.0 <= s <= 1.0
# ============================================================
# score_duration
# ============================================================
class TestScoreDuration:
def test_none_returns_mid(self):
assert score_duration(None) == 0.5
def test_zero_or_negative_returns_mid(self):
assert score_duration(0) == 0.5
assert score_duration(-1) == 0.5
def test_optimal_range_returns_1(self):
assert score_duration(3.0) == 1.0
assert score_duration(10.0) == 1.0
assert score_duration(30.0) == 1.0
assert score_duration(15.0) == 1.0
def test_short_duration_linear_decay(self):
# 1.5s: ratio = 1.5/3 = 0.5, score = 0.3 + 0.7*0.5 = 0.65
score = score_duration(1.5)
assert score == pytest.approx(0.65)
def test_very_short_above_floor(self):
score = score_duration(0.1)
assert 0.3 <= score < 0.5
def test_long_duration_penalty(self):
# 40s: excess=10, penalty=10/10*0.1=0.1, score=0.9
score = score_duration(40.0)
assert score == pytest.approx(0.9)
def test_very_long_minimum_floor(self):
# 超过很多,最低 0.2
score = score_duration(1000.0)
assert score >= 0.2
assert score < 0.5
def test_just_below_optimal(self):
score = score_duration(2.9)
assert 0.9 < score < 1.0
def test_just_above_optimal(self):
score = score_duration(30.1)
assert 0.9 < score < 1.0
# ============================================================
# score_bitrate
# ============================================================
class TestScoreBitrate:
def test_no_file_size_returns_mid(self):
assert score_bitrate(0, 10.0) == 0.5
def test_no_duration_returns_mid(self):
assert score_bitrate(1000000, None) == 0.5
assert score_bitrate(1000000, 0) == 0.5
assert score_bitrate(1000000, -1) == 0.5
def test_optimal_range_returns_1(self):
# 5 Mbps for 10s = 5*10^6 * 10 / 8 = 6,250,000 bytes
size_5mbps_10s = int(5_000_000 * 10 / 8)
assert score_bitrate(size_5mbps_10s, 10.0) == 1.0
def test_low_bitrate_decay(self):
# 500 Kbps for 10s
size_500kbps = int(500_000 * 10 / 8)
score = score_bitrate(size_500kbps, 10.0)
assert 0.3 < score < 0.7
def test_high_bitrate_moderate_penalty(self):
# 16 Mbps (2x optimal high), excess=1.0, penalty=min(0.5, 1.0*0.2)=0.2
# score = 0.8
size_16mbps = int(16_000_000 * 10 / 8)
score = score_bitrate(size_16mbps, 10.0)
assert 0.7 < score < 0.9
def test_very_high_bitrate_floor(self):
# 极高码率,最低 0.5
huge_size = 10**9 # 1GB for 1s = 8Gbps
score = score_bitrate(huge_size, 1.0)
assert score >= 0.5
def test_between_0_and_1(self):
for size, dur in [(1000, 1), (1000000, 10), (100000000, 5)]:
s = score_bitrate(size, dur)
assert 0.0 <= s <= 1.0
# ============================================================
# calculate_total_score
# ============================================================
class TestCalculateTotalScore:
def test_all_perfect_equals_1(self):
assert calculate_total_score(1.0, 1.0, 1.0, 1.0) == 1.0
def test_all_zero_equals_0(self):
assert calculate_total_score(0.0, 0.0, 0.0, 0.0) == 0.0
def test_weighted_sum(self):
# 0.5*0.5 + 0.2*0.5 + 0.2*0.5 + 0.1*0.5 = 0.25+0.1+0.1+0.05 = 0.5
assert calculate_total_score(0.5, 0.5, 0.5, 0.5) == pytest.approx(0.5)
def test_quality_has_highest_weight(self):
# 只提高质量分,对比只提高其他
q_high = calculate_total_score(1.0, 0.0, 0.0, 0.0)
r_high = calculate_total_score(0.0, 1.0, 0.0, 0.0)
assert q_high > r_high # 0.5 > 0.2
def test_bitrate_has_lowest_weight(self):
b_high = calculate_total_score(0.0, 0.0, 0.0, 1.0)
q_high = calculate_total_score(1.0, 0.0, 0.0, 0.0)
assert b_high < q_high # 0.1 < 0.5
def test_rounded_to_4_decimals(self):
result = calculate_total_score(0.3333, 0.3333, 0.3333, 0.3333)
assert round(result, 4) == result
# ============================================================
# score_asset_detail
# ============================================================
class TestScoreAssetDetail:
def test_returns_detail_object(self):
detail = score_asset_detail(
asset_id="a1",
quality=80.0,
width=1920,
height=1080,
duration=10.0,
file_size=5_000_000,
)
assert isinstance(detail, AssetScoreDetail)
assert detail.asset_id == "a1"
assert 0.0 <= detail.total_score <= 1.0
def test_perfect_asset_high_score(self):
detail = score_asset_detail(
asset_id="perfect",
quality=100.0,
width=1920,
height=1080,
duration=10.0,
file_size=6_250_000, # 5Mbps for 10s
)
assert detail.total_score > 0.9
def test_quality_none_defaults_mid(self):
detail = score_asset_detail(
asset_id="a1",
quality=None,
width=1920,
height=1080,
duration=10.0,
file_size=5_000_000,
)
assert detail.quality_score == 0.5
def test_quality_normalized(self):
detail = score_asset_detail(
asset_id="a1",
quality=50.0,
width=1920,
height=1080,
duration=10.0,
file_size=5_000_000,
)
assert detail.quality_score == pytest.approx(0.5)
def test_custom_target_resolution(self):
detail = score_asset_detail(
asset_id="a1",
quality=100.0,
width=1280,
height=720,
duration=10.0,
file_size=5_000_000,
target_width=1280,
target_height=720,
)
assert detail.resolution_score == 1.0
def test_total_score_matches_components(self):
detail = score_asset_detail(
asset_id="a1",
quality=80.0,
width=1920,
height=1080,
duration=10.0,
file_size=5_000_000,
)
expected = calculate_total_score(
detail.quality_score,
detail.resolution_score,
detail.duration_score,
detail.bitrate_score,
)
assert detail.total_score == pytest.approx(expected, abs=0.001)
# ============================================================
# _bucket_by_duration
# ============================================================
class TestBucketByDuration:
def test_none_is_unknown(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, None)
assert _bucket_by_duration(item) == "unknown"
def test_short(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 3.0)
assert _bucket_by_duration(item) == "short"
def test_short_boundary(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 4.9)
assert _bucket_by_duration(item) == "short"
def test_medium(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 10.0)
assert _bucket_by_duration(item) == "medium"
def test_medium_boundary(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 5.0)
assert _bucket_by_duration(item) == "medium"
def test_medium_upper_boundary(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 14.9)
assert _bucket_by_duration(item) == "medium"
def test_long(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 20.0)
assert _bucket_by_duration(item) == "long"
def test_long_boundary(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 15.0)
assert _bucket_by_duration(item) == "long"
# ============================================================
# diverse_selection
# ============================================================
def _make_detail(asset_id: str, score: float, duration: float) -> AssetScoreDetail:
return AssetScoreDetail(
asset_id=asset_id,
total_score=score,
quality_score=score,
resolution_score=score,
duration_score=score,
bitrate_score=score,
duration=duration,
)
class TestDiverseSelection:
def test_empty_input_returns_empty(self):
assert diverse_selection([], 5) == []
def test_zero_count_returns_empty(self):
items = [_make_detail("a1", 0.9, 10.0)]
assert diverse_selection(items, 0) == []
def test_negative_count_returns_empty(self):
items = [_make_detail("a1", 0.9, 10.0)]
assert diverse_selection(items, -1) == []
def test_fewer_items_than_count(self):
items = [_make_detail("a1", 0.9, 10.0), _make_detail("a2", 0.8, 3.0)]
result = diverse_selection(items, 10)
assert len(result) == 2
def test_picks_top_from_each_bucket(self):
# 3个桶各有3个素材,选3个
items = [
_make_detail("s1", 0.95, 2.0),
_make_detail("m1", 0.9, 10.0),
_make_detail("l1", 0.85, 20.0),
_make_detail("s2", 0.8, 3.0),
_make_detail("m2", 0.75, 8.0),
_make_detail("l2", 0.7, 25.0),
]
result = diverse_selection(items, 3)
assert len(result) == 3
ids = [d.asset_id for d in result]
assert "s1" in ids
assert "m1" in ids
assert "l1" in ids
def test_base_quota_when_count_large(self):
# count=6, base_quota=max(1, 6//3)=2
items = [
_make_detail("s1", 1.0, 2.0),
_make_detail("s2", 0.9, 3.0),
_make_detail("s3", 0.8, 4.0),
_make_detail("m1", 0.95, 10.0),
_make_detail("m2", 0.85, 12.0),
_make_detail("l1", 0.92, 20.0),
_make_detail("l2", 0.82, 30.0),
]
result = diverse_selection(items, 6)
assert len(result) == 6
ids = [d.asset_id for d in result]
# 每桶至少2个
short_count = sum(1 for d in result if d.duration and d.duration < 5)
assert short_count >= 2
def test_remaining_filled_by_global_score(self):
# 只有2个桶有内容,count=5,配额用完后剩余从全局取
items = [
_make_detail("s1", 1.0, 2.0),
_make_detail("s2", 0.9, 3.0),
_make_detail("m1", 0.95, 10.0),
_make_detail("m2", 0.8, 12.0),
_make_detail("s3", 0.7, 4.0),
_make_detail("s4", 0.6, 1.0),
_make_detail("m3", 0.5, 8.0),
]
result = diverse_selection(items, 5)
assert len(result) == 5
# 最高分的都应该在
ids = [d.asset_id for d in result]
assert "s1" in ids
assert "m1" in ids
def test_single_bucket(self):
items = [
_make_detail("s1", 1.0, 2.0),
_make_detail("s2", 0.9, 3.0),
_make_detail("s3", 0.8, 4.0),
]
result = diverse_selection(items, 2)
assert len(result) == 2
assert result[0].asset_id == "s1"
assert result[1].asset_id == "s2"
def test_unknown_duration_fallback(self):
# 已知素材不够时用未知时长的补充
items = [
_make_detail("s1", 1.0, 2.0),
_make_detail("u1", 0.95, None),
_make_detail("u2", 0.9, None),
]
result = diverse_selection(items, 3)
assert len(result) == 3
ids = [d.asset_id for d in result]
assert "s1" in ids
assert "u1" in ids
def test_no_duplicates(self):
items = [
_make_detail("s1", 1.0, 2.0),
_make_detail("m1", 0.9, 10.0),
]
result = diverse_selection(items, 5)
ids = [d.asset_id for d in result]
assert len(ids) == len(set(ids))
# ============================================================
# filter_candidates
# ============================================================
@dataclass
class FakeAsset:
status: str = "ready"
mime_type: str = "video/mp4"
quality_score: float | None = 50.0
class TestFilterCandidates:
def test_ready_video_passes(self):
assets = [FakeAsset()]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 1
assert filtered == 0
def test_non_ready_filtered(self):
assets = [FakeAsset(status="uploading"), FakeAsset(status="processing")]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 0
assert filtered == 0 # 被状态过滤的不计入质量门槛
def test_non_video_filtered(self):
assets = [FakeAsset(mime_type="image/jpeg"), FakeAsset(mime_type="audio/mp3")]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 0
def test_low_quality_filtered(self):
assets = [FakeAsset(quality_score=10.0), FakeAsset(quality_score=80.0)]
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
assert len(candidates) == 1
assert filtered == 1
def test_quality_none_passes(self):
assets = [FakeAsset(quality_score=None)]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 1
assert filtered == 0
def test_exactly_min_quality_passes(self):
assets = [FakeAsset(quality_score=30.0)]
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
assert len(candidates) == 1
def test_custom_min_quality(self):
assets = [
FakeAsset(quality_score=40.0),
FakeAsset(quality_score=60.0),
FakeAsset(quality_score=80.0),
]
candidates, filtered = filter_candidates(assets, min_quality_score=50.0)
assert len(candidates) == 2
assert filtered == 1
def test_empty_input(self):
candidates, filtered = filter_candidates([])
assert candidates == []
assert filtered == 0
def test_mime_type_none(self):
# None 的 mime_type 也应该被过滤掉(不是video开头)
asset = FakeAsset(mime_type="")
candidates, _ = filter_candidates([asset])
assert len(candidates) == 0
def test_with_enum_status(self):
from enum import Enum
class StatusEnum(Enum):
READY = "ready"
UPLOADING = "uploading"
@dataclass
class EnumAsset:
status: StatusEnum = StatusEnum.READY
mime_type: str = "video/mp4"
quality_score: float = 50.0
assets = [EnumAsset()]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 1
+521
View File
@@ -0,0 +1,521 @@
"""audio_track_config 多轨道音频配置单测."""
import pytest
from domain.audio_track_config import (
ALLOWED_AUDIO_EXTENSIONS,
DEFAULT_VOLUMES,
MAX_AUDIO_TRACKS,
TRACK_TYPE_AMBIENT,
TRACK_TYPE_BGM,
TRACK_TYPE_MAIN,
TRACK_TYPE_SFX,
TRACK_TYPE_VOICEOVER,
AudioTrack,
MultiTrackMixConfig,
clamp_volume,
is_valid_audio_extension,
)
# ── 常量测试 ──────────────────────────────────────────────────────────────────
class TestConstants:
"""模块常量"""
def test_track_type_constants(self):
assert TRACK_TYPE_MAIN == "main"
assert TRACK_TYPE_BGM == "bgm"
assert TRACK_TYPE_VOICEOVER == "voiceover"
assert TRACK_TYPE_SFX == "sfx"
assert TRACK_TYPE_AMBIENT == "ambient"
def test_max_tracks(self):
assert MAX_AUDIO_TRACKS == 8
def test_default_volumes(self):
assert DEFAULT_VOLUMES[TRACK_TYPE_MAIN] == 1.0
assert DEFAULT_VOLUMES[TRACK_TYPE_BGM] == 0.3
assert DEFAULT_VOLUMES[TRACK_TYPE_VOICEOVER] == 1.0
assert DEFAULT_VOLUMES[TRACK_TYPE_SFX] == 0.7
assert DEFAULT_VOLUMES[TRACK_TYPE_AMBIENT] == 0.2
def test_allowed_extensions(self):
assert ".mp3" in ALLOWED_AUDIO_EXTENSIONS
assert ".wav" in ALLOWED_AUDIO_EXTENSIONS
assert ".aac" in ALLOWED_AUDIO_EXTENSIONS
assert ".ogg" in ALLOWED_AUDIO_EXTENSIONS
assert ".flac" in ALLOWED_AUDIO_EXTENSIONS
assert ".m4a" in ALLOWED_AUDIO_EXTENSIONS
assert ".wma" in ALLOWED_AUDIO_EXTENSIONS
# ── AudioTrack ───────────────────────────────────────────────────────────────
class TestAudioTrackDefaults:
"""AudioTrack 默认值"""
def test_default_values(self):
t = AudioTrack()
assert t.track_id == ""
assert t.track_type == TRACK_TYPE_SFX
assert t.audio_path == ""
assert t.volume == 1.0
assert t.fade_in == 0.0
assert t.fade_out == 0.0
assert t.start_time == 0.0
assert t.duration == 0.0
assert t.enabled is True
def test_custom_track(self):
t = AudioTrack(
track_id="bgm_001",
track_type=TRACK_TYPE_BGM,
audio_path="/music/bgm.mp3",
volume=0.5,
fade_in=1.5,
fade_out=2.0,
start_time=3.0,
duration=30.0,
enabled=False,
)
assert t.track_id == "bgm_001"
assert t.track_type == TRACK_TYPE_BGM
assert t.audio_path == "/music/bgm.mp3"
assert t.volume == 0.5
assert t.fade_in == 1.5
assert t.start_time == 3.0
assert t.duration == 30.0
assert t.enabled is False
class TestAudioTrackFromDict:
"""AudioTrack.from_dict"""
def test_empty_dict(self):
t = AudioTrack.from_dict({})
assert t.track_type == TRACK_TYPE_SFX
assert t.audio_path == ""
assert t.volume == DEFAULT_VOLUMES[TRACK_TYPE_SFX]
assert t.enabled is True
def test_full_dict(self):
t = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
"volume": 0.8,
"fade_in": 1.0,
"fade_out": 2.0,
"start_time": 5.0,
"duration": 60.0,
"enabled": True,
}
)
assert t.track_id == "t1"
assert t.track_type == "bgm"
assert t.volume == 0.8
assert t.fade_in == 1.0
assert t.duration == 60.0
def test_volume_clamped_to_zero(self):
t = AudioTrack.from_dict({"volume": -0.5})
assert t.volume == 0.0
def test_volume_clamped_to_two(self):
t = AudioTrack.from_dict({"volume": 3.0})
assert t.volume == 2.0
def test_invalid_volume_falls_back_to_default(self):
t = AudioTrack.from_dict({"track_type": "bgm", "volume": "abc"})
assert t.volume == DEFAULT_VOLUMES[TRACK_TYPE_BGM]
def test_invalid_fade_in_falls_back(self):
t = AudioTrack.from_dict({"fade_in": "bad"})
assert t.fade_in == 0.0
def test_negative_fade_in_clamped(self):
t = AudioTrack.from_dict({"fade_in": -1.0})
assert t.fade_in == 0.0
def test_invalid_fade_out_falls_back(self):
t = AudioTrack.from_dict({"fade_out": None})
assert t.fade_out == 0.0
def test_negative_start_time_clamped(self):
t = AudioTrack.from_dict({"start_time": -5.0})
assert t.start_time == 0.0
def test_invalid_duration_falls_back(self):
t = AudioTrack.from_dict({"duration": "long"})
assert t.duration == 0.0
def test_bgm_default_volume(self):
t = AudioTrack.from_dict({"track_type": "bgm"})
assert t.volume == 0.3
def test_main_default_volume(self):
t = AudioTrack.from_dict({"track_type": "main"})
assert t.volume == 1.0
def test_voiceover_default_volume(self):
t = AudioTrack.from_dict({"track_type": "voiceover"})
assert t.volume == 1.0
def test_ambient_default_volume(self):
t = AudioTrack.from_dict({"track_type": "ambient"})
assert t.volume == 0.2
def test_unknown_type_default_volume(self):
t = AudioTrack.from_dict({"track_type": "unknown_type"})
assert t.volume == 1.0
def test_enabled_false(self):
t = AudioTrack.from_dict({"enabled": False})
assert t.enabled is False
class TestAudioTrackValidate:
"""AudioTrack.validate"""
def test_empty_path_invalid(self):
t = AudioTrack(audio_path="")
valid, msg = t.validate()
assert valid is False
assert "audio_path" in msg
def test_valid_track(self):
t = AudioTrack(audio_path="/a.mp3", volume=0.5)
valid, msg = t.validate()
assert valid is True
assert msg == ""
def test_volume_below_zero_invalid(self):
t = AudioTrack(audio_path="/a.mp3", volume=-0.1)
valid, msg = t.validate()
assert valid is False
assert "volume" in msg
def test_volume_above_two_invalid(self):
t = AudioTrack(audio_path="/a.mp3", volume=2.1)
valid, msg = t.validate()
assert valid is False
assert "volume" in msg
def test_volume_zero_valid(self):
t = AudioTrack(audio_path="/a.mp3", volume=0.0)
valid, _ = t.validate()
assert valid is True
def test_volume_two_valid(self):
t = AudioTrack(audio_path="/a.mp3", volume=2.0)
valid, _ = t.validate()
assert valid is True
def test_negative_fade_in_invalid(self):
t = AudioTrack(audio_path="/a.mp3", fade_in=-1.0)
valid, msg = t.validate()
assert valid is False
assert "fade_in" in msg
def test_negative_fade_out_invalid(self):
t = AudioTrack(audio_path="/a.mp3", fade_out=-1.0)
valid, msg = t.validate()
assert valid is False
assert "fade_out" in msg
def test_negative_start_time_invalid(self):
t = AudioTrack(audio_path="/a.mp3", start_time=-0.5)
valid, msg = t.validate()
assert valid is False
assert "start_time" in msg
def test_negative_duration_invalid(self):
t = AudioTrack(audio_path="/a.mp3", duration=-1.0)
valid, msg = t.validate()
assert valid is False
assert "duration" in msg
class TestAudioTrackIsEffective:
"""AudioTrack.is_effective 属性"""
def test_enabled_with_path_effective(self):
t = AudioTrack(audio_path="/a.mp3", enabled=True)
assert t.is_effective is True
def test_disabled_not_effective(self):
t = AudioTrack(audio_path="/a.mp3", enabled=False)
assert t.is_effective is False
def test_no_path_not_effective(self):
t = AudioTrack(audio_path="", enabled=True)
assert t.is_effective is False
def test_disabled_no_path_not_effective(self):
t = AudioTrack(audio_path="", enabled=False)
assert t.is_effective is False
# ── MultiTrackMixConfig ──────────────────────────────────────────────────────
class TestMultiTrackMixConfigDefaults:
"""MultiTrackMixConfig 默认值"""
def test_default_values(self):
c = MultiTrackMixConfig()
assert c.tracks == []
assert c.master_volume == 1.0
assert c.normalize is True
assert c.max_output_volume == 1.5
def test_custom_config(self):
t1 = AudioTrack(track_id="t1", audio_path="/a.mp3")
c = MultiTrackMixConfig(
tracks=[t1],
master_volume=0.8,
normalize=False,
max_output_volume=2.0,
)
assert len(c.tracks) == 1
assert c.master_volume == 0.8
assert c.normalize is False
assert c.max_output_volume == 2.0
class TestMultiTrackFromConfigDict:
"""MultiTrackMixConfig.from_config_dict"""
def test_none_returns_default(self):
c = MultiTrackMixConfig.from_config_dict(None)
assert len(c.tracks) == 0
assert c.master_volume == 1.0
def test_empty_dict_returns_default(self):
c = MultiTrackMixConfig.from_config_dict({})
assert len(c.tracks) == 0
def test_non_dict_returns_default(self):
c = MultiTrackMixConfig.from_config_dict("not a dict")
assert len(c.tracks) == 0
def test_single_track(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "track_type": "bgm", "audio_path": "/bgm.mp3", "volume": 0.5},
],
}
)
assert len(c.tracks) == 1
assert c.tracks[0].track_id == "t1"
assert c.tracks[0].volume == 0.5
def test_multiple_tracks(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "track_type": "main", "audio_path": "/main.wav"},
{"track_id": "t2", "track_type": "bgm", "audio_path": "/bgm.mp3"},
{"track_id": "t3", "track_type": "sfx", "audio_path": "/sfx.wav"},
],
}
)
assert len(c.tracks) == 3
assert c.tracks[0].track_type == "main"
assert c.tracks[1].track_type == "bgm"
assert c.tracks[2].track_type == "sfx"
def test_skip_disabled_tracks(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3", "enabled": True},
{"track_id": "t2", "audio_path": "/b.mp3", "enabled": False},
{"track_id": "t3", "audio_path": "/c.mp3"},
],
}
)
assert len(c.tracks) == 2
ids = [t.track_id for t in c.tracks]
assert "t1" in ids
assert "t2" not in ids
assert "t3" in ids
def test_skip_no_path_tracks(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3"},
{"track_id": "t2", "audio_path": ""},
{"track_id": "t3"},
],
}
)
assert len(c.tracks) == 1
assert c.tracks[0].track_id == "t1"
def test_skip_non_dict_tracks(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3"},
"not a dict",
123,
None,
],
}
)
assert len(c.tracks) == 1
def test_master_volume_clamped(self):
c = MultiTrackMixConfig.from_config_dict({"master_volume": 3.0})
assert c.master_volume == 2.0
def test_master_volume_negative_clamped(self):
c = MultiTrackMixConfig.from_config_dict({"master_volume": -1.0})
assert c.master_volume == 0.0
def test_invalid_master_volume_falls_back(self):
c = MultiTrackMixConfig.from_config_dict({"master_volume": "high"})
assert c.master_volume == 1.0
def test_normalize_false(self):
c = MultiTrackMixConfig.from_config_dict({"normalize": False})
assert c.normalize is False
def test_max_output_volume_custom(self):
c = MultiTrackMixConfig.from_config_dict({"max_output_volume": 2.0})
assert c.max_output_volume == 2.0
def test_invalid_max_output_volume_falls_back(self):
c = MultiTrackMixConfig.from_config_dict({"max_output_volume": "big"})
assert c.max_output_volume == 1.5
def test_tracks_not_list_ignored(self):
c = MultiTrackMixConfig.from_config_dict({"tracks": "not a list"})
assert len(c.tracks) == 0
class TestMultiTrackProperties:
"""MultiTrackMixConfig 属性方法"""
def _make_config(self):
return MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "m1", "track_type": "main", "audio_path": "/m.wav"},
{"track_id": "b1", "track_type": "bgm", "audio_path": "/b1.mp3"},
{"track_id": "b2", "track_type": "bgm", "audio_path": "/b2.mp3", "enabled": False},
{"track_id": "s1", "track_type": "sfx", "audio_path": "/s.wav"},
{"track_id": "x", "track_type": "ambient", "audio_path": ""},
],
}
)
def test_has_effect_true(self):
c = self._make_config()
assert c.has_effect is True
def test_has_effect_false(self):
c = MultiTrackMixConfig()
assert c.has_effect is False
def test_effective_track_count(self):
c = self._make_config()
# m1 + b1 + s1 = 3个有效(b2禁用,x无路径)
assert c.effective_track_count == 3
def test_main_tracks(self):
c = self._make_config()
mains = c.main_tracks
assert len(mains) == 1
assert mains[0].track_id == "m1"
def test_bgm_tracks(self):
c = self._make_config()
bgms = c.bgm_tracks
assert len(bgms) == 1 # 只有b1有效
assert bgms[0].track_id == "b1"
def test_empty_tracks(self):
c = MultiTrackMixConfig()
assert c.effective_track_count == 0
assert c.main_tracks == []
assert c.bgm_tracks == []
# ── 工具函数 ─────────────────────────────────────────────────────────────────
class TestIsValidAudioExtension:
"""is_valid_audio_extension 函数"""
def test_mp3(self):
assert is_valid_audio_extension("song.mp3") is True
def test_wav(self):
assert is_valid_audio_extension("sound.wav") is True
def test_aac(self):
assert is_valid_audio_extension("audio.aac") is True
def test_ogg(self):
assert is_valid_audio_extension("music.ogg") is True
def test_flac(self):
assert is_valid_audio_extension("lossless.flac") is True
def test_m4a(self):
assert is_valid_audio_extension("apple.m4a") is True
def test_wma(self):
assert is_valid_audio_extension("windows.wma") is True
def test_uppercase_extension(self):
assert is_valid_audio_extension("SONG.MP3") is True
def test_mixed_case_extension(self):
assert is_valid_audio_extension("song.Mp3") is True
def test_mp4_not_valid(self):
assert is_valid_audio_extension("video.mp4") is False
def test_txt_not_valid(self):
assert is_valid_audio_extension("notes.txt") is False
def test_no_extension(self):
assert is_valid_audio_extension("README") is False
def test_full_path(self):
assert is_valid_audio_extension("/home/user/music/song.mp3") is True
class TestClampVolume:
"""clamp_volume 函数"""
def test_within_range(self):
assert clamp_volume(0.5) == 0.5
def test_exact_min(self):
assert clamp_volume(0.0) == 0.0
def test_exact_max(self):
assert clamp_volume(2.0) == 2.0
def test_below_min(self):
assert clamp_volume(-1.0) == 0.0
def test_above_max(self):
assert clamp_volume(3.0) == 2.0
def test_custom_bounds(self):
assert clamp_volume(5.0, min_vol=1.0, max_vol=10.0) == 5.0
def test_custom_below_min(self):
assert clamp_volume(0.5, min_vol=1.0) == 1.0
def test_custom_above_max(self):
assert clamp_volume(15.0, max_vol=10.0) == 10.0
+159
View File
@@ -0,0 +1,159 @@
"""Auth ports (ABC接口) 单元测试.
验证抽象接口定义正确:不能直接实例化,子类必须实现所有抽象方法。
"""
from __future__ import annotations
from abc import ABC
import pytest
from domain.auth.email_service import EmailServicePort
from domain.auth.jwt_service import JWTServicePort
from domain.auth.password_hasher import PasswordHasherPort, PasswordValidatorPort
from domain.auth.session_store import SessionStorePort
from domain.auth.sms_service import SmsService
class TestSessionStorePort:
"""SessionStorePort 接口测试."""
def test_is_abstract(self):
assert issubclass(SessionStorePort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
SessionStorePort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = SessionStorePort.__abstractmethods__
expected = {
"save_session",
"get_session",
"get_session_by_refresh_token",
"get_refresh_token",
"update_last_active",
"delete_session",
"get_user_sessions",
"delete_all_user_sessions",
"session_exists",
}
assert expected.issubset(abstract_methods)
def test_concrete_subclass_works(self):
class ConcreteStore(SessionStorePort):
def save_session(self, **kwargs): # type: ignore[override]
return True
def get_session(self, session_id): # type: ignore[override]
return None
def get_session_by_refresh_token(self, token): # type: ignore[override]
return None
def get_refresh_token(self, session_id): # type: ignore[override]
return None
def update_last_active(self, session_id): # type: ignore[override]
return True
def delete_session(self, session_id): # type: ignore[override]
return True
def get_user_sessions(self, user_id): # type: ignore[override]
return []
def delete_all_user_sessions(self, user_id): # type: ignore[override]
return 0
def session_exists(self, session_id): # type: ignore[override]
return False
store = ConcreteStore()
assert isinstance(store, SessionStorePort)
assert store.session_exists("s1") is False
assert store.delete_session("s1") is True
class TestEmailServicePort:
"""EmailServicePort 接口测试."""
def test_is_abstract(self):
assert issubclass(EmailServicePort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
EmailServicePort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = EmailServicePort.__abstractmethods__
expected = {"send_email", "send_verification_email", "send_password_reset_email"}
assert expected.issubset(abstract_methods)
class TestJWTServicePort:
"""JWTServicePort 接口测试."""
def test_is_abstract(self):
assert issubclass(JWTServicePort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
JWTServicePort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = JWTServicePort.__abstractmethods__
expected = {
"create_access_token",
"create_refresh_token",
"verify_token",
"verify_access_token",
"verify_refresh_token",
}
assert expected.issubset(abstract_methods)
class TestPasswordHasherPort:
"""PasswordHasherPort 接口测试."""
def test_is_abstract(self):
assert issubclass(PasswordHasherPort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
PasswordHasherPort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = PasswordHasherPort.__abstractmethods__
expected = {"hash_password", "verify_password", "needs_rehash"}
assert expected.issubset(abstract_methods)
class TestPasswordValidatorPort:
"""PasswordValidatorPort 接口测试."""
def test_is_abstract(self):
assert issubclass(PasswordValidatorPort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
PasswordValidatorPort() # type: ignore[misc]
def test_has_validate_method(self):
assert "validate" in PasswordValidatorPort.__abstractmethods__
class TestSmsService:
"""SmsService 接口测试."""
def test_is_abstract(self):
assert issubclass(SmsService, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
SmsService() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = SmsService.__abstractmethods__
expected = {"send_verification_code", "send_template_sms"}
assert expected.issubset(abstract_methods)
+139
View File
@@ -0,0 +1,139 @@
"""bgm_utils 单元测试."""
from __future__ import annotations
from domain.bgm_utils import merge_bgm_config
class TestMergeBgmConfigEmptyInputs:
"""空输入测试."""
def test_both_empty(self):
result = merge_bgm_config({}, {})
assert result == {}
def test_user_empty_returns_template_copy(self):
template = {"enabled": True, "volume": 0.5}
result = merge_bgm_config(template, {})
assert result == {"enabled": True, "volume": 0.5}
# 返回的是副本不是同一个对象
assert result is not template
def test_template_empty_returns_user_copy(self):
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config({}, user)
assert result == {"enabled": False, "volume": 0.8}
assert result is not user
def test_user_none_returns_template(self):
template = {"enabled": True}
result = merge_bgm_config(template, None) # type: ignore[arg-type]
assert result == template
def test_template_none_returns_user(self):
user = {"enabled": True}
result = merge_bgm_config(None, user) # type: ignore[arg-type]
assert result == user
class TestMergeBgmConfigBasicMerge:
"""基础合并测试."""
def test_user_overrides_template_field(self):
template = {"volume": 0.5, "fade_in": 1.0}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.8
assert result["fade_in"] == 1.0
def test_user_adds_new_field(self):
template = {"volume": 0.5}
user = {"fade_out": 2.0}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.5
assert result["fade_out"] == 2.0
def test_all_fields_overridden(self):
template = {"enabled": True, "volume": 0.5, "track_id": "t1"}
user = {"enabled": False, "volume": 1.0, "track_id": "t2"}
result = merge_bgm_config(template, user)
assert result == {"enabled": False, "volume": 1.0, "track_id": "t2"}
class TestMergeBgmConfigEnabledSpecial:
"""enabled 特殊处理测试."""
def test_user_no_enabled_keeps_template_enabled_true(self):
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.8
def test_user_no_enabled_keeps_template_enabled_false(self):
template = {"enabled": False, "volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
assert result["volume"] == 0.8
def test_user_explicit_enabled_true_overrides_template_false(self):
template = {"enabled": False, "volume": 0.5}
user = {"enabled": True, "volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.8
def test_user_explicit_enabled_false_overrides_template_true(self):
template = {"enabled": True, "volume": 0.5}
user = {"enabled": False}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
def test_template_no_enabled_user_no_enabled(self):
template = {"volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert "enabled" not in result
assert result["volume"] == 0.8
def test_template_no_enabled_user_has_enabled(self):
template = {"volume": 0.5}
user = {"enabled": True, "volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
class TestMergeBgmConfigDoesNotMutate:
"""不修改原字典测试."""
def test_template_not_mutated(self):
template = {"enabled": True, "volume": 0.5}
original = dict(template)
user = {"volume": 0.8, "fade": 1.0}
merge_bgm_config(template, user)
assert template == original
def test_user_not_mutated(self):
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8}
original = dict(user)
merge_bgm_config(template, user)
assert user == original
class TestMergeBgmConfigNestedDict:
"""嵌套字典合并测试(简单合并,非深合并)."""
def test_nested_dict_user_overrides(self):
template = {"effects": {"fade_in": 1.0, "fade_out": 1.0}}
user = {"effects": {"fade_in": 2.0}}
result = merge_bgm_config(template, user)
# 简单合并,用户effects整个覆盖模板的
assert result["effects"] == {"fade_in": 2.0}
def test_nested_dict_preserved_when_no_user_override(self):
template = {"effects": {"fade_in": 1.0}}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["effects"] == {"fade_in": 1.0}
+470
View File
@@ -0,0 +1,470 @@
"""ChromaKeyConfig 绿幕抠像配置单测.
纯逻辑模块,覆盖:数据类、from_dict解析、from_preset预设、has_effect、
validate校验、normalize_color颜色归一化、colorkey滤镜构建、
chromakey滤镜构建、apply_chroma_key_if_needed便捷函数、get_preset_names。
"""
from __future__ import annotations
from packages.domain.chroma_key_config import (
CHROMA_KEY_PRESETS,
MAX_BLEND,
MAX_SIMILARITY,
MAX_SPILL_SUPPRESS,
MIN_BLEND,
MIN_SIMILARITY,
MIN_SPILL_SUPPRESS,
VALID_PRESETS,
ChromaKeyConfig,
apply_chroma_key_if_needed,
build_chromakey_filter,
build_colorkey_filter,
get_preset_names,
normalize_color,
)
class TestChromaKeyConfigDefaults:
def test_default_disabled(self):
config = ChromaKeyConfig()
assert config.enabled is False
assert config.key_color == "#00FF00"
assert config.similarity == 0.3
assert config.blend == 0.1
assert config.spill_suppress == 0.0
def test_default_has_no_effect(self):
config = ChromaKeyConfig()
assert config.has_effect() is False
def test_default_validate_passes(self):
config = ChromaKeyConfig()
ok, msg = config.validate()
assert ok is True
assert msg == ""
class TestFromDict:
def test_none_returns_disabled(self):
config = ChromaKeyConfig.from_dict(None)
assert config.enabled is False
def test_empty_dict_returns_disabled(self):
config = ChromaKeyConfig.from_dict({})
assert config.enabled is False
def test_enabled_false_returns_disabled(self):
config = ChromaKeyConfig.from_dict({"enabled": False})
assert config.enabled is False
def test_basic_enabled(self):
config = ChromaKeyConfig.from_dict({"enabled": True, "key_color": "#FF0000"})
assert config.enabled is True
assert config.key_color == "#FF0000"
def test_default_values_when_enabled(self):
config = ChromaKeyConfig.from_dict({"enabled": True})
assert config.enabled is True
assert config.key_color == "#00FF00"
assert config.similarity == 0.3
assert config.blend == 0.1
assert config.spill_suppress == 0.0
def test_custom_values(self):
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"key_color": "#0000FF",
"similarity": 0.5,
"blend": 0.2,
"spill_suppress": 0.3,
}
)
assert config.key_color == "#0000FF"
assert config.similarity == 0.5
assert config.blend == 0.2
assert config.spill_suppress == 0.3
def test_similarity_clamped_below_min(self):
config = ChromaKeyConfig.from_dict({"enabled": True, "similarity": 0.0})
assert config.similarity == MIN_SIMILARITY
def test_similarity_clamped_above_max(self):
config = ChromaKeyConfig.from_dict({"enabled": True, "similarity": 2.0})
assert config.similarity == MAX_SIMILARITY
def test_blend_clamped_below_min(self):
config = ChromaKeyConfig.from_dict({"enabled": True, "blend": -1.0})
assert config.blend == MIN_BLEND
def test_blend_clamped_above_max(self):
config = ChromaKeyConfig.from_dict({"enabled": True, "blend": 2.0})
assert config.blend == MAX_BLEND
def test_spill_suppress_clamped_below_min(self):
config = ChromaKeyConfig.from_dict({"enabled": True, "spill_suppress": -0.5})
assert config.spill_suppress == MIN_SPILL_SUPPRESS
def test_spill_suppress_clamped_above_max(self):
config = ChromaKeyConfig.from_dict({"enabled": True, "spill_suppress": 2.0})
assert config.spill_suppress == MAX_SPILL_SUPPRESS
def test_invalid_float_values_use_default(self):
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"similarity": "not_a_number",
"blend": None,
}
)
assert config.similarity == 0.3
assert config.blend == 0.1
def test_key_color_stripped(self):
config = ChromaKeyConfig.from_dict({"enabled": True, "key_color": " #FF0000 "})
assert config.key_color == "#FF0000"
class TestFromPreset:
def test_green_screen_preset(self):
config = ChromaKeyConfig.from_preset("green_screen")
assert config is not None
assert config.enabled is True
assert config.key_color == "#00FF00"
assert config.similarity == 0.3
assert config.blend == 0.1
assert config.spill_suppress == 0.5
def test_blue_screen_preset(self):
config = ChromaKeyConfig.from_preset("blue_screen")
assert config is not None
assert config.key_color == "#0000FF"
def test_red_screen_preset(self):
config = ChromaKeyConfig.from_preset("red_screen")
assert config is not None
assert config.key_color == "#FF0000"
assert config.spill_suppress == 0.0
def test_invalid_preset_returns_none(self):
config = ChromaKeyConfig.from_preset("nonexistent")
assert config is None
def test_all_presets_are_valid(self):
for name in CHROMA_KEY_PRESETS:
config = ChromaKeyConfig.from_preset(name)
assert config is not None
assert config.enabled is True
assert config.has_effect() is True
ok, _ = config.validate()
assert ok is True
class TestHasEffect:
def test_disabled_no_effect(self):
config = ChromaKeyConfig(enabled=False)
assert config.has_effect() is False
def test_enabled_but_zero_similarity(self):
config = ChromaKeyConfig(enabled=True, similarity=0.0)
assert config.has_effect() is False
def test_enabled_with_similarity_has_effect(self):
config = ChromaKeyConfig(enabled=True, similarity=0.01)
assert config.has_effect() is True
def test_full_config_has_effect(self):
config = ChromaKeyConfig(
enabled=True,
key_color="#00FF00",
similarity=0.3,
blend=0.1,
spill_suppress=0.5,
)
assert config.has_effect() is True
class TestValidate:
def test_disabled_valid(self):
config = ChromaKeyConfig(enabled=False)
ok, msg = config.validate()
assert ok is True
assert msg == ""
def test_valid_config(self):
config = ChromaKeyConfig(
enabled=True,
key_color="#00FF00",
similarity=0.3,
blend=0.1,
spill_suppress=0.5,
)
ok, msg = config.validate()
assert ok is True
assert msg == ""
def test_empty_key_color_invalid(self):
config = ChromaKeyConfig(enabled=True, key_color="")
ok, msg = config.validate()
assert ok is False
assert "key_color" in msg
def test_similarity_below_min_invalid(self):
config = ChromaKeyConfig(enabled=True, similarity=0.001)
ok, msg = config.validate()
assert ok is False
assert "similarity" in msg
def test_similarity_above_max_invalid(self):
config = ChromaKeyConfig(enabled=True, similarity=1.5)
ok, msg = config.validate()
assert ok is False
assert "similarity" in msg
def test_blend_below_min_invalid(self):
config = ChromaKeyConfig(enabled=True, blend=-0.1)
ok, msg = config.validate()
assert ok is False
assert "blend" in msg
def test_blend_above_max_invalid(self):
config = ChromaKeyConfig(enabled=True, blend=1.5)
ok, msg = config.validate()
assert ok is False
assert "blend" in msg
def test_spill_suppress_below_min_invalid(self):
config = ChromaKeyConfig(enabled=True, spill_suppress=-0.1)
ok, msg = config.validate()
assert ok is False
assert "spill_suppress" in msg
def test_spill_suppress_above_max_invalid(self):
config = ChromaKeyConfig(enabled=True, spill_suppress=1.5)
ok, msg = config.validate()
assert ok is False
assert "spill_suppress" in msg
def test_boundary_values_valid(self):
config = ChromaKeyConfig(
enabled=True,
key_color="#000",
similarity=MIN_SIMILARITY,
blend=MIN_BLEND,
spill_suppress=MIN_SPILL_SUPPRESS,
)
ok, _ = config.validate()
assert ok is True
config2 = ChromaKeyConfig(
enabled=True,
key_color="#fff",
similarity=MAX_SIMILARITY,
blend=MAX_BLEND,
spill_suppress=MAX_SPILL_SUPPRESS,
)
ok2, _ = config2.validate()
assert ok2 is True
class TestNormalizeColor:
def test_hex_with_hash(self):
assert normalize_color("#FF0000") == "0xFF0000"
def test_hex_without_hash(self):
assert normalize_color("FF0000") == "0xFF0000"
def test_hex_lowercase(self):
assert normalize_color("#ff0000") == "0xFF0000"
def test_hex_mixed_case(self):
assert normalize_color("#aBcDeF") == "0xABCDEF"
def test_hex_with_alpha(self):
assert normalize_color("#FF000080") == "0xFF0000"
def test_0x_format_passthrough(self):
assert normalize_color("0xFF0000") == "0XFF0000"
def test_0x_lowercase(self):
assert normalize_color("0xff0000") == "0XFF0000"
def test_color_name_passthrough(self):
assert normalize_color("green") == "green"
def test_color_name_blue(self):
assert normalize_color("blue") == "blue"
def test_whitespace_stripped(self):
assert normalize_color(" #FF0000 ") == "0xFF0000"
def test_black_color(self):
assert normalize_color("#000000") == "0x000000"
def test_white_color(self):
assert normalize_color("#FFFFFF") == "0xFFFFFF"
class TestBuildColorkeyFilter:
def test_disabled_returns_copy(self):
config = ChromaKeyConfig(enabled=False)
result = build_colorkey_filter(config, "[0:v]", "[out]")
assert result == "[0:v]copy[out]"
def test_zero_similarity_returns_copy(self):
config = ChromaKeyConfig(enabled=True, similarity=0.0)
result = build_colorkey_filter(config, "[v0]", "[ck]")
assert result == "[v0]copy[ck]"
def test_basic_colorkey(self):
config = ChromaKeyConfig(enabled=True, key_color="#00FF00", similarity=0.3, blend=0.1)
result = build_colorkey_filter(config, "[0:v]", "[ck]")
assert "colorkey=color=0x00FF00:similarity=0.3:blend=0.1" in result
assert result.startswith("[0:v]")
assert result.endswith("[ck]")
def test_with_spill_suppress(self):
config = ChromaKeyConfig(
enabled=True,
key_color="#00FF00",
similarity=0.3,
blend=0.1,
spill_suppress=0.5,
)
result = build_colorkey_filter(config, "[0:v]", "[ck]")
assert "colorkey=" in result
assert "colorchannelmixer=" in result
# Spill suppress reduces green gain
assert "gg=" in result
assert "rr=" in result
assert "bb=" in result
def test_no_spill_suppress_no_colorchannelmixer(self):
config = ChromaKeyConfig(
enabled=True,
key_color="#00FF00",
similarity=0.3,
blend=0.1,
spill_suppress=0.0,
)
result = build_colorkey_filter(config, "[0:v]", "[ck]")
assert "colorchannelmixer" not in result
def test_different_labels(self):
config = ChromaKeyConfig(enabled=True, key_color="#FF0000", similarity=0.5)
result = build_colorkey_filter(config, "[v_in]", "[v_out]")
assert result.startswith("[v_in]")
assert result.endswith("[v_out]")
def test_spill_suppress_gain_values(self):
config = ChromaKeyConfig(
enabled=True,
key_color="#00FF00",
similarity=0.3,
blend=0.1,
spill_suppress=1.0,
)
result = build_colorkey_filter(config, "[0:v]", "[ck]")
# At spill=1.0: g_gain = max(0.3, 1.0 - 1.0*0.7) = max(0.3, 0.3) = 0.3
assert "gg=0.3" in result
class TestBuildChromakeyFilter:
def test_disabled_returns_copy(self):
config = ChromaKeyConfig(enabled=False)
result = build_chromakey_filter(config, "[0:v]", "[out]")
assert result == "[0:v]copy[out]"
def test_basic_chromakey(self):
config = ChromaKeyConfig(enabled=True, key_color="#0000FF", similarity=0.4, blend=0.2)
result = build_chromakey_filter(config, "[0:v]", "[ck]")
assert "chromakey=color=0x0000FF:similarity=0.4:blend=0.2" in result
assert result.startswith("[0:v]")
assert result.endswith("[ck]")
def test_spill_suppress_not_included(self):
"""chromakey滤镜不包含spill_suppress(只有colorkey有)."""
config = ChromaKeyConfig(
enabled=True,
key_color="#00FF00",
similarity=0.3,
blend=0.1,
spill_suppress=0.5,
)
result = build_chromakey_filter(config, "[0:v]", "[ck]")
assert "colorchannelmixer" not in result
assert "chromakey=" in result
class TestApplyChromaKeyIfNeeded:
def test_none_clip_config_returns_none(self):
result = apply_chroma_key_if_needed(None, "[0:v]", "[ck]")
assert result is None
def test_no_chroma_key_returns_none(self):
result = apply_chroma_key_if_needed({"other": "data"}, "[0:v]", "[ck]")
assert result is None
def test_chroma_key_disabled_returns_none(self):
config = {"chroma_key": {"enabled": False}}
result = apply_chroma_key_if_needed(config, "[0:v]", "[ck]")
assert result is None
def test_chroma_key_enabled_returns_filter(self):
config = {
"chroma_key": {
"enabled": True,
"key_color": "#00FF00",
"similarity": 0.3,
"blend": 0.1,
}
}
result = apply_chroma_key_if_needed(config, "[0:v]", "[ck]")
assert result is not None
assert "colorkey=" in result
assert result.startswith("[0:v]")
assert result.endswith("[ck]")
def test_invalid_config_returns_none(self):
"""配置异常时应该返回None而不是抛异常."""
config = {"chroma_key": "invalid_data"}
result = apply_chroma_key_if_needed(config, "[0:v]", "[ck]")
assert result is None
class TestGetPresetNames:
def test_returns_sorted_list(self):
names = get_preset_names()
assert isinstance(names, list)
assert names == sorted(names)
def test_contains_known_presets(self):
names = get_preset_names()
assert "green_screen" in names
assert "blue_screen" in names
assert "red_screen" in names
def test_count_matches_presets_dict(self):
names = get_preset_names()
assert len(names) == len(CHROMA_KEY_PRESETS)
class TestPresetsAndConstants:
def test_valid_presets_equals_preset_keys(self):
assert VALID_PRESETS == set(CHROMA_KEY_PRESETS.keys())
def test_each_preset_has_required_keys(self):
for name, preset in CHROMA_KEY_PRESETS.items():
assert "key_color" in preset, f"{name} missing key_color"
assert "similarity" in preset, f"{name} missing similarity"
assert "blend" in preset, f"{name} missing blend"
assert "spill_suppress" in preset, f"{name} missing spill_suppress"
def test_min_less_than_max(self):
assert MIN_SIMILARITY < MAX_SIMILARITY
assert MIN_BLEND <= MAX_BLEND
assert MIN_SPILL_SUPPRESS <= MAX_SPILL_SUPPRESS
def test_min_similarity_positive(self):
assert MIN_SIMILARITY > 0
+184
View File
@@ -0,0 +1,184 @@
"""classification 单测.
domain 层素材分类模块纯逻辑,0 外部依赖。
覆盖:4个枚举 + ClassificationJob 工厂/校验。
"""
from __future__ import annotations
from packages.domain.classification import (
AssetClassification,
AssetLibraryKind,
ClassificationJob,
ClassificationJobStatus,
IngestJobStatus,
)
class TestAssetLibraryKind:
"""AssetLibraryKind 枚举测试."""
def test_three_values(self):
"""视频/配音/图片三类."""
assert len(AssetLibraryKind) == 3
def test_video(self):
assert AssetLibraryKind.VIDEO == "video"
def test_voice(self):
assert AssetLibraryKind.VOICE == "voice"
def test_image(self):
assert AssetLibraryKind.IMAGE == "image"
def test_str_compatible(self):
"""StrEnum 字符串兼容."""
assert AssetLibraryKind.VIDEO == "video"
class TestIngestJobStatus:
"""IngestJobStatus 枚举测试."""
def test_four_statuses(self):
assert len(IngestJobStatus) == 4
def test_pending(self):
assert IngestJobStatus.PENDING == "pending"
def test_processing(self):
assert IngestJobStatus.PROCESSING == "processing"
def test_completed(self):
assert IngestJobStatus.COMPLETED == "completed"
def test_failed(self):
assert IngestJobStatus.FAILED == "failed"
class TestClassificationJobStatus:
"""ClassificationJobStatus 枚举测试."""
def test_four_statuses(self):
assert len(ClassificationJobStatus) == 4
def test_pending(self):
assert ClassificationJobStatus.PENDING == "pending"
def test_processing(self):
assert ClassificationJobStatus.PROCESSING == "processing"
def test_completed(self):
assert ClassificationJobStatus.COMPLETED == "completed"
def test_failed(self):
assert ClassificationJobStatus.FAILED == "failed"
def test_same_values_as_ingest(self):
"""两种任务状态值相同."""
assert set(ClassificationJobStatus) == set(IngestJobStatus)
class TestAssetClassification:
"""AssetClassification 枚举测试."""
def test_nine_categories(self):
"""9个分类."""
assert len(AssetClassification) == 9
def test_scenic(self):
assert AssetClassification.SCENIC == "scenic"
def test_product(self):
assert AssetClassification.PRODUCT == "product"
def test_person(self):
assert AssetClassification.PERSON == "person"
def test_animal(self):
assert AssetClassification.ANIMAL == "animal"
def test_food(self):
assert AssetClassification.FOOD == "food"
def test_tech(self):
assert AssetClassification.TECH == "tech"
def test_sport(self):
assert AssetClassification.SPORT == "sport"
def test_music(self):
assert AssetClassification.MUSIC == "music"
def test_other(self):
assert AssetClassification.OTHER == "other"
def test_all_values_unique(self):
"""所有分类值唯一."""
values = [c.value for c in AssetClassification]
assert len(values) == len(set(values))
class TestClassificationJobCreate:
"""ClassificationJob.create 测试."""
def test_create_valid(self):
"""正常创建."""
job = ClassificationJob.create(project_id="proj1", asset_id="asset1")
assert job.project_id == "proj1"
assert job.asset_id == "asset1"
assert job.status == ClassificationJobStatus.PENDING
assert job.classification == ""
assert job.confidence == 0.0
assert job.error_message == ""
assert isinstance(job.id, str)
assert len(job.id) > 0
def test_create_strips(self):
"""project_id 和 asset_id 会 strip."""
job = ClassificationJob.create(project_id=" proj1 ", asset_id=" asset1 ")
assert job.project_id == "proj1"
assert job.asset_id == "asset1"
def test_create_empty_project_id(self):
"""空 project_id 无效."""
try:
ClassificationJob.create(project_id="", asset_id="a1")
assert False
except ValueError as e:
assert "project_id" in str(e)
def test_create_whitespace_project_id(self):
"""纯空白 project_id 无效."""
try:
ClassificationJob.create(project_id=" ", asset_id="a1")
assert False
except ValueError as e:
assert "project_id" in str(e)
def test_create_empty_asset_id(self):
"""空 asset_id 无效."""
try:
ClassificationJob.create(project_id="p1", asset_id="")
assert False
except ValueError as e:
assert "asset_id" in str(e)
def test_create_whitespace_asset_id(self):
"""纯空白 asset_id 无效."""
try:
ClassificationJob.create(project_id="p1", asset_id=" ")
assert False
except ValueError as e:
assert "asset_id" in str(e)
def test_create_unique_id(self):
"""不同 job id 不同."""
j1 = ClassificationJob.create("p", "a")
j2 = ClassificationJob.create("p", "a")
assert j1.id != j2.id
def test_create_has_timestamps(self):
"""有创建和更新时间."""
job = ClassificationJob.create("p", "a")
assert job.created_at is not None
assert job.updated_at is not None
+518
View File
@@ -0,0 +1,518 @@
"""片段操作工具单测.
纯函数模块,覆盖:分割校验/计算、合并校验/计算、
order重排、order偏移。
"""
from __future__ import annotations
from dataclasses import dataclass
from packages.domain.clip_operations import (
DEFAULT_SPLIT_DURATION,
MergeResult,
SplitResult,
calculate_merge,
calculate_reorder_new_orders,
calculate_shift_orders,
calculate_split,
validate_merge_clips,
validate_split_time,
)
class TestConstants:
def test_default_split_duration(self):
assert DEFAULT_SPLIT_DURATION == 5.0
class TestValidateSplitTime:
def test_valid_middle(self):
validate_split_time(5.0, 10.0) # 不抛异常就是通过
def test_valid_small(self):
validate_split_time(0.1, 10.0)
def test_valid_near_end(self):
validate_split_time(9.9, 10.0)
def test_zero_invalid(self):
try:
validate_split_time(0.0, 10.0)
except ValueError as e:
assert "分割时间" in str(e)
else:
raise AssertionError("expected ValueError")
def test_negative_invalid(self):
try:
validate_split_time(-1.0, 10.0)
except ValueError:
pass
else:
raise AssertionError("expected ValueError")
def test_equal_to_duration_invalid(self):
try:
validate_split_time(10.0, 10.0)
except ValueError:
pass
else:
raise AssertionError("expected ValueError")
def test_greater_than_duration_invalid(self):
try:
validate_split_time(15.0, 10.0)
except ValueError:
pass
else:
raise AssertionError("expected ValueError")
class TestCalculateSplit:
def test_split_half(self):
result = calculate_split(10.0, 5.0)
assert isinstance(result, SplitResult)
assert result.left_duration == 5.0
assert result.right_duration == 5.0
assert result.right_start_time == 5.0
assert result.left_trim_end == 5.0
assert result.right_trim_start == 5.0
def test_split_one_third(self):
result = calculate_split(9.0, 3.0)
assert result.left_duration == 3.0
assert result.right_duration == 6.0
assert result.right_start_time == 3.0
def test_split_with_start_time(self):
result = calculate_split(10.0, 4.0, start_time=100.0)
assert result.left_duration == 4.0
assert result.right_duration == 6.0
assert result.right_start_time == 104.0
def test_split_precision_rounding(self):
result = calculate_split(1.0, 1 / 3, precision=3)
assert result.left_duration == round(1 / 3, 3)
assert result.right_duration == round(2 / 3, 3)
def test_split_default_precision_is_3(self):
result = calculate_split(1.0, 0.123456)
# 默认精度3位
assert result.left_duration == 0.123
def test_custom_precision(self):
result = calculate_split(1.0, 0.123456, precision=5)
assert result.left_duration == 0.12346 # 5位精度,四舍五入
def test_split_returns_frozen_dataclass(self):
result = calculate_split(10.0, 5.0)
try:
result.left_duration = 3.0 # type: ignore
except AttributeError:
pass # frozen,应该抛异常
else:
raise AssertionError("SplitResult should be frozen")
def test_invalid_split_time_raises(self):
try:
calculate_split(10.0, 0.0)
except ValueError:
pass
else:
raise AssertionError("expected ValueError")
@dataclass
class FakeClip:
"""模拟 EditPlanClip 的最小数据类."""
id: str = ""
plan_id: str = "plan_1"
order: int = 0
duration: float = 3.0
clip_type: str = "main"
text_content: str = ""
config: dict | None = None
class TestValidateMergeClips:
def test_valid_two_clips(self):
clips = [
FakeClip(id="c1", order=0),
FakeClip(id="c2", order=1),
]
plan_id, first_order = validate_merge_clips(clips)
assert plan_id == "plan_1"
assert first_order == 0
def test_valid_three_clips(self):
clips = [
FakeClip(id="c1", order=2),
FakeClip(id="c2", order=3),
FakeClip(id="c3", order=4),
]
plan_id, first_order = validate_merge_clips(clips)
assert plan_id == "plan_1"
assert first_order == 2
def test_unordered_input_still_valid(self):
"""输入顺序不影响,内部会排序."""
clips = [
FakeClip(id="c3", order=2),
FakeClip(id="c1", order=0),
FakeClip(id="c2", order=1),
]
plan_id, first_order = validate_merge_clips(clips)
assert first_order == 0
def test_single_clip_invalid(self):
try:
validate_merge_clips([FakeClip()])
except ValueError as e:
assert "至少需要 2 个" in str(e)
else:
raise AssertionError("expected ValueError")
def test_empty_list_invalid(self):
try:
validate_merge_clips([])
except ValueError:
pass
else:
raise AssertionError("expected ValueError")
def test_different_plan_invalid(self):
clips = [
FakeClip(id="c1", plan_id="plan_a", order=0),
FakeClip(id="c2", plan_id="plan_b", order=1),
]
try:
validate_merge_clips(clips)
except ValueError as e:
assert "同一计划" in str(e)
else:
raise AssertionError("expected ValueError")
def test_non_consecutive_order_invalid(self):
clips = [
FakeClip(id="c1", order=0),
FakeClip(id="c2", order=2), # 跳过1
]
try:
validate_merge_clips(clips)
except ValueError as e:
assert "不连续" in str(e)
else:
raise AssertionError("expected ValueError")
def test_different_clip_type_invalid(self):
clips = [
FakeClip(id="c1", order=0, clip_type="main"),
FakeClip(id="c2", order=1, clip_type="title"),
]
try:
validate_merge_clips(clips)
except ValueError as e:
assert "相同类型" in str(e)
else:
raise AssertionError("expected ValueError")
class TestCalculateMerge:
def test_merge_two_clips_duration(self):
clips = [
FakeClip(id="c1", order=0, duration=3.0),
FakeClip(id="c2", order=1, duration=5.0),
]
result = calculate_merge(clips)
assert isinstance(result, MergeResult)
assert result.total_duration == 8.0
def test_merge_three_clips_duration(self):
clips = [
FakeClip(id="c1", order=0, duration=2.0),
FakeClip(id="c2", order=1, duration=3.0),
FakeClip(id="c3", order=2, duration=4.0),
]
result = calculate_merge(clips)
assert result.total_duration == 9.0
def test_merge_text_concatenation(self):
clips = [
FakeClip(id="c1", order=0, text_content="第一句"),
FakeClip(id="c2", order=1, text_content="第二句"),
]
result = calculate_merge(clips)
assert result.merged_text == "第一句\n第二句"
def test_merge_empty_text_skipped(self):
clips = [
FakeClip(id="c1", order=0, text_content="hello"),
FakeClip(id="c2", order=1, text_content=""),
FakeClip(id="c3", order=2, text_content="world"),
]
result = calculate_merge(clips)
assert result.merged_text == "hello\nworld"
def test_merge_whitespace_text_skipped(self):
clips = [
FakeClip(id="c1", order=0, text_content="a"),
FakeClip(id="c2", order=1, text_content=" "),
FakeClip(id="c3", order=2, text_content="b"),
]
result = calculate_merge(clips)
assert result.merged_text == "a\nb"
def test_merge_all_empty_text(self):
clips = [
FakeClip(id="c1", order=0, text_content=""),
FakeClip(id="c2", order=1, text_content=""),
]
result = calculate_merge(clips)
assert result.merged_text == ""
def test_merge_config_later_overrides(self):
clips = [
FakeClip(id="c1", order=0, config={"font_size": 20, "color": "red"}),
FakeClip(id="c2", order=1, config={"font_size": 24, "bold": True}),
]
result = calculate_merge(clips)
assert result.merged_config["font_size"] == 24 # 后面的覆盖
assert result.merged_config["color"] == "red"
assert result.merged_config["bold"] is True
def test_merge_config_removes_trim_fields(self):
clips = [
FakeClip(id="c1", order=0, config={"trim_start": 1.0, "a": 1}),
FakeClip(id="c2", order=1, config={"trim_end": 2.0, "b": 2}),
]
result = calculate_merge(clips)
assert "trim_start" not in result.merged_config
assert "trim_end" not in result.merged_config
assert result.merged_config["a"] == 1
assert result.merged_config["b"] == 2
def test_merge_none_config_handled(self):
clips = [
FakeClip(id="c1", order=0, config=None),
FakeClip(id="c2", order=1, config={"key": "val"}),
]
result = calculate_merge(clips)
assert result.merged_config == {"key": "val"}
def test_merge_first_order_and_shift(self):
clips = [
FakeClip(id="c1", order=5),
FakeClip(id="c2", order=6),
FakeClip(id="c3", order=7),
]
result = calculate_merge(clips)
assert result.first_order == 5
assert result.shift_amount == 2 # 3个合并成1个,前移2位
def test_merge_two_clips_shift(self):
clips = [FakeClip(id="c1", order=0), FakeClip(id="c2", order=1)]
result = calculate_merge(clips)
assert result.shift_amount == 1
def test_merge_unordered_input(self):
"""输入乱序也能正确处理(内部排序)."""
clips = [
FakeClip(id="c3", order=2, duration=4.0, text_content="C"),
FakeClip(id="c1", order=0, duration=2.0, text_content="A"),
FakeClip(id="c2", order=1, duration=3.0, text_content="B"),
]
result = calculate_merge(clips)
assert result.total_duration == 9.0
assert result.merged_text == "A\nB\nC"
assert result.first_order == 0
def test_merge_precision(self):
clips = [
FakeClip(id="c1", order=0, duration=1 / 3),
FakeClip(id="c2", order=1, duration=1 / 3),
]
result = calculate_merge(clips, precision=3)
assert result.total_duration == round(2 / 3, 3)
def test_merge_empty_list_raises(self):
try:
calculate_merge([])
except ValueError as e:
assert "不能为空" in str(e)
else:
raise AssertionError("expected ValueError")
def test_merge_result_is_frozen(self):
clips = [FakeClip(id="c1", order=0), FakeClip(id="c2", order=1)]
result = calculate_merge(clips)
try:
result.total_duration = 10.0 # type: ignore
except AttributeError:
pass
else:
raise AssertionError("MergeResult should be frozen")
@dataclass
class FakeItem:
id: str
order: int = 0
class TestCalculateReorderNewOrders:
def test_basic_reorder(self):
items = [
FakeItem(id="a", order=0),
FakeItem(id="b", order=1),
FakeItem(id="c", order=2),
]
new_order = ["c", "a", "b"]
result = calculate_reorder_new_orders(new_order, items)
assert result == {"c": 0, "a": 1, "b": 2}
def test_reverse_order(self):
items = [FakeItem(id="a"), FakeItem(id="b"), FakeItem(id="c")]
new_order = ["c", "b", "a"]
result = calculate_reorder_new_orders(new_order, items)
assert result["c"] == 0
assert result["b"] == 1
assert result["a"] == 2
def test_same_order(self):
items = [FakeItem(id="a"), FakeItem(id="b")]
new_order = ["a", "b"]
result = calculate_reorder_new_orders(new_order, items)
assert result == {"a": 0, "b": 1}
def test_mismatched_ids_raises(self):
items = [FakeItem(id="a"), FakeItem(id="b")]
try:
calculate_reorder_new_orders(["a", "c"], items)
except ValueError as e:
assert "不匹配" in str(e)
else:
raise AssertionError("expected ValueError")
def test_extra_id_in_list_raises(self):
items = [FakeItem(id="a")]
try:
calculate_reorder_new_orders(["a", "b"], items)
except ValueError:
pass
else:
raise AssertionError("expected ValueError")
def test_missing_id_raises(self):
items = [FakeItem(id="a"), FakeItem(id="b")]
try:
calculate_reorder_new_orders(["a"], items)
except ValueError:
pass
else:
raise AssertionError("expected ValueError")
def test_custom_id_attr(self):
@dataclass
class CustomItem:
key: str
order: int = 0
items = [CustomItem(key="x"), CustomItem(key="y")]
result = calculate_reorder_new_orders(["y", "x"], items, id_attr="key")
assert result == {"y": 0, "x": 1}
def test_custom_order_attr_does_not_affect_return(self):
"""order_attr不影响返回值(返回的是索引),只影响参数校验的ID提取."""
items = [FakeItem(id="a", order=10), FakeItem(id="b", order=20)]
result = calculate_reorder_new_orders(["b", "a"], items)
assert result == {"b": 0, "a": 1} # 新order是索引,不是原值
class TestCalculateShiftOrders:
def test_shift_positive(self):
items = [
FakeItem(id="a", order=0),
FakeItem(id="b", order=1),
FakeItem(id="c", order=2),
]
result = calculate_shift_orders(items, threshold_order=0, shift=5)
# order > 0 的是 b(1) 和 c(2)
shifted = {item.id: new_order for item, new_order in result}
assert len(result) == 2
assert shifted["b"] == 6
assert shifted["c"] == 7
def test_shift_negative(self):
items = [
FakeItem(id="a", order=0),
FakeItem(id="b", order=1),
FakeItem(id="c", order=2),
]
result = calculate_shift_orders(items, threshold_order=0, shift=-1)
shifted = {item.id: new_order for item, new_order in result}
assert shifted["b"] == 0
assert shifted["c"] == 1
def test_threshold_not_included(self):
"""threshold_order本身不包含在内(严格大于)."""
items = [FakeItem(id="a", order=5)]
result = calculate_shift_orders(items, threshold_order=5, shift=1)
assert len(result) == 0
def test_excluded_ids_skipped(self):
items = [
FakeItem(id="a", order=1),
FakeItem(id="b", order=2),
FakeItem(id="c", order=3),
]
result = calculate_shift_orders(items, threshold_order=0, shift=10, excluded_ids={"b"})
shifted = {item.id: new_order for item, new_order in result}
assert "b" not in shifted
assert shifted["a"] == 11
assert shifted["c"] == 13
def test_none_excluded_ids(self):
items = [FakeItem(id="a", order=1)]
result = calculate_shift_orders(items, threshold_order=0, shift=1, excluded_ids=None)
assert len(result) == 1
def test_empty_excluded_ids(self):
items = [FakeItem(id="a", order=1)]
result = calculate_shift_orders(items, threshold_order=0, shift=1, excluded_ids=set())
assert len(result) == 1
def test_no_items_above_threshold(self):
items = [
FakeItem(id="a", order=0),
FakeItem(id="b", order=1),
]
result = calculate_shift_orders(items, threshold_order=10, shift=5)
assert len(result) == 0
def test_custom_id_attr(self):
@dataclass
class CustomItem:
key: str
pos: int = 0
items = [CustomItem(key="x", pos=1), CustomItem(key="y", pos=2)]
result = calculate_shift_orders(
items,
threshold_order=0,
shift=3,
id_attr="key",
order_attr="pos",
)
assert len(result) == 2
assert result[0][1] == 4
assert result[1][1] == 5
def test_preserves_item_reference(self):
item = FakeItem(id="a", order=5)
items = [item]
result = calculate_shift_orders(items, threshold_order=3, shift=2)
assert len(result) == 1
assert result[0][0] is item # 是同一个对象引用
assert result[0][1] == 7

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