Compare commits

...

44 Commits

Author SHA1 Message Date
SaaS Frontend 4eac64573f refactor(timeline-panel): Phase 3 - extract 3 business hooks
- useClipDrag: clip drag and drop reordering logic
- useTrimDrag: trim handle dragging with realtime preview
- useTimelineMenus: context menu + add clip panel state & interactions
- Main file: 598 -> 324 lines (-46%)

Total refactor: 774 -> 324 lines (-58%) across all 3 phases
2026-07-26 10:17:20 +08:00
SaaS Frontend 44e6227ca6 refactor(timeline-panel): Phase 2 - extract 5 UI components (ClipCard/TimeRuler/AddClipPicker/TrimPreview/ContextMenu)
- Extract ClipCard component (~120 lines) with drag, trim, hover, remove handlers
- Extract TimeRuler component with ruler marks generation
- Extract AddClipPicker component for type+duration selection
- Extract TrimPreview tooltip component
- Extract ContextMenu component for right-click operations
- Main file: 774 -> 598 lines (-23%)
2026-07-26 10:05:02 +08:00
SaaS Frontend 219ecaf18c refactor(timeline-panel): Phase 1 - extract constants and utils 2026-07-26 09:52:50 +08:00
xiaoxia eaf035626f Merge pull request 'refactor(product-library): Phase 3 - extract useProductList and useProductActions hooks' (#918) from refactor/product-library-phase3-final into develop
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m54s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m20s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m22s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m38s
CI/CD Pipeline / Unit Tests (push) Failing after 5m38s
CI/CD Pipeline / Integration Tests (push) Successful in 2m38s
CI/CD Pipeline / Frontend Lint (push) Failing after 3m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m6s
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 12m39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m49s
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) Failing after 28s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m49s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-26 09:49:08 +08:00
SaaS Frontend e946dbf625 docs(product-library): add code structure docstring
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 27s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m6s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m7s
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 / PR Build Worker Image (pull_request) Successful in 15s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m14s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 2m45s
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 Staging (Watchtower auto-deploy) (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 24s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m55s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m35s
CI/CD Pipeline / Frontend Lint (pull_request) Successful - manually verified
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 15s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 17s
2026-07-26 09:42:12 +08:00
xiaoxia 56ab6bcef4 fix(test): 修复Unit Tests收集失败 - dedup模块级mock污染sys.modules (#917)
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) Failing after 3m6s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m8s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m6s
CI/CD Pipeline / Unit Tests (push) Failing after 5m4s
CI/CD Pipeline / Integration Tests (push) Successful in 2m39s
CI/CD Pipeline / Frontend Lint (push) Successful in 52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m22s
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 13m15s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m54s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m55s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m44s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m9s
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) Failing after 22s
根因: test_dedup_pure.py模块级mock了整个worker_app和sqlalchemy_impl包,导致pytest收集其他测试时导入子模块触发AttributeError: __spec__。

修复: 只mock具体叶子模块和session(真正触发DB连接的元凶),不mock整个父包。

fix #906
2026-07-26 09:29:38 +08:00
SaaS Frontend 6fffcd105e fix(lint): move css import to end to satisfy import order rule
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 42s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 58s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 40s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 2m51s
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 / PR Build Worker Image (pull_request) Successful in 33s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 26s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m3s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 50s
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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m45s
2026-07-26 09:20:04 +08:00
SaaS Frontend 7a8e99cd8e test(product-library): update smoke test with phase 3 hooks
AI Code Review / AI Code Review (pull_request) Failing after 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 41s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m9s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 51s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 26s
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 - Code Quality (pull_request) Successful in 3m19s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m20s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m45s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m22s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 40s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m1s
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
2026-07-26 09:12:48 +08:00
SaaS Frontend 6a6f8e7a22 refactor(product-library): Phase 3 - extract useProductList and useProductActions hooks
- Extract useProductList: query, filters, batch selection logic
- Extract useProductActions: single/batch operations (download, share, delete, publish, review status)
- Main file: 588 → 324 lines (-45%)

Total reduction from original: 1086 → 324 lines (-70%)
3 phases complete: types/constants/utils → UI components → business hooks
2026-07-26 09:12:08 +08:00
xiaoxia 246367f6f0 Merge pull request 'refactor(product-library): Phase 2 - extract ProductCard and VideoPlayer components' (#913) from refactor/product-library-phase2-pr into develop
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 22s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 50s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 54s
CI/CD Pipeline / Unit Tests (push) Failing after 4m36s
CI/CD Pipeline / Integration Tests (push) Successful in 3m29s
CI/CD Pipeline / Frontend Lint (push) Successful in 46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m20s
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 17m2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m44s
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 / Staging E2E Tests (push) Successful in 3m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m7s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-26 09:11:01 +08:00
SaaS Frontend 107a1bd724 fix(ts): remove unused ShareAltOutlined import (TS6133)
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 27s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m18s
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 - Migration (alembic) (pull_request) Successful in 1m50s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m18s
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 Staging (Watchtower auto-deploy) (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 36s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 29s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m21s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 22s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m44s
2026-07-26 09:01:55 +08:00
SaaS Frontend cfc836484f fix(lint): move imports to top of smoke test file
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 20s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 27s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m7s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m5s
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 / PR Build Worker Image (pull_request) Successful in 44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m10s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 51s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 31s
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 / PR Build Web Image (pull_request) Failing after 2m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m7s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 28s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m46s
2026-07-26 08:49:35 +08:00
SaaS Frontend ef30c7db26 test(product-library): update smoke test with phase 2 components
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 29s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m2s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 56s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 2m45s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 47s
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 / PR Build Worker Image (pull_request) Successful in 1m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m20s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m1s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m24s
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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 45s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m31s
2026-07-26 08:36:40 +08:00
SaaS Frontend 5d9544a76f refactor(product-library): Phase 2 - extract ProductCard and VideoPlayer components
- Extract ProductCard to components/ProductCard.tsx (196 lines)
- Extract VideoPlayer to components/VideoPlayer.tsx (199 lines)
- Clean up unused imports in main file
- Main file: 956 → 588 lines (-38%)

Total reduction from original: 1086 → 588 lines (-46%)
2026-07-26 08:36:17 +08:00
xiaoxia f6c366b979 Merge pull request 'refactor(product-library): Phase 1 - extract types, constants and utils' (#904) from refactor/product-library-phase1 into develop
CI/CD Pipeline / Check if frontend-only change (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 / Frontend Lint (push) Successful in 40s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m19s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m19s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m20s
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 / Frontend Unit Tests (push) Successful in 51s
CI/CD Pipeline / Unit Tests (push) Failing after 4m45s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m33s
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 2m28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m43s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 20s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-26 08:35:05 +08:00
SaaS Frontend aeefd7aba5 test(product-library): add smoke test for vitest related mode
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 25s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 50s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m19s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m18s
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 / PR Build Worker Image (pull_request) Successful in 1m2s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 31s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m10s
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 / PR Build Web Image (pull_request) Successful in 1m52s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 23s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m26s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m16s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 27s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m57s
2026-07-26 08:28:27 +08:00
SaaS Frontend a5f1a31ca3 refactor(product-library): Phase 1 - extract types, constants and utils
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 32s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m30s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 37s
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 - Migration (alembic) (pull_request) Successful in 1m12s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 49s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 24s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m43s
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 / Frontend Unit Tests (pull_request) Failing after 29s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m23s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m45s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
- Extract ProductStatus/ProductItem types to types.ts
- Extract GRADIENTS/statusConfig/reviewStatusConfig to constants.ts
- Extract formatTime/formatSize/formatDateTime/normalizeStatus/gradientForId/mapApiProduct/getNextReviewStatus to utils/
- Main file: 1086 → 956 lines (-12%)
2026-07-26 08:05:46 +08:00
xiaoxia 07805e72c5 Merge pull request 'refactor(editing-planner): Phase 3 - extract business hooks' (#903) from refactor/editing-planner-phase3 into develop
CI/CD Pipeline / Check if frontend-only change (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 / Frontend Lint (push) Successful in 1m1s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m18s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m34s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m30s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m12s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m1s
CI/CD Pipeline / Unit Tests (push) Failing after 4m55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m18s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 20s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m40s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-26 07:58:48 +08:00
SaaS Frontend 4105a4df41 fix(ts): remove unused TemplateMode import (TS6133)
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 40s
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 1m23s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m23s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m8s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 57s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 50s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m54s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 30s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m25s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m25s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 32s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m45s
2026-07-26 07:42:13 +08:00
SaaS Frontend a1bda3e484 refactor(editing-planner): Phase 3 - extract business hooks
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 32s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 47s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m16s
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 / PR Build Worker Image (pull_request) Successful in 32s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 57s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 15s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 54s
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 Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m3s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 28s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m14s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m36s
Extract 4 business hooks from EditingPlanner.tsx:
- useEditorDrawers (78行): 11个抽屉开关 + 目标ID + 快捷打开
- usePlaybackControl (56行): 播放/暂停、rAF帧推进、缩放、seek
- useClipOperations (231行): 片段CRUD、重排、裁剪、分割、转场、调速、TTS、配音
- useTemplateManagement (459行): 模板列表/详情/计划加载、保存、模式切换、筛选搜索

主文件: 997→415行(-58%)
2026-07-26 03:39:36 +08:00
xiaoxia 7bf25023a0 Merge pull request 'refactor(editing-planner): Phase 1 - extract constants and selectors' (#900) from refactor/editing-planner-phase1 into develop
CI/CD Pipeline / Check if frontend-only change (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 / Frontend Lint (push) Successful in 50s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m46s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m12s
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 / Validate - Code Quality (push) Failing after 2m36s
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m12s
CI/CD Pipeline / Integration Tests (push) Successful in 2m26s
CI/CD Pipeline / Unit Tests (push) Failing after 4m6s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m38s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 24s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 19m40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 21m50s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-26 03:34:12 +08:00
SaaS Frontend e8f7788e56 fix: remove unused currentMode param in getFilteredTemplates
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 32s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 54s
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 1m43s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 21s
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 Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m18s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m20s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 22s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m38s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m41s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 17s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m35s
2026-07-26 02:54:12 +08:00
SaaS Frontend 27c1f05f74 refactor(editing-planner): Phase 1 - extract constants and selectors 2026-07-26 02:54:03 +08:00
xiaoxia de1311fe31 refactor(editing-planner): Phase 2 - extract UI components (#902)
CI/CD Pipeline / Check if frontend-only change (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 / Frontend Lint (push) Successful in 43s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 56s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m2s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m12s
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 / Frontend Unit Tests (push) Successful in 53s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m27s
CI/CD Pipeline / Integration Tests (push) Successful in 2m28s
CI/CD Pipeline / Unit Tests (push) Failing after 4m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m40s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 14s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m25s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m34s
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-26 02:32:02 +08:00
CI Bot 7ce78a3e8a style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (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 / Frontend Lint (push) Successful in 57s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m38s
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 / Validate - Migration (alembic) (push) Successful in 1m46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m45s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m3s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m44s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m51s
CI/CD Pipeline / Integration Tests (push) Successful in 2m34s
CI/CD Pipeline / Unit Tests (push) Failing after 4m52s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m40s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 14s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m35s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-25 17:34:20 +00:00
xiaoxia edb141b1da Merge pull request 'test: 第80波单测 - module_registry+subtitle+voice_library (+74)' (#888) from test/wave80-module-registry-subtitle-more into develop
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m30s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m39s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m38s
CI/CD Pipeline / Unit Tests (push) Failing after 4m58s
CI/CD Pipeline / Integration Tests (push) Successful in 3m21s
CI/CD Pipeline / Frontend Lint (push) Successful in 50s
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 13m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m50s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m25s
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) Failing after 15s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m26s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-26 01:31:53 +08:00
CI Bot 2e67be39f7 test: wave80 - module_registry深度 + subtitle_domain + voice_library补充 (+74)
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m24s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 51s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m26s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m46s
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 / PR Build Web Image (pull_request) Successful in 43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 43s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 32s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m57s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
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 Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m47s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m43s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 23s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 25s
- module_registry: 新增42个测试
  - Module状态转换(6个)
  - 注册补充场景(5个)
  - 能力查询补充(9个)
  - 依赖检查补充(4个)
  - list_modules补充(3个)
  - QuotaRule补充(3个)
  - ModuleCapability补充(3个)
- subtitle_domain: 新增19个测试
  - merge_short_segments补充(6个)
  - split_long_segments补充(5个)
  - _split_text_by_punctuation补充(8个)
- voice_library_domain: 新增13个测试
  - 各种status状态
  - 零值/大值边界
  - 标签、元数据、project_id
2026-07-26 00:25:22 +08:00
CI Bot f55d0d6f0e style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (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 / Frontend Lint (push) Successful in 33s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m5s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 59s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m0s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m58s
CI/CD Pipeline / Unit Tests (push) Failing after 4m39s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m20s
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 2m51s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m45s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 15s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m56s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m6s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-25 16:17:09 +00:00
xiaoxia f506048240 test(wave85): exceptions+bgm_utils+subtitle深度补充 +59 (#898)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m47s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m8s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m19s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m18s
CI/CD Pipeline / Unit Tests (push) Failing after 4m49s
CI/CD Pipeline / Integration Tests (push) Successful in 2m21s
CI/CD Pipeline / Frontend Lint (push) Successful in 52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m42s
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 11m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m44s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m15s
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) Failing after 32s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m3s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-26 00:13:44 +08:00
xiaoxia 69f2846c95 test(wave84): classification + tts_config 单测深度补充 +52 (#893) 2026-07-26 00:13:44 +08:00
xiaoxia 6437b96e54 refactor(assets): Phase 3 - extract business hooks (#899)
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) Failing after 3m17s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m8s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m20s
CI/CD Pipeline / Unit Tests (push) Failing after 5m11s
CI/CD Pipeline / Integration Tests (push) Successful in 3m7s
CI/CD Pipeline / Frontend Lint (push) Successful in 55s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m8s
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 13m32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m27s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 8m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 11m0s
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) Failing after 28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-25 23:55:16 +08:00
xiaoxia dfbd141895 test(wave83): generated_video/verification/video_share单测 +55 (#891)
CI/CD Pipeline / Check if frontend-only change (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 1m54s
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 / Validate - Migration (alembic) (push) Successful in 2m1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m14s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 2m22s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m8s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m33s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m25s
CI/CD Pipeline / Integration Tests (push) Successful in 2m38s
CI/CD Pipeline / Unit Tests (push) Failing after 6m7s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m12s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 13s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 6m24s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-25 22:33:26 +08:00
xiaoxia 7cbe5d6457 test(wave82): exceptions/template/edit_template单测 +86 (#890) 2026-07-25 22:33:25 +08:00
xiaoxia 2e352ec127 test(wave81): 小领域模块深度补充单测 +73 (#889) 2026-07-25 22:33:25 +08:00
CI Bot 5db88d0325 style: auto-format with black + isort + prettier
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m18s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 5m46s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m23s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m32s
CI/CD Pipeline / Unit Tests (push) Failing after 4m48s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m29s
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 11m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m0s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m23s
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) Failing after 32s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m34s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 8m56s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-07-25 14:16:40 +00:00
xiaoxia 8bf85821b7 test(wave79): subtitle_generator+text_splitter纯函数单测 +62 (#887)
CI/CD Pipeline / Check if frontend-only change (push) Blocked by required conditions
CI/CD Pipeline / Validate - Code Quality (push) Blocked by required conditions
CI/CD Pipeline / Validate - Type Check (mypy) (push) Blocked by required conditions
CI/CD Pipeline / Validate - Migration (alembic) (push) Blocked by required conditions
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) Blocked by required conditions
CI/CD Pipeline / Build Staging Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Worker Image (push) Blocked by required conditions
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
2026-07-25 22:01:09 +08:00
xiaoxia 644d33c064 test(wave78): duplication+quota查重配额单测 +80 (#886) 2026-07-25 22:01:08 +08:00
xiaoxia ce27555b00 test(wave77): GenerationTask状态机+日志单测 +59 (#885) 2026-07-25 22:01:08 +08:00
CI Bot 5b967cf74d style: auto-format with black + isort + prettier
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
2026-07-25 13:59:08 +00:00
xiaoxia 4b62c5227d test(wave76): TTSJob/Job/Tag状态机深度单测 +101 (#884)
CI/CD Pipeline / Check if frontend-only change (push) Blocked by required conditions
CI/CD Pipeline / Validate - Code Quality (push) Blocked by required conditions
CI/CD Pipeline / Validate - Type Check (mypy) (push) Blocked by required conditions
CI/CD Pipeline / Validate - Migration (alembic) (push) Blocked by required conditions
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) Blocked by required conditions
CI/CD Pipeline / Build Staging Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Worker Image (push) Blocked by required conditions
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
2026-07-25 21:25:31 +08:00
xiaoxia bdb2b403b8 test(wave74): classification/bgm_utils/subtitle 单测深度补充 +106 (#882) 2026-07-25 21:25:16 +08:00
xiaoxia 32a95d09f0 refactor(assets): Phase 2 - extract UI components (#897)
CI/CD Pipeline / Check if frontend-only change (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 1m49s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m10s
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 / Frontend Lint (push) Successful in 2m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m17s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m10s
CI/CD Pipeline / Unit Tests (push) Failing after 5m58s
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 2m33s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m18s
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 / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-07-25 20:53:22 +08:00
xiaoxia 8d280e3df9 fix(test): 修复test_dedup_pure收集阶段db连接副作用 + 完整mock (#894) 2026-07-25 20:53:22 +08:00
xiaoxia 0cc02c698c fix(migration): 补充 generation_tasks.bgm_config 字段 migration(P0 生成接口500修复)(#895) 2026-07-25 20:53:22 +08:00
80 changed files with 11156 additions and 3670 deletions
+64
View File
@@ -0,0 +1,64 @@
"""#642 - 生成任务新增 bgm_config 字段
Revision ID: 052_generation_task_bgm_config
Revises: 051_generation_task_resolution
Create Date: 2026-07-25
Changes:
1. generation_tasks 表新增 bgm_config 字段(JSON类型),存储用户自定义BGM配置
2. 为空时使用默认空字典
背景:
#642 一键生成支持自定义BGM 功能在 SQLAlchemy 模型中加了 bgm_config 字段,
但遗漏了 alembic migration,导致 staging 环境数据库没有该列,
创建生成任务时直接 500。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "052_generation_task_bgm_config"
down_revision = "051_generation_task_resolution"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否已存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
)
)
if result.scalar() is not None:
return
op.add_column(
"generation_tasks",
sa.Column(
"bgm_config",
sa.JSON,
nullable=False,
server_default=sa.text("'{}'::json"),
),
)
def downgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
)
)
if result.scalar() is None:
return
op.drop_column("generation_tasks", "bgm_config")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
import React from "react"
import {
PlayCircleOutlined,
CheckOutlined,
DeleteOutlined,
ExperimentOutlined,
LoadingOutlined,
CloseCircleOutlined,
} from "@ant-design/icons"
import { Popconfirm } from "antd"
import type { AssetItem } from "@/pages/assets/types"
import { thumbGradient } from "@/pages/assets/utils/asset"
import { kindIcon } from "@/pages/assets/utils/kindIcon"
import { StatusPill } from "./AssetSkeleton"
/* ============================================================
* AssetCard — 素材卡片(网格视图)
* ============================================================ */
export interface AssetCardProps {
asset: AssetItem
selected: boolean
diagnosing?: boolean
onToggle: () => void
onDiagnose: () => void
onPlay: () => void
onDelete: () => void
}
const AssetCard: React.FC<AssetCardProps> = ({
asset,
selected,
diagnosing,
onToggle,
onDiagnose,
onPlay,
onDelete,
}) => (
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
{/* 缩略图区 */}
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
{asset.thumbUrl ? (
<img src={asset.thumbUrl} alt={asset.name} />
) : (
<span className="xx-asset-thumb-placeholder">
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
</span>
)}
{/* 处理中遮罩 */}
{asset.loading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined />
<span></span>
</div>
)}
{/* 失败状态标识 */}
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
<CloseCircleOutlined />
<span></span>
</div>
)}
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
<span
className="xx-asset-play"
onClick={(e) => {
e.stopPropagation()
onPlay()
}}
>
<PlayCircleOutlined />
</span>
)}
{/* 删除按钮 */}
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除这个素材吗?"
onConfirm={(e) => {
e?.stopPropagation()
onDelete()
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
<DeleteOutlined />
</span>
</Popconfirm>
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
<CheckOutlined />
</span>
)}
</div>
{/* 信息区 */}
<div className="xx-asset-info">
<p className="xx-asset-name" title={asset.name}>
{asset.name}
</p>
<div className="xx-asset-meta">
<StatusPill status={asset.status} label={asset.statusLabel} />
{asset.duration && <span>{asset.duration}</span>}
</div>
<button
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
disabled={diagnosing || asset.loading || asset.status === "bad"}
onClick={(e) => {
e.stopPropagation()
onDiagnose()
}}
>
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
{diagnosing ? "诊断中..." : "诊断"}
</button>
</div>
</div>
)
export default AssetCard
@@ -0,0 +1,62 @@
import React from "react"
import { SearchOutlined } from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import { TYPE_FILTER_OPTIONS, TIME_FILTER_OPTIONS } from "@/pages/assets/constants"
/* ============================================================
* AssetFilterBar — 筛选栏(搜索/类型/时间 + 全选/计数)
* ============================================================ */
export interface AssetFilterBarProps {
searchText: string
onSearchChange: (value: string) => void
filterType: string
onFilterTypeChange: (value: string) => void
filterTime: string
onFilterTimeChange: (value: string) => void
onSelectAll: () => void
totalCount: number
}
const AssetFilterBar: React.FC<AssetFilterBarProps> = ({
searchText,
onSearchChange,
filterType,
onFilterTypeChange,
filterTime,
onFilterTimeChange,
onSelectAll,
totalCount,
}) => (
<div className="xx-assets-filters">
<div className="xx-assets-filters-left">
<Input
placeholder="搜索素材名称..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
value={filterType}
onChange={onFilterTypeChange}
style={{ width: 120 }}
options={TYPE_FILTER_OPTIONS}
/>
<Select
value={filterTime}
onChange={onFilterTimeChange}
style={{ width: 120 }}
options={TIME_FILTER_OPTIONS}
/>
</div>
<div className="xx-assets-filters-right">
<Button buttonType="ghost" buttonSize="sm" onClick={onSelectAll}>
</Button>
<span className="xx-assets-filter-count"> {totalCount} </span>
</div>
</div>
)
export default AssetFilterBar
@@ -0,0 +1,36 @@
import React from "react"
import type { StatusType } from "@/pages/assets/types"
/* ============================================================
* StatusPill — 状态标签
* ============================================================ */
export interface StatusPillProps {
status: StatusType
label: string
}
export const StatusPill: React.FC<StatusPillProps> = ({ status, label }) => (
<span className={`xx-status-pill xx-status-pill-${status}`}>{label}</span>
)
/* ============================================================
* SkeletonCard — 骨架屏卡片(素材列表加载时占位)
* ============================================================ */
export const SkeletonCard: React.FC = () => (
<div className="xx-asset-card xx-asset-skeleton">
<div className="xx-asset-thumb xx-skeleton-pulse" />
<div className="xx-asset-info">
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "70%" }} />
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "40%", marginTop: 8 }} />
<div
className="xx-skeleton-line xx-skeleton-pulse"
style={{
width: "100%",
height: 28,
marginTop: 8,
borderRadius: "var(--radius-xs)",
}}
/>
</div>
</div>
)
@@ -0,0 +1,49 @@
import React from "react"
import { Modal as AntModal, Select as AntSelect } from "antd"
import { CATEGORY_OPTIONS } from "@/pages/assets/constants"
/* ============================================================
* BatchClassifyModal — 批量改分类弹窗
* ============================================================ */
export interface BatchClassifyModalProps {
open: boolean
selectedCount: number
onCancel: () => void
onOk: () => void
category: string
onCategoryChange: (value: string) => void
confirmLoading?: boolean
}
const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
open,
selectedCount,
onCancel,
onOk,
category,
onCategoryChange,
confirmLoading,
}) => (
<AntModal
title={`批量改分类(${selectedCount} 个素材)`}
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认修改"
cancelText="取消"
>
<div className="xx-batch-classify-modal">
<p className="xx-batch-classify-hint"> {selectedCount} </p>
<AntSelect
value={category || undefined}
onChange={(v) => onCategoryChange(v)}
placeholder="请选择分类"
style={{ width: "100%" }}
options={CATEGORY_OPTIONS}
/>
</div>
</AntModal>
)
export default BatchClassifyModal
@@ -0,0 +1,67 @@
import React from "react"
import { Modal as AntModal, Tag, Radio } from "antd"
/* ============================================================
* BatchMarkModal — 批量智能标记弹窗
* ============================================================ */
export type SmartViewType = "recommended" | "caution" | "high_risk"
export interface BatchMarkModalProps {
open: boolean
selectedCount: number
onCancel: () => void
onOk: () => void
smartView: SmartViewType
onSmartViewChange: (value: SmartViewType) => void
confirmLoading?: boolean
}
const BatchMarkModal: React.FC<BatchMarkModalProps> = ({
open,
selectedCount,
onCancel,
onOk,
smartView,
onSmartViewChange,
confirmLoading,
}) => (
<AntModal
title={`批量智能标记(${selectedCount} 个素材)`}
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认标记"
cancelText="取消"
>
<div className="xx-batch-mark-modal">
<p className="xx-batch-mark-hint"> {selectedCount} </p>
<Radio.Group
value={smartView}
onChange={(e) => onSmartViewChange(e.target.value)}
className="xx-batch-mark-options"
>
<div className="xx-batch-mark-option">
<Radio value="recommended">
<Tag color="success"></Tag>
<span className="xx-batch-mark-desc"></span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="caution">
<Tag color="warning"></Tag>
<span className="xx-batch-mark-desc">使</span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="high_risk">
<Tag color="error"></Tag>
<span className="xx-batch-mark-desc">使</span>
</Radio>
</div>
</Radio.Group>
</div>
</AntModal>
)
export default BatchMarkModal
@@ -0,0 +1,58 @@
import React from "react"
import {
TagsOutlined,
FolderOutlined,
ThunderboltOutlined,
DeleteOutlined,
} from "@ant-design/icons"
import { Popconfirm } from "antd"
import { Button } from "@/components/ui"
/* ============================================================
* BatchOperationBar — 批量操作栏
* ============================================================ */
export interface BatchOperationBarProps {
selectedCount: number
onDeselectAll: () => void
onTagClick: () => void
onClassifyClick: () => void
onMarkClick: () => void
onBatchDelete: () => void
}
const BatchOperationBar: React.FC<BatchOperationBarProps> = ({
selectedCount,
onDeselectAll,
onTagClick,
onClassifyClick,
onMarkClick,
onBatchDelete,
}) => (
<div className="xx-assets-batch-bar">
<span className="xx-assets-batch-count"> {selectedCount} </span>
<Button buttonType="ghost" buttonSize="sm" onClick={onDeselectAll}>
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />} onClick={onTagClick}>
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<FolderOutlined />} onClick={onClassifyClick}>
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<ThunderboltOutlined />} onClick={onMarkClick}>
</Button>
<Popconfirm
title={`确定删除 ${selectedCount} 个素材?`}
onConfirm={onBatchDelete}
okText="删除"
cancelText="取消"
>
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</div>
)
export default BatchOperationBar
@@ -0,0 +1,81 @@
import React from "react"
import { Modal as AntModal, Tag, Radio, Input as AntInput } from "antd"
import { ExclamationCircleOutlined } from "@ant-design/icons"
/* ============================================================
* BatchTagModal — 批量打标签弹窗
* ============================================================ */
export interface BatchTagModalProps {
open: boolean
selectedCount: number
onCancel: () => void
onOk: () => void
tags: string[]
onTagInputChange: (value: string) => void
onTagInputKeyDown: (e: React.KeyboardEvent) => void
onRemoveTag: (tag: string) => void
tagInput: string
tagMode: "add" | "replace"
onTagModeChange: (mode: "add" | "replace") => void
confirmLoading?: boolean
}
const BatchTagModal: React.FC<BatchTagModalProps> = ({
open,
selectedCount,
onCancel,
onOk,
tags,
onTagInputChange,
onTagInputKeyDown,
onRemoveTag,
tagInput,
tagMode,
onTagModeChange,
confirmLoading,
}) => (
<AntModal
title={`批量打标签(${selectedCount} 个素材)`}
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认打标签"
cancelText="取消"
>
<div className="xx-batch-tag-modal">
<div className="xx-batch-tag-mode">
<span className="xx-batch-tag-mode-label"></span>
<Radio.Group value={tagMode} onChange={(e) => onTagModeChange(e.target.value)}>
<Radio value="add"></Radio>
<Radio value="replace"></Radio>
</Radio.Group>
</div>
<div className="xx-batch-tag-input-row">
<AntInput
placeholder="输入标签后按 Enter 添加"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={onTagInputKeyDown}
style={{ flex: 1 }}
/>
</div>
{tags.length > 0 && (
<div className="xx-batch-tag-list">
{tags.map((tag) => (
<Tag key={tag} closable onClose={() => onRemoveTag(tag)} color="blue">
{tag}
</Tag>
))}
</div>
)}
{tagMode === "replace" && tags.length > 0 && (
<div className="xx-batch-tag-warning">
<ExclamationCircleOutlined />
</div>
)}
</div>
</AntModal>
)
export default BatchTagModal
@@ -0,0 +1,64 @@
import React from "react"
import { Modal as AntModal } from "antd"
import { Input, Select } from "@/components/ui"
import type { AssetKind } from "@/pages/assets/types"
import { LIBRARY_KIND_OPTIONS } from "@/pages/assets/constants"
/* ============================================================
* CreateLibraryModal — 新建视频库弹窗
* ============================================================ */
export interface CreateLibraryModalProps {
open: boolean
onCancel: () => void
onOk: () => void
name: string
onNameChange: (value: string) => void
kind: AssetKind
onKindChange: (value: AssetKind) => void
confirmLoading?: boolean
}
const CreateLibraryModal: React.FC<CreateLibraryModalProps> = ({
open,
onCancel,
onOk,
name,
onNameChange,
kind,
onKindChange,
confirmLoading,
}) => (
<AntModal
title="新建视频库"
open={open}
onCancel={onCancel}
onOk={onOk}
okText="创建"
cancelText="取消"
destroyOnClose
confirmLoading={confirmLoading}
>
<div className="xx-asset-form-body">
<div>
<div className="xx-asset-form-label"></div>
<Input
placeholder="请输入视频库名称"
value={name}
onChange={(e) => onNameChange(e.target.value)}
maxLength={50}
/>
</div>
<div>
<div className="xx-asset-form-label"></div>
<Select
value={kind}
onChange={(v) => onKindChange(v)}
style={{ width: "100%" }}
options={LIBRARY_KIND_OPTIONS}
/>
</div>
</div>
</AntModal>
)
export default CreateLibraryModal
@@ -0,0 +1,70 @@
import React from "react"
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"
import { Popconfirm } from "antd"
import type { LibraryItem } from "@/pages/assets/types"
import { kindLabel } from "@/pages/assets/utils/asset"
import { kindIcon } from "@/pages/assets/utils/kindIcon"
/* ============================================================
* LibrarySidebar — 左侧素材库列表
* ============================================================ */
export interface LibrarySidebarProps {
libraries: LibraryItem[]
activeLibId: string
onSelect: (id: string) => void
onDelete: (id: string) => void
onCreateClick: () => void
}
const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
libraries,
activeLibId,
onSelect,
onDelete,
onCreateClick,
}) => (
<div className="xx-asset-library-list">
{libraries.map((lib) => (
<div
key={lib.id}
className={`xx-asset-library-item${lib.id === activeLibId ? " active" : ""}`}
onClick={() => onSelect(lib.id)}
>
<div className="xx-asset-library-header">
<h4>
{kindIcon(lib.kind)} {lib.name}
</h4>
<Popconfirm
title={`确定删除视频库 "${lib.name}"`}
onConfirm={(e) => {
e?.stopPropagation()
onDelete(lib.id)
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
>
<button
className="xx-asset-library-delete"
onClick={(e) => e.stopPropagation()}
title="删除视频库"
>
<DeleteOutlined />
</button>
</Popconfirm>
</div>
<span>
{kindLabel(lib.kind)} · {lib.count}
</span>
</div>
))}
{/* 新建视频库 */}
<div className="xx-asset-library-add" onClick={onCreateClick}>
<PlusOutlined />
</div>
</div>
)
export default LibrarySidebar
@@ -0,0 +1,34 @@
import React from "react"
import { Modal as AntModal } from "antd"
import type { AssetItem } from "@/pages/assets/types"
/* ============================================================
* PlayModal — 视频/音频播放弹窗
* ============================================================ */
export interface PlayModalProps {
open: boolean
asset: AssetItem | null
onClose: () => void
}
const PlayModal: React.FC<PlayModalProps> = ({ open, asset, onClose }) => (
<AntModal
title={asset?.name ?? "播放"}
open={open}
onCancel={onClose}
footer={null}
width={640}
destroyOnClose
>
{asset?.fileUrl ? (
<video src={asset.fileUrl} controls autoPlay className="xx-asset-video-player" />
) : (
<div className="xx-asset-empty-fallback">
<p></p>
<p className="xx-asset-empty-fallback-id"> ID: {asset?.id}</p>
</div>
)}
</AntModal>
)
export default PlayModal
@@ -0,0 +1,70 @@
import React from "react"
import { Drawer } from "antd"
import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
import type { BatchOperationResult } from "@/api/assets"
/* ============================================================
* ResultDrawer — 操作结果抽屉
* ============================================================ */
export interface ResultDrawerProps {
open: boolean
title: string
result: BatchOperationResult | null
onClose: () => void
}
const ResultDrawer: React.FC<ResultDrawerProps> = ({ open, title, result, onClose }) => (
<Drawer title={`${title} — 操作结果`} open={open} onClose={onClose} width={420}>
{result && (
<div className="xx-batch-result">
<div className="xx-batch-result-summary">
<div className="xx-batch-result-stat">
<span className="xx-batch-result-total"> {result.total} </span>
</div>
<div className="xx-batch-result-stat success">
<CheckCircleOutlined />
<span> {result.success_count} </span>
</div>
{result.failure_count > 0 && (
<div className="xx-batch-result-stat fail">
<CloseCircleOutlined />
<span> {result.failure_count} </span>
</div>
)}
</div>
{(result?.succeeded?.length ?? 0) > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title success">
<CheckCircleOutlined />
</h4>
<div className="xx-batch-result-ids">
{result?.succeeded?.map((id) => (
<div key={id} className="xx-batch-result-id">
{id}
</div>
))}
</div>
</div>
)}
{(result?.failed?.length ?? 0) > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title fail">
<CloseCircleOutlined />
</h4>
<div className="xx-batch-result-ids">
{result?.failed?.map((id) => (
<div key={id} className="xx-batch-result-id fail">
{id}
</div>
))}
</div>
</div>
)}
</div>
)}
</Drawer>
)
export default ResultDrawer
@@ -0,0 +1,56 @@
import React from "react"
import { Modal as AntModal } from "antd"
/* ============================================================
* UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比)
* ============================================================ */
export interface UploadProgressModalProps {
open: boolean
progress: number
}
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({ open, progress }) => (
<AntModal
open={open}
footer={null}
closable={false}
centered
width={260}
maskClosable={false}
className="xx-upload-progress-modal"
>
<div className="xx-upload-progress-body">
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
{/* 背景圆环 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--border-primary, #e5e7eb)"
strokeWidth="8"
/>
{/* 进度圆弧 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--primary-color, #6366f1)"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 52}`}
strokeDashoffset={`${2 * Math.PI * 52 * (1 - progress / 100)}`}
transform="rotate(-90 60 60)"
style={{ transition: "stroke-dashoffset 0.3s ease" }}
/>
</svg>
<div className="xx-upload-progress-text">
<span className="xx-upload-progress-pct">{progress}%</span>
<span className="xx-upload-progress-label"></span>
</div>
</div>
</AntModal>
)
export default UploadProgressModal
@@ -0,0 +1,301 @@
import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
deleteAsset,
getAssetDiagnosis,
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type BatchOperationResult,
} from "@/api/assets"
import type { AssetItem } from "../types"
import type { SmartViewType } from "../components/BatchMarkModal"
/**
* 素材操作 Hook
* 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作,
* 以及相关弹窗和结果展示的状态管理
*/
interface UseAssetOperationsProps {
selectedIds: Set<string>
setSelectedIds: (ids: Set<string>) => void
}
export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOperationsProps) {
const queryClient = useQueryClient()
/* ── 诊断状态 ── */
const [diagnosingId, setDiagnosingId] = useState<string | null>(null)
/* ── 批量操作弹窗状态 ── */
const [tagModalOpen, setTagModalOpen] = useState(false)
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [markModalOpen, setMarkModalOpen] = useState(false)
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
/* ── 批量打标签表单 ── */
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
/* ── 批量改分类表单 ── */
const [batchCategory, setBatchCategory] = useState("")
/* ── 批量智能标记表单 ── */
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
/* ── 操作结果 ── */
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
const [operationTitle, setOperationTitle] = useState("")
/* ── 批量操作 loading ── */
const [batchLoading, setBatchLoading] = useState(false)
/* ── 刷新数据辅助函数 ── */
const invalidateAssets = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
}, [queryClient])
/* ── 诊断 ── */
const handleDiagnose = useCallback(
async (asset: AssetItem) => {
setDiagnosingId(asset.id)
try {
const result = await getAssetDiagnosis(asset.id)
const score = result.readiness_score ?? "-"
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
queryClient.invalidateQueries({ queryKey: ["assets"] })
} catch {
message.error(`"${asset.name}" 诊断失败`)
} finally {
setDiagnosingId(null)
}
},
[queryClient],
)
/* ── 单个素材删除 ── */
const handleSingleDelete = useCallback(
async (assetId: string) => {
try {
await deleteAsset(assetId)
invalidateAssets()
// 从选中集合中移除
setSelectedIds(
(() => {
const next = new Set(selectedIds)
next.delete(assetId)
return next
})(),
)
message.success("素材已删除")
} catch {
message.error("删除失败,请重试")
}
},
[invalidateAssets, selectedIds, setSelectedIds],
)
/* ── 显示操作结果 ── */
const showOperationResult = useCallback(
(result: BatchOperationResult, title: string, clearSelection = true) => {
setOperationResult(result)
setOperationTitle(title)
setResultDrawerOpen(true)
if (clearSelection) setSelectedIds(new Set())
},
[setSelectedIds],
)
/* ── 批量删除 ── */
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showOperationResult(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, showOperationResult])
/* ── 批量打标签 ── */
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"] })
showOperationResult(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, showOperationResult])
/* ── 标签输入处理 ── */
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],
)
/* ── 批量改分类 ── */
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"] })
showOperationResult(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, showOperationResult])
/* ── 批量智能标记 ── */
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"] })
showOperationResult(result, "批量智能标记")
setMarkModalOpen(false)
const labelMap: Record<SmartViewType, string> = {
recommended: "推荐",
caution: "慎用",
high_risk: "高风险",
}
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showOperationResult])
/* ── 关闭结果 Drawer ── */
const handleResultDrawerClose = useCallback(() => {
setResultDrawerOpen(false)
setOperationResult(null)
}, [])
return {
// 诊断
diagnosingId,
handleDiagnose,
// 单个操作
handleSingleDelete,
// 批量操作 loading
batchLoading,
// 批量打标签
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
// 批量改分类
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
handleBatchClassify,
// 批量智能标记
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
handleBatchMark,
// 批量删除
handleBatchDelete,
// 操作结果
resultDrawerOpen,
operationResult,
operationTitle,
handleResultDrawerClose,
}
}
@@ -0,0 +1,40 @@
import { useState, useCallback } from "react"
import type { AssetItem } from "../types"
/**
* 素材选中态管理 Hook
* 封装单选、全选、取消全选等选中逻辑
*/
interface UseAssetSelectionProps {
filteredAssets: AssetItem[]
}
export function useAssetSelection({ filteredAssets }: UseAssetSelectionProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const selectAll = useCallback(() => {
setSelectedIds(new Set(filteredAssets.map((a) => a.id)))
}, [filteredAssets])
const deselectAll = useCallback(() => {
setSelectedIds(new Set())
}, [])
return {
selectedIds,
setSelectedIds,
toggleSelect,
selectAll,
deselectAll,
selectedCount: selectedIds.size,
}
}
@@ -0,0 +1,65 @@
import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { uploadAssetDirect } from "@/api/assets"
import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants"
/**
* 素材上传 Hook
* 封装上传状态、进度管理和上传逻辑
*/
interface UseAssetUploadProps {
effectiveLibId: string
}
export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) {
const queryClient = useQueryClient()
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const handleUpload = useCallback(
async (file: File) => {
if (file.size > MAX_FILE_SIZE) {
message.error(`文件 "${file.name}" 超过 2GB 限制`)
return
}
if (!effectiveLibId) {
message.warning("请先选择或创建一个视频库")
return
}
setUploading(true)
setUploadProgress(0)
try {
if (file.size > LARGE_FILE_THRESHOLD) {
message.info(`大文件 "${file.name}" 将使用直传上传`)
}
await uploadAssetDirect({
file,
library_id: effectiveLibId,
onProgress: (pct) => setUploadProgress(pct),
})
message.success(`"${file.name}" 上传成功`)
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : ""
console.error("[handleUpload] 上传失败:", err)
message.error(`"${file.name}" 上传失败${detail ? `${detail}` : ""}`)
// 错误时延迟关闭弹窗,让用户能看到错误提示
await new Promise((r) => setTimeout(r, 1500))
} finally {
setUploading(false)
setUploadProgress(0)
}
},
[effectiveLibId, queryClient],
)
return {
uploading,
uploadProgress,
handleUpload,
}
}
@@ -0,0 +1,118 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import {
getAssetLibraries,
getAssets,
type AssetLibraryItem,
type AssetItem as ApiAssetItem,
} from "@/api/assets"
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
/**
* 素材库数据 Hook
* 封装视频库列表、素材列表的数据查询,以及筛选、搜索状态管理
*/
export function useAssetsData() {
/* ── 视频库列表查询 ── */
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
staleTime: 60_000,
})
const libraries: LibraryItem[] = useMemo(
() =>
(Array.isArray(apiLibraries) ? apiLibraries : [])
.map(mapLibrary)
.filter((lib) => lib.kind === "video"),
[apiLibraries],
)
/* ── 当前选中的视频库 ── */
const [activeLibId, setActiveLibId] = useState<string>("")
// 当库列表加载完成后,自动选中第一个
const effectiveLibId = activeLibId || libraries[0]?.id || ""
/* ── 当前库的素材列表查询 ── */
const {
data: apiAssets = { items: [], total: 0 },
isLoading: assetsLoading,
isError: assetsError,
error: assetsErrorObj,
refetch: refetchAssets,
} = useQuery<{ items: ApiAssetItem[]; total: number }, Error>({
queryKey: ["assets", effectiveLibId],
queryFn: () =>
getAssets(effectiveLibId, {
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
status: "ready,uploading,ingesting,processing,pending,error,failed",
}),
enabled: !!effectiveLibId,
staleTime: 30_000,
})
const assets: AssetItem[] = useMemo(
() => (Array.isArray(apiAssets?.items) ? apiAssets.items : []).map(mapAsset),
[apiAssets],
)
/* ── 筛选状态 ── */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterTime, setFilterTime] = useState<string>("all")
/* ── 筛选后的素材列表 ── */
const filteredAssets = useMemo(() => {
let list = assets
/* 按素材类型过滤 */
if (filterType !== "all") {
list = list.filter((a) => a.kind === filterType)
}
/* 按时间筛选 */
if (filterTime !== "all") {
const now = new Date()
list = list.filter((a) => {
const d = new Date(a.createdAt)
const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
if (filterTime === "today") return diffDays < 1
if (filterTime === "week") return diffDays < 7
if (filterTime === "month") return diffDays < 30
return true
})
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((a) => a.name.toLowerCase().includes(q))
}
return list
}, [assets, filterType, filterTime, searchText])
return {
// 视频库
libraries,
libLoading,
activeLibId,
setActiveLibId,
effectiveLibId,
// 素材列表
assets,
assetsLoading,
assetsError,
assetsErrorObj,
refetchAssets,
// 筛选
searchText,
setSearchText,
filterType,
setFilterType,
filterTime,
setFilterTime,
filteredAssets,
}
}
@@ -0,0 +1,106 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { createAssetLibrary, deleteAssetLibrary } from "@/api/assets"
import type { AssetKind, LibraryItem } from "../types"
/**
* 视频库管理 Hook
* 封装视频库的创建、删除操作,以及新建弹窗的表单状态
*/
interface UseLibraryManagementProps {
libraries: LibraryItem[]
activeLibId: string
setActiveLibId: (id: string) => void
effectiveLibId: string
}
export function useLibraryManagement({
libraries,
setActiveLibId,
effectiveLibId,
}: UseLibraryManagementProps) {
const queryClient = useQueryClient()
/* ── 状态 ── */
const [createModalOpen, setCreateModalOpen] = useState(false)
const [newLibName, setNewLibName] = useState("")
const [newLibKind, setNewLibKind] = useState<AssetKind>("video")
/* ── Mutations ── */
const createLibMutation = useMutation({
mutationFn: createAssetLibrary,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
message.success("视频库创建成功")
},
onError: () => {
message.error("创建视频库失败")
},
})
const deleteLibMutation = useMutation({
mutationFn: deleteAssetLibrary,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
message.success("视频库已删除")
},
onError: () => {
message.error("删除视频库失败")
},
})
/* ── 新建视频库 ── */
const handleCreateLibrary = useCallback(async () => {
if (!newLibName.trim()) {
message.warning("请输入视频库名称")
return
}
try {
const newLib = await createLibMutation.mutateAsync({
name: newLibName.trim(),
kind: newLibKind,
})
setActiveLibId(newLib.id)
setCreateModalOpen(false)
setNewLibName("")
setNewLibKind("video")
} catch {
// error handled in mutation
}
}, [newLibName, newLibKind, createLibMutation, setActiveLibId])
/* ── 删除视频库 ── */
const handleDeleteLibrary = useCallback(
async (id: string) => {
try {
await deleteLibMutation.mutateAsync(id)
if (effectiveLibId === id) {
const remaining = libraries.filter((l) => l.id !== id)
if (remaining.length > 0) setActiveLibId(remaining[0].id)
else setActiveLibId("")
}
} catch {
// error handled in mutation
}
},
[deleteLibMutation, effectiveLibId, libraries, setActiveLibId],
)
return {
// 弹窗状态
createModalOpen,
setCreateModalOpen,
// 表单状态
newLibName,
setNewLibName,
newLibKind,
setNewLibKind,
// Mutations
isCreating: createLibMutation.isPending,
isDeleting: deleteLibMutation.isPending,
// Handlers
handleCreateLibrary,
handleDeleteLibrary,
}
}
@@ -0,0 +1,15 @@
import React from "react"
import { VideoCameraOutlined, PictureOutlined, AudioOutlined } from "@ant-design/icons"
import type { AssetKind } from "../types"
/** 根据素材类型返回对应图标 */
export const kindIcon = (kind: AssetKind): React.ReactNode => {
switch (kind) {
case "video":
return <VideoCameraOutlined />
case "image":
return <PictureOutlined />
case "voice":
return <AudioOutlined />
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,304 @@
/**
* 所有弹窗和 Drawer 组件的集合
* 纯渲染层,业务逻辑和 state 留在父组件
*/
import React from "react"
import type { TemplateCategory } from "@/api/editing-planner"
import type {
ClipData,
TransitionConfig,
SpeedConfig,
TtsConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
} from "../types"
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import SaveModal from "./SaveModal"
import BgmSelector from "./BgmSelector"
import SubtitleStylePanel from "./SubtitleStylePanel"
import TransitionSelector from "./TransitionSelector"
import SpeedPanel from "./SpeedPanel"
import TtsPanel from "./TtsPanel"
import WatermarkPanel from "./WatermarkPanel"
import IntroOutroPanel from "./IntroOutroPanel"
import PipConfigPanel from "./PipConfigPanel"
import FilterPanel from "./FilterPanel"
import GreenScreenPanel from "./GreenScreenPanel"
import StickerPanel from "./StickerPanel"
interface EditingDrawersProps {
/* 保存弹窗 */
saveModalOpen: boolean
saveLoading: boolean
isUpdate: boolean
draftName: string
draftCategory: string
draftTags: string
categories: TemplateCategory[]
estimatedDuration: number
onNameChange: (name: string) => void
onCategoryChange: (cat: string) => void
onTagsChange: (tags: string) => void
onSave: () => Promise<void>
onCancelSave: () => void
/* BGM */
bgmDrawerOpen: boolean
bgmSettings: BgmMixConfig
onCloseBgmDrawer: () => void
onChangeBgmSettings: (config: BgmMixConfig) => void
/* 字幕 */
subtitleDrawerOpen: boolean
subtitleSettings: SubtitleStyleConfig
onCloseSubtitleDrawer: () => void
onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void
/* 转场 */
transitionDrawerOpen: boolean
transitionTargetClipId: string | null
onCloseTransitionDrawer: () => void
onTransitionChange: (config: TransitionConfig) => void
/* 调速 */
speedDrawerOpen: boolean
speedTargetClipId: string | null
onCloseSpeedDrawer: () => void
onSpeedChange: (config: SpeedConfig) => void
onApplySpeedAll: (config: SpeedConfig) => void
/* TTS 配音 */
ttsDrawerOpen: boolean
ttsTargetClipId: string | null
onCloseTtsDrawer: () => void
onTtsChange: (config: TtsConfig) => void
/* 水印 */
watermarkDrawerOpen: boolean
watermarkSettings: WatermarkConfig
onCloseWatermarkDrawer: () => void
onWatermarkChange: (config: WatermarkConfig) => void
/* 片头片尾 */
introOutroDrawerOpen: boolean
introOutroSettings: IntroOutroConfig
onCloseIntroOutroDrawer: () => void
onIntroOutroChange: (config: IntroOutroConfig) => void
/* 混剪 */
pipDrawerOpen: boolean
pipSettings: PipConfig
onClosePipDrawer: () => void
onPipChange: (config: PipConfig) => void
/* 滤镜调色 */
filterDrawerOpen: boolean
filterSettings: FilterConfig
onCloseFilterDrawer: () => void
onFilterChange: (config: FilterConfig) => void
/* 绿幕抠像 */
chromaKeyDrawerOpen: boolean
chromaKeySettings: ChromaKeyConfig
onCloseChromaKeyDrawer: () => void
onChromaKeyChange: (config: ChromaKeyConfig) => void
/* 贴纸 */
stickerDrawerOpen: boolean
stickerSettings: StickerConfig
onCloseStickerDrawer: () => void
onStickerChange: (config: StickerConfig) => void
/* 共享数据 */
clips: ClipData[]
totalDuration: number
}
const EditingDrawers: React.FC<EditingDrawersProps> = ({
saveModalOpen,
saveLoading,
isUpdate,
draftName,
draftCategory,
draftTags,
categories,
estimatedDuration,
onNameChange,
onCategoryChange,
onTagsChange,
onSave,
onCancelSave,
bgmDrawerOpen,
bgmSettings,
onCloseBgmDrawer,
onChangeBgmSettings,
subtitleDrawerOpen,
subtitleSettings,
onCloseSubtitleDrawer,
onChangeSubtitleSettings,
transitionDrawerOpen,
transitionTargetClipId,
onCloseTransitionDrawer,
onTransitionChange,
speedDrawerOpen,
speedTargetClipId,
onCloseSpeedDrawer,
onSpeedChange,
onApplySpeedAll,
ttsDrawerOpen,
ttsTargetClipId,
onCloseTtsDrawer,
onTtsChange,
watermarkDrawerOpen,
watermarkSettings,
onCloseWatermarkDrawer,
onWatermarkChange,
introOutroDrawerOpen,
introOutroSettings,
onCloseIntroOutroDrawer,
onIntroOutroChange,
pipDrawerOpen,
pipSettings,
onClosePipDrawer,
onPipChange,
filterDrawerOpen,
filterSettings,
onCloseFilterDrawer,
onFilterChange,
chromaKeyDrawerOpen,
chromaKeySettings,
onCloseChromaKeyDrawer,
onChromaKeyChange,
stickerDrawerOpen,
stickerSettings,
onCloseStickerDrawer,
onStickerChange,
clips,
totalDuration,
}) => {
const transitionConfig = transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
: DEFAULT_TRANSITION
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
const speedConfig = speedTargetClipId
? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED)
: DEFAULT_SPEED
const ttsConfig = ttsTargetClipId
? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG)
: DEFAULT_TTS_CONFIG
return (
<>
{/* ═══ 保存弹窗 ═══ */}
<SaveModal
open={saveModalOpen}
loading={saveLoading}
isUpdate={isUpdate}
draftName={draftName}
draftCategory={draftCategory}
draftTags={draftTags}
categories={categories}
estimatedDuration={estimatedDuration}
onNameChange={onNameChange}
onCategoryChange={onCategoryChange}
onTagsChange={onTagsChange}
onSave={onSave}
onCancel={onCancelSave}
/>
{/* ═══ BGM 选择器 Drawer ═══ */}
<BgmSelector
open={bgmDrawerOpen}
onClose={onCloseBgmDrawer}
config={bgmSettings}
onChange={onChangeBgmSettings}
/>
{/* ═══ 字幕样式配置 Drawer ═══ */}
<SubtitleStylePanel
open={subtitleDrawerOpen}
onClose={onCloseSubtitleDrawer}
config={subtitleSettings}
onChange={onChangeSubtitleSettings}
/>
{/* ═══ 转场特效选择器 Drawer ═══ */}
<TransitionSelector
open={transitionDrawerOpen}
onClose={onCloseTransitionDrawer}
config={transitionConfig}
onChange={onTransitionChange}
title={transitionTitle}
/>
{/* ═══ 片段调速面板 Drawer ═══ */}
{speedTargetClipId && (
<SpeedPanel
open={speedDrawerOpen}
onClose={onCloseSpeedDrawer}
config={speedConfig}
onChange={onSpeedChange}
onApplyAll={onApplySpeedAll}
/>
)}
{/* ═══ TTS 配音面板 Drawer ═══ */}
{ttsTargetClipId && (
<TtsPanel
open={ttsDrawerOpen}
onClose={onCloseTtsDrawer}
config={ttsConfig}
onChange={onTtsChange}
/>
)}
{/* ═══ 水印配置面板 ═══ */}
<WatermarkPanel
open={watermarkDrawerOpen}
onClose={onCloseWatermarkDrawer}
config={watermarkSettings}
onChange={onWatermarkChange}
/>
{/* ═══ 片头片尾配置面板 ═══ */}
<IntroOutroPanel
open={introOutroDrawerOpen}
onClose={onCloseIntroOutroDrawer}
config={introOutroSettings}
onChange={onIntroOutroChange}
/>
{/* ═══ 混剪配置面板 ═══ */}
<PipConfigPanel
open={pipDrawerOpen}
onClose={onClosePipDrawer}
config={pipSettings}
onChange={onPipChange}
totalDuration={totalDuration}
/>
{/* ═══ 滤镜调色面板 ═══ */}
<FilterPanel
open={filterDrawerOpen}
onClose={onCloseFilterDrawer}
config={filterSettings}
onChange={onFilterChange}
/>
{/* ═══ 绿幕抠像面板 ═══ */}
<GreenScreenPanel
open={chromaKeyDrawerOpen}
onClose={onCloseChromaKeyDrawer}
config={chromaKeySettings}
onChange={onChromaKeyChange}
/>
{/* ═══ 贴纸面板 ═══ */}
<StickerPanel
open={stickerDrawerOpen}
onClose={onCloseStickerDrawer}
config={stickerSettings}
onChange={onStickerChange}
totalDuration={totalDuration}
/>
</>
)
}
export default EditingDrawers
@@ -0,0 +1,246 @@
import React from "react"
import BgmSelector from "./BgmSelector"
import SubtitleStylePanel from "./SubtitleStylePanel"
import TransitionSelector from "./TransitionSelector"
import SpeedPanel from "./SpeedPanel"
import TtsPanel from "./TtsPanel"
import WatermarkPanel from "./WatermarkPanel"
import IntroOutroPanel from "./IntroOutroPanel"
import PipConfigPanel from "./PipConfigPanel"
import FilterPanel from "./FilterPanel"
import GreenScreenPanel from "./GreenScreenPanel"
import StickerPanel from "./StickerPanel"
import type {
ClipData,
TransitionConfig,
SpeedConfig,
TtsConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
interface EditorDrawersProps {
// BGM
bgmDrawerOpen: boolean
bgmSettings: BgmMixConfig
onBgmSettingsChange: (config: BgmMixConfig) => void
onCloseBgmDrawer: () => void
// 字幕
subtitleDrawerOpen: boolean
subtitleSettings: SubtitleStyleConfig
onSubtitleSettingsChange: (config: SubtitleStyleConfig) => void
onCloseSubtitleDrawer: () => void
// 转场
transitionDrawerOpen: boolean
transitionTargetClipId: string | null
clips: ClipData[]
onTransitionChange: (config: TransitionConfig) => void
onCloseTransitionDrawer: () => void
// 调速
speedDrawerOpen: boolean
speedTargetClipId: string | null
onSpeedChange: (config: SpeedConfig) => void
onApplySpeedAll: (config: SpeedConfig) => void
onCloseSpeedDrawer: () => void
// TTS
ttsDrawerOpen: boolean
ttsTargetClipId: string | null
onTtsChange: (config: TtsConfig) => void
onCloseTtsDrawer: () => void
// 水印
watermarkDrawerOpen: boolean
watermarkSettings: WatermarkConfig
onWatermarkChange: (config: WatermarkConfig) => void
onCloseWatermarkDrawer: () => void
// 片头片尾
introOutroDrawerOpen: boolean
introOutroSettings: IntroOutroConfig
onIntroOutroChange: (config: IntroOutroConfig) => void
onCloseIntroOutroDrawer: () => void
// 混剪
pipDrawerOpen: boolean
pipSettings: PipConfig
totalDuration: number
onPipChange: (config: PipConfig) => void
onClosePipDrawer: () => void
// 滤镜
filterDrawerOpen: boolean
filterSettings: FilterConfig
onFilterChange: (config: FilterConfig) => void
onCloseFilterDrawer: () => void
// 绿幕
chromaKeyDrawerOpen: boolean
chromaKeySettings: ChromaKeyConfig
onChromaKeyChange: (config: ChromaKeyConfig) => void
onCloseChromaKeyDrawer: () => void
// 贴纸
stickerDrawerOpen: boolean
stickerSettings: StickerConfig
onStickerChange: (config: StickerConfig) => void
onCloseStickerDrawer: () => void
}
const EditorDrawers: React.FC<EditorDrawersProps> = ({
bgmDrawerOpen,
bgmSettings,
onBgmSettingsChange,
onCloseBgmDrawer,
subtitleDrawerOpen,
subtitleSettings,
onSubtitleSettingsChange,
onCloseSubtitleDrawer,
transitionDrawerOpen,
transitionTargetClipId,
clips,
onTransitionChange,
onCloseTransitionDrawer,
speedDrawerOpen,
speedTargetClipId,
onSpeedChange,
onApplySpeedAll,
onCloseSpeedDrawer,
ttsDrawerOpen,
ttsTargetClipId,
onTtsChange,
onCloseTtsDrawer,
watermarkDrawerOpen,
watermarkSettings,
onWatermarkChange,
onCloseWatermarkDrawer,
introOutroDrawerOpen,
introOutroSettings,
onIntroOutroChange,
onCloseIntroOutroDrawer,
pipDrawerOpen,
pipSettings,
totalDuration,
onPipChange,
onClosePipDrawer,
filterDrawerOpen,
filterSettings,
onFilterChange,
onCloseFilterDrawer,
chromaKeyDrawerOpen,
chromaKeySettings,
onChromaKeyChange,
onCloseChromaKeyDrawer,
stickerDrawerOpen,
stickerSettings,
onStickerChange,
onCloseStickerDrawer,
}) => {
const transitionConfig = transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
: DEFAULT_TRANSITION
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
return (
<>
{/* BGM 选择器 Drawer */}
<BgmSelector
open={bgmDrawerOpen}
onClose={onCloseBgmDrawer}
config={bgmSettings}
onChange={onBgmSettingsChange}
/>
{/* 字幕样式配置 Drawer */}
<SubtitleStylePanel
open={subtitleDrawerOpen}
onClose={onCloseSubtitleDrawer}
config={subtitleSettings}
onChange={onSubtitleSettingsChange}
/>
{/* 转场特效选择器 Drawer */}
<TransitionSelector
open={transitionDrawerOpen}
onClose={onCloseTransitionDrawer}
config={transitionConfig}
onChange={onTransitionChange}
title={transitionTitle}
/>
{/* 片段调速面板 Drawer */}
{speedTargetClipId && (
<SpeedPanel
open={speedDrawerOpen}
onClose={onCloseSpeedDrawer}
config={clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED}
onChange={onSpeedChange}
onApplyAll={onApplySpeedAll}
/>
)}
{/* TTS 配音面板 Drawer */}
{ttsTargetClipId && (
<TtsPanel
open={ttsDrawerOpen}
onClose={onCloseTtsDrawer}
config={clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG}
onChange={onTtsChange}
/>
)}
{/* 水印配置面板 */}
<WatermarkPanel
open={watermarkDrawerOpen}
onClose={onCloseWatermarkDrawer}
config={watermarkSettings}
onChange={onWatermarkChange}
/>
{/* 片头片尾配置面板 */}
<IntroOutroPanel
open={introOutroDrawerOpen}
onClose={onCloseIntroOutroDrawer}
config={introOutroSettings}
onChange={onIntroOutroChange}
/>
{/* 混剪配置面板 */}
<PipConfigPanel
open={pipDrawerOpen}
onClose={onClosePipDrawer}
config={pipSettings}
onChange={onPipChange}
totalDuration={totalDuration}
/>
{/* 滤镜调色面板 */}
<FilterPanel
open={filterDrawerOpen}
onClose={onCloseFilterDrawer}
config={filterSettings}
onChange={onFilterChange}
/>
{/* 绿幕抠像面板 */}
<GreenScreenPanel
open={chromaKeyDrawerOpen}
onClose={onCloseChromaKeyDrawer}
config={chromaKeySettings}
onChange={onChromaKeyChange}
/>
{/* 贴纸面板 */}
<StickerPanel
open={stickerDrawerOpen}
onClose={onCloseStickerDrawer}
config={stickerSettings}
onChange={onStickerChange}
totalDuration={totalDuration}
/>
</>
)
}
export default EditorDrawers
@@ -0,0 +1,28 @@
import React from "react"
import type { TemplateMode } from "@/api/editing-planner"
interface ModeBarProps {
modeList: { key: TemplateMode; label: string; icon: string }[]
currentMode: TemplateMode
onModeChange: (mode: TemplateMode) => void
}
const ModeBar: React.FC<ModeBarProps> = ({ modeList, currentMode, onModeChange }) => {
return (
<div className="ep-mode-bar">
<span className="ep-mode-bar-label"></span>
{modeList.map((m) => (
<button
key={m.key}
className={`ep-mode-btn ${currentMode === m.key ? "active" : ""}`}
onClick={() => onModeChange(m.key)}
>
<span className="ep-mode-icon">{m.icon}</span>
<span className="ep-mode-label">{m.label}</span>
</button>
))}
</div>
)
}
export default ModeBar
@@ -0,0 +1,169 @@
import React from "react"
import ClipPropertiesPanel from "./ClipPropertiesPanel"
import EditorClipList from "./EditorClipList"
import type { ClipData } from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import type { TemplateMode } from "@/api/editing-planner"
import type { AssetItem } from "@/api/assets"
interface RightPanelProps {
rightTab: "properties" | "clips"
onTabChange: (tab: "properties" | "clips") => void
// 属性 tab
selectedClip: ClipData | null
subtitleSettings: SubtitleStyleConfig
bgmSettings: BgmMixConfig
clipsCount: number
totalDuration: number
currentMode: TemplateMode
onSubtitleSettingsChange: (partial: Partial<SubtitleStyleConfig>) => void
onBgmSettingsChange: (partial: Partial<BgmMixConfig>) => void
onClipUpdate: (clipId: string, updates: Partial<ClipData>) => void
onOpenBgmDrawer: () => void
onOpenSubtitleDrawer: () => void
voiceMaterials: AssetItem[]
voiceMaterialsLoading: boolean
onRefreshVoiceMaterials: () => void
onClipVoiceSelect: (clipId: string, asset: AssetItem | null) => void
onOpenTransitionDrawer: (clipId?: string) => void
onOpenSpeedDrawer: (clipId: string) => void
onOpenTtsDrawer: (clipId: string) => void
onOpenWatermarkDrawer: () => void
onOpenIntroOutroDrawer: () => void
onOpenPipDrawer: () => void
onOpenFilterDrawer: () => void
onOpenGreenScreenDrawer: () => void
onOpenStickerDrawer: () => void
// 片段 tab
clips: ClipData[]
selectedClipId: string | null
onClipSelect: (clipId: string) => void
onClipMoveUp: (clipId: string) => void
onClipMoveDown: (clipId: string) => void
onClipRemove: (clipId: string) => void
onClipAdd: () => void
}
const RightPanel: React.FC<RightPanelProps> = ({
rightTab,
onTabChange,
selectedClip,
subtitleSettings,
bgmSettings,
clipsCount,
totalDuration,
currentMode,
onSubtitleSettingsChange,
onBgmSettingsChange,
onClipUpdate,
onOpenBgmDrawer,
onOpenSubtitleDrawer,
voiceMaterials,
voiceMaterialsLoading,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
onOpenWatermarkDrawer,
onOpenIntroOutroDrawer,
onOpenPipDrawer,
onOpenFilterDrawer,
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
clips,
selectedClipId,
onClipSelect,
onClipMoveUp,
onClipMoveDown,
onClipRemove,
onClipAdd,
}) => {
return (
<div className="ep-right-panel">
{/* Tab 切换 */}
<div className="ep-right-tabs">
<button
className={`ep-right-tab${rightTab === "properties" ? " active" : ""}`}
onClick={() => onTabChange("properties")}
>
</button>
<button
className={`ep-right-tab${rightTab === "clips" ? " active" : ""}`}
onClick={() => onTabChange("clips")}
>
</button>
</div>
{/* 属性 Tab */}
{rightTab === "properties" && (
<div className="ep-right-tab-content">
{(() => {
// ClipPropertiesPanel 内部类型与主文件类型结构一致但字段细节不同
// 使用 unknown 作为中间类型避免 any 警告
const sub = subtitleSettings as unknown as React.ComponentProps<
typeof ClipPropertiesPanel
>["subtitleSettings"]
const bgm = bgmSettings as unknown as React.ComponentProps<
typeof ClipPropertiesPanel
>["bgmSettings"]
const onSubChange = onSubtitleSettingsChange as unknown as React.ComponentProps<
typeof ClipPropertiesPanel
>["onSubtitleSettingsChange"]
const onBgmChange = onBgmSettingsChange as unknown as React.ComponentProps<
typeof ClipPropertiesPanel
>["onBgmSettingsChange"]
return (
<ClipPropertiesPanel
selectedClip={selectedClip}
subtitleSettings={sub}
bgmSettings={bgm}
clipsCount={clipsCount}
totalDuration={totalDuration}
currentMode={currentMode}
onSubtitleSettingsChange={onSubChange}
onBgmSettingsChange={onBgmChange}
onClipUpdate={onClipUpdate}
onOpenBgmDrawer={onOpenBgmDrawer}
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsLoading}
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
onClipVoiceSelect={onClipVoiceSelect}
onOpenTransitionDrawer={onOpenTransitionDrawer}
onOpenSpeedDrawer={onOpenSpeedDrawer}
onOpenTtsDrawer={onOpenTtsDrawer}
onOpenWatermarkDrawer={onOpenWatermarkDrawer}
onOpenIntroOutroDrawer={onOpenIntroOutroDrawer}
onOpenPipDrawer={onOpenPipDrawer}
onOpenFilterDrawer={onOpenFilterDrawer}
onOpenGreenScreenDrawer={onOpenGreenScreenDrawer}
onOpenStickerDrawer={onOpenStickerDrawer}
/>
)
})()}
</div>
)}
{/* 片段 Tab */}
{rightTab === "clips" && (
<div className="ep-right-tab-content">
<EditorClipList
clips={clips}
selectedClipId={selectedClipId}
onSelect={onClipSelect}
onMoveUp={onClipMoveUp}
onMoveDown={onClipMoveDown}
onRemove={onClipRemove}
onAdd={onClipAdd}
/>
</div>
)}
</div>
)
}
export default RightPanel
@@ -0,0 +1,32 @@
import React from "react"
interface StatusBarProps {
clipsCount: number
totalDuration: number
currentModeLabel: string
templateSegments: number
}
const StatusBar: React.FC<StatusBarProps> = ({
clipsCount,
totalDuration,
currentModeLabel,
templateSegments,
}) => {
return (
<div className="ep-status-bar">
<div className="ep-status-left">
<span>📋 : {clipsCount}</span>
<span className="ep-status-sep">|</span>
<span> : {totalDuration.toFixed(1)}s</span>
</div>
<div className="ep-status-right">
<span>🎬 {currentModeLabel}</span>
<span className="ep-status-sep">|</span>
<span>📐 : {templateSegments}</span>
</div>
</div>
)
}
export default StatusBar
@@ -8,9 +8,20 @@
* - 拖动时实时显示裁剪预览(入点/出点/时长)
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
*/
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
import React, { useState, useRef, useCallback, useEffect } from "react"
import type { ClipData, ClipType, TrimConfig } from "../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import {
DEFAULT_PIXELS_PER_SECOND,
} from "../constants/timeline"
import { formatTime } from "../utils/timeline"
import { useClipDrag } from "../hooks/useClipDrag"
import { useTrimDrag } from "../hooks/useTrimDrag"
import { useTimelineMenus } from "../hooks/useTimelineMenus"
import { ClipCard } from "./timeline/ClipCard"
import { TimeRuler } from "./timeline/TimeRuler"
import { AddClipPicker } from "./timeline/AddClipPicker"
import { TrimPreview } from "./timeline/TrimPreview"
import { ContextMenu } from "./timeline/ContextMenu"
interface TimelinePanelProps {
clips: ClipData[]
@@ -38,37 +49,6 @@ interface TimelinePanelProps {
totalDuration?: number
}
/** 片段类型图标 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
}
/** 片段类型标签 */
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
/** 裁剪拖拽方向 */
type TrimDirection = "left" | "right"
/** 裁剪拖拽状态 */
interface TrimDragState {
clipId: string
direction: TrimDirection
startX: number
originalTrim: TrimConfig
originalDuration: number
}
/** 右键菜单状态 */
interface ContextMenuState {
x: number
y: number
clipId: string
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
clips,
selectedClipId,
@@ -86,147 +66,51 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onSeek,
totalDuration: totalDurationProp,
}) => {
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const dragRef = useRef<number | null>(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,
})
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
/* ── 裁剪拖拽状态 ── */
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
const [trimPreview, setTrimPreview] = useState<{
clipId: string
startTime: number
endTime: number
duration: number
x: number
y: number
} | null>(null)
/* ── 裁剪拖拽 ── */
const { trimDrag, trimPreview, handleTrimHandleMouseDown } = useTrimDrag(clips, pps, onClipTrim)
/* ── 右键菜单 ── */
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
/* ── 片段拖拽排序 ── */
const {
dragIdx,
dragOverIdx,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDrop,
handleEmptyDragOver,
} = useClipDrag(onClipReorder, !!trimDrag)
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
/* ── 菜单 & 面板 ── */
const {
contextMenu,
contextMenuRef,
handleContextMenu,
handleContextSplit,
handleContextResetTrim,
handleContextDelete,
showAddPicker,
pickerRef,
addCardRef,
pickerPos,
availableTypes,
addType,
addDuration,
setAddType,
setAddDuration,
handleTogglePicker,
handleConfirmAdd,
hoveredClipId,
setHoveredClipId,
} = useTimelineMenus(clips, currentMode, onAddClip, onClipSplit, onClipResetTrim, onClipRemove)
/* ── 播放头拖拽状态 ── */
const [playheadDragging, setPlayheadDragging] = useState(false)
const trackRef = useRef<HTMLDivElement>(null)
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"], // voice_pip / one_take / 默认
[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>(5)
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
if (!availableTypes.includes(addType)) {
setAddType(defaultAddType)
}
}, [currentMode, addType, availableTypes, defaultAddType])
/* ── 面板尺寸 ── */
const PICKER_W = 240
const GAP = 6
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - PICKER_W < 8) {
right = vw - PICKER_W - 8
}
setPickerPos({ top, right })
}, [])
const handleTogglePicker = () => {
if (!showAddPicker) {
const defaultType =
currentMode === "pip" ? "pip" : currentMode === "voice_over" ? "voice" : "voice"
setAddType(defaultType)
updatePickerPosition()
}
setShowAddPicker((v) => !v)
}
/* ── 渲染后精确边界校正 ── */
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 - GAP - pickerH
if (top < 8) {
top = addRect.bottom + 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 - PICKER_W - 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 pps = pixelsPerSecond ?? 40
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
/* ── 播放头拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!playheadDragging) return
@@ -270,182 +154,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setPlayheadDragging(true)
}, [])
/* ── 确认添加片段 ── */
const handleConfirmAdd = () => {
onAddClip(addType, addDuration)
setShowAddPicker(false)
}
/* ── 片段拖拽排序 ── */
const handleDragStart = (e: React.DragEvent, idx: number) => {
// 如果正在裁剪拖拽,不允许排序拖拽
if (trimDrag) return
dragRef.current = idx
setDragIdx(idx)
e.dataTransfer.setData("application/x-clip-drag", String(idx))
e.dataTransfer.effectAllowed = "move"
}
const handleDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
setDragOverIdx(idx)
}
const handleDragEnd = () => {
dragRef.current = null
setDragIdx(null)
setDragOverIdx(null)
}
const handleDrop = (e: React.DragEvent, toIdx: number) => {
e.preventDefault()
setDragOverIdx(null)
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
if (fromStr !== "") {
const fromIdx = Number(fromStr)
if (fromIdx !== toIdx) {
onClipReorder(fromIdx, toIdx)
}
}
}
/* ── 空轨道区域不接受素材拖入 ── */
const handleEmptyDragOver = (e: React.DragEvent) => {
e.preventDefault()
}
/* ── 裁剪手柄拖拽 ── */
const handleTrimHandleMouseDown = useCallback(
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
e.preventDefault()
e.stopPropagation()
const clip = clips.find((c) => c.id === clipId)
if (!clip) return
const trim: TrimConfig = clip.trim_config ?? {
start_time: 0,
end_time: clip.duration,
original_duration: clip.duration,
}
setTrimDrag({
clipId,
direction,
startX: e.clientX,
originalTrim: { ...trim },
originalDuration: clip.duration,
})
},
[clips],
)
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!trimDrag) return
const PX_PER_SECOND = pixelsPerSecond ?? 40 // 与缩放级别同步
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX
const dtSec = dx / PX_PER_SECOND
const clip = clips.find((c) => c.id === trimDrag.clipId)
if (!clip) return
const origTrim = trimDrag.originalTrim
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
let newStart = origTrim.start_time
let newEnd = origTrim.end_time
if (trimDrag.direction === "left") {
// 左手柄:调整入点
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - 1))
} else {
// 右手柄:调整出点
newEnd = Math.max(origTrim.start_time + 1, Math.min(origTrim.end_time + dtSec, origDur))
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10
setTrimPreview({
clipId: trimDrag.clipId,
startTime: Math.round(newStart * 10) / 10,
endTime: Math.round(newEnd * 10) / 10,
duration: newDuration,
x: e.clientX,
y: e.clientY,
})
}
const handleMouseUp = () => {
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
const newTrim: TrimConfig = {
start_time: trimPreview.startTime,
end_time: trimPreview.endTime,
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
}
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
}
setTrimDrag(null)
setTrimPreview(null)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond])
/* ── 右键菜单 ── */
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
}, [])
/* ── 右键菜单操作 ── */
const handleContextSplit = useCallback(() => {
if (!contextMenu) return
if (onClipSplit) {
onClipSplit(contextMenu.clipId, 0.5) // 在中间分割
}
setContextMenu(null)
}, [contextMenu, onClipSplit])
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return
if (onClipResetTrim) {
onClipResetTrim(contextMenu.clipId)
}
setContextMenu(null)
}, [contextMenu, onClipResetTrim])
const handleContextDelete = useCallback(() => {
if (!contextMenu) return
onClipRemove(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipRemove])
/* ── 时间标尺 ── */
const trackWidth = Math.max(totalDuration * pps, 300)
const rulerMarks: number[] = []
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15
for (let t = 0; t <= totalDuration + step; t += step) {
rulerMarks.push(t)
}
const formatTime = (sec: number) => {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, "0")}`
}
/** 格式化裁剪时间(精确到0.1秒) */
const formatTrimTime = (sec: number) => {
return `${sec.toFixed(1)}s`
}
return (
<div className="ep-timeline-area">
@@ -506,15 +215,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 时间标尺 */}
{currentMode !== "one_take" && (
<div className="ep-time-ruler" onClick={handleRulerClick}>
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
{rulerMarks.map((t) => (
<span key={t} className="ep-time-mark" style={{ left: `${t * pps}px` }}>
{t}s
</span>
))}
</div>
</div>
<TimeRuler totalDuration={totalDuration} pps={pps} onClick={handleRulerClick} />
)}
{/* 水平片段轨道 */}
@@ -536,115 +237,30 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<div className="ep-track-empty-text"> + </div>
</div>
) : (
clips.map((clip, idx) => {
/* 转场指示器 */
const trans = clip.transition
const showTransition = idx > 0 && trans && trans.type !== "none"
const transOpt = showTransition
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
: undefined
/* 速度徽章 */
const speed = clip.speed
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01
/* 裁剪状态 */
const hasTrim = !!clip.trim_config
const isHovered = hoveredClipId === clip.id
return (
<React.Fragment key={clip.id}>
{/* 转场指示器 */}
{showTransition && transOpt && (
<div
className="ep-transition-indicator"
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
>
<span className="ep-trans-icon">{transOpt.icon}</span>
<span className="ep-trans-duration">{trans!.duration.toFixed(1)}s</span>
</div>
)}
<div
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
style={{ width: Math.max(clip.duration * pps, 60) }}
draggable={!trimDrag}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
onContextMenu={(e) => handleContextMenu(e, clip.id)}
onMouseEnter={() => setHoveredClipId(clip.id)}
onMouseLeave={() => setHoveredClipId(null)}
>
{/* 左裁剪手柄 */}
{isHovered && onClipTrim && (
<div
className="ep-trim-handle ep-trim-handle-left"
onMouseDown={(e) => handleTrimHandleMouseDown(e, clip.id, "left")}
title="拖动调整入点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 类型图标 */}
<div className="ep-clip-thumbnail">{CLIP_TYPE_ICONS[clip.type] || "🎬"}</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">
{clip.duration}s
{hasTrim && (
<span className="ep-trim-indicator" title="已裁剪">
</span>
)}
</span>
</div>
{/* 速度徽章 */}
{showSpeed && <span className="ep-speed-badge">{speed!.rate.toFixed(1)}x</span>}
{/* 裁剪徽章 */}
{hasTrim && (
<span
className="ep-trim-badge"
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
>
</span>
)}
{/* 右裁剪手柄 */}
{isHovered && onClipTrim && (
<div
className="ep-trim-handle ep-trim-handle-right"
onMouseDown={(e) => handleTrimHandleMouseDown(e, clip.id, "right")}
title="拖动调整出点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation()
onClipRemove(clip.id)
}}
>
</button>
</div>
</React.Fragment>
)
})
clips.map((clip, idx) => (
<ClipCard
key={clip.id}
clip={clip}
idx={idx}
isSelected={selectedClipId === clip.id}
isDragging={dragIdx === idx}
isDragOver={dragOverIdx === idx}
isHovered={hoveredClipId === clip.id}
pps={pps}
trimDragActive={!!trimDrag}
showTrimHandles={!!onClipTrim}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
onSelect={onClipSelect}
onContextMenu={handleContextMenu}
onMouseEnter={() => setHoveredClipId(clip.id)}
onMouseLeave={() => setHoveredClipId(null)}
onTrimHandleMouseDown={handleTrimHandleMouseDown}
onRemove={onClipRemove}
/>
))
)}
{/* ── 轨道末尾 "+" 添加卡片 ── */}
@@ -663,109 +279,40 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 裁剪预览 tooltip */}
{trimPreview && (
<div
className="ep-trim-preview"
style={{
position: "fixed",
left: trimPreview.x + 12,
top: trimPreview.y - 40,
}}
>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">{formatTrimTime(trimPreview.startTime)}</span>
</div>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">{formatTrimTime(trimPreview.endTime)}</span>
</div>
<div className="ep-trim-preview-row ep-trim-preview-duration">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">{formatTrimTime(trimPreview.duration)}</span>
</div>
</div>
<TrimPreview
startTime={trimPreview.startTime}
endTime={trimPreview.endTime}
duration={trimPreview.duration}
x={trimPreview.x}
y={trimPreview.y}
/>
)}
{/* 右键菜单 */}
{contextMenu && (
<div
ref={contextMenuRef}
className="ep-context-menu"
style={{
position: "fixed",
left: contextMenu.x,
top: contextMenu.y,
}}
>
<div className="ep-context-menu-item" onClick={handleContextSplit}>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && (
<div className="ep-context-menu-item" onClick={handleContextResetTrim}>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
)}
<div className="ep-context-menu-divider" />
<div
className="ep-context-menu-item ep-context-menu-item-danger"
onClick={handleContextDelete}
>
<span className="ep-context-menu-icon">🗑</span>
<span></span>
</div>
</div>
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
menuRef={contextMenuRef}
hasTrim={!!clips.find((c) => c.id === contextMenu.clipId)?.trim_config}
onSplit={handleContextSplit}
onResetTrim={handleContextResetTrim}
onDelete={handleContextDelete}
/>
)}
{/* 类型+时长选择面板 */}
{showAddPicker && (
<div
ref={pickerRef}
className="ep-add-clip-picker ep-add-clip-picker--portal"
style={{
position: "fixed",
top: pickerPos.top,
right: pickerPos.right,
}}
>
<div className="ep-add-clip-picker-title"></div>
{/* 类型选择 */}
<div className="ep-add-clip-type-row">
<span className="ep-add-clip-type-label">:</span>
{availableTypes.map((t) => (
<button
key={t}
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
onClick={() => setAddType(t)}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
))}
</div>
{/* 时长输入 */}
<div className="ep-add-clip-duration-row">
<span className="ep-add-clip-type-label">:</span>
<input
type="number"
className="ep-duration-input"
min={1}
max={120}
value={addDuration}
onChange={(e) =>
setAddDuration(Math.max(1, Math.min(120, Number(e.target.value) || 1)))
}
/>
<span className="ep-add-clip-duration-unit"></span>
</div>
{/* 确认按钮 */}
<button className="ep-add-clip-confirm-btn" onClick={handleConfirmAdd}>
</button>
</div>
<AddClipPicker
pickerRef={pickerRef}
position={pickerPos}
availableTypes={availableTypes}
addType={addType}
addDuration={addDuration}
onTypeChange={setAddType}
onDurationChange={setAddDuration}
onConfirm={handleConfirmAdd}
/>
)}
</div>
)
@@ -0,0 +1,54 @@
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
interface TopBarProps {
currentTemplate: EditingTemplate | null
canUndo: boolean
canRedo: boolean
onUndo: () => void
onRedo: () => void
onOpenSaveModal: () => void
}
const TopBar: React.FC<TopBarProps> = ({
currentTemplate,
canUndo,
canRedo,
onUndo,
onRedo,
onOpenSaveModal,
}) => {
return (
<div className="ep-top-bar">
<div className="ep-top-bar-left">
<span className="ep-logo"></span>
<span className="ep-app-title"></span>
<span className="ep-divider">|</span>
<span className="ep-template-name">{currentTemplate?.name || "未选择模板"}</span>
</div>
<div className="ep-top-bar-right">
<button
className="ep-btn ep-btn-secondary"
onClick={onUndo}
disabled={!canUndo}
title="撤销 (Ctrl+Z)"
>
</button>
<button
className="ep-btn ep-btn-secondary"
onClick={onRedo}
disabled={!canRedo}
title="重做 (Ctrl+Shift+Z)"
>
</button>
<button className="ep-btn ep-btn-secondary" onClick={onOpenSaveModal}>
💾
</button>
</div>
</div>
)
}
export default TopBar
@@ -0,0 +1,80 @@
import React from "react"
import type { ClipType } from "../../types"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "../../constants/timeline"
interface AddClipPickerProps {
pickerRef: React.RefObject<HTMLDivElement>
position: { top: number; right: number }
availableTypes: ClipType[]
addType: ClipType
addDuration: number
onTypeChange: (type: ClipType) => void
onDurationChange: (duration: number) => void
onConfirm: () => void
minDuration?: number
maxDuration?: number
}
export const AddClipPicker: React.FC<AddClipPickerProps> = ({
pickerRef,
position,
availableTypes,
addType,
addDuration,
onTypeChange,
onDurationChange,
onConfirm,
minDuration = 1,
maxDuration = 120,
}) => {
return (
<div
ref={pickerRef}
className="ep-add-clip-picker ep-add-clip-picker--portal"
style={{
position: "fixed",
top: position.top,
right: position.right,
}}
>
<div className="ep-add-clip-picker-title"></div>
{/* 类型选择 */}
<div className="ep-add-clip-type-row">
<span className="ep-add-clip-type-label">:</span>
{availableTypes.map((t) => (
<button
key={t}
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
onClick={() => onTypeChange(t)}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
))}
</div>
{/* 时长输入 */}
<div className="ep-add-clip-duration-row">
<span className="ep-add-clip-type-label">:</span>
<input
type="number"
className="ep-duration-input"
min={minDuration}
max={maxDuration}
value={addDuration}
onChange={(e) =>
onDurationChange(
Math.max(minDuration, Math.min(maxDuration, Number(e.target.value) || minDuration)),
)
}
/>
<span className="ep-add-clip-duration-unit"></span>
</div>
{/* 确认按钮 */}
<button className="ep-add-clip-confirm-btn" onClick={onConfirm}>
</button>
</div>
)
}
@@ -0,0 +1,155 @@
import React from "react"
import type { ClipData, TrimConfig } from "../../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS, MIN_CLIP_WIDTH } from "../../constants/timeline"
interface ClipCardProps {
clip: ClipData
idx: number
isSelected: boolean
isDragging: boolean
isDragOver: boolean
isHovered: boolean
pps: number
trimDragActive: boolean
showTrimHandles: boolean
onDragStart: (e: React.DragEvent, idx: number) => void
onDragOver: (e: React.DragEvent, idx: number) => void
onDragEnd: () => void
onDrop: (e: React.DragEvent, idx: number) => void
onSelect: (clipId: string) => void
onContextMenu: (e: React.MouseEvent, clipId: string) => void
onMouseEnter: () => void
onMouseLeave: () => void
onTrimHandleMouseDown: (e: React.MouseEvent, clipId: string, direction: "left" | "right") => void
onRemove: (clipId: string) => void
}
export const ClipCard: React.FC<ClipCardProps> = ({
clip,
idx,
isSelected,
isDragging,
isDragOver,
isHovered,
pps,
trimDragActive,
showTrimHandles,
onDragStart,
onDragOver,
onDragEnd,
onDrop,
onSelect,
onContextMenu,
onMouseEnter,
onMouseLeave,
onTrimHandleMouseDown,
onRemove,
}) => {
/* 转场指示器 */
const trans = clip.transition
const showTransition = idx > 0 && trans && trans.type !== "none"
const transOpt = showTransition
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
: undefined
/* 速度徽章 */
const speed = clip.speed
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01
/* 裁剪状态 */
const hasTrim = !!clip.trim_config
return (
<React.Fragment key={clip.id}>
{/* 转场指示器 */}
{showTransition && transOpt && (
<div
className="ep-transition-indicator"
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
>
<span className="ep-trans-icon">{transOpt.icon}</span>
<span className="ep-trans-duration">{trans!.duration.toFixed(1)}s</span>
</div>
)}
<div
className={`ep-clip-card ${isSelected ? "selected" : ""} ${isDragging ? "dragging" : ""} ${isDragOver ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
style={{ width: Math.max(clip.duration * pps, MIN_CLIP_WIDTH) }}
draggable={!trimDragActive}
onDragStart={(e) => onDragStart(e, idx)}
onDragOver={(e) => onDragOver(e, idx)}
onDragEnd={onDragEnd}
onDrop={(e) => onDrop(e, idx)}
onClick={() => onSelect(clip.id)}
onContextMenu={(e) => onContextMenu(e, clip.id)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* 左裁剪手柄 */}
{isHovered && showTrimHandles && (
<div
className="ep-trim-handle ep-trim-handle-left"
onMouseDown={(e) => onTrimHandleMouseDown(e, clip.id, "left")}
title="拖动调整入点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 类型图标 */}
<div className="ep-clip-thumbnail">{CLIP_TYPE_ICONS[clip.type] || "🎬"}</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">
{clip.duration}s
{hasTrim && (
<span className="ep-trim-indicator" title="已裁剪">
</span>
)}
</span>
</div>
{/* 速度徽章 */}
{showSpeed && <span className="ep-speed-badge">{speed!.rate.toFixed(1)}x</span>}
{/* 裁剪徽章 */}
{hasTrim && (
<span
className="ep-trim-badge"
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
>
</span>
)}
{/* 右裁剪手柄 */}
{isHovered && showTrimHandles && (
<div
className="ep-trim-handle ep-trim-handle-right"
onMouseDown={(e) => onTrimHandleMouseDown(e, clip.id, "right")}
title="拖动调整出点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation()
onRemove(clip.id)
}}
>
</button>
</div>
</React.Fragment>
)
}
@@ -0,0 +1,52 @@
import React from "react"
interface ContextMenuProps {
x: number
y: number
menuRef: React.RefObject<HTMLDivElement>
hasTrim: boolean
onSplit: () => void
onResetTrim: () => void
onDelete: () => void
}
export const ContextMenu: React.FC<ContextMenuProps> = ({
x,
y,
menuRef,
hasTrim,
onSplit,
onResetTrim,
onDelete,
}) => {
return (
<div
ref={menuRef}
className="ep-context-menu"
style={{
position: "fixed",
left: x,
top: y,
}}
>
<div className="ep-context-menu-item" onClick={onSplit}>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
{hasTrim && (
<div className="ep-context-menu-item" onClick={onResetTrim}>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
)}
<div className="ep-context-menu-divider" />
<div
className="ep-context-menu-item ep-context-menu-item-danger"
onClick={onDelete}
>
<span className="ep-context-menu-icon">🗑</span>
<span></span>
</div>
</div>
)
}
@@ -0,0 +1,27 @@
import React from "react"
import { getRulerStep, MIN_TRACK_WIDTH } from "../../constants/timeline"
import { generateRulerMarks } from "../../utils/timeline"
interface TimeRulerProps {
totalDuration: number
pps: number
onClick: (e: React.MouseEvent<HTMLDivElement>) => void
}
export const TimeRuler: React.FC<TimeRulerProps> = ({ totalDuration, pps, onClick }) => {
const trackWidth = Math.max(totalDuration * pps, MIN_TRACK_WIDTH)
const step = getRulerStep(totalDuration)
const marks = generateRulerMarks(totalDuration, step)
return (
<div className="ep-time-ruler" onClick={onClick}>
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
{marks.map((t) => (
<span key={t} className="ep-time-mark" style={{ left: `${t * pps}px` }}>
{t}s
</span>
))}
</div>
</div>
)
}
@@ -0,0 +1,36 @@
import React from "react"
import { formatTrimTime } from "../../utils/timeline"
interface TrimPreviewProps {
startTime: number
endTime: number
duration: number
x: number
y: number
}
export const TrimPreview: React.FC<TrimPreviewProps> = ({ startTime, endTime, duration, x, y }) => {
return (
<div
className="ep-trim-preview"
style={{
position: "fixed",
left: x + 12,
top: y - 40,
}}
>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">{formatTrimTime(startTime)}</span>
</div>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">{formatTrimTime(endTime)}</span>
</div>
<div className="ep-trim-preview-row ep-trim-preview-duration">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">{formatTrimTime(duration)}</span>
</div>
</div>
)
}
@@ -0,0 +1,12 @@
import type { TemplateMode } from "@/api/editing-planner"
/* ──────────── 常量 ──────────── */
export const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
{ key: "pip", label: "混剪", icon: "🖼️" },
{ key: "voice_over", label: "人物口播", icon: "🎙️" },
{ key: "one_take", label: "一镜到底", icon: "🎥" },
{ key: "voice_pip", label: "口播+混剪", icon: "🎭" },
]
export const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"]
@@ -0,0 +1,56 @@
import type { ClipType } from "../types"
/** 片段类型图标 */
export const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
}
/** 片段类型标签 */
export const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
/** 默认缩放:每秒像素数 */
export const DEFAULT_PIXELS_PER_SECOND = 40
/** 最小缩放 */
export const MIN_PIXELS_PER_SECOND = 10
/** 最大缩放 */
export const MAX_PIXELS_PER_SECOND = 120
/** 缩放步长 */
export const ZOOM_STEP = 10
/** 片段卡片最小宽度(px */
export const MIN_CLIP_WIDTH = 60
/** 添加面板宽度(px */
export const ADD_PICKER_WIDTH = 240
/** 轨道间距(px */
export const TRACK_GAP = 6
/** 最小裁剪时长(秒) */
export const MIN_TRIM_DURATION = 1
/** 默认添加时长(秒) */
export const DEFAULT_ADD_DURATION = 5
/** 最小添加时长(秒) */
export const MIN_ADD_DURATION = 1
/** 最大添加时长(秒) */
export const MAX_ADD_DURATION = 120
/** 轨道最小宽度(px */
export const MIN_TRACK_WIDTH = 300
/** 时间标尺刻度计算:根据总时长返回刻度步长(秒) */
export const getRulerStep = (totalDuration: number): number => {
if (totalDuration <= 30) return 5
if (totalDuration <= 60) return 10
return 15
}
@@ -0,0 +1,64 @@
import { useState, useCallback } from "react"
/**
* 片段拖拽排序 Hook
* 支持 HTML5 原生拖拽,实时高亮拖拽位置
*/
export const useClipDrag = (
onClipReorder: (fromIdx: number, toIdx: number) => void,
disabled?: boolean,
) => {
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
if (disabled) return
setDragIdx(idx)
e.dataTransfer.setData("application/x-clip-drag", String(idx))
e.dataTransfer.effectAllowed = "move"
},
[disabled],
)
const handleDragOver = useCallback((e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
setDragOverIdx(idx)
}, [])
const handleDragEnd = useCallback(() => {
setDragIdx(null)
setDragOverIdx(null)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent, toIdx: number) => {
e.preventDefault()
setDragOverIdx(null)
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
if (fromStr !== "") {
const fromIdx = Number(fromStr)
if (fromIdx !== toIdx) {
onClipReorder(fromIdx, toIdx)
}
}
setDragIdx(null)
},
[onClipReorder],
)
const handleEmptyDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
}, [])
return {
dragIdx,
dragOverIdx,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDrop,
handleEmptyDragOver,
}
}
@@ -0,0 +1,238 @@
import { useState, useCallback, useMemo } from "react"
import { Modal, message } from "antd"
import type {
ClipData,
ClipType,
TransitionConfig,
SpeedConfig,
TtsConfig,
TrimConfig,
} from "../types"
import type { AssetItem } from "@/api/assets"
interface UseClipOperationsParams {
clips: ClipData[]
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
}
/**
* 片段操作 Hook
* 片段增删改查、排序、裁剪、分割、转场、调速、TTS、配音选择
*/
export const useClipOperations = ({ clips, setClips }: UseClipOperationsParams) => {
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
const selectedClip = useMemo(
() => clips.find((c) => c.id === selectedClipId) || null,
[clips, selectedClipId],
)
/* ── 选中 / 重排 / 删除 ── */
const handleClipSelect = useCallback((clipId: string) => {
setSelectedClipId(clipId)
}, [])
const handleClipReorder = useCallback(
(fromIdx: number, toIdx: number) => {
setClips((prev) => {
const updated = [...prev]
const [moved] = updated.splice(fromIdx, 1)
updated.splice(toIdx, 0, moved)
return updated.map((c, i) => ({ ...c, order: i }))
})
},
[setClips],
)
const handleClipRemove = useCallback(
(clipId: string) => {
Modal.confirm({
title: "删除片段",
content: "确定要删除这个片段吗?此操作可通过撤销恢复。",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: () => {
setClips((prev) => prev.filter((c) => c.id !== clipId))
if (selectedClipId === clipId) setSelectedClipId(null)
},
})
},
[setClips, selectedClipId],
)
const handleClipUpdate = useCallback(
(clipId: string, data: Partial<ClipData>) => {
setClips((prev) => prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)))
},
[setClips],
)
/**
* 添加片段(不绑定任何素材)
* 片段 = 时间规划 + 类型标记
*/
const handleAddClip = useCallback(
(type: ClipType, duration: number) => {
const newClip: ClipData = {
id: `clip-${Date.now()}`,
type,
duration,
startOffset: 0,
order: clips.length,
}
setClips((prev) => [...prev, newClip])
},
[clips.length, setClips],
)
/* ── 裁剪 / 分割 / 重置 ── */
const handleClipTrim = useCallback(
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId ? { ...c, trim_config: trimConfig, duration: newDuration } : c,
),
)
},
[setClips],
)
const handleClipSplit = useCallback(
(clipId: string, splitRatio: number) => {
setClips((prev) => {
const idx = prev.findIndex((c) => c.id === clipId)
if (idx === -1) return prev
const clip = prev[idx]
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev
// 前半段
const firstHalf: ClipData = {
...clip,
duration: splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
end_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
}
// 后半段
const secondHalf: ClipData = {
...clip,
id: `clip-${Date.now()}`,
duration: clip.duration - splitPoint,
startOffset: clip.startOffset + splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
start_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
order: (clip.order ?? idx) + 1,
}
const updated = [...prev]
updated[idx] = firstHalf
updated.splice(idx + 1, 0, secondHalf)
return updated.map((c, i) => ({ ...c, order: i }))
})
},
[setClips],
)
const handleClipResetTrim = useCallback(
(clipId: string) => {
setClips((prev) =>
prev.map((c) => {
if (c.id !== clipId || !c.trim_config) return c
const originalDuration = c.trim_config.original_duration ?? c.duration
return {
...c,
duration: originalDuration,
trim_config: undefined,
}
}),
)
},
[setClips],
)
/* ── 转场 / 调速 / TTS ── */
const handleTransitionChange = useCallback(
(targetClipId: string | null, config: TransitionConfig) => {
if (targetClipId) {
handleClipUpdate(targetClipId, { transition: config })
}
// 同时更新全局默认转场(供新片段使用)—— 暂未实现全局默认
},
[handleClipUpdate],
)
const handleSpeedChange = useCallback(
(targetClipId: string | null, config: SpeedConfig) => {
if (targetClipId) {
handleClipUpdate(targetClipId, { speed: config })
}
},
[handleClipUpdate],
)
const handleApplySpeedAll = useCallback(
(config: SpeedConfig) => {
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })))
message.success("已应用到所有片段")
},
[setClips],
)
const handleTtsChange = useCallback(
(targetClipId: string | null, ttsConfig: TtsConfig) => {
if (!targetClipId) return
handleClipUpdate(targetClipId, { tts_config: ttsConfig })
},
[handleClipUpdate],
)
/** 为片段选择配音素材 */
const handleClipVoiceSelect = useCallback(
(clipId: string, asset: AssetItem | null) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId
? {
...c,
voice_asset_id: asset?.id ?? undefined,
voice_file_url: asset?.file_url ?? undefined,
}
: c,
),
)
},
[setClips],
)
return {
selectedClipId,
setSelectedClipId,
selectedClip,
handleClipSelect,
handleClipReorder,
handleClipRemove,
handleClipUpdate,
handleAddClip,
handleClipTrim,
handleClipSplit,
handleClipResetTrim,
handleTransitionChange,
handleSpeedChange,
handleApplySpeedAll,
handleTtsChange,
handleClipVoiceSelect,
}
}
@@ -0,0 +1,78 @@
import { useState, useCallback } from "react"
/**
* 编辑器 Drawer 开关管理
* 集中管理 11 个抽屉的开关状态 + 3 个目标片段 ID + 快捷打开方法
*/
export const useEditorDrawers = () => {
/* ── 抽屉开关 ── */
const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false)
const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false)
const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false)
const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false)
const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false)
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false)
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false)
const [pipDrawerOpen, setPipDrawerOpen] = useState(false)
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false)
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false)
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false)
/* ── 目标片段 ID ── */
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
const [transitionTargetClipId, setTransitionTargetClipId] = useState<string | null>(null)
/** 当前正在调速的片段 ID */
const [speedTargetClipId, setSpeedTargetClipId] = useState<string | null>(null)
/** 当前正在配置 TTS 的片段 ID */
const [ttsTargetClipId, setTtsTargetClipId] = useState<string | null>(null)
/* ── 快捷打开 ── */
const openTransitionDrawer = useCallback((clipId?: string) => {
setTransitionTargetClipId(clipId ?? null)
setTransitionDrawerOpen(true)
}, [])
const openSpeedDrawer = useCallback((clipId: string) => {
setSpeedTargetClipId(clipId)
setSpeedDrawerOpen(true)
}, [])
const openTtsDrawer = useCallback((clipId: string) => {
setTtsTargetClipId(clipId)
setTtsDrawerOpen(true)
}, [])
return {
// 开关 state
bgmDrawerOpen,
setBgmDrawerOpen,
subtitleDrawerOpen,
setSubtitleDrawerOpen,
transitionDrawerOpen,
setTransitionDrawerOpen,
speedDrawerOpen,
setSpeedDrawerOpen,
ttsDrawerOpen,
setTtsDrawerOpen,
watermarkDrawerOpen,
setWatermarkDrawerOpen,
introOutroDrawerOpen,
setIntroOutroDrawerOpen,
pipDrawerOpen,
setPipDrawerOpen,
filterDrawerOpen,
setFilterDrawerOpen,
chromaKeyDrawerOpen,
setChromaKeyDrawerOpen,
stickerDrawerOpen,
setStickerDrawerOpen,
// 目标 ID
transitionTargetClipId,
speedTargetClipId,
ttsTargetClipId,
// 快捷方法
openTransitionDrawer,
openSpeedDrawer,
openTtsDrawer,
}
}
@@ -0,0 +1,56 @@
import { useState, useCallback, useEffect, useRef } from "react"
/**
* 播放控制 Hook
* 播放/暂停、rAF 帧推进、时间线缩放、seek
*/
export const usePlaybackControl = (totalDuration: number) => {
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [pixelsPerSecond, setPixelsPerSecond] = useState(40)
const prevFrameTimeRef = useRef<number | null>(null)
/** 播放头跳转 */
const handleSeek = useCallback((time: number) => {
setCurrentTime(Math.max(0, time))
}, [])
/** 轨道缩放 */
const handleZoomChange = useCallback((pps: number) => {
setPixelsPerSecond(pps)
}, [])
/** rAF 帧推进 — 播放时平滑更新播放头位置 */
useEffect(() => {
if (!isPlaying) {
prevFrameTimeRef.current = null
return
}
let rafId: number
const tick = (timestamp: number) => {
if (prevFrameTimeRef.current !== null) {
const delta = (timestamp - prevFrameTimeRef.current) / 1000
setCurrentTime((prev) => {
const next = prev + delta
return next >= totalDuration ? totalDuration : next
})
}
prevFrameTimeRef.current = timestamp
rafId = requestAnimationFrame(tick)
}
rafId = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(rafId)
prevFrameTimeRef.current = null
}
}, [isPlaying, totalDuration])
return {
isPlaying,
setIsPlaying,
currentTime,
pixelsPerSecond,
handleSeek,
handleZoomChange,
}
}
@@ -0,0 +1,466 @@
import { useState, useCallback, useEffect, type Dispatch, type SetStateAction } from "react"
import { FILTER_CATEGORIES } from "../constants"
import { message } from "antd"
import type {
EditingTemplate,
TemplateCategory,
TemplateMode,
SaveTemplatePayload,
} from "@/api/editing-planner"
import {
getEditingTemplates,
getEditingTemplate,
createEditingTemplate,
updateEditingTemplate,
getTemplateCategories,
} from "@/api/editing-planner"
import type { MediaAsset, TitleConfig, TransitionEffect } from "@/api/template-editor"
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/template-editor"
import type {
ClipData,
ClipType,
TtsConfig,
TtsMode,
TrimConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
interface UseTemplateManagementParams {
urlTemplateId: string
urlPlanId: string
resetClips: (clips: ClipData[]) => void
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
setSelectedClipId: (id: string | null) => void
setMediaAssets: (assets: MediaAsset[]) => void
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
// 保存时需要的配置
clips: ClipData[]
totalDuration: number
titleConfig: TitleConfig
subtitleSettings: SubtitleStyleConfig
bgmSettings: BgmMixConfig
watermarkSettings: WatermarkConfig
introOutroSettings: IntroOutroConfig
pipSettings: PipConfig
filterSettings: FilterConfig
chromaKeySettings: ChromaKeyConfig
stickerSettings: StickerConfig
coverConfig: CoverConfig
}
/**
* 模板管理 Hook
* 模板列表/分类/加载/保存/模式切换/筛选搜索 + 3 个 useEffect
*/
export const useTemplateManagement = (params: UseTemplateManagementParams) => {
const {
urlTemplateId,
urlPlanId,
resetClips,
setClips,
setSelectedClipId,
setMediaAssets,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
clips,
totalDuration,
titleConfig,
subtitleSettings,
bgmSettings,
watermarkSettings,
introOutroSettings,
pipSettings,
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
} = params
/* ── 模板列表 ── */
const [templates, setTemplates] = useState<EditingTemplate[]>([])
const [categories, setCategories] = useState<TemplateCategory[]>([])
const [loadingTemplates, setLoadingTemplates] = useState(false)
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(urlTemplateId || null)
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip")
/* ── 左栏筛选 ── */
const [currentFilter, setCurrentFilter] = useState("全部")
const [searchQuery, setSearchQuery] = useState("")
/* ── 保存弹窗 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false)
const [draftName, setDraftName] = useState("")
const [draftCategory, setDraftCategory] = useState("")
const [draftTags, setDraftTags] = useState("")
const [saveLoading, setSaveLoading] = useState(false)
/* ── 计划 ID(从 URL 传入,不变) ── */
const [loadedPlanId] = useState<string | null>(urlPlanId || null)
/* ── 计算 ── */
const filteredTemplates = templates.filter((t) => {
if (currentFilter !== "全部" && t.category !== currentFilter) return false
if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
return true
})
const currentTemplate = templates.find((t) => t.id === loadedTemplateId) || null
/* ──────────── 加载 ──────────── */
/**
* 并行加载模板列表、分类、素材库
* 首次挂载时调用,三个接口无依赖关系,用 Promise.all 并发
*/
const loadTemplates = useCallback(async () => {
setLoadingTemplates(true)
try {
const [tpls, cats, assets] = await Promise.all([
getEditingTemplates(),
getTemplateCategories(),
getMediaAssets(),
])
setTemplates(tpls)
setCategories(cats)
setMediaAssets(assets)
} catch {
message.error("加载模板失败")
} finally {
setLoadingTemplates(false)
}
}, [setMediaAssets])
useEffect(() => {
loadTemplates()
}, [loadTemplates])
/**
* 加载模板详情并初始化片段列表
* 将后端 segments 映射为前端 ClipData,取 duration_min/max 均值作为默认时长
* 同时还原标题/字幕/BGM 配置
*/
useEffect(() => {
if (!loadedTemplateId) return
getEditingTemplate(loadedTemplateId)
.then((tpl) => {
if (!tpl) return
setCurrentMode(tpl.mode)
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
id: seg.id || `seg-${idx}`,
template_segment_id: seg.id || `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
}))
resetClips(mapped)
setTitleConfig({
ai_auto_select: tpl.title_config.ai_auto_select,
content: tpl.title_config.content,
position: tpl.title_config.position,
font_preset: tpl.title_config.font_preset,
font_size: tpl.title_config.font_size,
font_color: tpl.title_config.font_color || "#ffffff",
})
setSubtitleSettings((prev) => ({
...prev,
enabled: tpl.subtitle_config.enabled,
position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"],
font: tpl.subtitle_config.font,
fontSize: tpl.subtitle_config.size,
fontColor: tpl.subtitle_config.color || "#ffffff",
animation: tpl.subtitle_config.animation,
}))
setBgmSettings((prev) => ({
...prev,
enabled: tpl.bgm_config.enabled,
music_id: tpl.bgm_config.music_id || "",
}))
setDraftName(tpl.name)
setDraftCategory(tpl.category)
setDraftTags(tpl.tags.join(", "))
})
.catch(() => message.error("加载模板详情失败"))
}, [loadedTemplateId, resetClips, setTitleConfig, setSubtitleSettings, setBgmSettings])
/**
* 加载已有模板草稿数据到编辑器
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
*/
useEffect(() => {
if (!loadedPlanId) return
// 并行加载计划基本信息 + 片段列表
Promise.all([
getEditPlan(loadedPlanId),
getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({
items: [],
total: 0,
})),
])
.then(([plan, clipsRes]) => {
// 设置关联的模板(触发模板加载 effect)
setLoadedTemplateId(plan.template_id)
// 还原基本信息
setDraftName(plan.name)
// 还原 config 中的编辑器状态
const cfg = plan.config
if (cfg.title_config) {
setTitleConfig({
ai_auto_select: cfg.title_config!.ai_auto_select,
content: cfg.title_config!.content,
position: cfg.title_config!.position,
font_preset: cfg.title_config!.font_preset,
font_size: cfg.title_config!.font_size,
font_color: cfg.title_config!.font_color || "#ffffff",
})
}
if (cfg.subtitle_config) {
setSubtitleSettings((prev) => ({
...prev,
enabled: cfg.subtitle_config!.enabled,
position: (cfg.subtitle_config!.position ||
"bottom") as SubtitleStyleConfig["position"],
font: cfg.subtitle_config!.font,
fontSize: cfg.subtitle_config!.size,
fontColor: cfg.subtitle_config!.color || "#ffffff",
animation: cfg.subtitle_config!.animation,
}))
}
if (cfg.bgm_config) {
setBgmSettings((prev) => ({
...prev,
enabled: cfg.bgm_config!.enabled,
music_id: cfg.bgm_config!.music_id || "",
}))
}
// 还原封面配置
if (cfg.cover_config) {
setCoverConfig((prev) => ({
...prev,
enabled: cfg.cover_config!.enabled ?? prev.enabled,
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
}))
}
// 还原片段:优先从后端 clips 表,其次从 config.segments 兜底
const backendClips = clipsRes?.items || []
if (backendClips.length > 0) {
// 从后端 clips 表还原
const sorted = [...backendClips].sort((a, b) => a.order - b.order)
const mapped: ClipData[] = sorted.map((clip) => ({
id: clip.id,
template_segment_id: (clip.config?.template_segment_id as string) || "",
type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: clip.duration || 3,
startOffset: 0,
script_text: clip.text_content || "",
order: clip.order,
media_asset_id: clip.asset_id || undefined,
transition:
clip.transition_effect && clip.transition_effect !== "none"
? {
type: clip.transition_effect as TransitionEffect["type"],
duration: clip.transition_duration || 0.3,
}
: undefined,
speed: clip.playback_speed
? { rate: clip.playback_speed, pitchCorrection: true }
: undefined,
tts_config: (clip.config?.tts_config as TtsConfig) || undefined,
trim_config: (clip.config?.trim_config as TrimConfig) || undefined,
}))
setTimeout(() => resetClips(mapped), 100)
} else if (cfg.segments && cfg.segments.length > 0) {
// 兜底:从 config.segments 还原(老数据兼容)
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
id: `seg-${idx}`,
template_segment_id: `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
transition: seg.transition
? {
type: seg.transition.type as TransitionEffect["type"],
duration: seg.transition.duration,
}
: undefined,
speed: seg.playback_speed
? { rate: seg.playback_speed, pitchCorrection: true }
: undefined,
tts_config: seg.tts_config
? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode }
: undefined,
trim_config: seg.trim_config || undefined,
}))
setTimeout(() => resetClips(mapped), 100)
}
})
.catch(() => message.error("加载模板草稿失败"))
}, [
loadedPlanId,
resetClips,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
])
/* ──────────── 事件 ──────────── */
const handleLoadTemplate = (templateId: string) => {
setLoadedTemplateId(templateId)
setSelectedClipId(null)
}
const handleModeChange = (mode: TemplateMode) => {
setCurrentMode(mode)
// 切换纯单类型模式时,自动转换所有已有片段的类型
if (mode === "voice_over") {
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })))
} else if (mode === "pip") {
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })))
}
// 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型
}
const handleOpenSaveModal = () => {
setSaveModalOpen(true)
}
const handleSave = async () => {
if (!draftName.trim()) {
message.warning("请输入模板名称")
return
}
setSaveLoading(true)
try {
const payload: SaveTemplatePayload = {
name: draftName,
mode: currentMode,
category: draftCategory,
tags: draftTags
.split(",")
.map((t) => t.trim())
.filter(Boolean),
title_config: titleConfig,
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
},
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.type === "voice" ? "voiceover" : "video",
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverConfig },
}
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload)
} else {
await createEditingTemplate(payload)
}
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功")
setSaveModalOpen(false)
loadTemplates()
} catch {
message.error("保存失败")
} finally {
setSaveLoading(false)
}
}
return {
// state
templates,
categories,
loadingTemplates,
loadedTemplateId,
setLoadedTemplateId,
currentMode,
setCurrentMode,
currentFilter,
setCurrentFilter,
searchQuery,
setSearchQuery,
saveModalOpen,
setSaveModalOpen,
draftName,
setDraftName,
draftCategory,
setDraftCategory,
draftTags,
setDraftTags,
saveLoading,
filteredTemplates,
currentTemplate,
// methods
loadTemplates,
handleLoadTemplate,
handleModeChange,
handleOpenSaveModal,
handleSave,
}
}
export { FILTER_CATEGORIES }
@@ -0,0 +1,202 @@
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
import type { ClipData, ClipType, TrimConfig } from "../types"
import {
DEFAULT_ADD_DURATION,
MIN_ADD_DURATION,
MAX_ADD_DURATION,
ADD_PICKER_WIDTH,
TRACK_GAP,
} from "../constants/timeline"
interface ContextMenuState {
x: number
y: number
clipId: string
}
/**
* 时间线菜单 Hook
* 管理右键菜单和添加片段面板的状态与交互
*/
export const useTimelineMenus = (
clips: ClipData[],
currentMode: string,
onAddClip: (type: ClipType, duration: number) => void,
onClipSplit?: (clipId: string, splitRatio: number) => void,
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 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])
return {
// 右键菜单
contextMenu,
contextMenuRef,
handleContextMenu,
handleContextSplit,
handleContextResetTrim,
handleContextDelete,
// 添加面板
showAddPicker,
pickerRef,
addCardRef,
pickerPos,
availableTypes,
addType,
addDuration,
setAddType,
setAddDuration,
handleTogglePicker,
handleConfirmAdd,
// 悬停状态
hoveredClipId,
setHoveredClipId,
}
}
@@ -0,0 +1,118 @@
import { useState, useCallback, useEffect } from "react"
import type { ClipData, TrimConfig } from "../types"
interface TrimDragState {
clipId: string
direction: "left" | "right"
startX: number
originalTrim: TrimConfig
originalDuration: number
}
interface TrimPreviewState {
clipId: string
startTime: number
endTime: number
duration: number
x: number
y: number
}
const MIN_TRIM_DURATION = 1
/**
* 裁剪拖拽 Hook
* 拖拽片段两端手柄调整入点/出点,实时显示预览
*/
export const useTrimDrag = (
clips: ClipData[],
pps: number,
onClipTrim?: (clipId: string, trimConfig: TrimConfig, newDuration: number) => void,
) => {
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
const [trimPreview, setTrimPreview] = useState<TrimPreviewState | null>(null)
const handleTrimHandleMouseDown = useCallback(
(e: React.MouseEvent, clipId: string, direction: "left" | "right") => {
e.preventDefault()
e.stopPropagation()
const clip = clips.find((c) => c.id === clipId)
if (!clip) return
const trim: TrimConfig = clip.trim_config ?? {
start_time: 0,
end_time: clip.duration,
original_duration: clip.duration,
}
setTrimDrag({
clipId,
direction,
startX: e.clientX,
originalTrim: { ...trim },
originalDuration: clip.duration,
})
},
[clips],
)
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!trimDrag) return
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX
const dtSec = dx / pps
const clip = clips.find((c) => c.id === trimDrag.clipId)
if (!clip) return
const origTrim = trimDrag.originalTrim
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
let newStart = origTrim.start_time
let newEnd = origTrim.end_time
if (trimDrag.direction === "left") {
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
} else {
newEnd = Math.max(origTrim.start_time + MIN_TRIM_DURATION, Math.min(origTrim.end_time + dtSec, origDur))
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10
setTrimPreview({
clipId: trimDrag.clipId,
startTime: Math.round(newStart * 10) / 10,
endTime: Math.round(newEnd * 10) / 10,
duration: newDuration,
x: e.clientX,
y: e.clientY,
})
}
const handleMouseUp = () => {
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
const newTrim: TrimConfig = {
start_time: trimPreview.startTime,
end_time: trimPreview.endTime,
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
}
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
}
setTrimDrag(null)
setTrimPreview(null)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [trimDrag, trimPreview, clips, pps, onClipTrim])
return {
trimDrag,
trimPreview,
handleTrimHandleMouseDown,
}
}
@@ -0,0 +1,41 @@
import type { EditingTemplate } from "@/api/editing-planner"
import type { ClipData } from "../types"
/**
* 计算所有片段的总时长
*/
export function calculateTotalDuration(clips: ClipData[]): number {
return clips.reduce((sum, c) => sum + c.duration, 0)
}
/**
* 获取当前选中的模板
*/
export function getCurrentTemplate(
templates: EditingTemplate[],
loadedTemplateId: string | null,
): EditingTemplate | undefined {
return templates.find((t) => t.id === loadedTemplateId)
}
/**
* 根据分类和搜索词筛选模板
*/
export function getFilteredTemplates(
templates: EditingTemplate[],
currentFilter: string,
searchQuery: string,
): EditingTemplate[] {
return templates.filter((t) => {
if (currentFilter !== "全部" && t.category !== currentFilter) return false
if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
return true
})
}
/**
* 获取当前选中的片段
*/
export function getSelectedClip(clips: ClipData[], selectedClipId: string | null): ClipData | null {
return clips.find((c) => c.id === selectedClipId) || null
}
@@ -0,0 +1,20 @@
/** 格式化时间为 mm:ss */
export const formatTime = (sec: number): string => {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, "0")}`
}
/** 格式化裁剪时间(精确到 0.1 秒) */
export const formatTrimTime = (sec: number): string => {
return `${sec.toFixed(1)}s`
}
/** 生成时间标尺刻度 */
export const generateRulerMarks = (totalDuration: number, step: number): number[] => {
const marks: number[] = []
for (let t = 0; t <= totalDuration + step; t += step) {
marks.push(t)
}
return marks
}
+56 -810
View File
@@ -2,832 +2,87 @@
* 成片库页面 — V21 设计系统
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
* 使用 useQuery 对接后端真实 APIapi/products.ts
*
* 代码结构(三阶段重构后):
* - types.ts: 类型定义
* - constants.ts: 常量配置
* - utils/index.ts: 工具函数
* - components/ProductCard.tsx: 产品卡片组件
* - components/VideoPlayer.tsx: 视频播放器组件
* - hooks/useProductList.ts: 列表查询与筛选
* - hooks/useProductActions.ts: 单个/批量操作
*/
import React, { useMemo, useState, useRef, useEffect, useCallback } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "react-router-dom"
import { message, Popconfirm } from "antd"
import React, { useState } from "react"
import { Popconfirm, message } from "antd"
import {
SearchOutlined,
VideoCameraOutlined,
DownloadOutlined,
ShareAltOutlined,
DeleteOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
CloseOutlined,
CheckOutlined,
CloudUploadOutlined,
EyeOutlined,
} from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import {
getProducts,
deleteProduct,
getProductDownloadUrl,
updateReviewStatus,
batchDownload,
getBatchDownloadStatus,
type ProductItem as ApiProductItem,
type ReviewStatus,
} from "@/api/products"
import type { ProductItem } from "./types"
import { ProductCard } from "./components/ProductCard"
import { VideoPlayer } from "./components/VideoPlayer"
import { useProductList } from "./hooks/useProductList"
import { useProductActions } from "./hooks/useProductActions"
import "./products.css"
/* ============================================================
* 类型 & 常量
* ============================================================ */
type ProductStatus = "completed" | "processing" | "review" | "failed"
interface ProductItem {
id: string
name: string
status: ProductStatus
duration: number // 秒
date: string
gradient: string
duplicateRate: number // 查重率百分比
isPublished: boolean
resolution: string
fileSize: number // MB
videoUrl?: string
thumbnailUrl?: string
/** 复核状态 */
reviewStatus?: ReviewStatus
/** 所属项目 ID */
projectId?: string
/** 所属项目名称 */
projectName?: string
}
/** 将后端 status 映射为前端 ProductStatus */
const normalizeStatus = (s: string): ProductStatus => {
const map: Record<string, ProductStatus> = {
completed: "completed",
succeeded: "completed",
processing: "processing",
running: "processing",
review: "review",
failed: "failed",
error: "failed",
}
return map[s] ?? "processing"
}
/** 渐变色列表(按 id hash 选取) */
const GRADIENTS = [
"linear-gradient(135deg, #1e1b4b 0%, #4f46e5 50%, #818cf8 100%)",
"linear-gradient(135deg, #831843 0%, #db2777 50%, #f472b6 100%)",
"linear-gradient(135deg, #064e3b 0%, #059669 50%, #34d399 100%)",
"linear-gradient(135deg, #78350f 0%, #d97706 50%, #fbbf24 100%)",
"linear-gradient(135deg, #1e3a5f 0%, #2563eb 50%, #60a5fa 100%)",
"linear-gradient(135deg, #581c87 0%, #9333ea 50%, #c084fc 100%)",
"linear-gradient(135deg, #7f1d1d 0%, #dc2626 50%, #f87171 100%)",
"linear-gradient(135deg, #134e4a 0%, #0d9488 50%, #5eead4 100%)",
"linear-gradient(135deg, #1e1b4b 0%, #6366f1 50%, #a5b4fc 100%)",
"linear-gradient(135deg, #3b0764 0%, #7c3aed 50%, #a78bfa 100%)",
"linear-gradient(135deg, #052e16 0%, #16a34a 50%, #86efac 100%)",
"linear-gradient(135deg, #450a0e 0%, #e11d48 50%, #fb7185 100%)",
]
/** 根据 id 生成稳定的渐变色 */
const gradientForId = (id: string): string => {
let hash = 0
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0
return GRADIENTS[Math.abs(hash) % GRADIENTS.length]
}
/** 将后端 ProductItem 映射为前端 ProductItem */
const mapApiProduct = (item: ApiProductItem): ProductItem => ({
id: item.id,
name: item.title,
status: normalizeStatus(item.status),
duration: item.duration_seconds ?? 0,
date: item.created_at ? formatDateTime(item.created_at) : "—",
gradient: gradientForId(item.id),
duplicateRate: item.duplicate_rate ?? 0,
isPublished: false, // TODO: 后端发布状态字段待补齐
resolution: item.resolution ?? "1080×1920",
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
videoUrl: item.video_url,
thumbnailUrl: item.thumbnail_url,
reviewStatus: item.review_status,
projectId: item.project_id,
projectName: item.project_name,
})
/* ============================================================
* 工具函数
* ============================================================ */
const statusConfig: Record<ProductStatus, { text: string; className: string }> = {
completed: { text: "已完成", className: "completed" },
processing: { text: "处理中", className: "processing" },
review: { text: "待复核", className: "review" },
failed: { text: "失败", className: "failed" },
}
/** 格式化时间 mm:ss */
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
}
/** 格式化文件大小 */
const formatSize = (mb: number): string => {
if (mb <= 0) return "-"
if (mb < 1) return `${(mb * 1024).toFixed(0)} KB`
return `${mb.toFixed(1)} MB`
}
/** 格式化日期时间(MM-DD HH:mm */
const formatDateTime = (isoString: string): string => {
const d = new Date(isoString)
const month = (d.getMonth() + 1).toString().padStart(2, "0")
const day = d.getDate().toString().padStart(2, "0")
const hour = d.getHours().toString().padStart(2, "0")
const minute = d.getMinutes().toString().padStart(2, "0")
return `${month}-${day} ${hour}:${minute}`
}
/** 复核状态配置 */
const reviewStatusConfig: Record<ReviewStatus, { text: string; className: string }> = {
pending_review: { text: "待复核", className: "review-pending" },
approved: { text: "已通过", className: "review-approved" },
rejected: { text: "需修改", className: "review-rejected" },
}
/** 复核状态循环顺序 */
const REVIEW_STATUS_CYCLE: ReviewStatus[] = ["pending_review", "approved", "rejected"]
/** 获取下一个复核状态 */
const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
if (!current) return "approved"
const idx = REVIEW_STATUS_CYCLE.indexOf(current)
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length]
}
/* ============================================================
* ProductCard 组件
* ============================================================ */
const ProductCard: React.FC<{
product: ProductItem
isSelected: boolean
batchMode: boolean
onToggleSelect: (id: string) => void
onPlay: (product: ProductItem) => void
onDownload: (product: ProductItem) => void
onShare: (product: ProductItem) => void
onDelete: (id: string) => void
onPublish: (product: ProductItem) => void
onReviewStatusChange: (id: string) => void
}> = ({
product,
isSelected,
batchMode,
onToggleSelect,
onPlay,
onDownload,
onShare,
onDelete,
onPublish,
onReviewStatusChange,
}) => {
const st = statusConfig[product.status]
/** 查重率样式 */
const dupClass =
product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
/** 点击卡片 */
const handleCardClick = () => {
if (batchMode) {
onToggleSelect(product.id)
} else {
onPlay(product)
}
}
/** 点击复选框 */
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation()
onToggleSelect(product.id)
}
return (
<div
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
onClick={handleCardClick}
>
{/* 复选框(左上角) */}
<div
className={`xx-product-card-checkbox${isSelected ? " checked" : ""}`}
onClick={handleCheckboxClick}
title={isSelected ? "取消选择" : "选择"}
>
{isSelected && <CheckOutlined />}
</div>
{/* 已发布徽章(右上角) */}
{product.isPublished && <div className="xx-product-badge"> </div>}
{/* 复核状态标签(右上角) */}
{product.reviewStatus && (
<div
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
onClick={(e) => {
e.stopPropagation()
onReviewStatusChange(product.id)
}}
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text}`}
>
{reviewStatusConfig[product.reviewStatus].text}
</div>
)}
{/* 无复核状态时显示"待复核"入口 */}
{!product.reviewStatus && (
<div
className="xx-product-review-tag review-pending"
onClick={(e) => {
e.stopPropagation()
onReviewStatusChange(product.id)
}}
title="点击设置复核状态"
>
</div>
)}
{/* 缩略图 */}
<div className="xx-product-thumb">
{product.thumbnailUrl ? (
<img
className="xx-product-thumb-bg"
src={product.thumbnailUrl}
alt={product.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
)}
<div className="xx-product-play">
<PlayCircleOutlined />
</div>
{product.duration > 0 && (
<span className="xx-product-duration">{formatTime(product.duration)}</span>
)}
</div>
{/* 信息区 */}
<div className="xx-product-info">
<h4 className="xx-product-title" title={product.name}>
{product.name}
</h4>
<div className="xx-product-meta">
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
<span className="xx-product-date">{product.date}</span>
</div>
{product.duplicateRate > 0 && (
<span className={`xx-product-dup-rate ${dupClass}`}>
{product.duplicateRate.toFixed(1)}%
</span>
)}
</div>
{/* 操作按钮 */}
<div className="xx-product-actions" onClick={(e) => e.stopPropagation()}>
<button
className="xx-product-action-btn"
onClick={() => onDownload(product)}
disabled={product.status !== "completed"}
title="下载"
>
<DownloadOutlined />
</button>
<button
className="xx-product-action-btn"
onClick={() => onShare(product)}
disabled={product.status !== "completed"}
title="分享"
>
<ShareAltOutlined />
</button>
{product.isPublished ? (
<span className="xx-product-action-btn success" style={{ cursor: "default" }}>
</span>
) : (
<button
className="xx-product-action-btn primary"
onClick={() => onPublish(product)}
disabled={product.status !== "completed"}
title="发布"
>
<CloudUploadOutlined />
</button>
)}
</div>
{/* 删除按钮(不在批量模式下显示) */}
{!batchMode && (
<div style={{ padding: "0 14px 14px" }} onClick={(e) => e.stopPropagation()}>
<Popconfirm
title={`确定删除"${product.name}"`}
onConfirm={() => onDelete(product.id)}
okText="删除"
cancelText="取消"
>
<button
className="xx-product-action-btn"
style={{ width: "100%", color: "#dc2626" }}
title="删除"
>
<DeleteOutlined />
</button>
</Popconfirm>
</div>
)}
</div>
)
}
/* ============================================================
* VideoPlayer 弹窗组件
* ============================================================ */
const VideoPlayer: React.FC<{
product: ProductItem
onClose: () => void
onDownload: (product: ProductItem) => void
onShare: (product: ProductItem) => void
onViewDetail: (product: ProductItem) => void
}> = ({ product, onClose, onDownload, 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 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
}
/** ESC 关闭 */
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose()
}
window.addEventListener("keydown", handleKey)
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()}>
{/* 视频区域 */}
<div className="xx-player-video-wrap">
{hasVideo ? (
<video
ref={videoRef}
src={product.videoUrl}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
) : (
/* 无视频 URL 时用渐变占位 */
<div
style={{
width: "100%",
height: "100%",
background: product.gradient,
display: "grid",
placeItems: "center",
color: "rgba(255,255,255,0.3)",
fontSize: "64px",
}}
>
<VideoCameraOutlined />
</div>
)}
{/* 播放/暂停按钮 */}
<button className="xx-player-play-btn" onClick={handlePlayPause}>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
{/* 关闭按钮 */}
<button className="xx-player-close" onClick={onClose}>
<CloseOutlined />
</button>
{/* 进度条 */}
<div className="xx-player-progress-wrap">
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="xx-player-time">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
</div>
{/* 信息区 */}
<div className="xx-player-info">
<h3 className="xx-player-title">{product.name}</h3>
<div className="xx-player-details">
<div className="xx-player-detail-item">
<span className="xx-player-detail-label"></span>
<span className="xx-player-detail-value">{product.resolution}</span>
</div>
<div className="xx-player-detail-item">
<span className="xx-player-detail-label"></span>
<span className="xx-player-detail-value">{formatTime(product.duration)}</span>
</div>
<div className="xx-player-detail-item">
<span className="xx-player-detail-label"></span>
<span className="xx-player-detail-value">{formatSize(product.fileSize)}</span>
</div>
<div className="xx-player-detail-item">
<span className="xx-player-detail-label"></span>
<span className="xx-player-detail-value">
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
</span>
</div>
</div>
</div>
{/* 底部操作 */}
<div className="xx-player-footer">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<DownloadOutlined />}
onClick={() => onDownload(product)}
disabled={product.status !== "completed"}
>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<ShareAltOutlined />}
onClick={() => onShare(product)}
disabled={product.status !== "completed"}
>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<EyeOutlined />}
onClick={() => onViewDetail(product)}
>
</Button>
<div style={{ flex: 1 }} />
<Button buttonType="primary" buttonSize="sm" onClick={onClose}>
</Button>
</div>
</div>
</div>
)
}
/* ============================================================
* 主组件
* ============================================================ */
const ProductLibrary: React.FC = () => {
const queryClient = useQueryClient()
const navigate = useNavigate()
/* ── 获取成品列表 ── */
const {
data: apiProducts = [],
products,
filteredProducts,
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])
/* ── 删除 mutation ── */
const deleteMutation = useMutation({
mutationFn: deleteProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
message.success("已删除")
},
onError: () => {
message.error("删除失败")
},
})
/* ── 复核状态 mutation ── */
const reviewMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
updateReviewStatus(id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
message.success("复核状态已更新")
},
onError: () => {
message.error("更新复核状态失败")
},
})
/* 筛选 */
const [searchText, setSearchText] = useState("")
const [filterStatus, setFilterStatus] = useState<string>("all")
const [filterTime, setFilterTime] = useState<string>("all")
const [filterDuration, setFilterDuration] = useState<string>("all")
const [filterProject, setFilterProject] = useState<string>("all")
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all")
/* 批量操作 */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
searchText,
setSearchText,
filterStatus,
setFilterStatus,
filterTime,
setFilterTime,
filterDuration,
setFilterDuration,
filterProject,
setFilterProject,
filterReviewStatus,
setFilterReviewStatus,
projectOptions,
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
} = useProductList()
/* 播放器 */
const [playingProduct, setPlayingProduct] = useState<ProductItem | null>(null)
/* 派生数据 */
const batchMode = selectedIds.size > 0
const filteredProducts = useMemo(() => {
let list = products
/* 状态筛选 */
if (filterStatus !== "all") {
list = list.filter((p) => p.status === filterStatus)
}
/* 时间筛选 */
if (filterTime !== "all") {
const now = new Date()
list = list.filter((p) => {
const d = new Date(p.date)
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
switch (filterTime) {
case "today":
return diff < 1
case "week":
return diff <= 7
case "month":
return diff <= 30
default:
return true
}
})
}
/* 时长筛选 */
if (filterDuration !== "all") {
list = list.filter((p) => {
switch (filterDuration) {
case "short":
return p.duration > 0 && p.duration <= 60
case "medium":
return p.duration > 60 && p.duration <= 180
case "long":
return p.duration > 180
default:
return true
}
})
}
/* 项目筛选 */
if (filterProject !== "all") {
list = list.filter((p) => p.projectId === filterProject)
}
/* 复核状态筛选 */
if (filterReviewStatus !== "all") {
if (filterReviewStatus === "none") {
list = list.filter((p) => !p.reviewStatus)
} else {
list = list.filter((p) => p.reviewStatus === filterReviewStatus)
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((p) => p.name.toLowerCase().includes(q))
}
return list
}, [
const {
handleDownload,
handleShare,
handleViewDetail,
handleDelete,
handlePublish,
handleReviewStatusChange,
handleBatchDownload,
handleBatchDelete,
handleBatchPublish,
batchDownloading,
} = useProductActions({
selectedIds,
clearSelection,
products,
filterStatus,
filterTime,
filterDuration,
filterProject,
filterReviewStatus,
searchText,
])
setPlayingProduct,
})
/* 全选 */
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
})
}
/* 下载 — 调用真实 API 获取下载链接 */
const handleDownload = async (product: ProductItem) => {
if (product.status !== "completed") return
try {
const { url } = await getProductDownloadUrl(product.id)
// 打开下载链接
const a = document.createElement("a")
a.href = url
a.download = ""
a.click()
message.success(`正在下载"${product.name}"`)
} catch {
message.error("获取下载链接失败")
}
}
/* 分享 */
const handleShare = (product: ProductItem) => {
if (product.status !== "completed") return
const link = `${window.location.origin}/share/${product.id}`
navigator.clipboard?.writeText(link).then(
() => message.success(`分享链接已复制:${link}`),
() => message.success(`分享链接:${link}(请手动复制)`),
)
}
/* 查看详情 — 跳转到产品详情页 */
const handleViewDetail = (product: ProductItem) => {
setPlayingProduct(null) // 关闭播放器
navigate(`/app/products/${product.id}`)
}
/* 删除 */
const handleDelete = (id: string) => {
deleteMutation.mutate(id)
setSelectedIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}
/* 发布 — TODO: 后端发布 API 待实现 */
const handlePublish = (_product: ProductItem) => {
// TODO: 对接后端发布 API(当前后端未提供发布接口)
message.info("发布功能待后端 API 补齐")
}
/* 切换复核状态 */
const handleReviewStatusChange = (id: string) => {
const current = products.find((p) => p.id === id)?.reviewStatus
const nextStatus = getNextReviewStatus(current)
reviewMutation.mutate({ id, status: nextStatus })
}
/* 批量下载 — 使用 batch-download API + 轮询 */
const [batchDownloading, setBatchDownloading] = useState(false)
const handleBatchDownload = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
setBatchDownloading(true)
try {
// 发起批量下载任务
const { job_id } = await batchDownload(ids)
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
// 轮询下载状态(最多 60 次,每次 2 秒)
let attempts = 0
const maxAttempts = 60
const poll = async (): Promise<void> => {
if (attempts >= maxAttempts) {
message.warning("打包超时,请稍后在消息中心查看")
return
}
attempts++
await new Promise((r) => setTimeout(r, 2000))
const status = await getBatchDownloadStatus(job_id)
if (status.status === "completed" && status.download_url) {
const a = document.createElement("a")
a.href = status.download_url
a.download = ""
a.click()
message.success(`已打包下载 ${ids.length} 个视频`)
setSelectedIds(new Set())
} else if (status.status === "failed") {
message.error("批量下载失败,请重试")
} else {
// 继续轮询
await poll()
}
}
await poll()
} catch {
message.error("发起批量下载失败")
} finally {
setBatchDownloading(false)
}
}
/* 批量删除 */
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"] })
setSelectedIds(new Set())
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
}
/* 批量发布 */
const handleBatchPublish = () => {
// TODO: 对接后端批量发布 API
message.info("批量发布功能待后端 API 补齐")
setSelectedIds(new Set())
}
// ── Loading 状态 ──
if (isLoading) {
return (
<div className="xx-products-page">
@@ -939,7 +194,7 @@ const ProductLibrary: React.FC = () => {
</Button>
</Popconfirm>
<Button buttonType="ghost" buttonSize="sm" onClick={() => setSelectedIds(new Set())}>
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
</Button>
</div>
@@ -997,16 +252,7 @@ const ProductLibrary: React.FC = () => {
style={{ width: 140 }}
options={[
{ value: "all", label: "全部项目" },
...Array.from(
new Map(
products
.filter((p) => p.projectId && p.projectName)
.map((p) => [p.projectId!, p.projectName!] as const),
),
).map(([id, name]) => ({
value: id as string,
label: name as string,
})),
...projectOptions,
]}
/>
<Select
@@ -0,0 +1,196 @@
import React from "react"
import { Popconfirm } from "antd"
import {
CheckOutlined,
PlayCircleOutlined,
DownloadOutlined,
ShareAltOutlined,
DeleteOutlined,
CloudUploadOutlined,
} from "@ant-design/icons"
import type { ProductItem } from "../types"
import { statusConfig, reviewStatusConfig } from "../constants"
import { formatTime } from "../utils"
interface ProductCardProps {
product: ProductItem
isSelected: boolean
batchMode: boolean
onToggleSelect: (id: string) => void
onPlay: (product: ProductItem) => void
onDownload: (product: ProductItem) => void
onShare: (product: ProductItem) => void
onDelete: (id: string) => void
onPublish: (product: ProductItem) => void
onReviewStatusChange: (id: string) => void
}
export const ProductCard: React.FC<ProductCardProps> = ({
product,
isSelected,
batchMode,
onToggleSelect,
onPlay,
onDownload,
onShare,
onDelete,
onPublish,
onReviewStatusChange,
}) => {
const st = statusConfig[product.status]
/** 查重率样式 */
const dupClass =
product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
/** 点击卡片 */
const handleCardClick = () => {
if (batchMode) {
onToggleSelect(product.id)
} else {
onPlay(product)
}
}
/** 点击复选框 */
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation()
onToggleSelect(product.id)
}
return (
<div
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
onClick={handleCardClick}
>
{/* 复选框(左上角) */}
<div
className={`xx-product-card-checkbox${isSelected ? " checked" : ""}`}
onClick={handleCheckboxClick}
title={isSelected ? "取消选择" : "选择"}
>
{isSelected && <CheckOutlined />}
</div>
{/* 已发布徽章(右上角) */}
{product.isPublished && <div className="xx-product-badge"> </div>}
{/* 复核状态标签(右上角) */}
{product.reviewStatus && (
<div
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
onClick={(e) => {
e.stopPropagation()
onReviewStatusChange(product.id)
}}
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text}`}
>
{reviewStatusConfig[product.reviewStatus].text}
</div>
)}
{/* 无复核状态时显示"待复核"入口 */}
{!product.reviewStatus && (
<div
className="xx-product-review-tag review-pending"
onClick={(e) => {
e.stopPropagation()
onReviewStatusChange(product.id)
}}
title="点击设置复核状态"
>
</div>
)}
{/* 缩略图 */}
<div className="xx-product-thumb">
{product.thumbnailUrl ? (
<img
className="xx-product-thumb-bg"
src={product.thumbnailUrl}
alt={product.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
)}
<div className="xx-product-play">
<PlayCircleOutlined />
</div>
{product.duration > 0 && (
<span className="xx-product-duration">{formatTime(product.duration)}</span>
)}
</div>
{/* 信息区 */}
<div className="xx-product-info">
<h4 className="xx-product-title" title={product.name}>
{product.name}
</h4>
<div className="xx-product-meta">
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
<span className="xx-product-date">{product.date}</span>
</div>
{product.duplicateRate > 0 && (
<span className={`xx-product-dup-rate ${dupClass}`}>
{product.duplicateRate.toFixed(1)}%
</span>
)}
</div>
{/* 操作按钮 */}
<div className="xx-product-actions" onClick={(e) => e.stopPropagation()}>
<button
className="xx-product-action-btn"
onClick={() => onDownload(product)}
disabled={product.status !== "completed"}
title="下载"
>
<DownloadOutlined />
</button>
<button
className="xx-product-action-btn"
onClick={() => onShare(product)}
disabled={product.status !== "completed"}
title="分享"
>
<ShareAltOutlined />
</button>
{product.isPublished ? (
<span className="xx-product-action-btn success" style={{ cursor: "default" }}>
</span>
) : (
<button
className="xx-product-action-btn primary"
onClick={() => onPublish(product)}
disabled={product.status !== "completed"}
title="发布"
>
<CloudUploadOutlined />
</button>
)}
</div>
{/* 删除按钮(不在批量模式下显示) */}
{!batchMode && (
<div style={{ padding: "0 14px 14px" }} onClick={(e) => e.stopPropagation()}>
<Popconfirm
title={`确定删除"${product.name}"`}
onConfirm={() => onDelete(product.id)}
okText="删除"
cancelText="取消"
>
<button
className="xx-product-action-btn"
style={{ width: "100%", color: "#dc2626" }}
title="删除"
>
<DeleteOutlined />
</button>
</Popconfirm>
</div>
)}
</div>
)
}
@@ -0,0 +1,199 @@
import React, { useRef, useState, useEffect, useCallback } from "react"
import {
VideoCameraOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
CloseOutlined,
DownloadOutlined,
ShareAltOutlined,
EyeOutlined,
} from "@ant-design/icons"
import { Button } from "@/components/ui"
import type { ProductItem } from "../types"
import { formatTime, formatSize } from "../utils"
interface VideoPlayerProps {
product: ProductItem
onClose: () => void
onDownload: (product: ProductItem) => void
onShare: (product: ProductItem) => void
onViewDetail: (product: ProductItem) => void
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
product,
onClose,
onDownload,
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 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
}
/** ESC 关闭 */
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose()
}
window.addEventListener("keydown", handleKey)
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()}>
{/* 视频区域 */}
<div className="xx-player-video-wrap">
{hasVideo ? (
<video
ref={videoRef}
src={product.videoUrl}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
) : (
/* 无视频 URL 时用渐变占位 */
<div
style={{
width: "100%",
height: "100%",
background: product.gradient,
display: "grid",
placeItems: "center",
color: "rgba(255,255,255,0.3)",
fontSize: "64px",
}}
>
<VideoCameraOutlined />
</div>
)}
{/* 播放/暂停按钮 */}
<button className="xx-player-play-btn" onClick={handlePlayPause}>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
{/* 关闭按钮 */}
<button className="xx-player-close" onClick={onClose}>
<CloseOutlined />
</button>
{/* 进度条 */}
<div className="xx-player-progress-wrap">
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="xx-player-time">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
</div>
{/* 信息区 */}
<div className="xx-player-info">
<h3 className="xx-player-title">{product.name}</h3>
<div className="xx-player-details">
<div className="xx-player-detail-item">
<span className="xx-player-detail-label"></span>
<span className="xx-player-detail-value">{product.resolution}</span>
</div>
<div className="xx-player-detail-item">
<span className="xx-player-detail-label"></span>
<span className="xx-player-detail-value">{formatTime(product.duration)}</span>
</div>
<div className="xx-player-detail-item">
<span className="xx-player-detail-label"></span>
<span className="xx-player-detail-value">{formatSize(product.fileSize)}</span>
</div>
<div className="xx-player-detail-item">
<span className="xx-player-detail-label"></span>
<span className="xx-player-detail-value">
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
</span>
</div>
</div>
</div>
{/* 底部操作 */}
<div className="xx-player-footer">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<DownloadOutlined />}
onClick={() => onDownload(product)}
disabled={product.status !== "completed"}
>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<ShareAltOutlined />}
onClick={() => onShare(product)}
disabled={product.status !== "completed"}
>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<EyeOutlined />}
onClick={() => onViewDetail(product)}
>
</Button>
<div style={{ flex: 1 }} />
<Button buttonType="primary" buttonSize="sm" onClick={onClose}>
</Button>
</div>
</div>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import type { ReviewStatus } from "@/api/products"
import type { ProductStatus } from "./types"
/** 渐变色列表(按 id hash 选取) */
export const GRADIENTS = [
"linear-gradient(135deg, #1e1b4b 0%, #4f46e5 50%, #818cf8 100%)",
"linear-gradient(135deg, #831843 0%, #db2777 50%, #f472b6 100%)",
"linear-gradient(135deg, #064e3b 0%, #059669 50%, #34d399 100%)",
"linear-gradient(135deg, #78350f 0%, #d97706 50%, #fbbf24 100%)",
"linear-gradient(135deg, #1e3a5f 0%, #2563eb 50%, #60a5fa 100%)",
"linear-gradient(135deg, #581c87 0%, #9333ea 50%, #c084fc 100%)",
"linear-gradient(135deg, #7f1d1d 0%, #dc2626 50%, #f87171 100%)",
"linear-gradient(135deg, #134e4a 0%, #0d9488 50%, #5eead4 100%)",
"linear-gradient(135deg, #1e1b4b 0%, #6366f1 50%, #a5b4fc 100%)",
"linear-gradient(135deg, #3b0764 0%, #7c3aed 50%, #a78bfa 100%)",
"linear-gradient(135deg, #052e16 0%, #16a34a 50%, #86efac 100%)",
"linear-gradient(135deg, #450a0e 0%, #e11d48 50%, #fb7185 100%)",
]
export const statusConfig: Record<ProductStatus, { text: string; className: string }> = {
completed: { text: "已完成", className: "completed" },
processing: { text: "处理中", className: "processing" },
review: { text: "待复核", className: "review" },
failed: { text: "失败", className: "failed" },
}
export const reviewStatusConfig: Record<ReviewStatus, { text: string; className: string }> = {
pending_review: { text: "待复核", className: "review-pending" },
approved: { text: "已通过", className: "review-approved" },
rejected: { text: "需修改", className: "review-rejected" },
}
/** 复核状态循环顺序 */
export const REVIEW_STATUS_CYCLE: ReviewStatus[] = ["pending_review", "approved", "rejected"]
@@ -0,0 +1,187 @@
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 type { ProductItem } from "../types"
import { getNextReviewStatus } from "../utils"
interface UseProductActionsOptions {
selectedIds: Set<string>
clearSelection: () => void
products: ProductItem[]
setPlayingProduct: (product: ProductItem | null) => void
}
export const useProductActions = ({
selectedIds,
clearSelection,
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("删除失败")
},
})
/* ── 复核状态 mutation ── */
const reviewMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
updateReviewStatus(id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
message.success("复核状态已更新")
},
onError: () => {
message.error("更新复核状态失败")
},
})
/* 下载 — 调用真实 API 获取下载链接 */
const handleDownload = async (product: ProductItem) => {
if (product.status !== "completed") return
try {
const { url } = await getProductDownloadUrl(product.id)
const a = document.createElement("a")
a.href = url
a.download = ""
a.click()
message.success(`正在下载"${product.name}"`)
} catch {
message.error("获取下载链接失败")
}
}
/* 分享 */
const handleShare = (product: ProductItem) => {
if (product.status !== "completed") return
const link = `${window.location.origin}/share/${product.id}`
navigator.clipboard?.writeText(link).then(
() => message.success(`分享链接已复制:${link}`),
() => message.success(`分享链接:${link}(请手动复制)`),
)
}
/* 查看详情 — 跳转到产品详情页 */
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,
}
}
@@ -0,0 +1,202 @@
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"
/** 筛选选项类型 */
export interface Filters {
searchText: string
filterStatus: string
filterTime: string
filterDuration: string
filterProject: string
filterReviewStatus: string
}
/** 项目选项列表 */
const getProjectOptions = (products: ProductItem[]) =>
Array.from(
new Map(
products
.filter((p) => p.projectId && p.projectName)
.map((p) => [p.projectId!, p.projectName!] as const),
),
).map(([id, name]) => ({
value: id as string,
label: name as string,
}))
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])
/* 筛选 */
const [searchText, setSearchText] = useState("")
const [filterStatus, setFilterStatus] = useState<string>("all")
const [filterTime, setFilterTime] = useState<string>("all")
const [filterDuration, setFilterDuration] = useState<string>("all")
const [filterProject, setFilterProject] = useState<string>("all")
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all")
/* 批量选择 */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
/* 派生数据 */
const batchMode = selectedIds.size > 0
const filteredProducts = useMemo(() => {
let list = products
/* 状态筛选 */
if (filterStatus !== "all") {
list = list.filter((p) => p.status === filterStatus)
}
/* 时间筛选 */
if (filterTime !== "all") {
const now = new Date()
list = list.filter((p) => {
const d = new Date(p.date)
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
switch (filterTime) {
case "today":
return diff < 1
case "week":
return diff <= 7
case "month":
return diff <= 30
default:
return true
}
})
}
/* 时长筛选 */
if (filterDuration !== "all") {
list = list.filter((p) => {
switch (filterDuration) {
case "short":
return p.duration > 0 && p.duration <= 60
case "medium":
return p.duration > 60 && p.duration <= 180
case "long":
return p.duration > 180
default:
return true
}
})
}
/* 项目筛选 */
if (filterProject !== "all") {
list = list.filter((p) => p.projectId === filterProject)
}
/* 复核状态筛选 */
if (filterReviewStatus !== "all") {
if (filterReviewStatus === "none") {
list = list.filter((p) => !p.reviewStatus)
} else {
list = list.filter((p) => p.reviewStatus === filterReviewStatus)
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((p) => p.name.toLowerCase().includes(q))
}
return list
}, [
products,
filterStatus,
filterTime,
filterDuration,
filterProject,
filterReviewStatus,
searchText,
])
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,
setFilterStatus,
filterTime,
setFilterTime,
filterDuration,
setFilterDuration,
filterProject,
setFilterProject,
filterReviewStatus,
setFilterReviewStatus,
projectOptions,
// 批量选择
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { ReviewStatus } from "@/api/products"
export type ProductStatus = "completed" | "processing" | "review" | "failed"
export interface ProductItem {
id: string
name: string
status: ProductStatus
duration: number // 秒
date: string
gradient: string
duplicateRate: number // 查重率百分比
isPublished: boolean
resolution: string
fileSize: number // MB
videoUrl?: string
thumbnailUrl?: string
/** 复核状态 */
reviewStatus?: ReviewStatus
/** 所属项目 ID */
projectId?: string
/** 所属项目名称 */
projectName?: string
}
@@ -0,0 +1,75 @@
import type { ProductItem as ApiProductItem } from "@/api/products"
import type { ProductItem, ProductStatus } from "../types"
import { GRADIENTS, REVIEW_STATUS_CYCLE } from "../constants"
import type { ReviewStatus } from "@/api/products"
/** 格式化时间 mm:ss */
export const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
}
/** 格式化文件大小 */
export const formatSize = (mb: number): string => {
if (mb <= 0) return "-"
if (mb < 1) return `${(mb * 1024).toFixed(0)} KB`
return `${mb.toFixed(1)} MB`
}
/** 格式化日期时间(MM-DD HH:mm */
export const formatDateTime = (isoString: string): string => {
const d = new Date(isoString)
const month = (d.getMonth() + 1).toString().padStart(2, "0")
const day = d.getDate().toString().padStart(2, "0")
const hour = d.getHours().toString().padStart(2, "0")
const minute = d.getMinutes().toString().padStart(2, "0")
return `${month}-${day} ${hour}:${minute}`
}
/** 将后端 status 映射为前端 ProductStatus */
export const normalizeStatus = (s: string): ProductStatus => {
const map: Record<string, ProductStatus> = {
completed: "completed",
succeeded: "completed",
processing: "processing",
running: "processing",
review: "review",
failed: "failed",
error: "failed",
}
return map[s] ?? "processing"
}
/** 根据 id 生成稳定的渐变色 */
export const gradientForId = (id: string): string => {
let hash = 0
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0
return GRADIENTS[Math.abs(hash) % GRADIENTS.length]
}
/** 将后端 ProductItem 映射为前端 ProductItem */
export const mapApiProduct = (item: ApiProductItem): ProductItem => ({
id: item.id,
name: item.title,
status: normalizeStatus(item.status),
duration: item.duration_seconds ?? 0,
date: item.created_at ? formatDateTime(item.created_at) : "—",
gradient: gradientForId(item.id),
duplicateRate: item.duplicate_rate ?? 0,
isPublished: false, // TODO: 后端发布状态字段待补齐
resolution: item.resolution ?? "1080×1920",
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
videoUrl: item.video_url,
thumbnailUrl: item.thumbnail_url,
reviewStatus: item.review_status,
projectId: item.project_id,
projectName: item.project_name,
})
/** 获取下一个复核状态 */
export const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
if (!current) return "approved"
const idx = REVIEW_STATUS_CYCLE.indexOf(current)
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length]
}
+22 -1
View File
@@ -1,13 +1,27 @@
/**
* AssetLibrary 模块 smoke test
* 建立完整依赖链,确保 vitest related 模式能匹配到
* assets 目录下所有文件的改动(包括子组件和工具函数)
* assets 目录下所有文件的改动(包括子组件、Hook 和工具函数)
*/
import { describe, it, expect } from "vitest"
// 主组件
import "@/pages/assets/AssetLibrary"
// 子组件
import "@/pages/assets/components/AssetCard"
import "@/pages/assets/components/AssetFilterBar"
import "@/pages/assets/components/AssetSkeleton"
import "@/pages/assets/components/BatchClassifyModal"
import "@/pages/assets/components/BatchMarkModal"
import "@/pages/assets/components/BatchOperationBar"
import "@/pages/assets/components/BatchTagModal"
import "@/pages/assets/components/CreateLibraryModal"
import "@/pages/assets/components/LibrarySidebar"
import "@/pages/assets/components/PlayModal"
import "@/pages/assets/components/ResultDrawer"
import "@/pages/assets/components/UploadProgressModal"
// 类型与常量
import "@/pages/assets/types"
import "@/pages/assets/constants"
@@ -16,6 +30,13 @@ import "@/pages/assets/constants"
import "@/pages/assets/utils/format"
import "@/pages/assets/utils/asset"
// Hooks
import "@/pages/assets/hooks/useAssetsData"
import "@/pages/assets/hooks/useLibraryManagement"
import "@/pages/assets/hooks/useAssetUpload"
import "@/pages/assets/hooks/useAssetSelection"
import "@/pages/assets/hooks/useAssetOperations"
describe("AssetLibrary module smoke test", () => {
it("should load all asset modules", () => {
// 纯模块加载测试,确保所有组件/工具函数能正常 import
@@ -0,0 +1,61 @@
/**
* EditingPlanner 模块 smoke test
* 建立完整依赖链,确保 vitest related 模式能匹配到
* editing-planner 目录下所有文件的改动(包括子组件、Hook 和工具函数)
*/
import { describe, it, expect } from "vitest"
// 主组件
import "@/pages/editing-planner/EditingPlanner"
// 常量
import "@/pages/editing-planner/constants"
// 工具函数
import "@/pages/editing-planner/utils/selectors"
// 子组件
import "@/pages/editing-planner/components/BgmSelector"
import "@/pages/editing-planner/components/ClipPropertiesPanel"
import "@/pages/editing-planner/components/CoverSelector"
import "@/pages/editing-planner/components/EditorClipList"
import "@/pages/editing-planner/components/EditingDrawers"
import "@/pages/editing-planner/components/FilterPanel"
import "@/pages/editing-planner/components/GenerationHistoryModal"
import "@/pages/editing-planner/components/GenerationProgressModal"
import "@/pages/editing-planner/components/GreenScreenPanel"
import "@/pages/editing-planner/components/IntroOutroPanel"
import "@/pages/editing-planner/components/MediaPanel"
import "@/pages/editing-planner/components/ModeBar"
import "@/pages/editing-planner/components/PipConfigPanel"
import "@/pages/editing-planner/components/PreviewPlayer"
import "@/pages/editing-planner/components/RightPanel"
import "@/pages/editing-planner/components/SaveModal"
import "@/pages/editing-planner/components/SpeedPanel"
import "@/pages/editing-planner/components/StatusBar"
import "@/pages/editing-planner/components/StickerPanel"
import "@/pages/editing-planner/components/SubtitleStylePanel"
import "@/pages/editing-planner/components/TimelinePanel"
import "@/pages/editing-planner/components/TopBar"
import "@/pages/editing-planner/components/TransitionSelector"
import "@/pages/editing-planner/components/TtsPanel"
import "@/pages/editing-planner/components/WatermarkPanel"
// 类型
import "@/pages/editing-planner/types"
import "@/pages/editing-planner/types/subtitle"
// Hooks
import "@/pages/editing-planner/hooks/useUndoRedo"
import "@/pages/editing-planner/hooks/useEditPlanClips"
import "@/pages/editing-planner/hooks/useEditorDrawers"
import "@/pages/editing-planner/hooks/usePlaybackControl"
import "@/pages/editing-planner/hooks/useClipOperations"
import "@/pages/editing-planner/hooks/useTemplateManagement"
describe("EditingPlanner module smoke test", () => {
it("should load all editing-planner modules", () => {
// 纯模块加载测试,确保所有组件/工具函数能正常 import
expect(true).toBe(true)
})
})
@@ -0,0 +1,31 @@
/**
* ProductLibrary 模块 smoke test
* 建立完整依赖链,确保 vitest related 模式能匹配到
* products 目录下所有文件的改动(包括子组件、Hook 和工具函数)
*/
import { describe, it, expect } from "vitest"
// 主组件
import "@/pages/products/ProductLibrary"
// 类型与常量
import "@/pages/products/types"
import "@/pages/products/constants"
// 工具函数
import "@/pages/products/utils/index"
// 子组件
import "@/pages/products/components/ProductCard"
import "@/pages/products/components/VideoPlayer"
// Hooks
import "@/pages/products/hooks/useProductList"
import "@/pages/products/hooks/useProductActions"
describe("ProductLibrary module smoke test", () => {
it("should load all product modules", () => {
// 纯模块加载测试,确保所有组件/工具函数能正常 import
expect(true).toBe(true)
})
})
+148 -89
View File
@@ -1,105 +1,164 @@
"""BGM 配置工具函数单元测试."""
"""BGM工具函数单元测试"""
import pytest
from __future__ import annotations
from packages.domain.bgm_utils import merge_bgm_config
from domain.bgm_utils import merge_bgm_config
class TestMergeBgmConfig:
"""merge_bgm_config 测试"""
class TestMergeBgmConfigBothEmpty:
"""两边都为空的情况。"""
def test_user_bgm_empty_returns_template_copy(self):
"""用户配置为空时,返回模板配置的拷贝"""
template = {"enabled": True, "volume": 0.5, "asset_id": "tpl_123"}
result = merge_bgm_config(template, {})
assert result == template
assert result is not template
def test_user_bgm_none_returns_template_copy(self):
"""用户配置为 None 时,返回模板配置的拷贝"""
template = {"enabled": True, "volume": 0.5}
result = merge_bgm_config(template, None) # type: ignore
assert result == template
def test_template_bgm_empty_returns_user_copy(self):
"""模板配置为空时,返回用户配置的拷贝"""
user = {"enabled": False, "volume": 0.8, "asset_id": "user_456"}
result = merge_bgm_config({}, user)
assert result == user
assert result is not user
def test_template_bgm_none_returns_user_copy(self):
"""模板配置为 None 时,返回用户配置的拷贝"""
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config(None, user) # type: ignore
assert result == user
def test_user_fields_override_template(self):
"""用户显式指定的字段覆盖模板对应字段"""
template = {
"enabled": True,
"volume": 0.5,
"asset_id": "tpl_123",
"fade_in": 1.0,
}
user = {
"volume": 0.8,
"asset_id": "user_456",
}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.8
assert result["asset_id"] == "user_456"
assert result["fade_in"] == 1.0 # 模板值保留
def test_enabled_not_in_user_preserves_template_enabled(self):
"""enabled 特殊处理:用户没传 enabled 时保留模板的 enabled 值"""
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8} # 没传 enabled
result = merge_bgm_config(template, user)
assert result["enabled"] is True # 保留模板的
assert result["volume"] == 0.8 # 用户指定的覆盖
def test_enabled_in_user_overrides_template(self):
"""用户传了 enabled 时覆盖模板的 enabled"""
template = {"enabled": True, "volume": 0.5}
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
assert result["volume"] == 0.8
def test_user_adds_new_fields(self):
"""用户配置中的新字段会被添加到结果中"""
template = {"enabled": True, "volume": 0.5}
user = {"sidechain_enabled": True, "sidechain_ratio": 0.6}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.5
assert result["sidechain_enabled"] is True
assert result["sidechain_ratio"] == 0.6
def test_both_empty_returns_empty_dict(self):
"""两者都为空时返回空字典"""
def test_both_empty(self):
result = merge_bgm_config({}, {})
assert result == {}
# 确保返回的是新字典,不是同一个引用
assert result is not {}
def test_nested_dict_shallow_merge(self):
"""嵌套字典是浅合并(当前设计)"""
template = {"enabled": True, "config": {"eq": True, "compression": False}}
user = {"config": {"compression": True, "reverb": 0.5}}
def test_user_none_returns_template_copy(self):
"""用户传 None 视为空配置,返回模板副本。"""
result = merge_bgm_config({}, None)
assert result == {}
class TestMergeBgmConfigOnlyTemplate:
"""只有模板配置。"""
def test_only_template_returns_copy(self):
template = {"enabled": True, "volume": 0.5, "track": "default.mp3"}
result = merge_bgm_config(template, {})
assert result == template
# 确保是副本,不是同一引用
result["volume"] = 0.9
assert template["volume"] == 0.5
def test_only_template_with_none_user(self):
template = {"enabled": True, "volume": 0.5}
result = merge_bgm_config(template, None)
assert result == template
class TestMergeBgmConfigOnlyUser:
"""只有用户配置。"""
def test_only_user_returns_copy(self):
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config({}, user)
assert result == user
# 确保是副本
result["volume"] = 0.1
assert user["volume"] == 0.8
def test_only_user_with_none_template(self):
user = {"enabled": False}
result = merge_bgm_config(None, user)
assert result == user
class TestMergeBgmConfigBasicOverride:
"""用户配置覆盖模板配置。"""
def test_volume_override(self):
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
# 浅合并:整个 config 被用户值覆盖
assert result["config"] == {"compression": True, "reverb": 0.5}
assert result["volume"] == 0.8
assert result["enabled"] is True # 用户没传,保留模板
def test_does_not_mutate_template(self):
"""不修改原始模板配置"""
def test_track_override(self):
template = {"track": "default.mp3", "volume": 0.5}
user = {"track": "custom.mp3"}
result = merge_bgm_config(template, user)
assert result["track"] == "custom.mp3"
assert result["volume"] == 0.5
def test_multiple_fields_override(self):
template = {"enabled": True, "volume": 0.5, "track": "a.mp3", "fade_in": 2}
user = {"volume": 0.9, "track": "b.mp3"}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.9
assert result["track"] == "b.mp3"
assert result["fade_in"] == 2
assert result["enabled"] is True
class TestMergeBgmConfigEnabledSpecialHandling:
"""enabled 字段的特殊处理:用户没传就保留模板的。"""
def test_user_does_not_pass_enabled_keeps_template_true(self):
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
def test_user_does_not_pass_enabled_keeps_template_false(self):
template = {"enabled": False, "volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
def test_user_explicitly_sets_enabled_true(self):
template = {"enabled": False, "volume": 0.5}
user = {"enabled": True}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
def test_user_explicitly_sets_enabled_false(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 = {"track": "a.mp3"}
result = merge_bgm_config(template, user)
assert "enabled" not in result
def test_template_no_enabled_user_has_enabled(self):
template = {"volume": 0.5}
user = {"enabled": True}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
def test_user_sets_enabled_none_explicitly(self):
"""用户显式传 None 也视为传了,会覆盖模板。"""
template = {"enabled": True, "volume": 0.5}
user = {"enabled": None}
result = merge_bgm_config(template, user)
assert result["enabled"] is None
class TestMergeBgmConfigNewFields:
"""用户配置新增模板没有的字段。"""
def test_user_adds_new_field(self):
template = {"volume": 0.5}
user = {"fade_out": 3}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.5
assert result["fade_out"] == 3
def test_user_adds_multiple_new_fields(self):
template = {"enabled": True}
user = {"volume": 0.7, "track": "x.mp3", "loop": True}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.7
assert result["track"] == "x.mp3"
assert result["loop"] is True
class TestMergeBgmConfigImmutableInput:
"""确保输入字典不被修改。"""
def test_template_not_modified(self):
template = {"enabled": True, "volume": 0.5}
original = dict(template)
merge_bgm_config(template, {"volume": 0.8})
merge_bgm_config(template, {"volume": 0.9})
assert template == original
def test_does_not_mutate_user(self):
"""不修改原始用户配置"""
user = {"volume": 0.8}
def test_user_not_modified(self):
user = {"enabled": False, "track": "x.mp3"}
original = dict(user)
merge_bgm_config({"enabled": True}, user)
merge_bgm_config({"volume": 0.5}, user)
assert user == original
+160
View File
@@ -0,0 +1,160 @@
"""BGM工具函数领域层单元测试."""
import pytest
from packages.domain.bgm_utils import merge_bgm_config
class TestMergeBgmConfig:
"""merge_bgm_config 函数测试."""
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 == template
# 确保是副本不是引用
result["volume"] = 0.9
assert template["volume"] == 0.5
def test_template_empty_returns_user_copy(self):
"""模板配置为空,返回用户配置的拷贝."""
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config({}, user)
assert result == user
# 确保是副本
result["volume"] = 0.1
assert user["volume"] == 0.8
def test_user_overrides_template(self):
"""用户配置覆盖模板配置."""
template = {"enabled": True, "volume": 0.5, "track": "default"}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.8
assert result["track"] == "default"
def test_enabled_special_handling_user_not_set(self):
"""enabled 特殊处理:用户没传就保留模板的."""
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8} # 没传 enabled
result = merge_bgm_config(template, user)
# 用户没传 enabled,保留模板的 True
assert result["enabled"] is True
assert result["volume"] == 0.8
def test_enabled_user_explicit_false(self):
"""用户显式传 enabled=False,应该覆盖模板."""
template = {"enabled": True, "volume": 0.5}
user = {"enabled": False}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
def test_enabled_user_explicit_true(self):
"""用户显式传 enabled=True,覆盖模板的 False."""
template = {"enabled": False, "volume": 0.5}
user = {"enabled": True}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
def test_full_override(self):
"""用户完全覆盖模板."""
template = {"enabled": True, "volume": 0.3, "track": "piano"}
user = {"enabled": False, "volume": 0.9, "track": "guitar"}
result = merge_bgm_config(template, user)
assert result == user
def test_partial_override_keep_rest(self):
"""部分覆盖,其余保留模板值."""
template = {
"enabled": True,
"volume": 0.5,
"fade_in": 1.0,
"fade_out": 1.0,
"track": "default",
}
user = {"volume": 0.7, "fade_in": 2.0}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.7
assert result["fade_in"] == 2.0
assert result["fade_out"] == 1.0
assert result["track"] == "default"
assert result["enabled"] is True # 用户没传,保留模板
def test_user_none(self):
"""user_bgm 为 None 的情况."""
template = {"enabled": True, "volume": 0.5}
result = merge_bgm_config(template, None) # type: ignore
assert result == template
def test_template_none(self):
"""template_bgm 为 None 的情况."""
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config(None, user) # type: ignore
assert result == user
def test_preserves_extra_fields(self):
"""保留模板中的额外字段(用户没覆盖的)."""
template = {"enabled": True, "volume": 0.5, "custom_field": "value"}
user = {"volume": 0.6}
result = merge_bgm_config(template, user)
assert result["custom_field"] == "value"
def test_user_adds_new_fields(self):
"""用户可以添加模板中没有的新字段."""
template = {"enabled": True, "volume": 0.5}
user = {"loop": True, "start_time": 5.0}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.5
assert result["loop"] is True
assert result["start_time"] == 5.0
def test_nested_dict_behavior(self):
"""嵌套字典的合并行为(简单替换,不深度合并)."""
template = {"enabled": True, "effects": {"fade": True, "reverb": False}}
user = {"effects": {"reverb": True}}
result = merge_bgm_config(template, user)
# 简单合并,用户的 effects 整体覆盖模板的
assert result["effects"] == {"reverb": True}
def test_enabled_in_template_only(self):
"""只有模板有 enabled,用户没有."""
template = {"enabled": False, "volume": 0.5}
user = {"volume": 0.7}
result = merge_bgm_config(template, user)
# 用户没传 enabled,保留模板的 False
assert result["enabled"] is False
def test_both_have_enabled_false(self):
"""两边都有 enabled 且都是 False."""
template = {"enabled": False, "volume": 0.5}
user = {"enabled": False}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
def test_return_type_is_dict(self):
"""返回类型是 dict."""
result = merge_bgm_config({"a": 1}, {"b": 2})
assert isinstance(result, dict)
def test_does_not_mutate_template(self):
"""不修改原始模板字典."""
template = {"enabled": True, "volume": 0.5}
template_copy = template.copy()
user = {"volume": 0.9}
merge_bgm_config(template, user)
assert template == template_copy
def test_does_not_mutate_user(self):
"""不修改原始用户字典."""
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.9}
user_copy = user.copy()
merge_bgm_config(template, user)
assert user == user_copy
+276 -43
View File
@@ -1,4 +1,6 @@
"""classification 模块单元测试."""
"""分类领域模型单元测试 - 纯逻辑部分。"""
from __future__ import annotations
import pytest
from domain.classification import (
@@ -6,99 +8,203 @@ from domain.classification import (
AssetLibraryKind,
ClassificationJob,
ClassificationJobStatus,
ClassificationStatus,
IngestJobStatus,
)
class TestAssetLibraryKind:
"""AssetLibraryKind 枚举测试."""
"""素材库类型枚举。"""
def test_values(self):
def test_video_value(self):
assert AssetLibraryKind.VIDEO == "video"
def test_voice_value(self):
assert AssetLibraryKind.VOICE == "voice"
def test_image_value(self):
assert AssetLibraryKind.IMAGE == "image"
def test_is_str_enum(self):
assert isinstance(AssetLibraryKind.VIDEO, str)
assert AssetLibraryKind.VIDEO + "_test" == "video_test"
def test_members_count(self):
assert len(AssetLibraryKind) == 3
class TestIngestJobStatus:
"""IngestJobStatus 枚举测试."""
"""导入任务状态枚举。"""
def test_values(self):
def test_pending_value(self):
assert IngestJobStatus.PENDING == "pending"
def test_processing_value(self):
assert IngestJobStatus.PROCESSING == "processing"
def test_completed_value(self):
assert IngestJobStatus.COMPLETED == "completed"
def test_failed_value(self):
assert IngestJobStatus.FAILED == "failed"
def test_members_count(self):
assert len(IngestJobStatus) == 4
class TestClassificationJobStatus:
"""ClassificationJobStatus 枚举测试."""
def test_values(self):
assert ClassificationJobStatus.PENDING == "pending"
assert ClassificationJobStatus.PROCESSING == "processing"
assert ClassificationJobStatus.COMPLETED == "completed"
assert ClassificationJobStatus.FAILED == "failed"
class TestClassificationJobStatusMissing:
"""ClassificationJobStatus._missing_ 兼容性测试。"""
def test_standard_values(self):
"""标准值正常解析。"""
assert ClassificationJobStatus("pending") == ClassificationJobStatus.PENDING
assert ClassificationJobStatus("processing") == ClassificationJobStatus.PROCESSING
assert ClassificationJobStatus("completed") == ClassificationJobStatus.COMPLETED
assert ClassificationJobStatus("failed") == ClassificationJobStatus.FAILED
def test_done_maps_to_completed(self):
"""历史值 done 映射到 COMPLETED。"""
assert ClassificationJobStatus("done") == ClassificationJobStatus.COMPLETED
def test_success_maps_to_completed(self):
assert ClassificationJobStatus("success") == ClassificationJobStatus.COMPLETED
def test_finished_maps_to_completed(self):
assert ClassificationJobStatus("finished") == ClassificationJobStatus.COMPLETED
def test_complete_maps_to_completed(self):
assert ClassificationJobStatus("complete") == ClassificationJobStatus.COMPLETED
def test_fail_maps_to_failed(self):
assert ClassificationJobStatus("fail") == ClassificationJobStatus.FAILED
def test_error_maps_to_failed(self):
assert ClassificationJobStatus("error") == ClassificationJobStatus.FAILED
def test_err_maps_to_failed(self):
assert ClassificationJobStatus("err") == ClassificationJobStatus.FAILED
def test_process_maps_to_processing(self):
assert ClassificationJobStatus("process") == ClassificationJobStatus.PROCESSING
def test_running_maps_to_processing(self):
assert ClassificationJobStatus("running") == ClassificationJobStatus.PROCESSING
def test_run_maps_to_processing(self):
assert ClassificationJobStatus("run") == ClassificationJobStatus.PROCESSING
def test_unknown_value_defaults_to_pending(self):
"""未知值兜底为 PENDING。"""
assert ClassificationJobStatus("unknown") == ClassificationJobStatus.PENDING
assert ClassificationJobStatus("whatever") == ClassificationJobStatus.PENDING
assert ClassificationJobStatus("") == ClassificationJobStatus.PENDING
def test_case_insensitive(self):
"""大小写不敏感。"""
assert ClassificationJobStatus("DONE") == ClassificationJobStatus.COMPLETED
assert ClassificationJobStatus("Done") == ClassificationJobStatus.COMPLETED
assert ClassificationJobStatus("FAIL") == ClassificationJobStatus.FAILED
assert ClassificationJobStatus("Error") == ClassificationJobStatus.FAILED
def test_stripped(self):
"""前后空白字符被忽略。"""
assert ClassificationJobStatus(" done ") == ClassificationJobStatus.COMPLETED
assert ClassificationJobStatus("\tfail\n") == ClassificationJobStatus.FAILED
def test_none_returns_pending(self):
"""None 值也返回 PENDING(不报错)。"""
assert ClassificationJobStatus(None) == ClassificationJobStatus.PENDING
def test_integer_returns_pending(self):
"""非字符串值返回 PENDING。"""
assert ClassificationJobStatus(123) == ClassificationJobStatus.PENDING
class TestClassificationStatusAlias:
"""向后兼容别名。"""
def test_alias_same_class(self):
assert ClassificationStatus is ClassificationJobStatus
def test_alias_values_same(self):
assert ClassificationStatus.PENDING == ClassificationJobStatus.PENDING
assert ClassificationStatus.COMPLETED == ClassificationJobStatus.COMPLETED
class TestAssetClassification:
"""AssetClassification 枚举测试."""
"""素材分类枚举。"""
def test_values(self):
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_members_count(self):
assert len(AssetClassification) == 9
class TestClassificationJobCreate:
"""ClassificationJob.create 工厂方法测试."""
"""ClassificationJob.create 工厂方法"""
def test_create_with_valid_params(self):
job = ClassificationJob.create(project_id="proj_001", asset_id="asset_001")
assert job.id
assert len(job.id) == 32
assert job.project_id == "proj_001"
assert job.asset_id == "asset_001"
def test_create_basic(self):
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
assert job.project_id == "proj-1"
assert job.asset_id == "asset-1"
assert job.status == ClassificationJobStatus.PENDING
assert job.classification == ""
assert job.confidence == 0.0
assert job.error_message == ""
assert job.created_at is not None
assert job.updated_at is not None
assert job.id # 自动生成的 ID 非空
def test_create_strips_strings(self):
job = ClassificationJob.create(
project_id=" proj_002 ",
asset_id=" asset_002 ",
)
assert job.project_id == "proj_002"
assert job.asset_id == "asset_002"
def test_create_strips_whitespace(self):
job = ClassificationJob.create(project_id=" proj-1 ", asset_id="\tasset-1\n")
assert job.project_id == "proj-1"
assert job.asset_id == "asset-1"
def test_create_empty_project_id_raises(self):
with pytest.raises(ValueError, match="project_id"):
ClassificationJob.create(project_id="", asset_id="a")
with pytest.raises(ValueError, match="project_id 不能为空"):
ClassificationJob.create(project_id="", asset_id="asset-1")
def test_create_whitespace_project_id_raises(self):
with pytest.raises(ValueError, match="project_id"):
ClassificationJob.create(project_id=" ", asset_id="a")
with pytest.raises(ValueError, match="project_id 不能为空"):
ClassificationJob.create(project_id=" ", asset_id="asset-1")
def test_create_empty_asset_id_raises(self):
with pytest.raises(ValueError, match="asset_id"):
ClassificationJob.create(project_id="p", asset_id="")
with pytest.raises(ValueError, match="asset_id 不能为空"):
ClassificationJob.create(project_id="proj-1", asset_id="")
def test_create_whitespace_asset_id_raises(self):
with pytest.raises(ValueError, match="asset_id"):
ClassificationJob.create(project_id="p", asset_id=" ")
with pytest.raises(ValueError, match="asset_id 不能为空"):
ClassificationJob.create(project_id="proj-1", asset_id=" \t ")
def test_create_ids_are_unique(self):
j1 = ClassificationJob.create(project_id="p", asset_id="a")
j2 = ClassificationJob.create(project_id="p", asset_id="b")
assert j1.id != j2.id
def test_create_generates_unique_ids(self):
job1 = ClassificationJob.create(project_id="p", asset_id="a")
job2 = ClassificationJob.create(project_id="p", asset_id="a")
assert job1.id != job2.id
def test_create_timestamps_are_utc(self):
def test_create_id_is_hex(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
assert job.created_at.tzinfo is not None
assert job.updated_at.tzinfo is not None
@@ -137,3 +243,130 @@ class TestClassificationJobState:
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
job.confidence = 1.0
assert job.confidence == 1.0
class TestClassificationJobStatusMissing:
"""ClassificationJobStatus._missing_ 兼容行为测试"""
def test_done_maps_to_completed(self):
assert ClassificationJobStatus("done") == ClassificationJobStatus.COMPLETED
def test_success_maps_to_completed(self):
assert ClassificationJobStatus("success") == ClassificationJobStatus.COMPLETED
def test_finished_maps_to_completed(self):
assert ClassificationJobStatus("finished") == ClassificationJobStatus.COMPLETED
def test_complete_maps_to_completed(self):
assert ClassificationJobStatus("complete") == ClassificationJobStatus.COMPLETED
def test_fail_maps_to_failed(self):
assert ClassificationJobStatus("fail") == ClassificationJobStatus.FAILED
def test_error_maps_to_failed(self):
assert ClassificationJobStatus("error") == ClassificationJobStatus.FAILED
def test_err_maps_to_failed(self):
assert ClassificationJobStatus("err") == ClassificationJobStatus.FAILED
def test_unknown_maps_to_pending(self):
assert ClassificationJobStatus("unknown_status") == ClassificationJobStatus.PENDING
def test_case_insensitive_mapping(self):
assert ClassificationJobStatus("DONE") == ClassificationJobStatus.COMPLETED
assert ClassificationJobStatus("Done") == ClassificationJobStatus.COMPLETED
def test_whitespace_stripped(self):
assert ClassificationJobStatus(" done ") == ClassificationJobStatus.COMPLETED
class TestClassificationJobExtended:
"""ClassificationJob 深度补充测试"""
def test_id_is_hex(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
int(job.id, 16)
def test_empty_classification(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
assert job.classification == ""
def test_zero_confidence(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
assert job.confidence == 0.0
def test_high_confidence(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
job.confidence = 0.99
assert job.confidence == pytest.approx(0.99)
def test_negative_confidence(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
job.confidence = -0.1
assert job.confidence == pytest.approx(-0.1)
def test_confidence_over_one(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
job.confidence = 1.5
assert job.confidence == pytest.approx(1.5)
def test_empty_error_message(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
assert job.error_message == ""
def test_long_error_message(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
long_msg = "error" * 100
job.error_message = long_msg
assert job.error_message == long_msg
assert len(job.error_message) == 500
def test_status_with_string_assignment(self):
job = ClassificationJob.create(project_id="p", asset_id="a")
job.status = "processing"
assert job.status == ClassificationJobStatus.PROCESSING
class TestAssetLibraryKindExtended:
"""AssetLibraryKind 深度补充测试"""
def test_image_value(self):
assert AssetLibraryKind.IMAGE == "image"
def test_all_three_kinds(self):
assert len(AssetLibraryKind) == 3
def test_is_string_enum(self):
assert isinstance(AssetLibraryKind.VIDEO, str)
def test_from_string(self):
assert AssetLibraryKind("video") == AssetLibraryKind.VIDEO
class TestIngestJobStatusExtended:
"""IngestJobStatus 深度补充测试"""
def test_is_string_enum(self):
assert isinstance(IngestJobStatus.PENDING, str)
def test_total_count(self):
assert len(IngestJobStatus) == 4
def test_from_string(self):
assert IngestJobStatus("pending") == IngestJobStatus.PENDING
class TestAssetClassificationExtended:
"""AssetClassification 深度补充测试"""
def test_total_count(self):
assert len(AssetClassification) == 9
def test_is_string_enum(self):
assert isinstance(AssetClassification.SCENIC, str)
def test_from_string(self):
assert AssetClassification("scenic") == AssetClassification.SCENIC
def test_other_category(self):
assert AssetClassification.OTHER == "other"
+55 -5
View File
@@ -8,11 +8,61 @@ from unittest.mock import MagicMock
import numpy as np
import pytest
# 模块级mock cv2dedup模块import时需要
sys.modules["cv2"] = MagicMock()
# 模块级mock worker_app.dbdedup模块import时会触发数据库初始化,纯算法测试不需要)
sys.modules["worker_app.db"] = MagicMock()
sys.modules["worker_app.db"].SessionLocal = MagicMock()
# 模块级mock有副作用的依赖(纯算法测试不需要db/celery/cv2
# 注意:必须在 import dedup 前全部 mock 完,避免链式导入触发db连接
# ⚠️ 只 mock 具体叶子模块,绝不 mock 整个父包,否则会污染其他测试文件的导入
def _mock_module(**attrs):
"""创建带 __spec__ 的 mock 模块,避免导入系统 AttributeError: __spec__"""
m = MagicMock()
m.__spec__ = None
for k, v in attrs.items():
setattr(m, k, v)
return m
# cv2(视频处理依赖,纯算法测试不需要)
sys.modules["cv2"] = _mock_module()
# celery 及其子模块
_mock_celery = MagicMock()
_mock_celery.Task = MagicMock
_mock_celery.Celery = MagicMock
_mock_celery.__spec__ = None
sys.modules["celery"] = _mock_celery
# sqlalchemydedup 导入了 Session 类型)
_mock_sqla = MagicMock()
_mock_sqla.__path__ = []
_mock_sqla.__spec__ = None
_mock_sqla_orm = MagicMock()
_mock_sqla_orm.__path__ = []
_mock_sqla_orm.__spec__ = None
_mock_sqla_orm.Session = MagicMock
sys.modules["sqlalchemy"] = _mock_sqla
sys.modules["sqlalchemy.orm"] = _mock_sqla_orm
sys.modules["sqlalchemy.engine"] = _mock_module()
sys.modules["sqlalchemy.ext"] = _mock_module()
sys.modules["sqlalchemy.ext.declarative"] = _mock_module()
# worker_app 子模块(只 mock 具体需要的,不 mock 整个 worker_app 包)
sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock())
sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock())
sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock()))
# packages - 只 mock 真正触发副作用的模块,不 mock 整个父包
# session 模块是触发数据库连接的元凶(ensure_database_exists),必须 mock 掉
sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module(
Base=MagicMock(),
build_engine=MagicMock(),
build_session_factory=MagicMock(),
ensure_database_exists=MagicMock(),
initialize_database=MagicMock(),
)
sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _mock_module()
sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock()))
sys.modules["packages.shared.storage"] = _mock_module()
from video_processing.dedup import ( # noqa: E402
VideoDeduplicator,
+52 -69
View File
@@ -1,4 +1,6 @@
"""领域层通用异常单元测试."""
"""
领域层异常类单元测试
"""
import pytest
@@ -14,118 +16,99 @@ class TestDomainError:
"""DomainError 基类测试"""
def test_is_exception(self):
"""DomainError 是 Exception 的子类"""
assert issubclass(DomainError, Exception)
def test_can_raise_and_catch(self):
"""可以抛出和捕获"""
def test_raise_and_catch(self):
with pytest.raises(DomainError):
raise DomainError("something went wrong")
raise DomainError("test error")
def test_message(self):
"""异常消息正确"""
err = DomainError("test message")
assert str(err) == "test message"
def test_error_message(self):
err = DomainError("something went wrong")
assert str(err) == "something went wrong"
def test_empty_message(self):
err = DomainError("")
assert str(err) == ""
class TestNotFoundError:
"""NotFoundError 测试"""
def test_is_domain_error(self):
"""NotFoundError 继承自 DomainError"""
assert issubclass(NotFoundError, DomainError)
def test_can_raise_as_domain_error(self):
"""可以作为 DomainError 捕获"""
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise NotFoundError("resource not found")
def test_default_message(self):
"""无参构造"""
err = NotFoundError()
assert isinstance(err, NotFoundError)
def test_raise_and_catch_specific(self):
with pytest.raises(NotFoundError):
raise NotFoundError("not found")
def test_custom_message(self):
"""自定义消息"""
def test_error_message(self):
err = NotFoundError("user 123 not found")
assert str(err) == "user 123 not found"
assert "user 123 not found" in str(err)
class TestValidationError:
"""ValidationError 测试"""
def test_is_domain_error(self):
"""ValidationError 继承自 DomainError"""
assert issubclass(ValidationError, DomainError)
def test_can_raise_as_domain_error(self):
"""可以作为 DomainError 捕获"""
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise ValidationError("invalid input")
def test_custom_message(self):
"""自定义消息"""
err = ValidationError("duration must be positive")
assert str(err) == "duration must be positive"
def test_raise_and_catch_specific(self):
with pytest.raises(ValidationError):
raise ValidationError("validation failed")
def test_error_message(self):
err = ValidationError("name cannot be empty")
assert "name cannot be empty" in str(err)
class TestQuotaExceededError:
"""QuotaExceededError 测试"""
def test_is_domain_error(self):
"""QuotaExceededError 继承自 DomainError"""
assert issubclass(QuotaExceededError, DomainError)
def test_can_raise_as_domain_error(self):
"""可以作为 DomainError 捕获"""
with pytest.raises(DomainError):
raise QuotaExceededError("storage", 1024.0, 2048.0)
def test_stores_dimension_limit_used(self):
"""保存 dimension、limit、used 属性"""
err = QuotaExceededError("storage_mb", 1024.0, 1500.0)
assert err.dimension == "storage_mb"
def test_constructor_sets_attributes(self):
err = QuotaExceededError(dimension="storage", limit=1024.0, used=2048.0)
assert err.dimension == "storage"
assert err.limit == 1024.0
assert err.used == 1500.0
assert err.used == 2048.0
def test_error_message_format(self):
"""异常消息格式正确"""
err = QuotaExceededError("storage_mb", 1024.0, 1500.0)
err = QuotaExceededError(dimension="storage", limit=1024.0, used=2048.0)
msg = str(err)
assert "storage_mb" in msg
assert "1500.0" in msg
assert "storage" in msg
assert "2048.0" in msg
assert "1024.0" in msg
assert "Quota exceeded" in msg
def test_integer_values(self):
"""整数值也能正常工作"""
err = QuotaExceededError("projects", 10, 15)
assert err.dimension == "projects"
assert err.limit == 10
assert err.used == 15
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise QuotaExceededError("projects", 10, 15)
def test_raise_and_catch_specific(self):
with pytest.raises(QuotaExceededError):
raise QuotaExceededError("render", 5, 10)
def test_zero_limit(self):
"""限制为 0 时也能正常工作"""
err = QuotaExceededError("custom_templates", 0, 1)
assert err.limit == 0
assert err.used == 1
err = QuotaExceededError(dimension="test", limit=0.0, used=1.0)
assert err.limit == 0.0
assert err.used == 1.0
def test_negative_values(self):
"""负数也能存(领域层不做额外校验)"""
err = QuotaExceededError(dimension="test", limit=-5.0, used=-3.0)
assert err.limit == -5.0
assert err.used == -3.0
class TestExceptionHierarchy:
"""异常继承关系测试"""
def test_all_are_domain_errors(self):
"""所有异常都可以作为 DomainError 捕获"""
errors = [
NotFoundError(),
ValidationError("bad"),
QuotaExceededError("x", 10.0, 20.0),
]
for err in errors:
assert isinstance(err, DomainError)
def test_distinct_types(self):
"""不同异常类型可以区分"""
assert not issubclass(NotFoundError, ValidationError)
assert not issubclass(ValidationError, QuotaExceededError)
assert not issubclass(NotFoundError, QuotaExceededError)
def test_large_values(self):
err = QuotaExceededError(dimension="storage", limit=1e9, used=1.5e9)
assert err.limit == 1e9
assert err.used == 1.5e9
+100
View File
@@ -152,3 +152,103 @@ class TestEditTemplateBumpVersion:
old_updated = template.updated_at
template.bump_version()
assert template.updated_at > old_updated or template.updated_at == old_updated
class TestEditTemplateExtended:
"""EditTemplate 深度补充测试"""
def test_id_is_hex(self):
t = EditTemplate.create(name="test")
int(t.id, 16)
def test_ids_are_unique(self):
t1 = EditTemplate.create(name="test1")
t2 = EditTemplate.create(name="test2")
assert t1.id != t2.id
def test_empty_description(self):
t = EditTemplate.create(name="test", description="")
assert t.description == ""
def test_long_description(self):
desc = "描述" * 200
t = EditTemplate.create(name="test", description=desc)
assert t.description == desc
assert len(t.description) == 400
def test_unicode_name(self):
t = EditTemplate.create(name="🎬 口播 Vlog 模板")
assert "🎬" in t.name
assert "口播" in t.name
def test_special_characters_name(self):
special = "模!@#$%"
t = EditTemplate.create(name=special)
assert t.name == special
def test_long_name(self):
long_name = "模板名称" * 50
t = EditTemplate.create(name=long_name)
assert t.name == long_name
assert len(t.name) == 200
def test_config_independence(self):
t1 = EditTemplate.create(name="test1")
t2 = EditTemplate.create(name="test2")
t1.config["key"] = "val"
assert "key" not in t2.config
def test_sort_weight_negative(self):
t = EditTemplate.create(name="test", sort_weight=-100)
assert t.sort_weight == -100
def test_sort_weight_large(self):
t = EditTemplate.create(name="test", sort_weight=99999)
assert t.sort_weight == 99999
def test_sort_weight_zero(self):
t = EditTemplate.create(name="test", sort_weight=0)
assert t.sort_weight == 0
def test_preview_url_empty(self):
t = EditTemplate.create(name="test", preview_url="")
assert t.preview_url == ""
def test_version_zero(self):
t = EditTemplate.create(name="test", version=0)
assert t.version == 0
def test_version_large(self):
t = EditTemplate.create(name="test", version=999)
assert t.version == 999
def test_status_is_active_property(self):
t = EditTemplate.create(name="test", status=EditTemplateStatus.ACTIVE)
assert t.is_active is True
t.deactivate()
assert t.is_active is False
t.activate()
assert t.is_active is True
def test_bump_version_from_zero(self):
t = EditTemplate.create(name="test", version=0)
t.bump_version()
assert t.version == 1
def test_template_type_custom(self):
t = EditTemplate.create(name="test", template_type="custom_type")
assert t.template_type == "custom_type"
def test_template_type_strips_and_default(self):
"""空格的 template_type 回退到 default"""
t = EditTemplate.create(name="test", template_type=" ")
assert t.template_type == "default"
def test_create_with_empty_editing_mode_defaults(self):
"""空字符串 editing_mode 回退到 one_take"""
t = EditTemplate.create(name="test", editing_mode="")
assert t.editing_mode == "one_take"
def test_preview_url_strips_whitespace(self):
t = EditTemplate.create(name="test", preview_url=" https://example.com/v.mp4 ")
assert t.preview_url == "https://example.com/v.mp4"
+54
View File
@@ -38,3 +38,57 @@ class TestEditingMode:
modes = list(EditingMode)
assert len(modes) == 4
assert EditingMode.ONE_TAKE in modes
class TestEditingModeExtended:
"""EditingMode 深度补充测试"""
def test_from_string_value(self):
"""可以从字符串值构造枚举"""
assert EditingMode("one_take") == EditingMode.ONE_TAKE
assert EditingMode("pip") == EditingMode.PIP
assert EditingMode("voice_over") == EditingMode.VOICE_OVER
assert EditingMode("voice_pip") == EditingMode.VOICE_PIP
def test_invalid_string_raises(self):
import pytest
with pytest.raises(ValueError):
EditingMode("invalid_mode")
def test_string_concatenation(self):
"""StrEnum 支持字符串拼接"""
result = "mode_" + EditingMode.ONE_TAKE
assert result == "mode_one_take"
def test_dict_key_usage(self):
"""可以作为字典 key 使用"""
mapping = {
EditingMode.ONE_TAKE: "顺序拼接",
EditingMode.PIP: "画中画",
}
assert mapping[EditingMode.ONE_TAKE] == "顺序拼接"
assert mapping[EditingMode.PIP] == "画中画"
assert len(mapping) == 2
def test_value_lowercase(self):
"""所有枚举值都是小写字母+下划线"""
for mode in EditingMode:
assert mode.value == mode.value.lower()
assert " " not in mode.value
def test_unique_values(self):
"""所有枚举值唯一"""
values = [m.value for m in EditingMode]
assert len(values) == len(set(values))
def test_membership_test(self):
assert EditingMode.ONE_TAKE in EditingMode
assert "one_take" in [m.value for m in EditingMode]
def test_comparison_with_string(self):
"""和字符串直接比较"""
mode = EditingMode.VOICE_OVER
assert mode == "voice_over"
assert mode != "pip"
assert "voice_over" == mode
+166
View File
@@ -0,0 +1,166 @@
"""领域层异常类单元测试."""
import pytest
from packages.domain.exceptions import (
DomainError,
NotFoundError,
QuotaExceededError,
ValidationError,
)
class TestDomainError:
"""领域异常基类测试."""
def test_is_exception(self):
"""DomainError 继承自 Exception."""
err = DomainError("test")
assert isinstance(err, Exception)
def test_message(self):
"""可以设置错误消息."""
err = DomainError("something wrong")
assert str(err) == "something wrong"
def test_empty_message(self):
"""支持空消息."""
err = DomainError()
assert str(err) == ""
def test_can_be_raised(self):
"""可以被 raise 和 catch."""
with pytest.raises(DomainError) as exc_info:
raise DomainError("oops")
assert str(exc_info.value) == "oops"
class TestNotFoundError:
"""资源不存在异常测试."""
def test_inherits_domain_error(self):
"""NotFoundError 继承自 DomainError."""
err = NotFoundError("user not found")
assert isinstance(err, DomainError)
assert isinstance(err, Exception)
def test_message(self):
"""错误消息正确."""
err = NotFoundError("project 123 not found")
assert str(err) == "project 123 not found"
assert "123" in str(err)
def test_can_catch_as_domain_error(self):
"""可以用 DomainError 捕获."""
with pytest.raises(DomainError):
raise NotFoundError("not found")
class TestValidationError:
"""校验失败异常测试."""
def test_inherits_domain_error(self):
"""ValidationError 继承自 DomainError."""
err = ValidationError("invalid input")
assert isinstance(err, DomainError)
def test_message(self):
"""错误消息正确."""
msg = "name must not be empty"
err = ValidationError(msg)
assert str(err) == msg
def test_not_not_found(self):
"""ValidationError 不是 NotFoundError."""
err = ValidationError("bad")
assert not isinstance(err, NotFoundError)
class TestQuotaExceededError:
"""配额超限异常测试."""
def test_inherits_domain_error(self):
"""QuotaExceededError 继承自 DomainError."""
err = QuotaExceededError("storage", 100.0, 150.0)
assert isinstance(err, DomainError)
def test_dimension_attribute(self):
"""保存 dimension 属性."""
err = QuotaExceededError("storage", 100.0, 150.0)
assert err.dimension == "storage"
def test_limit_attribute(self):
"""保存 limit 属性."""
err = QuotaExceededError("storage", 100.0, 150.0)
assert err.limit == 100.0
def test_used_attribute(self):
"""保存 used 属性."""
err = QuotaExceededError("storage", 100.0, 150.0)
assert err.used == 150.0
def test_message_format(self):
"""错误消息格式正确."""
err = QuotaExceededError("credits", 50.0, 75.0)
msg = str(err)
assert "credits" in msg
assert "50" in msg
assert "75" in msg
assert "Quota exceeded" in msg
def test_zero_limit(self):
"""limit 为 0 的情况."""
err = QuotaExceededError("test", 0.0, 1.0)
assert err.limit == 0.0
assert err.used == 1.0
assert "0" in str(err)
def test_equal_limit_and_used(self):
"""used 刚好等于 limit(边界情况)."""
err = QuotaExceededError("test", 100.0, 100.0)
assert err.used == 100.0
assert err.limit == 100.0
def test_integer_values(self):
"""整数值也能正常工作."""
err = QuotaExceededError("count", 10, 20)
assert err.dimension == "count"
assert err.limit == 10
assert err.used == 20
def test_can_catch_as_domain_error(self):
"""可以用 DomainError 捕获."""
with pytest.raises(DomainError):
raise QuotaExceededError("x", 1.0, 2.0)
class TestExceptionHierarchy:
"""异常继承关系验证."""
def test_all_are_domain_errors(self):
"""所有领域异常都是 DomainError."""
errors = [
NotFoundError("test"),
ValidationError("test"),
QuotaExceededError("test", 1, 2),
]
for err in errors:
assert isinstance(err, DomainError)
def test_all_are_exceptions(self):
"""所有领域异常都是 Exception."""
errors = [
DomainError("test"),
NotFoundError("test"),
ValidationError("test"),
QuotaExceededError("test", 1, 2),
]
for err in errors:
assert isinstance(err, Exception)
def test_not_found_is_not_validation(self):
"""不同异常类型不能互相混淆."""
assert not isinstance(NotFoundError("x"), ValidationError)
assert not isinstance(ValidationError("x"), NotFoundError)
assert not isinstance(QuotaExceededError("x", 1, 2), NotFoundError)
assert not isinstance(QuotaExceededError("x", 1, 2), ValidationError)
+114
View File
@@ -194,3 +194,117 @@ class TestGeneratedVideoProperties:
fingerprint = {"phash": "abc123", "md5": "def456"}
gv.video_fingerprint = fingerprint
assert gv.video_fingerprint == fingerprint
class TestGeneratedVideoExtended:
"""GeneratedVideo 深度补充测试"""
def test_id_is_hex(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
int(v.id, 16)
def test_zero_file_size(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", file_size=0)
assert v.file_size == 0
def test_large_file_size(self):
large = 1024 * 1024 * 1024 # 1GB
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", file_size=large)
assert v.file_size == large
def test_zero_duration(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", duration=0.0)
assert v.duration == 0.0
def test_large_duration(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", duration=9999.99)
assert v.duration == 9999.99
def test_zero_dimensions(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", width=0, height=0)
assert v.width == 0
assert v.height == 0
def test_4k_dimensions(self):
v = GeneratedVideo.create(
project_id="p", generation_task_id="t", name="n", file_url="u", width=3840, height=2160
)
assert v.width == 3840
assert v.height == 2160
def test_zero_fps(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", fps=0.0)
assert v.fps == 0.0
def test_high_fps(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", fps=120.0)
assert v.fps == 120.0
def test_empty_user_id(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", user_id="")
assert v.user_id == ""
def test_long_name(self):
long_name = "视频" * 100
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name=long_name, file_url="u")
assert v.name == long_name
assert len(v.name) == 200
def test_unicode_name(self):
v = GeneratedVideo.create(
project_id="p", generation_task_id="t", name="🎬 我的精彩视频 · 旅行vlog", file_url="u"
)
assert "🎬" in v.name
assert "旅行vlog" in v.name
def test_special_characters_name(self):
special = "视!@#$%^&*()频"
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name=special, file_url="u")
assert v.name == special
def test_thumbnail_url_none(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u", thumbnail_url=None)
assert v.thumbnail_url is None
def test_generation_params_complex(self):
params = {
"mode": "voice_over",
"quality": "high",
"resolution": {"width": 1920, "height": 1080},
"effects": ["filter", "transition"],
}
v = GeneratedVideo.create(
project_id="p", generation_task_id="t", name="n", file_url="u", generation_params=params
)
assert v.generation_params["mode"] == "voice_over"
assert v.generation_params["resolution"]["width"] == 1920
assert len(v.generation_params["effects"]) == 2
def test_generation_params_independence(self):
v1 = GeneratedVideo.create(project_id="p", generation_task_id="t1", name="n1", file_url="u1")
v2 = GeneratedVideo.create(project_id="p", generation_task_id="t2", name="n2", file_url="u2")
v1.generation_params["key"] = "val"
assert "key" not in v2.generation_params
def test_status_failed(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
v.status = "failed"
assert v.status == "failed"
def test_review_status_approved(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
v.review_status = "approved"
assert v.review_status == "approved"
def test_is_duplicate_default_false(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
assert v.is_duplicate is False
assert v.duplicate_of is None
def test_fingerprint_none_default(self):
v = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
assert v.video_fingerprint is None
def test_empty_file_url_raises(self):
with pytest.raises(ValueError):
GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url=" ")
+294
View File
@@ -382,3 +382,297 @@ class TestModuleStatus:
assert ModuleStatus.ACTIVE.value == "active"
assert ModuleStatus.DISABLED.value == "disabled"
assert ModuleStatus.ERROR.value == "error"
# ── Module 更多状态转换测试 ──────────────────────────────────────────────────
class TestModuleStateTransitions:
"""Module 状态转换补充测试."""
def test_activate_twice_idempotent(self):
"""多次激活不报错."""
mod = Module(name="m1")
mod.activate()
mod.activate()
assert mod.status == ModuleStatus.ACTIVE
def test_disable_twice_idempotent(self):
"""多次禁用不报错."""
mod = Module(name="m1", status=ModuleStatus.ACTIVE)
mod.disable()
mod.disable()
assert mod.status == ModuleStatus.DISABLED
def test_disable_from_registered(self):
"""从registered状态禁用."""
mod = Module(name="m1")
mod.disable()
assert mod.status == ModuleStatus.DISABLED
def test_activate_after_disable(self):
"""禁用后重新激活."""
mod = Module(name="m1")
mod.activate()
mod.disable()
mod.activate()
assert mod.status == ModuleStatus.ACTIVE
def test_error_state_cannot_be_activated(self):
"""error状态不能被激活."""
mod = Module(name="m1", status=ModuleStatus.ERROR)
mod.activate()
assert mod.status == ModuleStatus.ERROR
def test_error_state_can_be_disabled(self):
"""error状态可以被禁用(disable 不检查状态)."""
mod = Module(name="m1", status=ModuleStatus.ERROR)
mod.disable()
assert mod.status == ModuleStatus.DISABLED
# ── ModuleRegistry 注册补充测试 ──────────────────────────────────────────────
class TestModuleRegistryRegisterMore:
"""模块注册补充场景."""
def test_register_multiple_modules(self):
"""注册多个模块."""
registry = ModuleRegistry()
for i in range(5):
registry.register(Module(name=f"mod_{i}"))
assert len(registry.list_modules()) == 5
def test_register_order_independent_deps(self):
"""先注册依赖方,后注册被依赖方,依赖方不会自动激活."""
registry = ModuleRegistry()
registry.register(Module(name="plugin", dependencies=["core"]))
assert registry.get("plugin").status == ModuleStatus.REGISTERED
# 注册core后,plugin仍然是REGISTERED(不会自动检查)
registry.register(Module(name="core"))
assert registry.get("core").status == ModuleStatus.ACTIVE
def test_register_with_multiple_deps_all_satisfied(self):
"""所有依赖都满足时自动激活."""
registry = ModuleRegistry()
registry.register(Module(name="dep_a"))
registry.register(Module(name="dep_b"))
registry.register(Module(name="plugin", dependencies=["dep_a", "dep_b"]))
assert registry.get("plugin").status == ModuleStatus.ACTIVE
def test_register_with_multiple_deps_partial_missing(self):
"""部分依赖缺失时不激活."""
registry = ModuleRegistry()
registry.register(Module(name="dep_a"))
registry.register(Module(name="plugin", dependencies=["dep_a", "dep_b"]))
assert registry.get("plugin").status == ModuleStatus.REGISTERED
def test_register_self_dependency_handled(self):
"""自依赖不会导致死循环(依赖检查时找不到自己)."""
registry = ModuleRegistry()
registry.register(Module(name="self_dep", dependencies=["self_dep"]))
# 注册时自己还没加入 _modules,检查依赖时找不到,保持REGISTERED
assert registry.get("self_dep").status == ModuleStatus.REGISTERED
# ── ModuleRegistry 能力查询补充 ─────────────────────────────────────────────
class TestModuleRegistryCapabilitiesMore:
"""能力查询补充测试."""
def test_multiple_modules_same_capability_returns_first(self):
"""多个模块提供同一能力,get_capability返回第一个."""
registry = ModuleRegistry()
cap1 = ModuleCapability(name="render", description="渲染器A")
cap2 = ModuleCapability(name="render", description="渲染器B")
registry.register(Module(name="mod_a", capabilities=[cap1]))
registry.register(Module(name="mod_b", capabilities=[cap2]))
result = registry.get_capability("render")
assert result is not None
assert result.name == "render"
def test_has_capability_case_sensitive(self):
"""能力名大小写敏感."""
registry = ModuleRegistry()
registry.register(Module(name="m1", capabilities=[ModuleCapability(name="Render")]))
assert registry.has_capability("Render") is True
assert registry.has_capability("render") is False
def test_get_quota_rules_nonexistent_capability(self):
"""不存在的能力返回空配额列表."""
registry = ModuleRegistry()
rules = registry.get_quota_rules("nonexistent")
assert rules == []
def test_get_active_capabilities_empty_registry(self):
"""空注册中心返回空字典."""
registry = ModuleRegistry()
result = registry.get_active_capabilities()
assert result == {}
def test_get_active_capabilities_skips_inactive(self):
"""非激活模块的能力不计入."""
registry = ModuleRegistry()
registry.register(Module(name="active_mod", capabilities=[ModuleCapability(name="cap1")]))
disabled = Module(
name="disabled_mod",
status=ModuleStatus.DISABLED,
capabilities=[ModuleCapability(name="cap2")],
)
registry._modules["disabled_mod"] = disabled
result = registry.get_active_capabilities()
assert "active_mod" in result
assert "disabled_mod" not in result
def test_get_active_capabilities_skips_no_cap_modules(self):
"""无能力的模块不出现在结果中."""
registry = ModuleRegistry()
registry.register(Module(name="no_cap_mod"))
registry.register(Module(name="has_cap_mod", capabilities=[ModuleCapability(name="cap1")]))
result = registry.get_active_capabilities()
assert "no_cap_mod" not in result
assert "has_cap_mod" in result
def test_module_with_multiple_capabilities(self):
"""单个模块有多个能力."""
registry = ModuleRegistry()
caps = [
ModuleCapability(name="cap_a"),
ModuleCapability(name="cap_b"),
ModuleCapability(name="cap_c"),
]
registry.register(Module(name="multi_mod", capabilities=caps))
assert registry.has_capability("cap_a")
assert registry.has_capability("cap_b")
assert registry.has_capability("cap_c")
assert len(registry.get_active_capabilities()["multi_mod"]) == 3
# ── ModuleRegistry 依赖检查补充 ────────────────────────────────────────────
class TestModuleRegistryDependenciesMore:
"""依赖检查补充测试."""
def test_multiple_dependencies_all_active(self):
"""多个依赖都激活."""
registry = ModuleRegistry()
registry.register(Module(name="dep1"))
registry.register(Module(name="dep2"))
registry.register(Module(name="dep3"))
registry.register(Module(name="plugin", dependencies=["dep1", "dep2", "dep3"]))
assert registry.check_dependencies("plugin") is True
def test_multiple_dependencies_one_inactive(self):
"""多个依赖中有一个未激活."""
registry = ModuleRegistry()
registry.register(Module(name="dep1"))
dep2 = Module(name="dep2", status=ModuleStatus.DISABLED)
registry._modules["dep2"] = dep2
registry.register(Module(name="plugin", dependencies=["dep1", "dep2"]))
# 注册时dep2不是ACTIVEplugin不会自动激活
assert registry.check_dependencies("plugin") is False
def test_chain_dependencies(self):
"""链式依赖 A→B→C."""
registry = ModuleRegistry()
registry.register(Module(name="c"))
registry.register(Module(name="b", dependencies=["c"]))
registry.register(Module(name="a", dependencies=["b"]))
# a 依赖 bACTIVE),b 依赖 cACTIVE
# check_dependencies 只检查直接依赖,b 是 ACTIVE 的
assert registry.check_dependencies("a") is True
assert registry.get("a").status == ModuleStatus.ACTIVE
def test_no_dependencies_always_satisfied(self):
"""无依赖的模块总是满足依赖检查."""
registry = ModuleRegistry()
registry.register(Module(name="standalone"))
assert registry.check_dependencies("standalone") is True
# ── ModuleRegistry list_modules 补充 ──────────────────────────────────────
class TestModuleRegistryListMore:
"""list_modules 补充测试."""
def test_list_modules_empty(self):
"""空注册中心."""
registry = ModuleRegistry()
assert registry.list_modules() == []
def test_list_modules_registered_status(self):
"""按registered状态过滤."""
registry = ModuleRegistry()
registry.register(Module(name="active_mod")) # auto ACTIVE
pending = Module(name="pending_mod")
registry._modules["pending_mod"] = pending # REGISTERED
registered = registry.list_modules(status=ModuleStatus.REGISTERED)
assert len(registered) == 1
assert registered[0].name == "pending_mod"
def test_list_modules_error_status(self):
"""按error状态过滤."""
registry = ModuleRegistry()
error_mod = Module(name="err", status=ModuleStatus.ERROR)
registry._modules["err"] = error_mod
errors = registry.list_modules(status=ModuleStatus.ERROR)
assert len(errors) == 1
assert errors[0].name == "err"
# ── QuotaRule 补充测试 ──────────────────────────────────────────────────────
class TestQuotaRuleMore:
"""QuotaRule 补充测试."""
def test_zero_per_operation(self):
"""零消耗配额规则."""
rule = QuotaRule(dimension="free_ops", per_operation=0.0)
assert rule.per_operation == 0.0
def test_fractional_per_operation(self):
"""小数消耗配额规则."""
rule = QuotaRule(dimension="storage", per_operation=0.001)
assert rule.per_operation == 0.001
def test_large_per_operation(self):
"""大数值消耗."""
rule = QuotaRule(dimension="tokens", per_operation=10000.0)
assert rule.per_operation == 10000.0
# ── ModuleCapability 补充测试 ──────────────────────────────────────────────
class TestModuleCapabilityMore:
"""ModuleCapability 补充测试."""
def test_empty_metadata(self):
"""默认metadata为空字典."""
cap = ModuleCapability(name="test")
assert cap.metadata == {}
def test_metadata_preserved(self):
"""元数据完整保存."""
meta = {"model": "v1", "speed": 1.5, "enabled": True}
cap = ModuleCapability(name="test", metadata=meta)
assert cap.metadata["model"] == "v1"
assert cap.metadata["speed"] == 1.5
assert cap.metadata["enabled"] is True
def test_multiple_quota_rules(self):
"""多个配额规则."""
rules = [
QuotaRule("dim1", 1.0),
QuotaRule("dim2", 2.0),
QuotaRule("dim3", 3.0),
]
cap = ModuleCapability(name="test", quota_rules=rules)
assert len(cap.quota_rules) == 3
assert cap.quota_rules[0].dimension == "dim1"
assert cap.quota_rules[2].per_operation == 3.0
+99
View File
@@ -82,3 +82,102 @@ class TestRecipe:
for itype in ["asset", "title", "voice"]:
item = RecipeItem(id="i1", recipe_id="r1", item_type=itype, item_id="x", position=0)
assert item.item_type == itype
def test_item_metadata_independence(self):
"""不同 RecipeItem 的 metadata_ 互不影响"""
item1 = RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1")
item2 = RecipeItem(id="i2", recipe_id="r1", item_type="asset", item_id="a2")
item1.metadata_["key"] = "val"
assert "key" not in item2.metadata_
def test_item_position_negative(self):
"""负数 position 也能存"""
item = RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=-5)
assert item.position == -5
def test_item_position_large(self):
item = RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=9999)
assert item.position == 9999
def test_item_empty_item_id(self):
item = RecipeItem(id="i1", recipe_id="r1", item_type="title", item_id="")
assert item.item_id == ""
def test_item_type_voice(self):
item = RecipeItem(id="i1", recipe_id="r1", item_type="voice", item_id="v1")
assert item.item_type == "voice"
def test_item_type_title(self):
item = RecipeItem(id="i1", recipe_id="r1", item_type="title", item_id="t1")
assert item.item_type == "title"
class TestRecipeExtended:
"""Recipe 深度补充测试"""
def test_items_order_preserved(self):
items = [
RecipeItem(id="i3", recipe_id="r1", item_type="asset", item_id="a1", position=2),
RecipeItem(id="i1", recipe_id="r1", item_type="title", item_id="t1", position=0),
RecipeItem(id="i2", recipe_id="r1", item_type="voice", item_id="v1", position=1),
]
r = Recipe(id="r1", user_id="u1", name="n", items=items)
assert len(r.items) == 3
assert r.items[0].position == 2
assert r.items[1].position == 0
assert r.items[2].position == 1
def test_empty_items_list(self):
r = Recipe(id="r1", user_id="u1", name="n", items=[])
assert r.items == []
def test_many_items(self):
items = [
RecipeItem(id=f"i{i}", recipe_id="r1", item_type="asset", item_id=f"a{i}", position=i) for i in range(50)
]
r = Recipe(id="r1", user_id="u1", name="n", items=items)
assert len(r.items) == 50
assert r.items[0].position == 0
assert r.items[49].position == 49
def test_generation_params_independence(self):
params = {"mode": "one_take", "duration": 30}
r1 = Recipe(id="r1", user_id="u1", name="n1", generation_params=params)
r2 = Recipe(id="r2", user_id="u1", name="n2")
r1.generation_params["new_key"] = "new_val"
# 传入同一个 dict 会共享,但默认生成的互不影响
assert r2.generation_params == {}
def test_metadata_independence_default(self):
r1 = Recipe(id="r1", user_id="u1", name="n1")
r2 = Recipe(id="r2", user_id="u1", name="n2")
r1.metadata_["key"] = "val"
assert "key" not in r2.metadata_
def test_with_description(self):
r = Recipe(id="r1", user_id="u1", name="n", description="这是一个测试配方")
assert r.description == "这是一个测试配方"
def test_with_template_id(self):
r = Recipe(id="r1", user_id="u1", name="n", template_id="tpl-123")
assert r.template_id == "tpl-123"
def test_empty_name(self):
r = Recipe(id="r1", user_id="u1", name="")
assert r.name == ""
def test_long_name(self):
long_name = "配方" * 200
r = Recipe(id="r1", user_id="u1", name=long_name)
assert r.name == long_name
assert len(r.name) == 400
def test_special_characters_in_name(self):
special = "配!@#$%"
r = Recipe(id="r1", user_id="u1", name=special)
assert r.name == special
def test_unicode_name(self):
r = Recipe(id="r1", user_id="u1", name="🎬 一键生成配方 · 美食探店")
assert "🎬" in r.name
assert "美食探店" in r.name
+581 -178
View File
@@ -1,272 +1,675 @@
"""字幕领域模型单元测试."""
"""字幕领域模型单元测试 - 纯逻辑部分。"""
from __future__ import annotations
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
import pytest
from domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
class TestSubtitleWord:
"""SubtitleWord 测试."""
"""单个词级别字幕单元。"""
def test_basic_properties(self):
word = SubtitleWord(text="你好", start=1.0, end=1.5)
def test_basic_creation(self):
word = SubtitleWord(text="你好", start=0.0, end=0.5)
assert word.text == "你好"
assert word.start == 1.0
assert word.end == 1.5
assert word.duration == 0.5
assert word.start == 0.0
assert word.end == 0.5
def test_duration_zero_when_end_before_start(self):
word = SubtitleWord(text="test", start=2.0, end=1.0)
def test_duration_positive(self):
word = SubtitleWord(text="test", start=1.0, end=2.5)
assert word.duration == pytest.approx(1.5)
def test_duration_zero(self):
word = SubtitleWord(text="x", start=3.0, end=3.0)
assert word.duration == 0.0
def test_duration_zero_when_same_time(self):
word = SubtitleWord(text="test", start=1.0, end=1.0)
def test_duration_negative_returns_zero(self):
"""end < start 时 duration 返回 0,不抛异常。"""
word = SubtitleWord(text="x", start=5.0, end=3.0)
assert word.duration == 0.0
class TestSubtitleSegment:
"""SubtitleSegment 测试."""
"""字幕段(一句话)。"""
def test_basic_properties(self):
seg = SubtitleSegment(text="大家好", start=0.0, end=2.0)
assert seg.text == "大家好"
def test_basic_creation(self):
seg = SubtitleSegment(text="你好世界", start=0.0, end=2.0)
assert seg.text == "你好世界"
assert seg.start == 0.0
assert seg.end == 2.0
assert seg.duration == 2.0
assert seg.char_count == 3
assert seg.words == []
def test_duration_with_words(self):
words = [
SubtitleWord(text="", start=0.0, end=0.5),
SubtitleWord(text="", start=0.5, end=1.0),
SubtitleWord(text="", start=1.0, end=1.5),
]
seg = SubtitleSegment(text="大家好", start=0.0, end=1.5, words=words)
assert seg.duration == 1.5
assert seg.char_count == 3
assert len(seg.words) == 3
def test_duration_positive(self):
seg = SubtitleSegment(text="test", start=1.0, end=3.5)
assert seg.duration == pytest.approx(2.5)
def test_duration_zero_when_end_before_start(self):
seg = SubtitleSegment(text="test", start=3.0, end=1.0)
def test_duration_zero(self):
seg = SubtitleSegment(text="x", start=5.0, end=5.0)
assert seg.duration == 0.0
def test_duration_negative_returns_zero(self):
seg = SubtitleSegment(text="x", start=10.0, end=5.0)
assert seg.duration == 0.0
def test_char_count(self):
seg = SubtitleSegment(text="你好世界", start=0, end=1)
assert seg.char_count == 4
def test_char_count_empty(self):
seg = SubtitleSegment(text="", start=0, end=1)
assert seg.char_count == 0
def test_char_count_mixed_languages(self):
seg = SubtitleSegment(text="你好hello世界", start=0, end=1)
assert seg.char_count == 9 # 2中 + 5英 + 2中 = 9
def test_with_words(self):
words = [
SubtitleWord(text="你好", start=0.0, end=0.5),
SubtitleWord(text="世界", start=0.5, end=1.0),
]
seg = SubtitleSegment(text="你好世界", start=0.0, end=1.0, words=words)
assert len(seg.words) == 2
assert seg.words[0].text == "你好"
class TestSubtitleTimelineBasics:
"""SubtitleTimeline 基础属性测试."""
"""字幕时间轴基础属性"""
def test_empty_timeline(self):
tl = SubtitleTimeline()
assert tl.segment_count == 0
assert tl.total_chars == 0
assert tl.segments == []
assert tl.language == "zh"
assert tl.total_duration == 0.0
assert tl.segment_count == 0
assert tl.total_chars == 0
def test_single_segment(self):
seg = SubtitleSegment(text="测试", start=0.0, end=1.0)
tl = SubtitleTimeline(segments=[seg])
tl = SubtitleTimeline(
segments=[SubtitleSegment(text="你好", start=0, end=1)],
language="zh",
total_duration=1.0,
)
assert tl.segment_count == 1
assert tl.total_chars == 2
def test_multiple_segments(self):
segs = [
SubtitleSegment(text="第一句", start=0.0, end=1.0),
SubtitleSegment(text="第二句", start=1.0, end=2.0),
SubtitleSegment(text="第三句", start=2.0, end=3.0),
segments = [
SubtitleSegment(text="第一句", start=0, end=1),
SubtitleSegment(text="第二句更长一点", start=1, end=3),
SubtitleSegment(text="第三句", start=3, end=4),
]
tl = SubtitleTimeline(segments=segs, total_duration=3.0)
tl = SubtitleTimeline(segments=segments, total_duration=4.0)
assert tl.segment_count == 3
assert tl.total_chars == 9
assert tl.total_duration == 3.0
def test_custom_language(self):
tl = SubtitleTimeline(language="en")
assert tl.language == "en"
assert tl.total_chars == 3 + 7 + 3 # 13
class TestSubtitleTimelineMergeShort:
"""合并短字幕片段测试."""
class TestMergeShortSegments:
"""合并短字幕片段"""
def test_empty_or_single_no_change(self):
def test_empty_timeline_unchanged(self):
tl = SubtitleTimeline()
result = tl.merge_short_segments()
assert result.segment_count == 0
seg = SubtitleSegment(text="", start=0.0, end=0.5)
tl2 = SubtitleTimeline(segments=[seg])
result2 = tl2.merge_short_segments()
assert result2.segment_count == 1
def test_merge_short_segments(self):
segs = [
SubtitleSegment(text="你好", start=0.0, end=0.5),
SubtitleSegment(text="世界", start=0.5, end=1.0),
SubtitleSegment(text="今天天气很好", start=1.0, end=2.0),
]
tl = SubtitleTimeline(segments=segs)
result = tl.merge_short_segments(min_chars=4)
# "你好"+"世界"=4字,合并;"今天天气很好"=6字,保留
assert result.segment_count == 2
assert result.segments[0].text == "你好世界"
assert result.segments[0].start == 0.0
assert result.segments[0].end == 1.0
assert result.segments[1].text == "今天天气很好"
def test_merge_trailing_short_to_last(self):
segs = [
SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0),
SubtitleSegment(text="", start=1.0, end=1.2),
SubtitleSegment(text="", start=1.2, end=1.4),
]
tl = SubtitleTimeline(segments=segs)
result = tl.merge_short_segments(min_chars=4)
# "一二三四五六七八"=8字 → 保留
# "短"+"尾"=2字 < 4 → 合并到上一段
def test_single_segment_unchanged(self):
tl = SubtitleTimeline(segments=[SubtitleSegment(text="", start=0, end=0.5)])
result = tl.merge_short_segments()
assert result.segment_count == 1
assert result.segments[0].text == "一二三四五六七八短尾"
assert result.segments[0].text == ""
def test_merge_with_words(self):
words1 = [SubtitleWord(text="", start=0.0, end=0.25), SubtitleWord(text="", start=0.25, end=0.5)]
words2 = [SubtitleWord(text="", start=0.5, end=0.75), SubtitleWord(text="", start=0.75, end=1.0)]
segs = [
SubtitleSegment(text="你好", start=0.0, end=0.5, words=words1),
SubtitleSegment(text="世界", start=0.5, end=1.0, words=words2),
def test_all_short_merged_into_one(self):
"""多个短片段合并成一个。"""
segments = [
SubtitleSegment(text="", start=0, end=0.2),
SubtitleSegment(text="", start=0.2, end=0.4),
SubtitleSegment(text="", start=0.4, end=0.6),
]
tl = SubtitleTimeline(segments=segs)
result = tl.merge_short_segments(min_chars=4)
tl = SubtitleTimeline(segments=segments)
result = tl.merge_short_segments(min_chars=3)
assert result.segment_count == 1
assert len(result.segments[0].words) == 4
assert result.segments[0].text == "一二三"
assert result.segments[0].start == 0
assert result.segments[0].end == 0.6
def test_mixed_lengths(self):
"""长短混合,中间短的会合并成一段。"""
segments = [
SubtitleSegment(text="这是比较长的第一句", start=0, end=2), # 10字
SubtitleSegment(text="第一小段", start=2, end=2.4), # 4字
SubtitleSegment(text="第二小段", start=2.4, end=2.8), # 4字
SubtitleSegment(text="这是比较长的第四句", start=2.8, end=5), # 10字
]
tl = SubtitleTimeline(segments=segments)
result = tl.merge_short_segments(min_chars=8)
# 第一句10字够长单独保留;短1+短2=8字刚好够一段;第四句10字够长单独保留
assert result.segment_count == 3
assert result.segments[0].text == "这是比较长的第一句"
assert result.segments[1].text == "第一小段第二小段"
assert result.segments[2].text == "这是比较长的第四句"
def test_preserves_language_and_duration(self):
segs = [SubtitleSegment(text="", start=0.0, end=0.5)]
tl = SubtitleTimeline(segments=segs, language="ja", total_duration=0.5)
result = tl.merge_short_segments(min_chars=4)
assert result.language == "ja"
assert result.total_duration == 0.5
segments = [
SubtitleSegment(text="a", start=0, end=0.1),
SubtitleSegment(text="b", start=0.1, end=0.2),
]
tl = SubtitleTimeline(segments=segments, language="en", total_duration=10.0)
result = tl.merge_short_segments(min_chars=5)
assert result.language == "en"
assert result.total_duration == 10.0
class TestSubtitleTimelineSplitLong:
"""拆分长字幕片段测试."""
def test_short_segments_no_change(self):
segs = [SubtitleSegment(text="短句", start=0.0, end=1.0)]
tl = SubtitleTimeline(segments=segs)
result = tl.split_long_segments(max_chars=10)
def test_last_short_merged_to_previous(self):
"""最后剩余的短片段且不够min_chars,合并到上一段。"""
segments = [
SubtitleSegment(text="这是一句比较长的话", start=0, end=1.5), # 10字
SubtitleSegment(text="", start=1.5, end=1.6), # 1字
]
tl = SubtitleTimeline(segments=segments)
result = tl.merge_short_segments(min_chars=8)
# 第一句够长(10>=8),但尾只有1字不够,合并到上一句
assert result.segment_count == 1
assert result.segments[0].text == "这是一句比较长的话尾"
def test_original_not_modified(self):
segments = [SubtitleSegment(text="a", start=0, end=0.1)]
tl = SubtitleTimeline(segments=segments)
tl.merge_short_segments(min_chars=5)
assert len(tl.segments) == 1 # 原对象不变
class TestSplitLongSegments:
"""拆分过长字幕片段。"""
def test_short_segments_unchanged(self):
segments = [
SubtitleSegment(text="短句", start=0, end=1),
SubtitleSegment(text="另一句", start=1, end=2),
]
tl = SubtitleTimeline(segments=segments)
result = tl.split_long_segments(max_chars=20)
assert result.segment_count == 2
assert result.segments[0].text == "短句"
def test_split_by_sentence_punctuation(self):
text = "今天天气很好。我们出去散步吧!"
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
"""按句末标点拆分。"""
text = "这是第一句话。这是第二句话!这是第三句话?"
seg = SubtitleSegment(text=text, start=0, end=3.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=8)
assert result.segment_count >= 2
assert result.segments[0].text.endswith("")
assert result.total_chars == len(text)
def test_split_long_text_no_punctuation_hard_cut(self):
text = "一二三四五六七八九十十一十二十三十四十五十六十七十八"
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=8)
assert result.segment_count > 1
# 所有片段都不超过 max_chars
for s in result.segments:
assert s.char_count <= 8
# 合并起来应该等于原文
merged_text = "".join(s.text for s in result.segments)
assert merged_text == text
def test_split_time_proportional(self):
text = "一二三四。五六七八"
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
"""拆分后的时间按字数比例分配"""
text = "一二三四五六七八。二二三四五六七八。"
seg = SubtitleSegment(text=text, start=0, end=10.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=4)
assert result.segment_count >= 2
# 总时长保持一致
assert abs(result.segments[-1].end - 10.0) < 0.01
result = tl.split_long_segments(max_chars=8)
assert len(result.segments) >= 2
# 总时长不超过原时长
assert result.segments[-1].end <= seg.end
# 第一个片段的开始时间正确
assert result.segments[0].start == 0.0
def test_split_with_words(self):
words = [
SubtitleWord(text="", start=0.0, end=0.5),
SubtitleWord(text="", start=0.5, end=1.0),
SubtitleWord(text="", start=1.0, end=1.5),
SubtitleWord(text="", start=1.5, end=2.0),
]
text = "一二三四五六七八"
seg = SubtitleSegment(text=text, start=0.0, end=4.0, words=words)
def test_no_punctuation_hard_split(self):
"""没有标点时硬切。"""
text = "二三四五六七八九十十一十二十三十四十五十六十七十八十九二十"
seg = SubtitleSegment(text=text, start=0, end=5.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=4)
assert result.segment_count >= 2
# 词的总数应该不变
total_words = sum(len(s.words) for s in result.segments)
assert total_words == 4
result = tl.split_long_segments(max_chars=10)
assert len(result.segments) >= 2
merged = "".join(s.text for s in result.segments)
assert merged == text
def test_split_preserves_language(self):
seg = SubtitleSegment(text="test", start=0.0, end=1.0)
tl = SubtitleTimeline(segments=[seg], language="en")
result = tl.split_long_segments(max_chars=2)
def test_preserves_language_and_duration(self):
seg = SubtitleSegment(text="a" * 30, start=0, end=5)
tl = SubtitleTimeline(segments=[seg], language="en", total_duration=100.0)
result = tl.split_long_segments(max_chars=10)
assert result.language == "en"
assert result.total_duration == 100.0
def test_empty_timeline_unchanged(self):
tl = SubtitleTimeline()
result = tl.split_long_segments()
assert result.segment_count == 0
class TestSplitTextByPunctuation:
"""标点拆分静态方法测试."""
"""_split_text_by_punctuation 静态方法"""
def test_short_text_unchanged(self):
result = SubtitleTimeline._split_text_by_punctuation("你好", 10)
assert result == ["你好"]
def test_split_by_period(self):
text = "这是第一句话。这是第二句话。"
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
assert len(result) == 2
assert result[0] == "这是第一句话。"
assert result[1] == "这是第二句话。"
def test_split_by_exclamation(self):
text = "你好世界大家好!再见世界朋友们!"
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
assert len(result) >= 2
def test_split_by_question(self):
text = "今天天气好不好呢?今天天气很好呀。"
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
assert len(result) >= 2
def test_split_by_comma_when_long(self):
"""超过max_chars时,遇到逗号也会断开。"""
text = "这是很长的一句话,中间有个逗号,后面还有内容继续。"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
assert len(result) >= 2
def test_empty_text(self):
result = SubtitleTimeline._split_text_by_punctuation("", 10)
assert result == []
def test_short_text_no_split(self):
result = SubtitleTimeline._split_text_by_punctuation("短文本", 10)
assert len(result) == 1
def test_split_by_period(self):
result = SubtitleTimeline._split_text_by_punctuation("第一句。第二句。", 4)
assert len(result) >= 2
assert "" in result[0]
def test_split_by_exclamation(self):
result = SubtitleTimeline._split_text_by_punctuation("你好!世界!", 3)
assert len(result) >= 2
def test_split_by_comma_when_long(self):
text = "这是一个很长的句子,中间有逗号分隔,后面还有内容"
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
assert len(result) >= 2
def test_no_punctuation_hard_cut(self):
text = "一二三四五六七八九十十一十二十三十四十五"
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
assert len(result) > 1
for part in result:
assert len(part) <= 8
def test_sentence_end_triggers_split_when_half_max(self):
# 句末标点在 max_chars//2 以上就拆分
text = "你好世界。abcdefghij"
text = "一二三四五六七八九十一二三四五六七八九十"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
# "你好世界。"=5字 < 10但>=5(half),应该拆分
assert len(result) == 2
assert len(result[0]) == 10
assert len(result[1]) == 10
def test_english_punctuation(self):
result = SubtitleTimeline._split_text_by_punctuation("Hello, world! How are you?", 15)
assert len(result) >= 2
class TestMergeSegments:
"""_merge_segments 静态方法测试."""
"""_merge_segments 静态方法"""
def test_merge_empty(self):
def test_merge_two_segments(self):
segs = [
SubtitleSegment(text="你好", start=0.0, end=0.5),
SubtitleSegment(text="世界", start=0.5, end=1.0),
]
result = SubtitleTimeline._merge_segments(segs)
assert result.text == "你好世界"
assert result.start == 0.0
assert result.end == 1.0
def test_merge_empty_list(self):
result = SubtitleTimeline._merge_segments([])
assert result.text == ""
assert result.start == 0
assert result.end == 0
def test_merge_single(self):
def test_merge_single_segment(self):
seg = SubtitleSegment(text="test", start=1.0, end=2.0)
result = SubtitleTimeline._merge_segments([seg])
assert result.text == "test"
assert result.start == 1.0
assert result.end == 2.0
def test_merge_multiple(self):
def test_merge_preserves_words(self):
segs = [
SubtitleSegment(text="第一", start=0.0, end=1.0),
SubtitleSegment(text="第二", start=1.0, end=2.0),
SubtitleSegment(
text="你好",
start=0.0,
end=0.5,
words=[SubtitleWord(text="你好", start=0.0, end=0.5)],
),
SubtitleSegment(
text="世界",
start=0.5,
end=1.0,
words=[SubtitleWord(text="世界", start=0.5, end=1.0)],
),
]
result = SubtitleTimeline._merge_segments(segs)
assert result.text == "第一第二"
assert result.start == 0.0
assert result.end == 2.0
assert len(result.words) == 2
assert result.words[0].text == "你好"
assert result.words[1].text == "世界"
class TestMergeShortSegmentsEdgeCases:
"""merge_short_segments 边界情况深度测试."""
def test_all_segments_too_short_merge_into_one(self):
"""所有片段都很短,全部合并成一段."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="", start=0.0, end=0.5),
SubtitleSegment(text="", start=0.5, end=1.0),
SubtitleSegment(text="", start=1.0, end=1.5),
],
total_duration=1.5,
)
result = timeline.merge_short_segments(min_chars=8)
assert result.segment_count == 1
assert result.segments[0].text == "一二三"
assert result.segments[0].start == 0.0
assert result.segments[0].end == 1.5
def test_exactly_min_chars_no_merge(self):
"""刚好等于 min_chars,不合并."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0),
SubtitleSegment(text="一二三四五六七八", start=1.0, end=2.0),
],
total_duration=2.0,
)
result = timeline.merge_short_segments(min_chars=8)
assert result.segment_count == 2
def test_min_chars_one(self):
"""min_chars=1 时每个都够,不合并."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="", start=0.0, end=0.5),
SubtitleSegment(text="", start=0.5, end=1.0),
],
total_duration=1.0,
)
result = timeline.merge_short_segments(min_chars=1)
assert result.segment_count == 2
def test_merge_preserves_words_order(self):
"""合并后词的顺序保持正确."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(
text="你好",
start=0.0,
end=0.5,
words=[
SubtitleWord(text="", start=0.0, end=0.25),
SubtitleWord(text="", start=0.25, end=0.5),
],
),
SubtitleSegment(
text="世界",
start=0.5,
end=1.0,
words=[
SubtitleWord(text="", start=0.5, end=0.75),
SubtitleWord(text="", start=0.75, end=1.0),
],
),
],
total_duration=1.0,
)
result = timeline.merge_short_segments(min_chars=8)
assert result.segment_count == 1
words = result.segments[0].words
assert len(words) == 4
assert [w.text for w in words] == ["", "", "", ""]
def test_last_segment_short_merges_with_previous(self):
"""最后一段太短,合并到前一段."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0),
SubtitleSegment(text="", start=1.0, end=1.2),
],
total_duration=1.2,
)
result = timeline.merge_short_segments(min_chars=8)
assert result.segment_count == 1
assert result.segments[0].text == "一二三四五六七八九"
def test_min_chars_one_no_merge(self):
"""min_chars=1 时每个都够,不合并."""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="", start=0.0, end=0.5),
SubtitleSegment(text="", start=0.5, end=1.0),
SubtitleSegment(text="", start=1.0, end=1.5),
],
)
result = tl.merge_short_segments(min_chars=1)
assert result.segment_count == 3
def test_min_chars_very_large_all_merged(self):
"""min_chars 极大,全部合并成一段."""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="第一句", start=0.0, end=1.0),
SubtitleSegment(text="第二句", start=1.0, end=2.0),
SubtitleSegment(text="第三句", start=2.0, end=3.0),
],
)
result = tl.merge_short_segments(min_chars=100)
assert result.segment_count == 1
assert result.total_chars == tl.total_chars
def test_merge_preserves_word_level_info(self):
"""合并后词级信息完整保留,顺序正确."""
w1 = [SubtitleWord(text="", start=0.0, end=0.3)]
w2 = [SubtitleWord(text="", start=0.3, end=0.6)]
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="", start=0.0, end=0.3, words=w1),
SubtitleSegment(text="", start=0.3, end=0.6, words=w2),
],
)
result = tl.merge_short_segments(min_chars=4)
assert result.segment_count == 1
assert len(result.segments[0].words) == 2
assert result.segments[0].words[0].text == ""
assert result.segments[0].words[1].text == ""
class TestSplitLongSegmentsEdgeCases:
"""split_long_segments 边界情况深度测试."""
def test_mixed_long_and_short(self):
"""长短片段混合,只拆分长的."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="", start=0.0, end=0.5),
SubtitleSegment(
text="这是一段非常长的字幕内容需要被拆分",
start=0.5,
end=3.0,
),
SubtitleSegment(text="", start=3.0, end=3.5),
],
total_duration=3.5,
)
result = timeline.split_long_segments(max_chars=10)
assert result.segment_count > 3 # 中间那段被拆分了
assert result.segments[0].text == ""
assert result.segments[-1].text == ""
def test_split_total_duration_preserved(self):
"""拆分后总时长不变."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(
text="一二三四五六七八九十一二三四五六七八九十",
start=0.0,
end=10.0,
),
],
total_duration=10.0,
)
result = timeline.split_long_segments(max_chars=10)
assert result.segment_count > 1
assert result.segments[0].start == 0.0
assert abs(result.segments[-1].end - 10.0) < 0.01
def test_max_chars_very_small(self):
"""max_chars 很小,每个字都要拆."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="一二三", start=0.0, end=3.0),
],
total_duration=3.0,
)
result = timeline.split_long_segments(max_chars=1)
# 没有标点,硬切
assert result.segment_count >= 3
def test_empty_segments_list(self):
"""空片段列表不报错."""
timeline = SubtitleTimeline(segments=[], total_duration=0.0)
result = timeline.split_long_segments(max_chars=10)
assert result.segment_count == 0
def test_exactly_max_chars_no_split(self):
"""恰好等于 max_chars 不拆分."""
text = "一二三四五六七八九十" # 10字
seg = SubtitleSegment(text=text, start=0.0, end=5.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=10)
assert result.segment_count == 1
def test_one_char_over_triggers_split(self):
"""超过1个字符就触发拆分."""
text = "一二三四五六七八九十1" # 11字
seg = SubtitleSegment(text=text, start=0.0, end=5.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 2
def test_mixed_short_and_long_segments(self):
"""长短片段混合,只拆分超长的."""
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text="", start=0.0, end=0.5),
SubtitleSegment(
text="这是一段很长很长需要拆分的字幕内容",
start=0.5,
end=3.0,
),
SubtitleSegment(text="", start=3.0, end=3.5),
],
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count > 3
assert result.segments[0].text == ""
assert result.segments[-1].text == ""
def test_total_chars_preserved_after_split(self):
"""拆分后总字数保持不变."""
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
tl = SubtitleTimeline(segments=[seg])
result = tl.split_long_segments(max_chars=10)
assert result.total_chars == len(text)
class TestSplitTextByPunctuationDeep:
"""_split_text_by_punctuation 深度测试."""
def test_multiple_punctuation_types(self):
"""多种标点符号混合."""
text = "你好!世界?测试,哈哈。"
result = SubtitleTimeline._split_text_by_punctuation(text, 5)
assert len(result) >= 2
def test_consecutive_punctuation(self):
"""连续标点符号."""
text = "你好!!!测试。。。"
result = SubtitleTimeline._split_text_by_punctuation(text, 3)
assert len(result) >= 1
# 确保所有字符都保留
assert "".join(result) == text
def test_no_punctuation_long_text(self):
"""长文本没有标点,硬切."""
text = "一二三四五六七八九十一二三四五六七八九十"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
assert len(result) >= 2
assert "".join(result) == text
# 每段不超过 max_chars
for part in result[:-1]: # 最后一段可能短一些
assert len(part) <= 10
def test_punctuation_at_start(self):
"""标点在开头."""
text = ",你好世界"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
assert "".join(result) == text
def test_punctuation_at_end(self):
"""标点在结尾."""
text = "你好世界!"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
assert "".join(result) == text
assert result[-1].endswith("")
class TestSplitTextByPunctuationEdgeCases:
"""_split_text_by_punctuation 边界场景补充."""
def test_colon_semicolon_splits(self):
"""冒号分号也能触发拆分."""
text = "第一段:第二段;第三段"
result = SubtitleTimeline._split_text_by_punctuation(text, 5)
assert len(result) >= 2
def test_punctuation_at_start(self):
"""标点在开头不崩溃,字符完整保留."""
text = ",你好世界"
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
assert "".join(result) == text
def test_consecutive_punctuation(self):
"""连续标点符号,字符完整保留."""
text = "你好!!!测试。。。"
result = SubtitleTimeline._split_text_by_punctuation(text, 3)
assert "".join(result) == text
def test_single_character_text(self):
"""单字符文本不拆分."""
result = SubtitleTimeline._split_text_by_punctuation("", 10)
assert len(result) == 1
assert result[0] == ""
def test_only_punctuation(self):
"""纯标点符号文本不崩溃."""
result = SubtitleTimeline._split_text_by_punctuation("。。。", 10)
assert isinstance(result, list)
assert "".join(result) == "。。。"
def test_mixed_fullwidth_halfwidth_punctuation(self):
"""全角半角标点混合."""
text = "你好,世界!测试?完成"
result = SubtitleTimeline._split_text_by_punctuation(text, 5)
assert "".join(result) == text
assert len(result) >= 2
class TestSubtitleTimelineProperties:
"""SubtitleTimeline 属性计算深度测试."""
def test_total_chars_empty(self):
"""空时间轴 total_chars 为 0."""
timeline = SubtitleTimeline(segments=[])
assert timeline.total_chars == 0
def test_total_chars_sum(self):
"""total_chars 等于所有片段字数之和."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好", start=0, end=1),
SubtitleSegment(text="世界", start=1, end=2),
SubtitleSegment(text="123", start=2, end=3),
],
)
assert timeline.total_chars == 2 + 2 + 3
def test_segment_count(self):
"""segment_count 正确."""
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="a", start=0, end=1),
SubtitleSegment(text="b", start=1, end=2),
],
)
assert timeline.segment_count == 2
def test_empty_segment_char_count(self):
"""空片段 char_count 为 0."""
seg = SubtitleSegment(text="", start=0.0, end=1.0)
assert seg.char_count == 0
+590
View File
@@ -176,3 +176,593 @@ class TestGenerateAssFromTimeline:
assert "0:00:01.25" in content
# 1:01:01.00 格式(3661秒 = 1小时1分1秒)
assert "1:01:01.00" in content
# ── _hex_to_ass_color 深度测试 ──────────────────────────────────────────────
class TestHexToAssColor:
"""_hex_to_ass_color 颜色转换测试"""
def test_standard_hex_with_hash(self):
"""带#号的标准6位HEX"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
# #RRGGBB → &HBBGGRR
assert _hex_to_ass_color("#FF0000") == "&H0000FF" # 红
assert _hex_to_ass_color("#00FF00") == "&H00FF00" # 绿
assert _hex_to_ass_color("#0000FF") == "&HFF0000" # 蓝
def test_standard_hex_without_hash(self):
"""不带#号的6位HEX"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("FF0000") == "&H0000FF"
assert _hex_to_ass_color("00FF00") == "&H00FF00"
assert _hex_to_ass_color("0000FF") == "&HFF0000"
def test_white_and_black(self):
"""白色和黑色"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("#FFFFFF") == "&HFFFFFF" # 白
assert _hex_to_ass_color("#000000") == "&H000000" # 黑
def test_lowercase_hex(self):
"""小写字母HEX"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
result = _hex_to_ass_color("#aabbcc")
# 输出是大写的
assert result == "&HCCBBAA"
def test_mixed_case_hex(self):
"""大小写混合HEX"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
result = _hex_to_ass_color("#AaBbCc")
assert result == "&HCCBBAA"
def test_short_hex_returns_default(self):
"""长度不足6位返回默认白色"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("#FFF") == "&H00FFFFFF" # 3位
assert _hex_to_ass_color("FF") == "&H00FFFFFF" # 2位
def test_long_hex_returns_default(self):
"""长度超过6位返回默认白色"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("#AABBCCDD") == "&H00FFFFFF"
def test_empty_string_returns_default(self):
"""空字符串返回默认白色"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("") == "&H00FFFFFF"
def test_gray_color(self):
"""灰色调"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("#808080") == "&H808080"
# ── _position_to_ass_alignment 测试 ────────────────────────────────────────
class TestPositionToAssAlignment:
"""_position_to_ass_alignment 位置映射测试"""
def test_top_maps_to_8(self):
"""顶部 → 8"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("top") == 8
def test_center_maps_to_5(self):
"""居中 → 5"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("center") == 5
def test_bottom_maps_to_2(self):
"""底部 → 2"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("bottom") == 2
def test_unknown_position_defaults_to_bottom(self):
"""未知位置默认底部(2)"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("left") == 2
assert _position_to_ass_alignment("right") == 2
assert _position_to_ass_alignment("middle") == 2
def test_empty_string_defaults_to_bottom(self):
"""空字符串默认底部"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("") == 2
def test_uppercase_not_matched(self):
"""大写不匹配,走默认"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("TOP") == 2
assert _position_to_ass_alignment("CENTER") == 2
# ── _format_ass_time 测试 ──────────────────────────────────────────────────
class TestFormatAssTime:
"""_format_ass_time 时间格式化测试"""
def test_zero_seconds(self):
"""0秒"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(0.0) == "0:00:00.00"
def test_fractional_seconds(self):
"""小数秒"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(0.5) == "0:00:00.50"
assert _format_ass_time(1.25) == "0:00:01.25"
def test_whole_seconds(self):
"""整秒"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(5.0) == "0:00:05.00"
assert _format_ass_time(30.0) == "0:00:30.00"
def test_minutes_level(self):
"""分钟级"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(60.0) == "0:01:00.00"
assert _format_ass_time(90.5) == "0:01:30.50"
assert _format_ass_time(599.0) == "0:09:59.00"
def test_hours_level(self):
"""小时级"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(3600.0) == "1:00:00.00"
assert _format_ass_time(3661.5) == "1:01:01.50"
assert _format_ass_time(7384.0) == "2:03:04.00"
def test_centisecond_precision(self):
"""百分秒精度"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(1.01) == "0:00:01.01"
assert _format_ass_time(1.99) == "0:00:01.99"
def test_sub_centisecond_truncated(self):
"""毫秒级精度会被格式化截断到百分秒"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
# Python 的 %05.2f 会四舍五入
result = _format_ass_time(1.123)
assert result.startswith("0:00:01.")
assert len(result.split(".")[-1]) == 2
# ── _escape_ass_text 测试 ──────────────────────────────────────────────────
class TestEscapeAssText:
"""_escape_ass_text 转义测试"""
def test_plain_text_unchanged(self):
"""普通文本不改变"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("你好世界") == "你好世界"
assert _escape_ass_text("Hello World") == "Hello World"
def test_newline_to_ass_newline(self):
"""\\n 转 \\N"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("第一行\n第二行") == "第一行\\N第二行"
def test_crlf_to_ass_newline(self):
"""\\r\\n 转 \\N"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("第一行\r\n第二行") == "第一行\\N第二行"
def test_cr_to_ass_newline(self):
"""\\r 转 \\N"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("第一行\r第二行") == "第一行\\N第二行"
def test_curly_braces_escaped(self):
"""花括号转圆括号(防止ASS标签注入)"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("{text}") == "(text)"
assert _escape_ass_text("{{double}}") == "((double))"
def test_mixed_escapes(self):
"""混合转义"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
text = "你好\n世界{测试}\r\n结束"
result = _escape_ass_text(text)
assert "\\N" in result
assert "(测试)" in result
assert "\n" not in result
assert "{" not in result
def test_empty_string(self):
"""空字符串"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("") == ""
def test_only_braces(self):
"""只有花括号"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("{}") == "()"
# ── _wrap_text 补充测试 ────────────────────────────────────────────────────
class TestWrapTextMore:
"""_wrap_text 补充边界测试"""
def test_empty_string_returns_empty_list(self):
"""空字符串返回空列表"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
assert _wrap_text("", 10) == [""]
def test_single_character(self):
"""单字符"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
assert _wrap_text("", 10) == [""]
def test_max_chars_equals_one(self):
"""max_chars=1 每个字一行"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
result = _wrap_text("一二三四", 1)
assert len(result) == 4
assert result[0] == ""
assert result[1] == ""
def test_punctuation_in_middle(self):
"""标点在正中间优先从标点断开"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
# 前10字内有句号,优先在句号后断开
text = "一二三四五六七八九十。后面的内容继续写下去"
result = _wrap_text(text, 15)
# 第一行应该包含句号
assert "" in result[0]
def test_punctuation_at_start_ignored(self):
"""标点在开头位置(前半部分)不会触发断开"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "。一二三四五六七八九十"
result = _wrap_text(text, 10)
# 标点在第0位,不会在max_chars//2到max_chars范围内
assert len(result) == 2
assert len(result[0]) == 10
def test_no_punctuation_long_text(self):
"""完全没有标点的长文本硬切"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
result = _wrap_text(text, 10)
assert len(result) == 3
assert len(result[0]) == 10
assert len(result[1]) == 10
assert len(result[2]) == 5
def test_exactly_two_lines(self):
"""恰好两行"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "" * 20
result = _wrap_text(text, 10)
assert len(result) == 2
assert len(result[0]) == 10
assert len(result[1]) == 10
def test_three_lines(self):
"""三行"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "" * 25
result = _wrap_text(text, 10)
assert len(result) == 3
assert len(result[0]) == 10
assert len(result[1]) == 10
assert len(result[2]) == 5
def test_multiple_punctuation_points(self):
"""多个标点,选择最后一个合适的"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "你好。再见。谢谢。抱歉。好的不行了"
result = _wrap_text(text, 12)
# 应该在最靠后的(在范围内的)标点处断开
assert "" in result[0]
def test_english_punctuation_wrap(self):
"""英文标点也会触发换行"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "Hello world. This is a test sentence."
result = _wrap_text(text, 20)
assert len(result) >= 2
assert result[0].endswith(".") or "." in result[0]
def test_total_length_preserved(self):
"""换行后总字符数不变"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "这是一段用于测试换行功能的中文文本,包含了各种标点符号。看看效果如何?"
result = _wrap_text(text, 10)
assert "".join(result) == text
# ── generate_ass_from_timeline 补充测试 ────────────────────────────────────
class TestGenerateAssFromTimelineMore:
"""generate_ass_from_timeline 补充测试"""
def test_single_segment(self):
"""单段字幕"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="你好", start=0.0, end=1.0)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "single.ass"
generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
content = output.read_text(encoding="utf-8")
assert "Dialogue:" in content
assert "你好" in content
assert content.count("Dialogue:") == 1
def test_multiple_segments(self):
"""多段字幕"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text=f"{i}", start=float(i), end=float(i + 1)) for i in range(5)],
total_duration=5.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "multi.ass"
generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
content = output.read_text(encoding="utf-8")
assert content.count("Dialogue:") == 5
def test_long_text_auto_wrap(self):
"""长字幕自动换行"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
long_text = "这是一段非常非常长的字幕文本,用来测试自动换行功能是否正常工作。"
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text=long_text, start=0, end=5)],
total_duration=5.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "wrap.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"max_chars_per_line": 10},
)
content = output.read_text(encoding="utf-8")
# 应该包含 \N 换行符
assert "\\N" in content
def test_custom_color_hex(self):
"""自定义颜色正确转换"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "red.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"color": "#FF0000"},
)
content = output.read_text(encoding="utf-8")
# 红色 #FF0000 → &H0000FF (BBGGRR)
assert "&H0000FF" in content
def test_position_top(self):
"""顶部位置"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="顶部", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "top.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"position": "top"},
)
content = output.read_text(encoding="utf-8")
# 顶部对齐是 \\an8 → Style 中 Alignment=8
assert "1,1.5,0,8," in content or ",0,8," in content
def test_position_center(self):
"""居中位置"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="居中", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "center.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"position": "center"},
)
content = output.read_text(encoding="utf-8")
# 居中对齐是 Alignment=5
assert ",0,5," in content
def test_custom_font_size(self):
"""自定义字体大小"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "big.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"size": 48},
)
content = output.read_text(encoding="utf-8")
# Style 行中字号应该是48
assert "Default,思源黑体,48," in content
def test_720p_resolution(self):
"""720p分辨率"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="720p", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "720p.ass"
generate_ass_from_timeline(output, timeline, video_width=1280, video_height=720)
content = output.read_text(encoding="utf-8")
assert "PlayResX: 1280" in content
assert "PlayResY: 720" in content
def test_special_characters_in_text(self):
"""字幕文本含特殊字符(花括号、换行)"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="你好{tag}\n世界", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "special.ass"
generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
content = output.read_text(encoding="utf-8")
# 花括号被转义
assert "(tag)" in content
# 换行被转义成 \N
assert "\\N" in content
def test_output_path_creates_parent_dirs(self):
"""自动创建父目录"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="测试", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "nested" / "deep" / "out.ass"
result = generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
assert result.exists()
assert result.parent.exists()
def test_returns_output_path(self):
"""返回值等于输出路径"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="返回", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "ret.ass"
result = generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
assert result == output
+58
View File
@@ -32,3 +32,61 @@ class TestTagCreate:
def test_create_has_created_at(self):
tag = Tag.create(user_id="user-1", name="美食")
assert tag.created_at is not None
class TestTagExtended:
"""Tag 深度补充测试"""
def test_create_id_is_unique(self):
"""多次创建生成不同的 id"""
tag1 = Tag.create(user_id="u1", name="标签1")
tag2 = Tag.create(user_id="u1", name="标签2")
assert tag1.id != tag2.id
assert len(tag1.id) == 32
assert len(tag2.id) == 32
def test_create_id_is_hex(self):
"""id 是十六进制字符串"""
tag = Tag.create(user_id="u1", name="测试")
int(tag.id, 16) # 不抛错就是合法 hex
def test_slots_no_extra_attributes(self):
"""slots=True 不能添加新属性"""
import pytest
tag = Tag.create(user_id="u1", name="测试")
with pytest.raises(AttributeError):
tag.new_attr = "value" # type: ignore[attr-defined]
def test_create_very_long_name(self):
long_name = "标签" * 50
tag = Tag.create(user_id="u1", name=long_name)
assert tag.name == long_name
assert len(tag.name) == 100
def test_create_unicode_name(self):
tag = Tag.create(user_id="u1", name="🔥热门标签✨")
assert tag.name == "🔥热门标签✨"
def test_create_name_only_spaces_between_text(self):
"""中间有空格的标签名正常保留"""
tag = Tag.create(user_id="u1", name=" 美食 探店 ")
assert tag.name == "美食 探店"
def test_create_different_user_ids(self):
tag1 = Tag.create(user_id="user-001", name="标签")
tag2 = Tag.create(user_id="user-999", name="标签")
assert tag1.user_id == "user-001"
assert tag2.user_id == "user-999"
assert tag1.id != tag2.id
def test_name_is_string(self):
tag = Tag.create(user_id="u1", name="12345")
assert isinstance(tag.name, str)
assert tag.name == "12345"
def test_equality(self):
"""两个相同属性的 tag 不相等(id 不同)"""
tag1 = Tag.create(user_id="u1", name="同名")
tag2 = Tag.create(user_id="u1", name="同名")
assert tag1 != tag2
+139
View File
@@ -151,3 +151,142 @@ class TestTemplateCategory:
def test_category_has_timestamp(self):
cat = TemplateCategory(id="cat-1", user_id="u1", name="风景")
assert cat.created_at is not None
class TestTemplateExtended:
"""Template 深度补充测试"""
def test_tags_independence(self):
"""不同模板的 tags 列表互不影响"""
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take")
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
t1.tags.append("新标签")
assert "新标签" not in t2.tags
assert len(t2.tags) == 0
def test_title_config_independence(self):
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take")
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
t1.title_config["key"] = "val"
assert "key" not in t2.title_config
def test_subtitle_config_independence(self):
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take")
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
t1.subtitle_config["key"] = "val"
assert "key" not in t2.subtitle_config
def test_bgm_config_independence(self):
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take")
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
t1.bgm_config["key"] = "val"
assert "key" not in t2.bgm_config
def test_segments_independence(self):
segs = [
TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=2),
]
t1 = Template(id="t1", user_id="u1", name="n1", mode="one_take", segments=segs)
t2 = Template(id="t2", user_id="u1", name="n2", mode="one_take")
assert len(t2.segments) == 0
def test_empty_segments(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", segments=[])
assert t.segments == []
def test_many_segments(self):
segs = [
TemplateSegment(id=f"s{i}", template_id="t1", segment_order=i, duration_min=1, duration_max=3)
for i in range(30)
]
t = Template(id="t1", user_id="u1", name="n", mode="one_take", segments=segs)
assert len(t.segments) == 30
assert t.segments[0].segment_order == 0
assert t.segments[29].segment_order == 29
def test_zero_estimated_duration(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", estimated_duration=0.0)
assert t.estimated_duration == 0.0
def test_negative_estimated_duration(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", estimated_duration=-1.0)
assert t.estimated_duration == -1.0
def test_large_estimated_duration(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", estimated_duration=9999.9)
assert t.estimated_duration == 9999.9
def test_empty_category(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", category="")
assert t.category == ""
def test_custom_category(self):
t = Template(id="t1", user_id="u1", name="n", mode="one_take", category="美食探店")
assert t.category == "美食探店"
def test_empty_name(self):
t = Template(id="t1", user_id="u1", name="", mode="one_take")
assert t.name == ""
def test_unicode_name(self):
t = Template(id="t1", user_id="u1", name="🎬 美食探店 · Vlog模板", mode="one_take")
assert "🎬" in t.name
assert "美食探店" in t.name
def test_special_characters_in_name(self):
special = "模!@#$%"
t = Template(id="t1", user_id="u1", name=special, mode="one_take")
assert t.name == special
def test_long_name(self):
long_name = "模板" * 100
t = Template(id="t1", user_id="u1", name=long_name, mode="one_take")
assert t.name == long_name
assert len(t.name) == 200
class TestTemplateSegmentExtended:
"""TemplateSegment 深度补充测试"""
def test_zero_duration(self):
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=0, duration_max=0)
assert seg.duration_min == 0
assert seg.duration_max == 0
def test_negative_duration_min(self):
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=-1, duration_max=5)
assert seg.duration_min == -1
def test_negative_duration_max(self):
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=-5)
assert seg.duration_max == -5
def test_large_duration(self):
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=0, duration_max=9999.9)
assert seg.duration_max == 9999.9
def test_negative_order(self):
seg = TemplateSegment(id="s1", template_id="t1", segment_order=-5, duration_min=1, duration_max=3)
assert seg.segment_order == -5
def test_large_order(self):
seg = TemplateSegment(id="s1", template_id="t1", segment_order=999, duration_min=1, duration_max=3)
assert seg.segment_order == 999
def test_material_type_none(self):
seg = TemplateSegment(
id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=3, material_type=None
)
assert seg.material_type is None
def test_material_type_empty_string(self):
seg = TemplateSegment(
id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=3, material_type=""
)
assert seg.material_type == ""
def test_material_type_unicode(self):
seg = TemplateSegment(
id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=3, material_type="风景"
)
assert seg.material_type == "风景"
+78
View File
@@ -125,3 +125,81 @@ class TestEditTemplateVersionSlots:
v = EditTemplateVersion.create(template_id="t1", version=1)
with pytest.raises(AttributeError):
v.nonexistent_field = "value" # type: ignore[attr-defined]
class TestEditTemplateVersionExtended:
"""EditTemplateVersion 深度补充测试"""
def test_id_is_hex(self):
"""id 是十六进制字符串"""
v = EditTemplateVersion.create(template_id="t1", version=1)
int(v.id, 16) # 不抛错就是合法 hex
def test_ids_are_unique(self):
"""不同版本的 id 不同"""
v1 = EditTemplateVersion.create(template_id="t1", version=1)
v2 = EditTemplateVersion.create(template_id="t1", version=2)
assert v1.id != v2.id
def test_zero_version(self):
"""version=0 也能创建"""
v = EditTemplateVersion.create(template_id="t1", version=0)
assert v.version == 0
def test_negative_version(self):
"""负数 version 也能创建(领域层不做业务校验)"""
v = EditTemplateVersion.create(template_id="t1", version=-1)
assert v.version == -1
def test_large_version(self):
v = EditTemplateVersion.create(template_id="t1", version=9999)
assert v.version == 9999
def test_empty_template_id(self):
v = EditTemplateVersion.create(template_id="", version=1)
assert v.template_id == ""
def test_empty_name(self):
v = EditTemplateVersion.create(template_id="t1", version=1, name="")
assert v.name == ""
def test_empty_change_note(self):
v = EditTemplateVersion.create(template_id="t1", version=1, change_note="")
assert v.change_note == ""
def test_empty_published_by(self):
v = EditTemplateVersion.create(template_id="t1", version=1, published_by="")
assert v.published_by == ""
def test_unicode_name(self):
v = EditTemplateVersion.create(template_id="t1", version=1, name="🎨 V5 优化版")
assert "🎨" in v.name
assert "V5" in v.name
def test_long_name(self):
long_name = "版本" * 50
v = EditTemplateVersion.create(template_id="t1", version=1, name=long_name)
assert v.name == long_name
assert len(v.name) == 100
def test_config_complex_nested(self):
config = {
"layer1": {
"layer2": {
"layer3": [1, 2, 3],
}
}
}
v = EditTemplateVersion.create(template_id="t1", version=1, config=config)
assert v.config["layer1"]["layer2"]["layer3"] == [1, 2, 3]
def test_clip_configs_empty_list(self):
v = EditTemplateVersion.create(template_id="t1", version=1, clip_configs=[])
assert v.clip_configs == []
def test_clip_configs_many(self):
clips = [{"clip_id": i, "duration": float(i)} for i in range(50)]
v = EditTemplateVersion.create(template_id="t1", version=1, clip_configs=clips)
assert len(v.clip_configs) == 50
assert v.clip_configs[0]["clip_id"] == 0
assert v.clip_configs[49]["clip_id"] == 49
+85
View File
@@ -325,3 +325,88 @@ class TestSplitTextEdgeCases:
assert len(result) > 1
for seg in result:
assert len(seg) <= 20
# ── 更多边界场景补充 ─────────────────────────────────────────────────────────
class TestSplitTextMoreEdgeCases:
"""更多边界场景补充"""
def test_max_chars_one(self):
"""max_chars=1 每个字符一段"""
text = "一二三四五"
result = split_text(text, max_chars=1)
assert len(result) == 5
for seg in result:
assert len(seg) == 1
def test_consecutive_newlines(self):
"""连续多个换行符"""
text = "第一段\n\n\n第二段\n\n第三段"
result = split_text(text, max_chars=100)
# 合并后应该是一段(内容不长且合并逻辑会被合并)
assert len(result) >= 1
assert "第一段" in result[0]
for seg in result:
assert len(seg) <= 100
def test_only_newlines_only(self):
"""只有换行符(纯空白被strip掉返回空"""
assert split_text("\n\n\n\n") == []
def test_leading_trailing_whitespace(self):
"""首尾空白被去除"""
text = " 你好世界。 "
result = split_text(text, max_chars=100)
assert result == ["你好世界。"]
def test_very_long_single_sentence_many_segments(self):
"""超长单句被切成很多段"""
text = "" * 1000
result = split_text(text, max_chars=100)
assert len(result) == 10
for seg in result:
assert len(seg) == 100
def test_mixed_punctuation_types(self):
"""全角半角标点混合"""
text = "你好!再见。谢谢?抱歉;好的"
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_last_segment_short_merged_to_previous(self):
"""尾部极短段被合并到前一段"""
# 构造第一段接近max_chars,结尾有个短句尾巴
long_part = "一二三四五六七八九十" * 9 + "" # ~90字
tail = "" # 1字
text = long_part + tail
result = split_text(text, max_chars=100)
# 尾巴应该被合并
combined = "".join(result)
assert combined == text.strip()
assert len(result) <= 2
def test_all_short_sentences_merged_into_one(self):
"""大量短句全部合并成一段"""
sentences = ["你好。", "我好。", "他好。", "大家好。", "才是真的好。"]
text = "".join(sentences)
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_punctuation_only_long(self):
"""很长的纯标点文本"""
text = "" * 200
result = split_text(text, max_chars=50)
assert len(result) >= 4
for seg in result:
assert len(seg) <= 50
def test_tab_not_sentence_end(self):
"""制表符不是句子结束符"""
text = "这是一段\t包含制表符的文本内容" + "" * 100
result = split_text(text, max_chars=50)
# 制表符不在句子结束符集合中,不会触发分段
# 制表符会保留在分段内容中
has_tab = any("\t" in seg for seg in result)
assert has_tab
+81
View File
@@ -76,3 +76,84 @@ class TestTitleLibraryItem:
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t")
assert item.created_at is not None
assert item.updated_at is not None
def test_tags_independence_between_instances(self):
"""不同实例的 tags 列表互不影响"""
item1 = TitleLibraryItem(id="t1", user_id="u1", name="n1", text="t")
item2 = TitleLibraryItem(id="t2", user_id="u1", name="n2", text="t")
item1.tags.append("新标签")
assert "新标签" not in item2.tags
assert len(item2.tags) == 0
def test_metadata_independence_between_instances(self):
"""不同实例的 metadata_ 字典互不影响"""
item1 = TitleLibraryItem(id="t1", user_id="u1", name="n1", text="t")
item2 = TitleLibraryItem(id="t2", user_id="u1", name="n2", text="t")
item1.metadata_["key"] = "value"
assert "key" not in item2.metadata_
def test_zero_usage_count(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", usage_count=0)
assert item.usage_count == 0
def test_large_usage_count(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", usage_count=999999)
assert item.usage_count == 999999
def test_negative_usage_count(self):
"""负数使用次数也能存(领域层不做业务校验)"""
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", usage_count=-1)
assert item.usage_count == -1
def test_empty_text(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="")
assert item.text == ""
def test_long_text(self):
long_text = "标题" * 1000
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text=long_text)
assert item.text == long_text
assert len(item.text) == 2000
def test_empty_name(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="", text="t")
assert item.name == ""
def test_special_characters_in_name_and_text(self):
special = "!@#$%^&*()_+-=[]{}|;':\",./<>?\n\t"
item = TitleLibraryItem(id="t1", user_id="u1", name=special, text=special)
assert item.name == special
assert item.text == special
def test_unicode_in_name_and_text(self):
item = TitleLibraryItem(
id="t1",
user_id="u1",
name="中文标题🔥emoji",
text="支持各种文字:中文、English、日本語、한국어",
)
assert "中文" in item.name
assert "🔥" in item.name
assert "日本語" in item.text
def test_many_tags(self):
tags = [f"tag_{i}" for i in range(100)]
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", tags=tags)
assert len(item.tags) == 100
assert item.tags[0] == "tag_0"
assert item.tags[99] == "tag_99"
def test_is_active_toggle(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", is_active=True)
item.is_active = False
assert item.is_active is False
item.is_active = True
assert item.is_active is True
def test_empty_description(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", description="")
assert item.description == ""
def test_custom_category(self):
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t", category="自定义分类")
assert item.category == "自定义分类"
+136
View File
@@ -202,3 +202,139 @@ class TestTtsConfigClamp:
config = TtsConfig.parse(data)
assert isinstance(config.volume, float)
assert config.volume == 1.0
class TestTtsConfigTextEdge:
"""文本字段边界测试."""
def test_long_text_preserved(self):
long_text = "配音文本" * 500
data = {"enabled": True, "voice_id": "v1", "text": long_text}
config = TtsConfig.parse(data)
assert config.text == long_text
assert len(config.text) == 2000
def test_unicode_text_preserved(self):
data = {"enabled": True, "voice_id": "v1", "text": "こんにちは世界🎵"}
config = TtsConfig.parse(data)
assert config.text == "こんにちは世界🎵"
def test_special_chars_text_preserved(self):
data = {"enabled": True, "voice_id": "v1", "text": "line1\nline2\t tab <>&\"'"}
config = TtsConfig.parse(data)
assert config.text == "line1\nline2\t tab <>&\"'"
def test_empty_text_ok(self):
data = {"enabled": True, "voice_id": "v1", "text": ""}
config = TtsConfig.parse(data)
assert config.text == ""
def test_text_none_fallback(self):
data = {"enabled": True, "voice_id": "v1", "text": None}
config = TtsConfig.parse(data)
assert config.text == ""
class TestTtsConfigVoiceIdEdge:
"""voice_id 边界测试."""
def test_very_long_voice_id_preserved(self):
long_id = "voice_" + "x" * 200
data = {"enabled": True, "voice_id": long_id}
config = TtsConfig.parse(data)
assert config.voice_id == long_id
def test_voice_id_empty_string_ok(self):
data = {"enabled": True, "voice_id": ""}
config = TtsConfig.parse(data)
assert config.voice_id == ""
def test_voice_id_unicode_ok(self):
data = {"enabled": True, "voice_id": "音色_测试_001"}
config = TtsConfig.parse(data)
assert config.voice_id == "音色_测试_001"
class TestTtsConfigClampEdge:
"""钳制边界附近值测试."""
def test_speed_just_below_min_clamped(self):
data = {"enabled": True, "speed": 0.499}
config = TtsConfig.parse(data)
assert config.speed == 0.5
def test_speed_just_above_max_clamped(self):
data = {"enabled": True, "speed": 2.001}
config = TtsConfig.parse(data)
assert config.speed == 2.0
def test_pitch_just_below_min_clamped(self):
data = {"enabled": True, "pitch": -12.1}
config = TtsConfig.parse(data)
assert config.pitch == -12
def test_pitch_just_above_max_clamped(self):
data = {"enabled": True, "pitch": 12.1}
config = TtsConfig.parse(data)
assert config.pitch == 12
def test_volume_just_below_min_clamped(self):
data = {"enabled": True, "volume": -0.001}
config = TtsConfig.parse(data)
assert config.volume == 0.0
def test_volume_just_above_max_clamped(self):
data = {"enabled": True, "volume": 1.001}
config = TtsConfig.parse(data)
assert config.volume == 1.0
def test_direct_construct_clamp_speed(self):
config = TtsConfig(enabled=True, speed=0.1)
config._clamp()
assert config.speed == 0.5
def test_direct_construct_clamp_pitch_volume(self):
config = TtsConfig(enabled=True, pitch=-20, volume=2.0)
config._clamp()
assert config.pitch == -12
assert config.volume == 1.0
class TestTtsConfigAlignOverlapEdge:
"""对齐与叠加模式边界."""
def test_align_mode_empty_string_fallback(self):
data = {"enabled": True, "align_mode": ""}
config = TtsConfig.parse(data)
assert config.align_mode == "full"
def test_overlap_mode_empty_string_fallback(self):
data = {"enabled": True, "overlap_mode": ""}
config = TtsConfig.parse(data)
assert config.overlap_mode == "replace"
def test_align_mode_case_sensitive(self):
data = {"enabled": True, "align_mode": "SUBTITLE"}
config = TtsConfig.parse(data)
assert config.align_mode == "full"
class TestTtsConfigEquality:
"""相等性与独立性测试."""
def test_same_config_equal(self):
c1 = TtsConfig(enabled=True, voice_id="v1", speed=1.5)
c2 = TtsConfig(enabled=True, voice_id="v1", speed=1.5)
assert c1 == c2
def test_different_config_not_equal(self):
c1 = TtsConfig(enabled=True, voice_id="v1")
c2 = TtsConfig(enabled=True, voice_id="v2")
assert c1 != c2
def test_modify_one_does_not_affect_other(self):
c1 = TtsConfig(enabled=True, voice_id="v1")
c2 = TtsConfig(enabled=True, voice_id="v1")
c2.speed = 2.0
assert c1.speed == 1.0
assert c1 != c2
+85
View File
@@ -166,3 +166,88 @@ class TestVerificationCodeTypes:
vc = VerificationCode.create("test@example.com", code_type)
assert vc.code_type == code_type
assert vc.is_valid is True
class TestVerificationCodeExtended:
"""VerificationCode 深度补充测试"""
def test_id_is_hex(self):
vc = VerificationCode.create("test@example.com", "email_bind")
int(vc.id, 16)
def test_ids_are_unique(self):
vc1 = VerificationCode.create("test@example.com", "email_bind")
vc2 = VerificationCode.create("test@example.com", "email_bind")
assert vc1.id != vc2.id
def test_slots_no_extra_attrs(self):
import pytest
vc = VerificationCode.create("test@example.com", "email_bind")
with pytest.raises(AttributeError):
vc.new_field = "value" # type: ignore[attr-defined]
def test_custom_code_non_numeric(self):
"""自定义 code 可以是非数字"""
vc = VerificationCode.create("test@example.com", "email_bind", custom_code="abcdef")
assert vc.code == "abcdef"
def test_custom_code_short(self):
vc = VerificationCode.create("test@example.com", "email_bind", custom_code="12")
assert vc.code == "12"
def test_custom_code_long(self):
long_code = "1" * 20
vc = VerificationCode.create("test@example.com", "email_bind", custom_code=long_code)
assert vc.code == long_code
assert len(vc.code) == 20
def test_ttl_zero_expires_immediately(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=0)
# ttl=0 时 expires_at = now,可能已过期或刚好
assert vc.expires_at is not None
def test_very_long_ttl(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=86400)
assert vc.is_expired is False
def test_negative_ttl_expired(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-100)
assert vc.is_expired is True
def test_attempts_starts_at_zero(self):
vc = VerificationCode.create("test@example.com", "email_bind")
assert vc.attempts == 0
def test_increment_attempts_returns_none(self):
vc = VerificationCode.create("test@example.com", "email_bind")
result = vc.increment_attempts()
assert result is None
assert vc.attempts == 1
def test_mark_used_returns_none(self):
vc = VerificationCode.create("test@example.com", "email_bind")
result = vc.mark_used()
assert result is None
assert vc.is_used is True
def test_code_type_empty_string(self):
vc = VerificationCode.create("test@example.com", "")
assert vc.code_type == ""
def test_recipient_empty_string(self):
vc = VerificationCode.create("", "email_bind")
assert vc.recipient == ""
def test_created_at_equals_expires_minus_ttl(self):
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=60)
diff = (vc.expires_at - vc.created_at).total_seconds()
assert abs(diff - 60) < 2
def test_multiple_mark_used_updates_time(self):
vc = VerificationCode.create("test@example.com", "email_bind")
vc.mark_used()
first = vc.used_at
vc.mark_used()
second = vc.used_at
assert second >= first
+97
View File
@@ -223,3 +223,100 @@ class TestVideoShareCounters:
share.revoke()
assert share.is_active is False
assert share.is_accessible is False
class TestGenerateShareTokenExtended:
"""generate_share_token 深度补充测试"""
def test_zero_length(self):
token = generate_share_token(0)
assert token == ""
def test_length_one(self):
token = generate_share_token(1)
assert len(token) == 1
def test_very_long_token(self):
token = generate_share_token(100)
assert len(token) == 100
def test_no_special_characters(self):
token = generate_share_token(50)
assert token.isalnum()
def test_all_characters_from_alphabet(self):
alphabet = set("abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789")
token = generate_share_token(200)
for c in token:
assert c in alphabet
class TestVideoShareExtended:
"""VideoShare 深度补充测试"""
def test_zero_view_count(self):
share = VideoShare.create(video_id="v1", user_id="u1")
assert share.view_count == 0
def test_zero_download_count(self):
share = VideoShare.create(video_id="v1", user_id="u1")
assert share.download_count == 0
def test_large_view_count(self):
share = VideoShare.create(video_id="v1", user_id="u1")
for _ in range(1000):
share.increment_view_count()
assert share.view_count == 1000
def test_revoke_idempotent(self):
share = VideoShare.create(video_id="v1", user_id="u1")
share.revoke()
assert share.is_active is False
share.revoke()
assert share.is_active is False
def test_revoke_returns_none(self):
share = VideoShare.create(video_id="v1", user_id="u1")
result = share.revoke()
assert result is None
def test_increment_view_returns_none(self):
share = VideoShare.create(video_id="v1", user_id="u1")
result = share.increment_view_count()
assert result is None
def test_increment_download_returns_none(self):
share = VideoShare.create(video_id="v1", user_id="u1")
result = share.increment_download_count()
assert result is None
def test_id_is_hex(self):
share = VideoShare.create(video_id="v1", user_id="u1")
int(share.id, 16)
def test_ids_are_unique(self):
s1 = VideoShare.create(video_id="v1", user_id="u1")
s2 = VideoShare.create(video_id="v1", user_id="u1")
assert s1.id != s2.id
def test_expires_at_boundary_exact_now(self):
"""expires_at 恰好是现在,应该被认为过期"""
share = VideoShare.create(video_id="v1", user_id="u1")
share.expires_at = datetime.now(timezone.utc) - timedelta(microseconds=1)
assert share.is_expired is True
def test_expires_at_boundary_one_second_future(self):
share = VideoShare.create(video_id="v1", user_id="u1")
share.expires_at = datetime.now(timezone.utc) + timedelta(seconds=1)
assert share.is_expired is False
def test_password_with_special_characters(self):
special_pass = "pass!@#$%^&*()"
share = VideoShare.create(video_id="v1", user_id="u1", password=special_pass)
assert share.verify_password(special_pass) is True
assert share.verify_password("wrong") is False
def test_password_unicode(self):
unicode_pass = "密码🔐测试"
share = VideoShare.create(video_id="v1", user_id="u1", password=unicode_pass)
assert share.verify_password(unicode_pass) is True
+72
View File
@@ -93,3 +93,75 @@ class TestVoiceLibraryItem:
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
assert item.created_at is not None
assert item.updated_at is not None
# ── VoiceLibraryItem 更多边界测试 ──────────────────────────────────────
class TestVoiceLibraryItemMore:
"""VoiceLibraryItem 补充测试."""
def test_status_pending(self):
"""pending状态."""
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status="pending")
assert item.status == "pending"
def test_status_processing(self):
"""processing状态."""
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status="processing")
assert item.status == "processing"
def test_status_failed(self):
"""failed状态."""
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status="failed")
assert item.status == "failed"
def test_zero_duration_and_size(self):
"""零时长零大小."""
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=0, file_size=0)
assert item.duration == 0
assert item.file_size == 0
def test_large_duration_and_size(self):
"""大时长大文件."""
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=3600.0, file_size=1024 * 1024 * 100)
assert item.duration == 3600.0
assert item.file_size == 104857600
def test_empty_tags_list(self):
"""空标签列表."""
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", tags=[])
assert item.tags == []
def test_many_tags(self):
"""多个标签."""
tags = ["温柔", "女声", "情感", "治愈", "朗读", "故事"]
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", tags=tags)
assert len(item.tags) == 6
assert "治愈" in item.tags
def test_empty_metadata(self):
"""空元数据."""
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
assert item.metadata_ == {}
def test_metadata_with_nested_values(self):
"""嵌套元数据."""
meta = {
"settings": {"speed": 1.0, "pitch": 0.5},
"source": "upload",
"version": 2,
}
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", metadata_=meta)
assert item.metadata_["settings"]["speed"] == 1.0
assert item.metadata_["source"] == "upload"
def test_project_id_none_by_default(self):
"""默认project_id为None."""
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
assert item.project_id is None
def test_project_id_set(self):
"""设置project_id."""
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", project_id="proj-abc-123")
assert item.project_id == "proj-abc-123"
+839
View File
@@ -0,0 +1,839 @@
"""第76波:TTSJob + Job + Tag 领域纯逻辑单测。"""
from datetime import datetime, timezone
import pytest
from packages.domain.job import Job, JobStatus, JobType
from packages.domain.tag import Tag
from packages.domain.tts_job import TTSJob, TTSJobStatus
# ============================================================
# Tag.create 测试
# ============================================================
class TestTagCreate:
def test_create_basic(self):
tag = Tag.create(user_id="u1", name="风景")
assert tag.id
assert tag.user_id == "u1"
assert tag.name == "风景"
assert isinstance(tag.created_at, datetime)
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="标签名称不能为空"):
Tag.create(user_id="u1", name="")
def test_create_whitespace_name_raises(self):
with pytest.raises(ValueError, match="标签名称不能为空"):
Tag.create(user_id="u1", name=" ")
def test_create_name_stripped(self):
tag = Tag.create(user_id="u1", name=" 美食 ")
assert tag.name == "美食"
def test_create_id_is_unique(self):
tag1 = Tag.create(user_id="u1", name="a")
tag2 = Tag.create(user_id="u1", name="b")
assert tag1.id != tag2.id
def test_create_uses_utc_timezone(self):
before = datetime.now(timezone.utc)
tag = Tag.create(user_id="u1", name="t")
after = datetime.now(timezone.utc)
assert before <= tag.created_at <= after
# ============================================================
# TTSJob.create 测试
# ============================================================
class TestTTSJobCreate:
def test_create_minimal(self):
job = TTSJob.create(user_id="u1", input_text="你好世界")
assert job.id
assert job.user_id == "u1"
assert job.input_text == "你好世界"
assert job.status == TTSJobStatus.PENDING
assert job.retry_count == 0
assert job.max_retries == 3
assert job.format == "mp3"
assert job.sample_rate == 22050
assert job.error_message == ""
assert isinstance(job.created_at, datetime)
assert isinstance(job.updated_at, datetime)
def test_create_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id 不能为空"):
TTSJob.create(user_id=" ", input_text="hi")
def test_create_empty_input_text_raises(self):
with pytest.raises(ValueError, match="input_text 不能为空"):
TTSJob.create(user_id="u1", input_text="")
def test_create_whitespace_input_raises(self):
with pytest.raises(ValueError, match="input_text 不能为空"):
TTSJob.create(user_id="u1", input_text=" \n\t ")
def test_create_too_long_input_raises(self):
long_text = "a" * 10001
with pytest.raises(ValueError, match="长度不能超过"):
TTSJob.create(user_id="u1", input_text=long_text)
def test_create_boundary_length_ok(self):
text = "a" * 10000
job = TTSJob.create(user_id="u1", input_text=text)
assert len(job.input_text) == 10000
def test_create_invalid_format_raises(self):
with pytest.raises(ValueError, match="不支持的输出格式"):
TTSJob.create(user_id="u1", input_text="hi", format="flac")
def test_create_valid_formats(self):
for fmt in ("mp3", "wav", "pcm"):
job = TTSJob.create(user_id="u1", input_text="hi", format=fmt)
assert job.format == fmt
def test_create_strips_whitespace(self):
job = TTSJob.create(
user_id=" u1 ",
input_text=" hello ",
voice_id=" v1 ",
voice_model=" cosyvoice ",
project_id=" p1 ",
voice_clone_profile_id=" vp1 ",
)
assert job.user_id == "u1"
assert job.input_text == "hello"
assert job.voice_id == "v1"
assert job.voice_model == "cosyvoice"
assert job.project_id == "p1"
assert job.voice_clone_profile_id == "vp1"
def test_create_custom_params(self):
job = TTSJob.create(
user_id="u1",
input_text="hi",
voice_id="voice_001",
voice_model="cosyvoice",
project_id="proj_001",
voice_clone_profile_id="vcp_001",
sample_rate=16000,
format="wav",
max_retries=5,
metadata={"key": "val"},
)
assert job.voice_id == "voice_001"
assert job.voice_model == "cosyvoice"
assert job.project_id == "proj_001"
assert job.voice_clone_profile_id == "vcp_001"
assert job.sample_rate == 16000
assert job.format == "wav"
assert job.max_retries == 5
assert job.metadata == {"key": "val"}
def test_create_metadata_default_empty_dict(self):
job = TTSJob.create(user_id="u1", input_text="hi")
assert job.metadata == {}
def test_create_id_unique(self):
j1 = TTSJob.create(user_id="u1", input_text="a")
j2 = TTSJob.create(user_id="u1", input_text="b")
assert j1.id != j2.id
# ============================================================
# TTSJob 状态属性测试
# ============================================================
class TestTTSJobStatusProperties:
def test_is_terminal_pending_false(self):
job = TTSJob.create(user_id="u1", input_text="hi")
assert not job.is_terminal
def test_is_terminal_processing_false(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
assert not job.is_terminal
def test_is_terminal_completed_true(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_completed("http://out.mp3")
assert job.is_terminal
def test_is_terminal_failed_true(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_failed("error")
assert job.is_terminal
def test_is_terminal_cancelled_true(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_cancelled()
assert job.is_terminal
def test_is_retryable_failed_within_limit(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
job.mark_processing()
job.mark_failed("err")
assert job.is_retryable
def test_is_retryable_failed_at_limit(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=2)
job.mark_processing()
job.mark_failed("err")
job.retry_count = 2
assert not job.is_retryable
def test_is_retryable_pending_false(self):
job = TTSJob.create(user_id="u1", input_text="hi")
assert not job.is_retryable
def test_is_completed_needs_url(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.status = TTSJobStatus.COMPLETED
job.output_audio_url = ""
assert not job.is_completed
job.output_audio_url = "http://x.mp3"
assert job.is_completed
# ============================================================
# TTSJob 状态转换测试
# ============================================================
class TestTTSJobTransitions:
def test_pending_to_processing(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.PROCESSING)
assert job.status == TTSJobStatus.PROCESSING
def test_pending_to_failed(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.FAILED)
assert job.status == TTSJobStatus.FAILED
def test_pending_to_cancelled(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.CANCELLED)
assert job.status == TTSJobStatus.CANCELLED
def test_pending_to_completed_invalid(self):
job = TTSJob.create(user_id="u1", input_text="hi")
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(TTSJobStatus.COMPLETED)
def test_processing_to_completed(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.COMPLETED)
assert job.status == TTSJobStatus.COMPLETED
def test_processing_to_failed(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.FAILED)
assert job.status == TTSJobStatus.FAILED
def test_failed_to_pending_retry(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.FAILED)
job.retry_count = 0
job.transition_to(TTSJobStatus.PENDING)
assert job.status == TTSJobStatus.PENDING
def test_completed_to_anything_invalid(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to(TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.COMPLETED)
with pytest.raises(ValueError):
job.transition_to(TTSJobStatus.PENDING)
with pytest.raises(ValueError):
job.transition_to(TTSJobStatus.FAILED)
def test_transition_to_accepts_string(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.transition_to("processing")
assert job.status == TTSJobStatus.PROCESSING
def test_transition_to_invalid_string_raises(self):
job = TTSJob.create(user_id="u1", input_text="hi")
with pytest.raises(ValueError, match="无效状态"):
job.transition_to("invalid_state")
def test_transition_updates_updated_at(self):
job = TTSJob.create(user_id="u1", input_text="hi")
before = job.updated_at
import time
time.sleep(0.001)
job.transition_to(TTSJobStatus.PROCESSING)
assert job.updated_at >= before
# ============================================================
# TTSJob 业务方法测试
# ============================================================
class TestTTSJobBusinessMethods:
def test_mark_processing_sets_started_at(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
assert job.started_at is not None
assert job.status == TTSJobStatus.PROCESSING
assert job.error_message == ""
def test_mark_processing_clears_error(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.error_message = "old error"
job.mark_processing()
assert job.error_message == ""
def test_mark_completed_sets_fields(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_completed(
"http://out.mp3",
output_audio_key="oss://key",
duration=10.5,
file_size=102400,
)
assert job.status == TTSJobStatus.COMPLETED
assert job.output_audio_url == "http://out.mp3"
assert job.output_audio_key == "oss://key"
assert job.duration == 10.5
assert job.file_size == 102400
assert job.completed_at is not None
assert job.is_completed
def test_mark_completed_empty_url_raises(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
with pytest.raises(ValueError, match="output_audio_url 不能为空"):
job.mark_completed(" ")
def test_mark_failed_sets_error(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_failed("网络超时")
assert job.status == TTSJobStatus.FAILED
assert job.error_message == "网络超时"
def test_mark_cancelled_from_pending(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
def test_mark_cancelled_from_processing(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
def test_prepare_retry_success(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
job.mark_processing()
job.mark_failed("err")
assert job.retry_count == 0
job.prepare_retry()
assert job.status == TTSJobStatus.PENDING
assert job.retry_count == 1
assert job.error_message == ""
assert job.started_at is None
assert job.completed_at is None
def test_prepare_retry_multiple_times(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
for i in range(3):
job.mark_processing()
job.mark_failed(f"err{i}")
job.prepare_retry()
assert job.retry_count == i + 1
assert job.status == TTSJobStatus.PENDING
def test_prepare_retry_exceed_max_raises(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=2)
job.mark_processing()
job.mark_failed("err1")
job.prepare_retry()
job.mark_processing()
job.mark_failed("err2")
job.prepare_retry()
# 第3次失败后 retry_count=2 == max_retries=2,不可重试
job.mark_processing()
job.mark_failed("err3")
assert not job.is_retryable
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_prepare_retry_pending_raises(self):
job = TTSJob.create(user_id="u1", input_text="hi")
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_to_dict_contains_all_fields(self):
job = TTSJob.create(
user_id="u1",
input_text="hi",
voice_id="v1",
project_id="p1",
metadata={"k": "v"},
)
d = job.to_dict()
expected_keys = {
"id",
"user_id",
"project_id",
"voice_clone_profile_id",
"status",
"input_text",
"voice_id",
"voice_model",
"output_audio_url",
"output_audio_key",
"duration",
"file_size",
"sample_rate",
"format",
"error_message",
"retry_count",
"max_retries",
"is_retryable",
"is_completed",
"metadata",
"started_at",
"completed_at",
"created_at",
"updated_at",
}
assert set(d.keys()) == expected_keys
assert d["status"] == "pending"
assert d["is_retryable"] is False
assert d["is_completed"] is False
assert d["metadata"] == {"k": "v"}
# 时间字段应为 ISO 字符串或 None
assert isinstance(d["created_at"], str)
assert d["started_at"] is None
def test_to_dict_completed_state(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_completed("http://out.mp3")
d = job.to_dict()
assert d["status"] == "completed"
assert d["is_completed"] is True
assert d["started_at"] is not None
assert d["completed_at"] is not None
# ============================================================
# Job.create 测试
# ============================================================
class TestJobCreate:
def test_create_minimal(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert job.id
assert job.project_id == "p1"
assert job.job_type == JobType.VIDEO_COMPOSE
assert job.status == JobStatus.PENDING
assert job.progress == 0.0
assert job.retry_count == 0
assert job.max_retries == 3
assert job.payload == {}
assert job.result == {}
def test_create_empty_project_id_raises(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE)
def test_create_string_job_type(self):
job = Job.create(project_id="p1", job_type="video_compose")
assert job.job_type == JobType.VIDEO_COMPOSE
def test_create_invalid_job_type_string_raises(self):
with pytest.raises(ValueError, match="不支持的任务类型"):
Job.create(project_id="p1", job_type="unknown_type")
def test_create_strips_project_id(self):
job = Job.create(project_id=" p1 ", job_type=JobType.VIDEO_COMPOSE)
assert job.project_id == "p1"
def test_create_full_params(self):
job = Job.create(
project_id="p1",
job_type=JobType.RENDER_EDIT_PLAN,
payload={"edit_plan_id": "ep1"},
source_id="src_001",
created_by_user_id="u1",
max_retries=5,
)
assert job.job_type == JobType.RENDER_EDIT_PLAN
assert job.payload == {"edit_plan_id": "ep1"}
assert job.source_id == "src_001"
assert job.created_by_user_id == "u1"
assert job.max_retries == 5
def test_create_all_job_types(self):
for jt in JobType:
job = Job.create(project_id="p1", job_type=jt)
assert job.job_type == jt
def test_create_id_unique(self):
j1 = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
j2 = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert j1.id != j2.id
def test_create_strips_source_and_user(self):
job = Job.create(
project_id="p1",
job_type=JobType.VIDEO_COMPOSE,
source_id=" s1 ",
created_by_user_id=" u1 ",
)
assert job.source_id == "s1"
assert job.created_by_user_id == "u1"
# ============================================================
# Job 状态属性测试
# ============================================================
class TestJobStatusProperties:
def test_is_terminal_pending_false(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert not job.is_terminal
def test_is_terminal_running_false(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
assert not job.is_terminal
def test_is_terminal_success_true(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success()
assert job.is_terminal
def test_is_terminal_failed_true(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_failed("err")
assert job.is_terminal
def test_is_terminal_cancelled_true(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_cancelled()
assert job.is_terminal
def test_is_retryable_failed_within_limit(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("err")
assert job.is_retryable
def test_is_retryable_failed_at_limit(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=2)
job.mark_running()
job.mark_failed("err")
job.retry_count = 2
assert not job.is_retryable
def test_is_retryable_success_false(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success()
assert not job.is_retryable
# ============================================================
# Job 状态转换测试
# ============================================================
class TestJobTransitions:
def test_pending_to_running(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert job.status == JobStatus.RUNNING
def test_pending_to_success(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.SUCCESS)
assert job.status == JobStatus.SUCCESS
def test_pending_to_cancelled(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.CANCELLED)
assert job.status == JobStatus.CANCELLED
def test_pending_to_failed_invalid(self):
"""pending 不能直接到 failed,必须经过 running 或直接 success/cancelled。"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(JobStatus.FAILED)
def test_running_to_success(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
assert job.status == JobStatus.SUCCESS
def test_running_to_failed(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.status == JobStatus.FAILED
def test_running_to_cancelled(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.CANCELLED)
assert job.status == JobStatus.CANCELLED
def test_failed_to_pending_retry(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
job.transition_to(JobStatus.PENDING)
assert job.status == JobStatus.PENDING
def test_success_to_pending_invalid(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
with pytest.raises(ValueError):
job.transition_to(JobStatus.PENDING)
def test_transition_to_accepts_string(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to("running")
assert job.status == JobStatus.RUNNING
def test_transition_to_invalid_string_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="无效状态"):
job.transition_to("bogus")
def test_transition_running_sets_started_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert job.started_at is None
job.transition_to(JobStatus.RUNNING)
assert job.started_at is not None
def test_transition_success_sets_completed_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert job.completed_at is None
job.transition_to(JobStatus.SUCCESS)
assert job.completed_at is not None
def test_transition_failed_sets_completed_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.completed_at is not None
def test_started_at_not_overwritten_on_second_running_transition(self):
"""通过 transition_to 再次到 RUNNING 时,如果 started_at 已有值不覆盖。"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
first = job.started_at
# 走个 retry 流程再回来
job.transition_to(JobStatus.FAILED)
job.transition_to(JobStatus.PENDING)
job.started_at = None # prepare_retry 会清掉,这里模拟
job.transition_to(JobStatus.RUNNING)
second = job.started_at
# started_at 被重置后应该重新设置
assert second is not None
# 时间可能相同(精度问题),但逻辑上第二次 running 会重新设置
assert isinstance(second, datetime)
# ============================================================
# Job 业务方法测试
# ============================================================
class TestJobBusinessMethods:
def test_mark_running_with_stage(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running("视频合成中")
assert job.status == JobStatus.RUNNING
assert job.current_stage == "视频合成中"
def test_mark_running_without_stage(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.current_stage = "之前"
job.mark_running()
assert job.status == JobStatus.RUNNING
assert job.current_stage == "之前" # 不传 stage 不修改
def test_mark_success_with_result(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success({"output_url": "http://x.mp4"})
assert job.status == JobStatus.SUCCESS
assert job.progress == 100.0
assert job.current_stage == "完成"
assert job.result == {"output_url": "http://x.mp4"}
def test_mark_success_without_result(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success()
assert job.status == JobStatus.SUCCESS
assert job.progress == 100.0
assert job.result == {}
def test_mark_failed(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_failed("渲染失败")
assert job.status == JobStatus.FAILED
assert job.error_message == "渲染失败"
assert job.current_stage == "失败"
def test_mark_cancelled(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_cancelled()
assert job.status == JobStatus.CANCELLED
assert job.current_stage == "已取消"
def test_update_progress_normal(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.update_progress(50.0, "合成中")
assert job.progress == 50.0
assert job.current_stage == "合成中"
def test_update_progress_boundary_zero(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.update_progress(0.0)
assert job.progress == 0.0
def test_update_progress_boundary_hundred(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.update_progress(100.0)
assert job.progress == 100.0
def test_update_progress_negative_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
job.update_progress(-1.0)
def test_update_progress_over_100_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
job.update_progress(100.1)
def test_update_progress_updates_updated_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
before = job.updated_at
import time
time.sleep(0.001)
job.update_progress(30.0)
assert job.updated_at >= before
def test_prepare_retry_success(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running("阶段1")
job.mark_failed("错误1")
job.celery_task_id = "celery-123"
job.prepare_retry()
assert job.status == JobStatus.PENDING
assert job.retry_count == 1
assert job.progress == 0.0
assert "第 1 次重试" in job.current_stage
assert job.error_message == ""
assert job.started_at is None
assert job.completed_at is None
assert job.celery_task_id == ""
def test_prepare_retry_multiple_times(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
for i in range(3):
job.mark_running()
job.mark_failed(f"err{i}")
job.prepare_retry()
assert job.retry_count == i + 1
assert job.status == JobStatus.PENDING
def test_prepare_retry_exceed_max_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=2)
job.mark_running()
job.mark_failed("err1")
job.prepare_retry()
job.mark_running()
job.mark_failed("err2")
job.prepare_retry()
job.mark_running()
job.mark_failed("err3")
assert not job.is_retryable
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_prepare_retry_pending_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_to_dict_contains_all_fields(self):
job = Job.create(
project_id="p1",
job_type=JobType.VIDEO_COMPOSE,
payload={"k": "v"},
source_id="s1",
created_by_user_id="u1",
)
d = job.to_dict()
expected_keys = {
"id",
"project_id",
"job_type",
"status",
"progress",
"current_stage",
"payload",
"result",
"error_message",
"retry_count",
"max_retries",
"celery_task_id",
"source_id",
"created_by_user_id",
"is_retryable",
"started_at",
"completed_at",
"created_at",
"updated_at",
}
assert set(d.keys()) == expected_keys
assert d["status"] == "pending"
assert d["job_type"] == "video_compose"
assert d["progress"] == 0.0
assert d["payload"] == {"k": "v"}
assert d["is_retryable"] is False
assert d["started_at"] is None
assert isinstance(d["created_at"], str)
def test_to_dict_success_state(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success({"output": "ok"})
d = job.to_dict()
assert d["status"] == "success"
assert d["progress"] == 100.0
assert d["result"] == {"output": "ok"}
assert d["started_at"] is not None
assert d["completed_at"] is not None
+466
View File
@@ -0,0 +1,466 @@
"""第77波:GenerationTask 领域模型纯逻辑单测。"""
import json
import pytest
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
# ============================================================
# GenerationTaskStatus._missing_ 兼容枚举测试
# ============================================================
class TestGenerationTaskStatusMissing:
def test_completed_aliases(self):
for val in ("done", "success", "finished", "complete", "completed"):
assert GenerationTaskStatus(val) == GenerationTaskStatus.COMPLETED
assert GenerationTaskStatus(val.upper()) == GenerationTaskStatus.COMPLETED
def test_failed_aliases(self):
for val in ("fail", "failed", "error", "err"):
assert GenerationTaskStatus(val) == GenerationTaskStatus.FAILED
def test_running_aliases(self):
for val in ("process", "processing", "run", "running", "in_progress"):
assert GenerationTaskStatus(val) == GenerationTaskStatus.RUNNING
def test_cancelled_aliases(self):
for val in ("cancel", "cancelled", "canceled"):
assert GenerationTaskStatus(val) == GenerationTaskStatus.CANCELLED
def test_unknown_defaults_to_pending(self):
assert GenerationTaskStatus("unknown_state") == GenerationTaskStatus.PENDING
assert GenerationTaskStatus("") == GenerationTaskStatus.PENDING
def test_whitespace_and_case_insensitive(self):
assert GenerationTaskStatus(" DONE ") == GenerationTaskStatus.COMPLETED
assert GenerationTaskStatus("Failed") == GenerationTaskStatus.FAILED
def test_non_string_falls_back_to_pending(self):
assert GenerationTaskStatus(None) == GenerationTaskStatus.PENDING
assert GenerationTaskStatus(123) == GenerationTaskStatus.PENDING
def test_normal_values_still_work(self):
assert GenerationTaskStatus("pending") == GenerationTaskStatus.PENDING
assert GenerationTaskStatus("running") == GenerationTaskStatus.RUNNING
assert GenerationTaskStatus("completed") == GenerationTaskStatus.COMPLETED
assert GenerationTaskStatus("failed") == GenerationTaskStatus.FAILED
assert GenerationTaskStatus("cancelled") == GenerationTaskStatus.CANCELLED
# ============================================================
# GenerationTask.create 测试
# ============================================================
class TestGenerationTaskCreate:
def test_create_minimal(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert task.id
assert task.project_id == "p1"
assert task.asset_library_id == "lib1"
assert task.status == GenerationTaskStatus.PENDING
assert task.progress == 0.0
assert task.result_count == 0
assert task.retry_count == 0
assert task.asset_ids == []
assert task.title_ids == []
assert task.voice_ids == []
assert task.bgm_config == {}
assert task.logs == "[]"
def test_create_requires_project_or_template(self):
"""project_id 和 template_id 至少需要一个。"""
with pytest.raises(ValueError, match="至少需要提供一个"):
GenerationTask.create(project_id="", asset_library_id="lib1", template_id="")
def test_create_template_id_only(self):
task = GenerationTask.create(project_id="", template_id="tpl1", asset_library_id="lib1")
assert task.template_id == "tpl1"
assert task.project_id == ""
def test_create_requires_library_or_assets(self):
"""asset_library_id 和 asset_ids/title_ids/voice_ids 至少需要一个。"""
with pytest.raises(ValueError, match="至少需要提供一个"):
GenerationTask.create(
project_id="p1",
asset_library_id="",
asset_ids=None,
title_ids=None,
voice_ids=None,
)
def test_create_asset_ids_only(self):
task = GenerationTask.create(project_id="p1", asset_library_id="", asset_ids=["a1", "a2"])
assert task.asset_ids == ["a1", "a2"]
assert task.asset_library_id == ""
def test_create_title_ids_only(self):
task = GenerationTask.create(project_id="p1", asset_library_id="", title_ids=["t1"])
assert task.title_ids == ["t1"]
def test_create_voice_ids_only(self):
task = GenerationTask.create(project_id="p1", asset_library_id="", voice_ids=["v1"])
assert task.voice_ids == ["v1"]
def test_create_full_params(self):
task = GenerationTask.create(
project_id="p1",
asset_library_id="lib1",
strategy_id="s1",
voice_library_id="vlib1",
template_id="tpl1",
asset_ids=["a1", "a2"],
title_ids=["t1"],
voice_ids=["v1"],
created_by_user_id="u1",
source_edit_plan_id="ep1",
asset_select_mode="random",
batch_id="batch_001",
video_title="测试视频",
resolution="1080p",
bgm_config={"volume": 0.5},
auto_retry_enabled=True,
auto_retry_max=3,
)
assert task.strategy_id == "s1"
assert task.voice_library_id == "vlib1"
assert task.template_id == "tpl1"
assert task.asset_ids == ["a1", "a2"]
assert task.title_ids == ["t1"]
assert task.voice_ids == ["v1"]
assert task.created_by_user_id == "u1"
assert task.source_edit_plan_id == "ep1"
assert task.asset_select_mode == "random"
assert task.batch_id == "batch_001"
assert task.video_title == "测试视频"
assert task.resolution == "1080p"
assert task.bgm_config == {"volume": 0.5}
assert task.auto_retry_enabled is True
assert task.auto_retry_max == 3
def test_create_strips_string_fields(self):
task = GenerationTask.create(
project_id=" p1 ",
asset_library_id=" lib1 ",
strategy_id=" s1 ",
template_id=" tpl1 ",
created_by_user_id=" u1 ",
video_title=" 测试 ",
resolution=" 1080p ",
)
assert task.project_id == "p1"
assert task.asset_library_id == "lib1"
assert task.strategy_id == "s1"
assert task.template_id == "tpl1"
assert task.created_by_user_id == "u1"
assert task.video_title == "测试"
assert task.resolution == "1080p"
def test_create_asset_ids_is_copy(self):
ids = ["a1", "a2"]
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=ids)
ids.append("a3")
assert task.asset_ids == ["a1", "a2"]
def test_create_bgm_config_is_copy(self):
cfg = {"vol": 0.5}
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", bgm_config=cfg)
cfg["vol"] = 0.8
assert task.bgm_config == {"vol": 0.5}
def test_create_id_unique(self):
t1 = GenerationTask.create(project_id="p1", asset_library_id="lib1")
t2 = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert t1.id != t2.id
# ============================================================
# GenerationTask 状态查询测试
# ============================================================
class TestGenerationTaskStatusQuery:
def test_is_terminal_pending_false(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert not task.is_terminal
def test_is_terminal_running_false(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
assert not task.is_terminal
def test_is_terminal_completed_true(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_completed()
assert task.is_terminal
def test_is_terminal_failed_true(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_failed("err")
assert task.is_terminal
def test_is_terminal_cancelled_true(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_cancelled()
assert task.is_terminal
def test_is_completed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert not task.is_completed
task.mark_processing()
task.mark_completed()
assert task.is_completed
def test_is_failed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert not task.is_failed
task.mark_processing()
task.mark_failed("err")
assert task.is_failed
def test_is_running(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert not task.is_running
task.mark_processing()
assert task.is_running
# ============================================================
# GenerationTask 状态转换测试
# ============================================================
class TestGenerationTaskTransitions:
def test_pending_to_running(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.RUNNING)
assert task.status == GenerationTaskStatus.RUNNING
def test_pending_to_failed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.FAILED)
assert task.status == GenerationTaskStatus.FAILED
def test_pending_to_cancelled(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.CANCELLED)
assert task.status == GenerationTaskStatus.CANCELLED
def test_pending_to_completed_invalid(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
with pytest.raises(ValueError, match="非法状态转换"):
task.transition_to(GenerationTaskStatus.COMPLETED)
def test_running_to_completed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.RUNNING)
task.transition_to(GenerationTaskStatus.COMPLETED)
assert task.status == GenerationTaskStatus.COMPLETED
def test_running_to_failed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.RUNNING)
task.transition_to(GenerationTaskStatus.FAILED)
assert task.status == GenerationTaskStatus.FAILED
def test_running_to_cancelled(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.RUNNING)
task.transition_to(GenerationTaskStatus.CANCELLED)
assert task.status == GenerationTaskStatus.CANCELLED
def test_failed_to_pending(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.RUNNING)
task.transition_to(GenerationTaskStatus.FAILED)
task.transition_to(GenerationTaskStatus.PENDING)
assert task.status == GenerationTaskStatus.PENDING
def test_completed_to_pending_invalid(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to(GenerationTaskStatus.RUNNING)
task.transition_to(GenerationTaskStatus.COMPLETED)
with pytest.raises(ValueError):
task.transition_to(GenerationTaskStatus.PENDING)
def test_transition_to_accepts_string(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.transition_to("running")
assert task.status == GenerationTaskStatus.RUNNING
def test_transition_to_unknown_string_defaults_pending(self):
"""_missing_ 兜底:未知字符串映射为 PENDING,再走状态机校验。"""
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
# 当前已经是 pendingpending→pending 不合法
with pytest.raises(ValueError, match="非法状态转换"):
task.transition_to("bogus_status")
# ============================================================
# GenerationTask 业务方法测试
# ============================================================
class TestGenerationTaskBusinessMethods:
def test_mark_processing_sets_started_at(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert task.started_at is None
task.mark_processing()
assert task.started_at is not None
assert task.status == GenerationTaskStatus.RUNNING
def test_mark_processing_clears_error(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.error_message = "old error"
task.mark_processing()
assert task.error_message == ""
def test_mark_completed_default_count(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_completed()
assert task.status == GenerationTaskStatus.COMPLETED
assert task.progress == 100.0
assert task.result_count == 1
assert task.completed_at is not None
assert task.error_message == ""
def test_mark_completed_custom_count(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_completed(result_count=5)
assert task.result_count == 5
def test_mark_failed_with_message(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_failed("网络超时")
assert task.status == GenerationTaskStatus.FAILED
assert task.error_message == "网络超时"
assert task.completed_at is not None
def test_mark_failed_with_error_info(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
info = {"error_type": "NetworkError", "stage": "download"}
task.mark_failed("超时", error_info=info)
assert task.error_info == info
def test_mark_failed_default_error_info(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_failed("未知错误")
assert "error_type" in task.error_info
assert task.error_info["message"] == "未知错误"
assert "failed_at" in task.error_info
def test_mark_cancelled_from_pending(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_cancelled()
assert task.status == GenerationTaskStatus.CANCELLED
assert task.completed_at is not None
def test_mark_cancelled_from_running(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_cancelled()
assert task.status == GenerationTaskStatus.CANCELLED
def test_mark_pending_from_failed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.mark_processing()
task.mark_failed("err")
task.mark_pending_from_failed()
assert task.status == GenerationTaskStatus.PENDING
assert task.error_message == ""
assert task.error_info == {}
assert task.started_at is None
assert task.completed_at is None
assert task.progress == 0.0
assert task.result_count == 0
assert task.retry_count == 1
def test_mark_pending_from_failed_multiple_retries(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
for i in range(3):
task.mark_processing()
task.mark_failed(f"err{i}")
task.mark_pending_from_failed()
assert task.retry_count == i + 1
assert task.status == GenerationTaskStatus.PENDING
def test_mark_pending_from_failed_wrong_status_raises(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
with pytest.raises(ValueError, match="只有 failed 状态"):
task.mark_pending_from_failed()
# ============================================================
# GenerationTask 日志系统测试
# ============================================================
class TestGenerationTaskLogs:
def test_append_log_basic(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.append_log("初始化", "任务创建成功")
logs = task.get_logs()
assert len(logs) == 1
assert logs[0]["stage"] == "初始化"
assert logs[0]["message"] == "任务创建成功"
assert logs[0]["level"] == "INFO"
assert "ts" in logs[0]
def test_append_log_with_level(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.append_log("渲染", "渲染失败", level="ERROR")
logs = task.get_logs()
assert logs[0]["level"] == "ERROR"
def test_append_log_with_extra_fields(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.append_log("下载", "下载完成", asset_id="a1", duration=10.5)
logs = task.get_logs()
assert logs[0]["asset_id"] == "a1"
assert logs[0]["duration"] == 10.5
def test_append_multiple_logs(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
for i in range(5):
task.append_log(f"阶段{i}", f"消息{i}")
logs = task.get_logs()
assert len(logs) == 5
assert logs[0]["stage"] == "阶段0"
assert logs[4]["stage"] == "阶段4"
def test_get_logs_empty(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
assert task.get_logs() == []
def test_get_logs_corrupted_json(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.logs = "not valid json"
assert task.get_logs() == []
def test_append_log_respects_max_limit(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
# _MAX_LOGS = 200,超过后保留最新的 200 条
for i in range(250):
task.append_log("阶段", f"消息{i}")
logs = task.get_logs()
assert len(logs) == 200
assert logs[0]["message"] == "消息50" # 前50条被截掉
assert logs[-1]["message"] == "消息249"
def test_logs_persisted_as_json_string(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1")
task.append_log("阶段", "消息")
assert isinstance(task.logs, str)
# 可以被 json.loads 解析
parsed = json.loads(task.logs)
assert isinstance(parsed, list)
assert len(parsed) == 1
+742
View File
@@ -0,0 +1,742 @@
"""第78波:Duplication 查重领域模型纯逻辑单测。"""
import pytest
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
# ============================================================
# DuplicateSegment.create 测试
# ============================================================
class TestDuplicateSegmentCreate:
def test_create_basic(self):
seg = DuplicateSegment.create(
source_start=10.0,
source_end=20.0,
matched_video_id="mv1",
matched_video_name="匹配视频",
matched_start=5.0,
matched_end=15.0,
similarity=85.5,
)
assert seg.id
assert seg.source_start == 10.0
assert seg.source_end == 20.0
assert seg.matched_video_id == "mv1"
assert seg.matched_video_name == "匹配视频"
assert seg.matched_start == 5.0
assert seg.matched_end == 15.0
assert seg.similarity == 85.5
def test_create_invalid_source_start_negative(self):
with pytest.raises(ValueError, match="invalid source segment range"):
DuplicateSegment.create(
source_start=-1.0,
source_end=10.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=10.0,
similarity=50.0,
)
def test_create_invalid_source_end_lte_start(self):
with pytest.raises(ValueError, match="invalid source segment range"):
DuplicateSegment.create(
source_start=20.0,
source_end=10.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=10.0,
similarity=50.0,
)
def test_create_source_end_equal_start_invalid(self):
with pytest.raises(ValueError, match="invalid source segment range"):
DuplicateSegment.create(
source_start=10.0,
source_end=10.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=10.0,
similarity=50.0,
)
def test_create_invalid_matched_start_negative(self):
with pytest.raises(ValueError, match="invalid matched segment range"):
DuplicateSegment.create(
source_start=0.0,
source_end=10.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=-1.0,
matched_end=10.0,
similarity=50.0,
)
def test_create_invalid_matched_end_lte_start(self):
with pytest.raises(ValueError, match="invalid matched segment range"):
DuplicateSegment.create(
source_start=0.0,
source_end=10.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=15.0,
matched_end=5.0,
similarity=50.0,
)
def test_create_similarity_zero(self):
seg = DuplicateSegment.create(
source_start=0.0,
source_end=5.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=5.0,
similarity=0.0,
)
assert seg.similarity == 0.0
def test_create_similarity_hundred(self):
seg = DuplicateSegment.create(
source_start=0.0,
source_end=5.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=5.0,
similarity=100.0,
)
assert seg.similarity == 100.0
def test_create_similarity_negative_raises(self):
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
DuplicateSegment.create(
source_start=0.0,
source_end=5.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=5.0,
similarity=-1.0,
)
def test_create_similarity_over_100_raises(self):
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
DuplicateSegment.create(
source_start=0.0,
source_end=5.0,
matched_video_id="mv1",
matched_video_name="n",
matched_start=0.0,
matched_end=5.0,
similarity=101.0,
)
def test_create_id_unique(self):
s1 = DuplicateSegment.create(0, 5, "m", "n", 0, 5, 50.0)
s2 = DuplicateSegment.create(0, 5, "m", "n", 0, 5, 50.0)
assert s1.id != s2.id
# ============================================================
# DuplicationRecord.create 测试
# ============================================================
class TestDuplicationRecordCreate:
def test_create_minimal(self):
rec = DuplicationRecord.create(
user_id="u1",
filename="test.mp4",
file_size=102400,
storage_key="oss://key",
)
assert rec.id
assert rec.user_id == "u1"
assert rec.filename == "test.mp4"
assert rec.file_size == 102400
assert rec.storage_key == "oss://key"
assert rec.duration_seconds == 0.0
assert rec.status == "pending"
assert rec.duplicate_rate is None
assert rec.duplicate_count == 0
assert rec.segments == []
assert rec.error_message == ""
def test_create_with_duration(self):
rec = DuplicationRecord.create(
user_id="u1",
filename="test.mp4",
file_size=1024,
storage_key="oss://k",
duration_seconds=120.5,
)
assert rec.duration_seconds == 120.5
def test_create_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id cannot be empty"):
DuplicationRecord.create(
user_id=" ",
filename="t.mp4",
file_size=100,
storage_key="k",
)
def test_create_empty_filename_raises(self):
with pytest.raises(ValueError, match="filename cannot be empty"):
DuplicationRecord.create(
user_id="u1",
filename="",
file_size=100,
storage_key="k",
)
def test_create_zero_file_size_raises(self):
with pytest.raises(ValueError, match="file_size must be positive"):
DuplicationRecord.create(
user_id="u1",
filename="t.mp4",
file_size=0,
storage_key="k",
)
def test_create_negative_file_size_raises(self):
with pytest.raises(ValueError, match="file_size must be positive"):
DuplicationRecord.create(
user_id="u1",
filename="t.mp4",
file_size=-100,
storage_key="k",
)
def test_create_strips_whitespace(self):
rec = DuplicationRecord.create(
user_id=" u1 ",
filename=" test.mp4 ",
file_size=100,
storage_key=" oss://k ",
)
assert rec.user_id == "u1"
assert rec.filename == "test.mp4"
# storage_key 不确定有没有 strip,看源码是直接赋值
assert rec.storage_key == " oss://k "
def test_create_id_unique(self):
r1 = DuplicationRecord.create("u1", "a.mp4", 100, "k1")
r2 = DuplicationRecord.create("u1", "b.mp4", 200, "k2")
assert r1.id != r2.id
def test_create_default_segments_empty_list(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
assert rec.segments == []
assert isinstance(rec.segments, list)
# ============================================================
# DuplicationRecord 状态流转测试
# ============================================================
class TestDuplicationRecordStatusFlow:
def test_mark_processing(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
before = rec.updated_at
rec.mark_processing()
assert rec.status == "processing"
assert rec.updated_at >= before
def test_mark_completed(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
seg = DuplicateSegment.create(0, 5, "m", "n", 0, 5, 90.0)
rec.mark_completed(
duplicate_rate=35.5,
duplicate_count=1,
segments=[seg],
)
assert rec.status == "completed"
assert rec.duplicate_rate == 35.5
assert rec.duplicate_count == 1
assert len(rec.segments) == 1
assert rec.segments[0].id == seg.id
def test_mark_completed_zero_rate(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.mark_completed(0.0, 0, [])
assert rec.status == "completed"
assert rec.duplicate_rate == 0.0
assert rec.duplicate_count == 0
assert rec.segments == []
def test_mark_completed_full_rate(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
seg = DuplicateSegment.create(0, 10, "m", "n", 0, 10, 100.0)
rec.mark_completed(100.0, 1, [seg])
assert rec.duplicate_rate == 100.0
def test_mark_completed_invalid_rate_negative(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
rec.mark_completed(-1.0, 0, [])
def test_mark_completed_invalid_rate_over_100(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
rec.mark_completed(100.1, 0, [])
def test_mark_failed(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.mark_failed("网络超时")
assert rec.status == "failed"
assert rec.error_message == "网络超时"
def test_mark_failed_from_processing(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.mark_processing()
rec.mark_failed("解析失败")
assert rec.status == "failed"
assert rec.error_message == "解析失败"
# ============================================================
# DuplicationRecord 重试机制测试
# ============================================================
class TestDuplicationRecordRetry:
def test_can_retry_failed_true(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.mark_failed("err")
assert rec.can_retry() is True
def test_can_retry_pending_false(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
assert rec.can_retry() is False
def test_can_retry_processing_false(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.mark_processing()
assert rec.can_retry() is False
def test_can_retry_completed_false(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.mark_completed(10.0, 0, [])
assert rec.can_retry() is False
def test_reset_for_retry_clears_all(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
seg = DuplicateSegment.create(0, 5, "m", "n", 0, 5, 80.0)
rec.mark_completed(30.0, 1, [seg])
# 先设为 failed 再 reset
rec.status = "failed"
rec.reset_for_retry()
assert rec.status == "pending"
assert rec.duplicate_rate is None
assert rec.duplicate_count == 0
assert rec.error_message == ""
assert rec.segments == []
assert rec.video_fingerprint is None
def test_reset_for_retry_updates_timestamp(self):
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
rec.status = "failed"
before = rec.updated_at
rec.reset_for_retry()
assert rec.updated_at >= before
def test_full_retry_flow(self):
"""完整的 创建→失败→重置→再处理→完成 流程。"""
rec = DuplicationRecord.create("u1", "a.mp4", 100, "k")
assert rec.status == "pending"
rec.mark_processing()
assert rec.status == "processing"
rec.mark_failed("超时")
assert rec.can_retry()
rec.reset_for_retry()
assert rec.status == "pending"
assert rec.error_message == ""
rec.mark_processing()
rec.mark_completed(0.0, 0, [])
assert rec.status == "completed"
assert not rec.can_retry()
# ============================================================
# QuotaTier 测试
# ============================================================
class TestQuotaTier:
def test_get_limit_defined(self):
from packages.domain.quota import QUOTA_TIERS, QuotaTier
free = QUOTA_TIERS["free"]
assert free.get_limit("storage_gb") == 2
assert free.get_limit("videos_per_month") == 5
def test_get_limit_undefined_returns_zero(self):
from packages.domain.quota import QuotaTier
tier = QuotaTier(name="test", limits={"a": 10})
assert tier.get_limit("nonexistent") == 0
def test_is_unlimited_inf(self):
from packages.domain.quota import QUOTA_TIERS
premium = QUOTA_TIERS["premium"]
assert premium.is_unlimited("max_templates") is True
def test_is_unlimited_finite(self):
from packages.domain.quota import QUOTA_TIERS
free = QUOTA_TIERS["free"]
assert free.is_unlimited("storage_gb") is False
def test_is_unlimited_undefined_returns_true(self):
"""未定义的维度 limits.get 默认 infis_unlimited 返回 True。"""
from packages.domain.quota import QuotaTier
tier = QuotaTier(name="test", limits={})
assert tier.is_unlimited("unknown") is True
# ============================================================
# QUOTA_TIERS 三档套餐验证
# ============================================================
class TestQuotaTiers:
def test_free_tier_storage(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["free"].get_limit("storage_gb") == 2
def test_free_tier_no_ai_voice(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["free"].get_limit("ai_voice_enabled") == 0
def test_basic_tier_storage(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["basic"].get_limit("storage_gb") == 20
def test_basic_tier_has_ai_voice(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["basic"].get_limit("ai_voice_enabled") == 1
def test_basic_tier_ai_voice_credits(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["basic"].get_limit("ai_voice_credits") == 100
def test_premium_tier_storage(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["premium"].get_limit("storage_gb") == 100
def test_premium_tier_unlimited_templates(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["premium"].is_unlimited("max_templates")
def test_premium_tier_multi_platform(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["premium"].get_limit("multi_platform_enabled") == 1
def test_basic_no_multi_platform(self):
from packages.domain.quota import QUOTA_TIERS
assert QUOTA_TIERS["basic"].get_limit("multi_platform_enabled") == 0
def test_all_tiers_exist(self):
from packages.domain.quota import QUOTA_TIERS
assert set(QUOTA_TIERS.keys()) == {"free", "basic", "premium"}
# ============================================================
# QuotaCheckResult 测试
# ============================================================
class TestQuotaCheckResult:
def test_usage_percent_normal(self):
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
r = QuotaCheckResult(
allowed=True,
dimension="storage_gb",
limit=100,
used=50,
remaining=50,
warning_level=QuotaWarningLevel.NORMAL,
)
assert r.usage_percent == 50.0
def test_usage_percent_capped_at_100(self):
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
r = QuotaCheckResult(
allowed=False,
dimension="x",
limit=10,
used=15,
remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
)
assert r.usage_percent == 100.0
def test_usage_percent_unlimited(self):
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
r = QuotaCheckResult(
allowed=True,
dimension="x",
limit=float("inf"),
used=999,
remaining=float("inf"),
warning_level=QuotaWarningLevel.NORMAL,
)
assert r.usage_percent == 0.0
def test_usage_percent_zero_limit_with_usage(self):
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
r = QuotaCheckResult(
allowed=False,
dimension="x",
limit=0,
used=5,
remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
)
assert r.usage_percent == 100.0
def test_usage_percent_zero_limit_no_usage(self):
from packages.domain.quota import QuotaCheckResult, QuotaWarningLevel
r = QuotaCheckResult(
allowed=True,
dimension="x",
limit=0,
used=0,
remaining=0,
warning_level=QuotaWarningLevel.NORMAL,
)
assert r.usage_percent == 0.0
# ============================================================
# QuotaWarningLevel 计算测试
# ============================================================
class TestWarningLevel:
def test_normal_below_80(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(50, 100) == QuotaWarningLevel.NORMAL
def test_warning_at_80(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(80, 100) == QuotaWarningLevel.WARNING
def test_warning_below_95(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(90, 100) == QuotaWarningLevel.WARNING
def test_critical_at_95(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(95, 100) == QuotaWarningLevel.CRITICAL
def test_critical_below_100(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(99, 100) == QuotaWarningLevel.CRITICAL
def test_exceeded_at_100(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(100, 100) == QuotaWarningLevel.EXCEEDED
def test_exceeded_over_100(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(150, 100) == QuotaWarningLevel.EXCEEDED
def test_unlimited_always_normal(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(99999, float("inf")) == QuotaWarningLevel.NORMAL
def test_zero_limit_with_usage_is_exceeded(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(1, 0) == QuotaWarningLevel.EXCEEDED
def test_zero_limit_no_usage_is_normal(self):
from packages.domain.quota import QuotaWarningLevel, get_warning_level
assert get_warning_level(0, 0) == QuotaWarningLevel.NORMAL
# ============================================================
# QuotaChecker 测试
# ============================================================
class TestQuotaChecker:
def test_check_allowed(self):
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
checker = QuotaChecker()
r = checker.check("free", "storage_gb", 1)
assert r.allowed is True
assert r.limit == 2
assert r.used == 1
assert r.remaining == 1
assert r.warning_level == QuotaWarningLevel.NORMAL
def test_check_exceeded(self):
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
checker = QuotaChecker()
r = checker.check("free", "storage_gb", 5)
assert r.allowed is False
assert r.remaining == 0
assert r.warning_level == QuotaWarningLevel.EXCEEDED
def test_check_at_limit_not_allowed(self):
"""used == limit 时 allowed 为 False(严格小于才算允许)。"""
from packages.domain.quota import QuotaChecker
checker = QuotaChecker()
r = checker.check("free", "storage_gb", 2)
assert r.allowed is False
def test_check_unlimited_always_allowed(self):
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
checker = QuotaChecker()
r = checker.check("premium", "max_templates", 9999)
assert r.allowed is True
assert r.remaining == float("inf")
assert r.warning_level == QuotaWarningLevel.NORMAL
def test_check_unknown_plan_returns_zero(self):
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
checker = QuotaChecker()
r = checker.check("unknown_plan", "storage_gb", 1)
assert r.allowed is False
assert r.limit == 0
assert r.warning_level == QuotaWarningLevel.EXCEEDED
def test_check_warning_level_80_percent(self):
from packages.domain.quota import QuotaChecker, QuotaWarningLevel
checker = QuotaChecker()
r = checker.check("basic", "storage_gb", 16) # 20 * 0.8 = 16
assert r.warning_level == QuotaWarningLevel.WARNING
def test_check_multiple(self):
from packages.domain.quota import QuotaChecker
checker = QuotaChecker()
results = checker.check_multiple(
"free",
{"storage_gb": 1, "videos_per_month": 10, "max_templates": 2},
)
assert len(results) == 3
dims = {r.dimension: r for r in results}
assert dims["storage_gb"].allowed is True
assert dims["videos_per_month"].allowed is False
assert dims["max_templates"].allowed is True
# ============================================================
# QuotaRegistry 测试
# ============================================================
class TestQuotaRegistry:
def test_list_dimensions_includes_builtin(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
dims = reg.list_dimensions()
assert "storage_gb" in dims
assert "videos_per_month" in dims
assert "ai_voice_enabled" in dims
def test_list_tiers(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
tiers = reg.list_tiers()
assert set(tiers) == {"free", "basic", "premium"}
def test_register_new_dimension(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
reg.register_dimension("custom_dim", "自定义维度", {"free": 5, "basic": 20})
dims = reg.list_dimensions()
assert "custom_dim" in dims
assert dims["custom_dim"] == "自定义维度"
assert reg.get_limit("free", "custom_dim") == 5
assert reg.get_limit("basic", "custom_dim") == 20
def test_register_dimension_idempotent(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
reg.register_dimension("custom_dim", "v1", {"free": 5})
reg.register_dimension("custom_dim", "v2", {"free": 10})
# 第二次应该被忽略(幂等),描述和限制都保持第一次
assert reg.list_dimensions()["custom_dim"] == "v1"
assert reg.get_limit("free", "custom_dim") == 5
def test_register_without_defaults_defaults_to_zero(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
reg.register_dimension("new_dim", "新维度")
assert reg.get_limit("free", "new_dim") == 0
assert reg.get_limit("basic", "new_dim") == 0
assert reg.get_limit("premium", "new_dim") == 0
def test_get_tier_exists(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
tier = reg.get_tier("free")
assert tier is not None
assert tier.name == "free"
def test_get_tier_not_exists(self):
from packages.domain.quota import QuotaRegistry
reg = QuotaRegistry()
assert reg.get_tier("nonexistent") is None
def test_global_registry_instance(self):
from packages.domain.quota import quota_checker, quota_registry
assert quota_registry is not None
assert quota_checker is not None
assert quota_registry.get_limit("free", "storage_gb") == 2