Compare commits

..

18 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 / 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 / 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 / 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) Failing after 1253h14m17s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1253h33m35s
CI/CD Pipeline / Deploy Production (push) Failing after 1253h33m39s
CI/CD Pipeline / Build Production API Image (push) Failing after 1253h34m9s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1253h34m5s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1253h36m30s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1253h36m32s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1253h36m31s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1254h6m44s
CI/CD Pipeline / PR Build API Image (push) Failing after 1254h9m9s
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 / 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 / Validate - Code Quality (pull_request) Successful in 2m45s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 24s
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
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 15s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 17s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1253h42m55s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1253h42m58s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1253h42m56s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1253h42m59s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1253h43m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1253h43m2s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1253h43m4s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1253h43m8s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1253h43m23s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1253h44m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1253h44m43s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m1s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1254h15m38s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1254h15m43s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1254h17m16s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1254h15m34s
2026-07-26 09:42:12 +08:00
xiaoxia 56ab6bcef4 fix(test): 修复Unit Tests收集失败 - dedup模块级mock污染sys.modules (#917)
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 / 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 / ACR Image Cleanup (push) Failing after 22s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1253h36m33s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1253h52m55s
CI/CD Pipeline / Deploy Production (push) Failing after 1253h52m59s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1253h54m1s
CI/CD Pipeline / Build Production API Image (push) Failing after 1253h54m1s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1253h55m57s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1253h55m59s
CI/CD Pipeline / PR Build API Image (push) Failing after 1253h55m59s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1253h57m7s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1254h26m36s
根因: 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 / 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 50s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m45s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1254h0m51s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1254h0m57s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1254h0m59s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1254h1m3s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1254h0m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1254h1m13s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1254h1m15s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1254h2m34s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1254h1m20s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1254h1m22s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1254h1m24s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1254h1m28s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1254h2m36s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1254h2m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1254h33m38s
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 / Validate - Code Quality (pull_request) Successful in 3m19s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m20s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m45s
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
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1254h8m38s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1254h9m6s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1254h10m25s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1254h8m38s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1254h10m27s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1254h10m29s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1254h41m17s
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 / 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 / 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 / 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) Failing after 1253h52m57s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1254h9m8s
CI/CD Pipeline / Deploy Production (push) Failing after 1254h13m45s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1254h15m25s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1254h15m59s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1254h16m1s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1254h17m8s
CI/CD Pipeline / Build Production API Image (push) Failing after 1254h15m27s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1254h48m0s
CI/CD Pipeline / PR Build API Image (push) Failing after 1254h48m40s
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 / PR Build Worker Image (pull_request) Successful in 1m18s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m50s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m2s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m18s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 36s
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 / 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
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1254h22m32s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1254h23m25s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1254h23m23s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1254h23m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1254h23m29s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1254h23m27s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1254h23m31s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1254h23m35s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1254h23m52s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1254h24m9s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1254h24m10s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1254h56m10s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1254h56m5s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1254h56m45s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1254h56m1s
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 / 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
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 31s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m10s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m7s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 28s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m46s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1254h34m34s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1254h34m36s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1254h34m36s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1254h34m38s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1254h34m40s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1254h34m44s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1254h34m46s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1254h34m48s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1254h36m31s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1254h36m33s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1254h36m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1255h7m19s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1255h7m15s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1255h7m11s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1255h7m38s
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 / 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 / PR Build Web Image (pull_request) Failing after 2m24s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 45s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m31s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1254h43m48s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1254h43m52s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1254h43m54s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1254h43m56s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1254h43m58s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1254h44m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1254h44m2s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1254h44m8s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1254h44m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1254h44m27s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1254h46m30s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1254h46m32s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1255h16m47s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1255h19m6s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1255h16m27s
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 / 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 / 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 / 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) Failing after 1254h29m13s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1254h44m0s
CI/CD Pipeline / Deploy Production (push) Failing after 1254h44m29s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1254h49m54s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1254h49m56s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1254h52m52s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1254h52m54s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1254h53m26s
CI/CD Pipeline / Build Production API Image (push) Failing after 1255h22m36s
CI/CD Pipeline / PR Build API Image (push) Failing after 1255h25m34s
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 / Validate - Migration (alembic) (pull_request) Successful in 1m18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m2s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m10s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m52s
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 / 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
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1254h55m3s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1254h55m49s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1254h55m50s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1254h55m51s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1254h55m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1254h55m54s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1254h55m59s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1254h55m56s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1254h56m6s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1254h57m9s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1254h57m11s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1255h28m29s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1255h28m34s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1255h28m25s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1255h29m48s
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
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 24s
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
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 35s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m6s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m24s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m27s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m12s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 24s
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m54s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 27s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1255h49m46s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1255h49m50s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1255h17m13s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1255h17m15s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1255h18m16s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1255h18m17s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1255h18m19s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1255h49m44s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1255h50m13s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1255h16m37s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1255h17m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1255h17m11s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1255h17m8s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1255h17m9s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1255h17m10s
- 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 / 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 / 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) Failing after 1255h7m50s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1255h27m59s
CI/CD Pipeline / Deploy Production (push) Failing after 1255h28m3s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1255h28m7s
CI/CD Pipeline / Build Production API Image (push) Failing after 1255h28m9s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1255h29m40s
CI/CD Pipeline / PR Build API Image (push) Failing after 1255h29m42s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1255h29m44s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1256h0m42s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1256h2m15s
2026-07-26 07:58:48 +08:00
21 changed files with 1950 additions and 1405 deletions
@@ -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,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,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,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,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 对接后端真实 API(api/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]
}
@@ -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)
})
})
+35 -41
View File
@@ -10,65 +10,59 @@ import pytest
# 模块级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"] = MagicMock()
# 模块级mock worker_app.db(dedup模块import时会触发数据库初始化,纯算法测试不需要)
sys.modules["worker_app.db"] = MagicMock()
sys.modules["worker_app.db"].SessionLocal = MagicMock()
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
# sqlalchemy 作为包结构 mock
# sqlalchemy(dedup 导入了 Session 类型)
_mock_sqla = MagicMock()
_mock_sqla.__path__ = []
_mock_sqla.__package__ = "sqlalchemy"
_mock_sqla.__spec__ = None
_mock_sqla_orm = MagicMock()
_mock_sqla_orm.__path__ = []
_mock_sqla_orm.__spec__ = None
_mock_sqla_orm.Session = MagicMock
_mock_sqla_engine = MagicMock()
sys.modules["sqlalchemy"] = _mock_sqla
sys.modules["sqlalchemy.orm"] = _mock_sqla_orm
sys.modules["sqlalchemy.engine"] = _mock_sqla_engine
sys.modules["sqlalchemy.ext"] = MagicMock()
sys.modules["sqlalchemy.ext.declarative"] = MagicMock()
sys.modules["sqlalchemy.engine"] = _mock_module()
sys.modules["sqlalchemy.ext"] = _mock_module()
sys.modules["sqlalchemy.ext.declarative"] = _mock_module()
# worker_app 及其子模块(避免导入时触发数据库连接)
_mock_worker_app = MagicMock()
_mock_worker_app.__path__ = []
_mock_worker_db = MagicMock()
_mock_worker_db.SessionLocal = MagicMock()
_mock_worker_celery = MagicMock()
_mock_worker_celery.celery_app = MagicMock()
_mock_worker_core = MagicMock()
_mock_worker_core.__path__ = []
_mock_worker_config = MagicMock()
_mock_worker_config.get_settings = MagicMock(return_value=MagicMock())
sys.modules["worker_app"] = _mock_worker_app
sys.modules["worker_app.db"] = _mock_worker_db
sys.modules["worker_app.celery_app"] = _mock_worker_celery
sys.modules["worker_app.core"] = _mock_worker_core
sys.modules["worker_app.core.config"] = _mock_worker_config
# 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.adapters.sqlalchemy_impl(整个包mock掉)
_mock_sqla_impl = MagicMock()
_mock_sqla_impl.__path__ = []
sys.modules["packages.adapters.sqlalchemy_impl"] = _mock_sqla_impl
sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = MagicMock()
sys.modules["packages.adapters.sqlalchemy_impl.schema_guard"] = MagicMock()
# packages.shared
_mock_packages_shared = MagicMock()
_mock_packages_shared.__path__ = []
_mock_shared_config = MagicMock()
_mock_shared_config.get_shared_settings = MagicMock(return_value=MagicMock())
sys.modules["packages.shared"] = _mock_packages_shared
sys.modules["packages.shared.config"] = _mock_shared_config
sys.modules["packages.shared.storage"] = 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,