Compare commits

...

20 Commits

Author SHA1 Message Date
xiaoxia 7ec2c86746 fix(ci): black format test_dedup_pure.py
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 32s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 34s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 17s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m15s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m0s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m43s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m6s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m30s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m6s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 16s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 48m59s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1250h30m19s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1250h37m50s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1250h37m54s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1250h37m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1250h40m26s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1250h40m30s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1250h40m32s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1250h40m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1250h43m33s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1250h43m37s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1251h10m30s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1251h13m6s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1251h16m13s
2026-07-26 12:43:33 +08:00
xiaoxia a75a27d639 fix(ci): fix remaining unit test failures (3x assert mismatch + ai_service shallow copy)
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 23s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 54s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m47s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m59s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m55s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m53s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m12s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1250h54m16s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1250h54m19s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1250h54m17s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1250h54m20s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1250h54m32s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1250h54m22s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1250h54m34s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1250h54m38s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1250h55m19s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1250h55m19s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1250h55m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1251h27m7s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1251h27m14s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1251h26m55s
2026-07-26 12:31:22 +08:00
xiaoxia 22438842f0 fix(ci): fix test_dedup_pure sys.modules pollution + add ffmpeg step
AI Code Review / AI Code Review (pull_request) Failing after 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 35s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m22s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m26s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 40s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m36s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m8s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m21s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m47s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m36s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 5m35s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1252h17m8s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1252h17m10s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1252h17m13s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1252h17m14s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1252h17m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1252h17m26s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1252h17m28s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1252h17m32s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1252h17m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1252h18m20s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1252h18m22s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1252h18m24s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1252h50m8s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1252h49m50s
2026-07-26 11:07:39 +08:00
xiaoxia 59574f919a fix(ci): fix preview deploy frontend build - remove DooD, use direct runner
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 30s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 59s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 2m59s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m56s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 4m48s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m13s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1253h21m6s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1253h21m7s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1253h23m49s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1253h23m51s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1253h23m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1253h23m55s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1253h23m57s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1253h23m59s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1253h23m59s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1253h25m35s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1253h25m37s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1253h56m28s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1253h56m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1253h58m10s
2026-07-26 10:01:08 +08:00
CI Bot 1943630f8a style: auto-format with black + isort + prettier
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m44s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 45s
CI/CD Pipeline / Frontend Lint (push) Successful in 43s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 48s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m21s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m5s
CI/CD Pipeline / Integration Tests (push) Successful in 2m27s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m37s
CI/CD Pipeline / Unit Tests (push) Failing after 5m54s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m28s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 22s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m49s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m55s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1253h0m55s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1253h14m59s
CI/CD Pipeline / Deploy Production (push) Failing after 1253h18m42s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1253h21m8s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1253h21m10s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1253h21m35s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1253h23m47s
CI/CD Pipeline / Build Production API Image (push) Failing after 1253h21m12s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1253h21m34s
CI/CD Pipeline / PR Build API Image (push) Failing after 1253h21m37s
2026-07-26 01:55:14 +00: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
16 changed files with 1110 additions and 901 deletions
+24 -37
View File
@@ -93,44 +93,31 @@ jobs:
shell: sh
run: |
set -eu
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
echo "Created npm cache volume: $NPM_CACHE_VOLUME"
fi
cd apps/web
docker run --rm \
-v "$PWD:/workspace" \
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
-w /workspace/apps/web \
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
docker.m.daocloud.io/library/node:20 \
sh -lc '
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
CACHE_HASH_FILE="node_modules/.package-lock-hash"
CACHE_VALID=false
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
CACHE_VALID=true
echo "Cache hit: dependencies valid, skipping npm ci"
fi
if [ "$CACHE_VALID" = "false" ]; then
echo "Cache miss or invalid: running npm ci..."
if ! npm ci; then
echo "npm ci failed, cleaning node_modules and retrying..."
rm -rf node_modules
mkdir -p node_modules
npm ci
fi
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
echo "Dependencies installed, cache updated"
fi
echo "Running TypeScript check..."
npx --no-install tsc
echo "Running Vite build..."
npx --no-install vite build
echo "Build completed successfully"
ls -la dist/
'
# Config npm mirror for speed
npm config set registry https://registry.npmmirror.com
# Install dependencies with retry
for i in 1 2 3; do
npm ci --no-audit --no-fund && break
echo "npm ci failed, retry $i/3..."
[ $i -eq 3 ] && exit 1
rm -rf node_modules
sleep 5
done
# TypeScript check
echo "=== TypeScript check ==="
npx --no-install tsc --noEmit
# Vite build
echo "=== Vite build ==="
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
npx --no-install vite build
echo "=== Build completed ==="
ls -la dist/
- name: Install SSH client and rsync
shell: sh
+56 -813
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>
@@ -995,19 +250,7 @@ const ProductLibrary: React.FC = () => {
value={filterProject}
onChange={setFilterProject}
style={{ width: 140 }}
options={[
{ value: "all", label: "全部项目" },
...Array.from(
new Map(
products
.filter((p) => p.projectId && p.projectName)
.map((p) => [p.projectId!, p.projectName!] as const),
),
).map(([id, name]) => ({
value: id as string,
label: name as string,
})),
]}
options={[{ value: "all", label: "全部项目" }, ...projectOptions]}
/>
<Select
value={filterReviewStatus}
@@ -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)
})
})
+3 -2
View File
@@ -6,6 +6,7 @@ API 层和 Worker 层都从此模块导入,避免 API 直接依赖 Worker 代
from __future__ import annotations
import copy
import json
import logging
import random
@@ -87,7 +88,7 @@ def _fallback_recommend_clips(
order += 1
# 生成推荐 config
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
config["title"]["text"] = f"精选视频 — {len(asset_ids)} 个片段"
config["title"]["ai_auto"] = True
@@ -167,7 +168,7 @@ def _parse_recommend_response(
for i, clip in enumerate(clips):
clip["order"] = i
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
title = data.get("title", "")
if title:
config["title"]["text"] = str(title)
+3
View File
@@ -45,6 +45,9 @@ for i in 1 2 3; do
done
pytest --version
# --- 安装 ffmpeg(视频处理相关测试依赖)---
bash scripts/ci/step_install_ffmpeg.sh
# 双保险:确保numpy已安装
python3 -m pip install -q numpy==1.26.4 || true
+73 -46
View File
@@ -8,74 +8,101 @@ from unittest.mock import MagicMock
import numpy as np
import pytest
# 模块级mock有副作用的依赖(纯算法测试不需要db/celery/cv2)
# 注意:必须在 import dedup 前全部 mock 完,避免链式导入触发db连接
# cv2(视频处理依赖,纯算法测试不需要)
sys.modules["cv2"] = MagicMock()
# 模块级mock worker_app.db(dedup模块import时会触发数据库初始化,纯算法测试不需要)
sys.modules["worker_app.db"] = MagicMock()
sys.modules["worker_app.db"].SessionLocal = MagicMock()
def _mock_module(**attrs):
"""Create a mock module with __spec__ to avoid AttributeError: __spec__."""
m = MagicMock()
m.__spec__ = None
for k, v in attrs.items():
setattr(m, k, v)
return m
# ── Module-level setup: mock deps, import dedup, then restore sys.modules ──
# This pattern ensures:
# 1. dedup is imported with mocks active (no db/celery/cv2 side effects)
# 2. sys.modules is restored immediately so other test files are not polluted
# 3. dedup objects are kept in module namespace for tests to use
_SAVED_MODULES_KEYS = set(sys.modules.keys())
_SAVED_MODULES_VALUES = {
k: sys.modules.get(k)
for k in [
"cv2",
"celery",
"sqlalchemy",
"sqlalchemy.orm",
"sqlalchemy.engine",
"sqlalchemy.ext",
"sqlalchemy.ext.declarative",
"worker_app.db",
"worker_app.celery_app",
"worker_app.core.config",
"packages.adapters.sqlalchemy_impl.session",
"packages.adapters.sqlalchemy_impl.generated_video_repository",
"packages.shared.config",
"packages.shared.storage",
]
}
# Set up mocks
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
_mock_sqla = MagicMock()
_mock_sqla.__path__ = []
_mock_sqla.__package__ = "sqlalchemy"
_mock_sqla.__spec__ = None
sys.modules["sqlalchemy"] = _mock_sqla
_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
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()
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()
# Import dedup while mocks are active
from video_processing.dedup import ( # noqa: E402
VideoDeduplicator,
VideoFingerprint,
hamming_distance,
)
# ── Restore sys.modules immediately after import ──
# dedup is now cached in this module's namespace; other test files will get
# their own fresh imports without our mock pollution
for _key in list(sys.modules.keys()):
if _key not in _SAVED_MODULES_KEYS:
del sys.modules[_key]
for _key, _value in _SAVED_MODULES_VALUES.items():
if _value is not None:
sys.modules[_key] = _value
elif _key in sys.modules:
del sys.modules[_key]
del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value
class TestHammingDistance:
"""hamming_distance 汉明距离计算测试."""
+1 -1
View File
@@ -208,7 +208,7 @@ class TestGenerationTaskStateTransitions:
def test_transition_to_invalid_string_raises(self):
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
with pytest.raises(ValueError, match="无效状态"):
with pytest.raises(ValueError, match="非法状态转换"):
task.transition_to("invalid_status")
+1 -1
View File
@@ -403,7 +403,7 @@ class TestGenerationTaskTransitions:
def test_invalid_status_string(self, new_task):
"""测试无效状态字符串"""
with pytest.raises(ValueError, match="无效状态"):
with pytest.raises(ValueError, match="非法状态转换"):
new_task.transition_to("invalid_status")
+1 -1
View File
@@ -356,7 +356,7 @@ class TestTransitionTo:
def test_invalid_string_raises(self) -> None:
"""无效的状态字符串抛出 ValueError。"""
task = _make_task()
with pytest.raises(ValueError, match="无效状态"):
with pytest.raises(ValueError, match="非法状态转换"):
task.transition_to("invalid_status")
def test_enum_status(self) -> None: