Compare commits

..

103 Commits

Author SHA1 Message Date
xiaoxia 08179d1369 fix(ci): strip ANSI codes before grep in vitest_incremental.sh
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 47s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 54s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 53s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 56s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m8s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m11s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m22s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m0s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m5s
AI Code Review / AI Code Review (pull_request) Successful in 5m11s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m50s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 5m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m22s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 12s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 41s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1057h28m6s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1057h28m10s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1057h28m12s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1057h28m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1057h29m14s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1057h29m16s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1057h29m18s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1057h30m7s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1057h30m9s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1057h30m11s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1058h1m23s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1058h1m31s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1058h2m31s
vitest outputs ANSI color codes that interfere with grep matching.
'No test files found' skip logic was not triggered, causing CI failure.
Added sed to strip ANSI codes before grep check.

Closes #1221
2026-08-03 13:57:10 +08:00
SaaS Frontend 7594caaf02 fix(auth): 401拦截器排除auth端点 + login默认跳转/app/dashboard
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 32s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 50s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 48s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m28s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m10s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 56s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m0s
AI Code Review / AI Code Review (pull_request) Successful in 4m14s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m37s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 6m36s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 11m1s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 14m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m48s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1058h26m55s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1058h41m33s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1058h41m38s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1058h41m34s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1058h41m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1058h41m50s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1058h41m54s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1058h41m51s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1058h42m38s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1058h44m41s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1058h44m39s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1059h14m51s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1059h15m7s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1059h17m55s
- client.ts: 401响应拦截器增加isAuthEndpoint判断,排除/auth/login、
  /auth/register、/auth/forgot-password、/auth/reset-password,
  避免登录密码错误时触发clearAuth+硬跳转,让401正常走错误提示逻辑
- useAuth.ts: login()默认跳转路径从'/'改为'/app/dashboard',
  与Login.tsx onFinish保持一致,消除双重navigate竞态

Closes #1221
2026-08-03 12:40:39 +08:00
xiaoxia d932f6e0f7 Merge pull request 'refactor(timeline-panel): Phase 1 - extract constants and utils' (#920) from refactor/timeline-panel-phase1-pr into develop
CI/CD Pipeline / Frontend Lint (push) Successful in 38s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m28s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m28s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m0s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m5s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m5s
CI/CD Pipeline / Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Unit Tests (push) Failing after 6m35s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m41s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 14s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m5s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1251h35m38s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1251h53m4s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1251h53m30s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1251h53m32s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1251h56m1s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1251h56m3s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1251h56m26s
CI/CD Pipeline / Build Production API Image (push) Failing after 1252h26m12s
CI/CD Pipeline / PR Build API Image (push) Failing after 1252h28m43s
CI/CD Pipeline / Deploy Production (push) Failing after 1252h25m46s
2026-07-26 11:32:04 +08:00
SaaS Frontend 35e171c2ac fix(phase1): prettier format + add timeline modules to smoke test
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 / Frontend Lint (pull_request) Successful in 48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 53s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m45s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m47s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 24s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m40s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m51s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 14s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m6s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1251h58m55s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1252h0m7s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1252h0m9s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1252h0m13s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1252h0m15s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1252h0m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1252h0m17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1252h0m26s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1252h0m19s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1252h0m52s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1252h0m54s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1252h32m49s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1252h32m59s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1252h33m30s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1252h32m45s
- Run prettier on TimelinePanel.tsx
- Add constants/timeline and utils/timeline to smoke test for vitest related mode
2026-07-26 11:25:26 +08:00
SaaS Frontend 39c89f018a fix(lint): add pps to useEffect deps to fix exhaustive-deps warning
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m48s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m54s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 26s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m4s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 24s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m42s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m59s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m5s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1252h6m0s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1252h7m38s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1252h7m39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1252h7m39s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1252h7m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1252h7m41s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1252h7m45s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1252h7m41s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1252h8m6s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1252h8m49s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1252h8m51s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1252h40m17s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1252h40m21s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1252h41m25s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1252h40m15s
2026-07-26 11:18:03 +08:00
SaaS Frontend c704f9b844 fix(phase1): replace all magic numbers with named constants
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 40s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m31s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m48s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 22s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 24s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m41s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m43s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m5s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m8s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1252h10m29s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1252h12m12s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1252h12m13s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1252h12m15s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1252h12m16s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1252h12m20s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1252h12m22s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1252h12m43s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1252h13m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1252h13m9s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1252h44m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1252h44m56s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1252h45m2s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1252h44m49s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1252h45m45s
- Replace hardcoded values with imported constants
- Fix unused import errors (TS6133)
- MIN/MAX pixels per second, zoom step, clip width, trim duration, add duration, track width, picker width, track gap
2026-07-26 11:13:30 +08:00
SaaS Frontend c71352c2b7 fix(phase1): remove duplicate local definitions, use imported utils
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 39s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m54s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m22s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m4s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 18s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m37s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 24s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m55s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m15s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1252h17m16s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1252h17m19s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1252h17m43s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1252h17m47s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1252h17m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1252h19m54s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1252h19m55s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1252h19m57s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1252h20m5s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1252h21m21s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1252h21m23s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1252h50m23s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1252h52m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1252h53m58s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1252h49m55s
2026-07-26 11:06:02 +08:00
SaaS Frontend 7e63bf7e26 refactor(timeline-panel): Phase 1 - extract constants and utils
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 28s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 49s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 45s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m4s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 2m36s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m9s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m1s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1m1s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m22s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m23s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m56s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1253h14m10s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1253h14m13s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1253h14m11s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1253h14m14s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1253h14m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1253h14m18s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1253h14m20s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1253h14m22s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1253h14m27s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1253h14m57s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1253h15m32s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1253h15m34s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1253h15m36s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1253h47m2s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1253h46m50s
2026-07-26 10:07:13 +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
SaaS Frontend 4105a4df41 fix(ts): remove unused TemplateMode import (TS6133)
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 40s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m23s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m8s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 57s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 50s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m54s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 30s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m25s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m25s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 32s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m45s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1255h42m31s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1255h43m37s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1255h43m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1255h43m40s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1255h43m41s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1255h43m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1255h43m42s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1255h43m48s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1255h43m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1255h44m43s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1255h44m39s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1256h16m16s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1256h16m21s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1256h16m13s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1256h17m18s
2026-07-26 07:42:13 +08:00
SaaS Frontend a1bda3e484 refactor(editing-planner): Phase 3 - extract business hooks
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 32s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 47s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 32s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 57s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 15s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 54s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m3s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 28s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m14s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m36s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1259h45m28s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1259h45m32s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1259h45m34s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1259h45m36s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1259h45m48s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1259h45m38s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1259h45m50s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1259h45m40s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1259h47m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1259h45m42s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1259h47m16s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1259h45m58s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1260h18m23s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1260h19m49s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1260h18m7s
Extract 4 business hooks from EditingPlanner.tsx:
- useEditorDrawers (78行): 11个抽屉开关 + 目标ID + 快捷打开
- usePlaybackControl (56行): 播放/暂停、rAF帧推进、缩放、seek
- useClipOperations (231行): 片段CRUD、重排、裁剪、分割、转场、调速、TTS、配音
- useTemplateManagement (459行): 模板列表/详情/计划加载、保存、模式切换、筛选搜索

主文件: 997→415行(-58%)
2026-07-26 03:39:36 +08:00
xiaoxia 7bf25023a0 Merge pull request 'refactor(editing-planner): Phase 1 - extract constants and selectors' (#900) from refactor/editing-planner-phase1 into develop
CI/CD Pipeline / Frontend Lint (push) Successful in 50s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m46s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m12s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m12s
CI/CD Pipeline / Integration Tests (push) Successful in 2m26s
CI/CD Pipeline / Unit Tests (push) Failing after 4m6s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m38s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 24s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 19m40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 21m50s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1259h17m29s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1259h51m38s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1259h51m43s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1259h51m44s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1259h54m0s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1259h54m2s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1259h54m19s
CI/CD Pipeline / Build Production API Image (push) Failing after 1260h24m23s
CI/CD Pipeline / PR Build API Image (push) Failing after 1260h26m41s
CI/CD Pipeline / Deploy Production (push) Failing after 1260h24m17s
2026-07-26 03:34:12 +08:00
SaaS Frontend e8f7788e56 fix: remove unused currentMode param in getFilteredTemplates
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 32s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 54s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m43s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m2s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m18s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m20s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 22s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m38s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m41s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 17s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m35s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1260h28m19s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1260h29m42s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1260h29m43s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1260h29m45s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1260h29m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1260h29m48s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1260h29m52s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1260h29m54s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1260h31m5s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1260h31m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1260h31m7s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1261h2m21s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1261h2m27s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1261h2m18s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1261h2m34s
2026-07-26 02:54:12 +08:00
SaaS Frontend 27c1f05f74 refactor(editing-planner): Phase 1 - extract constants and selectors 2026-07-26 02:54:03 +08:00
xiaoxia de1311fe31 refactor(editing-planner): Phase 2 - extract UI components (#902)
CI/CD Pipeline / Frontend Lint (push) Successful in 43s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 56s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m2s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m12s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 53s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m27s
CI/CD Pipeline / Integration Tests (push) Successful in 2m28s
CI/CD Pipeline / Unit Tests (push) Failing after 4m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m40s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 14s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m25s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m34s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1260h36m56s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1260h51m1s
CI/CD Pipeline / Deploy Production (push) Failing after 1260h51m5s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1260h51m26s
CI/CD Pipeline / Build Production API Image (push) Failing after 1260h51m30s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1260h54m15s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1260h54m17s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1260h54m33s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1261h24m5s
CI/CD Pipeline / PR Build API Image (push) Failing after 1261h26m56s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-26 02:32:02 +08:00
CI Bot 7ce78a3e8a style: auto-format with black + isort + prettier
CI/CD Pipeline / Frontend Lint (push) Successful in 57s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m38s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m45s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m3s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m44s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m51s
CI/CD Pipeline / Integration Tests (push) Successful in 2m34s
CI/CD Pipeline / Unit Tests (push) Failing after 4m52s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m40s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 14s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m35s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1261h23m32s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1261h40m56s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1261h41m5s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1261h41m7s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1261h42m46s
CI/CD Pipeline / Build Production API Image (push) Failing after 1261h41m9s
CI/CD Pipeline / PR Build API Image (push) Failing after 1261h42m47s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1261h42m48s
CI/CD Pipeline / Deploy Production (push) Failing after 1262h13m35s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1262h15m22s
2026-07-25 17:34:20 +00:00
xiaoxia edb141b1da Merge pull request 'test: 第80波单测 - module_registry+subtitle+voice_library (+74)' (#888) from test/wave80-module-registry-subtitle-more into develop
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m0s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m30s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m39s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m38s
CI/CD Pipeline / Unit Tests (push) Failing after 4m58s
CI/CD Pipeline / Integration Tests (push) Successful in 3m21s
CI/CD Pipeline / Frontend Lint (push) Successful in 50s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m50s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m25s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 15s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m26s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1261h36m5s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1261h54m15s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1261h54m19s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1261h54m21s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1261h56m15s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1261h56m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1261h56m40s
CI/CD Pipeline / Build Production API Image (push) Failing after 1262h27m0s
CI/CD Pipeline / PR Build API Image (push) Failing after 1262h28m54s
CI/CD Pipeline / Deploy Production (push) Failing after 1262h26m54s
2026-07-26 01:31:53 +08:00
CI Bot 2e67be39f7 test: wave80 - module_registry深度 + subtitle_domain + voice_library补充 (+74)
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m24s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 51s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m26s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m46s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 43s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 32s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m57s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m48s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m47s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m43s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 23s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 25s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1262h50m27s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1262h50m35s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1262h50m39s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1262h50m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1262h51m32s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1262h53m1s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1262h53m5s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1262h53m7s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1262h57m36s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1262h57m38s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1262h57m40s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1263h23m13s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1263h25m39s
- module_registry: 新增42个测试
  - Module状态转换(6个)
  - 注册补充场景(5个)
  - 能力查询补充(9个)
  - 依赖检查补充(4个)
  - list_modules补充(3个)
  - QuotaRule补充(3个)
  - ModuleCapability补充(3个)
- subtitle_domain: 新增19个测试
  - merge_short_segments补充(6个)
  - split_long_segments补充(5个)
  - _split_text_by_punctuation补充(8个)
- voice_library_domain: 新增13个测试
  - 各种status状态
  - 零值/大值边界
  - 标签、元数据、project_id
2026-07-26 00:25:22 +08:00
CI Bot f55d0d6f0e style: auto-format with black + isort + prettier
CI/CD Pipeline / Frontend Lint (push) Successful in 33s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m5s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m0s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m58s
CI/CD Pipeline / Unit Tests (push) Failing after 4m39s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m20s
CI/CD Pipeline / Integration Tests (push) Successful in 2m51s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m45s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 15s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m56s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m6s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1262h39m14s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1262h50m46s
CI/CD Pipeline / Deploy Production (push) Failing after 1262h54m9s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1263h1m11s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1263h1m13s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1263h2m1s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1263h2m3s
CI/CD Pipeline / PR Build API Image (push) Failing after 1263h2m5s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1263h2m27s
CI/CD Pipeline / Build Production API Image (push) Failing after 1263h33m51s
2026-07-25 16:17:09 +00:00
xiaoxia f506048240 test(wave85): exceptions+bgm_utils+subtitle深度补充 +59 (#898)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m47s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m8s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m19s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m18s
CI/CD Pipeline / Unit Tests (push) Failing after 4m49s
CI/CD Pipeline / Integration Tests (push) Successful in 2m21s
CI/CD Pipeline / Frontend Lint (push) Successful in 52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m42s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m44s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m15s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 32s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m3s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1262h48m31s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1263h9m53s
CI/CD Pipeline / Build Production API Image (push) Failing after 1263h13m3s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1263h13m59s
CI/CD Pipeline / Deploy Production (push) Failing after 1263h10m40s
CI/CD Pipeline / PR Build API Image (push) Failing after 1263h14m3s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1263h12m59s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1263h14m30s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1263h45m37s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1263h46m37s
2026-07-26 00:13:44 +08:00
xiaoxia 69f2846c95 test(wave84): classification + tts_config 单测深度补充 +52 (#893) 2026-07-26 00:13:44 +08:00
xiaoxia 6437b96e54 refactor(assets): Phase 3 - extract business hooks (#899)
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m17s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m8s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m20s
CI/CD Pipeline / Unit Tests (push) Failing after 5m11s
CI/CD Pipeline / Integration Tests (push) Successful in 3m7s
CI/CD Pipeline / Frontend Lint (push) Successful in 55s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m8s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m27s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 8m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 11m0s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 28s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1263h2m30s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1263h24m8s
CI/CD Pipeline / Deploy Production (push) Failing after 1263h24m14s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1263h25m30s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1263h25m32s
CI/CD Pipeline / Build Production API Image (push) Failing after 1263h25m34s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1263h29m42s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1263h30m8s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1263h29m44s
CI/CD Pipeline / PR Build API Image (push) Failing after 1264h2m22s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-25 23:55:16 +08:00
xiaoxia dfbd141895 test(wave83): generated_video/verification/video_share单测 +55 (#891)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m54s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m14s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m22s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m8s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m33s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m25s
CI/CD Pipeline / Integration Tests (push) Successful in 2m38s
CI/CD Pipeline / Unit Tests (push) Failing after 6m7s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m12s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 13s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 6m24s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1264h24m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1264h44m33s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1264h44m53s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1264h44m55s
CI/CD Pipeline / Build Production API Image (push) Failing after 1264h44m57s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1264h46m51s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1264h46m51s
CI/CD Pipeline / PR Build API Image (push) Failing after 1264h46m51s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1264h46m53s
CI/CD Pipeline / Deploy Production (push) Failing after 1265h17m12s
2026-07-25 22:33:26 +08:00
xiaoxia 7cbe5d6457 test(wave82): exceptions/template/edit_template单测 +86 (#890) 2026-07-25 22:33:25 +08:00
xiaoxia 2e352ec127 test(wave81): 小领域模块深度补充单测 +73 (#889) 2026-07-25 22:33:25 +08:00
CI Bot 5db88d0325 style: auto-format with black + isort + prettier
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m18s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 5m46s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m23s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m32s
CI/CD Pipeline / Unit Tests (push) Failing after 4m48s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m0s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m23s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 32s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m34s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 8m56s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1264h34m34s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1264h58m16s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1264h58m20s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1264h58m22s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1264h58m29s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1264h58m31s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1264h59m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1265h30m55s
CI/CD Pipeline / Build Production API Image (push) Failing after 1265h31m1s
CI/CD Pipeline / PR Build API Image (push) Failing after 1265h31m10s
2026-07-25 14:16:40 +00:00
xiaoxia 8bf85821b7 test(wave79): subtitle_generator+text_splitter纯函数单测 +62 (#887)
CI/CD Pipeline / Canary Release to Production (push) Failing after 1264h49m3s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1265h7m18s
CI/CD Pipeline / Deploy Production (push) Failing after 1265h9m54s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1265h11m24s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1265h11m42s
CI/CD Pipeline / PR Build API Image (push) Failing after 1265h11m45s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1265h11m26s
CI/CD Pipeline / Build Production API Image (push) Failing after 1265h11m28s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1265h13m15s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m23s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 52s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 53s
CI/CD Pipeline / Unit Tests (push) Failing after 5m1s
CI/CD Pipeline / Integration Tests (push) Successful in 2m35s
CI/CD Pipeline / Frontend Lint (push) Successful in 28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m34s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1265h44m19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m23s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 7m25s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 23s
2026-07-25 22:01:09 +08:00
xiaoxia 644d33c064 test(wave78): duplication+quota查重配额单测 +80 (#886) 2026-07-25 22:01:08 +08:00
xiaoxia ce27555b00 test(wave77): GenerationTask状态机+日志单测 +59 (#885) 2026-07-25 22:01:08 +08:00
CI Bot 5b967cf74d style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-07-25 13:59:08 +00:00
xiaoxia 4b62c5227d test(wave76): TTSJob/Job/Tag状态机深度单测 +101 (#884)
CI/CD Pipeline / Canary Release to Production (push) Failing after 1265h1m56s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1265h7m27s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1265h14m47s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1265h14m49s
CI/CD Pipeline / Build Production API Image (push) Failing after 1265h14m51s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1265h28m11s
CI/CD Pipeline / PR Build API Image (push) Failing after 1265h28m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1265h30m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m10s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 6m50s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 7m33s
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m17s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 35s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m0s
CI/CD Pipeline / Unit Tests (push) Failing after 3m30s
CI/CD Pipeline / Integration Tests (push) Successful in 2m6s
CI/CD Pipeline / Frontend Lint (push) Successful in 28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m1s
CI/CD Pipeline / Build Staging API Image (push) Successful in 10m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m31s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m36s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 6s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1266h0m49s
2026-07-25 21:25:31 +08:00
xiaoxia bdb2b403b8 test(wave74): classification/bgm_utils/subtitle 单测深度补充 +106 (#882) 2026-07-25 21:25:16 +08:00
xiaoxia 32a95d09f0 refactor(assets): Phase 2 - extract UI components (#897)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m49s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m10s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m17s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m10s
CI/CD Pipeline / Unit Tests (push) Failing after 5m58s
CI/CD Pipeline / Integration Tests (push) Successful in 2m33s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m18s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1266h21m15s
CI/CD Pipeline / Deploy Production (push) Failing after 1266h29m2s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1266h32m49s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1266h35m3s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1266h32m51s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1266h35m4s
CI/CD Pipeline / PR Build API Image (push) Failing after 1266h35m5s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1266h35m6s
CI/CD Pipeline / Build Production API Image (push) Failing after 1267h5m29s
2026-07-25 20:53:22 +08:00
xiaoxia 8d280e3df9 fix(test): 修复test_dedup_pure收集阶段db连接副作用 + 完整mock (#894) 2026-07-25 20:53:22 +08:00
xiaoxia 0cc02c698c fix(migration): 补充 generation_tasks.bgm_config 字段 migration(P0 生成接口500修复)(#895) 2026-07-25 20:53:22 +08:00
xiaoxia 2972f19954 test(ci): 修复10个ruff错误 + Unit Tests dedup收集失败 (#839 #881)
CI/CD Pipeline / Frontend Lint (push) Successful in 58s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m38s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m42s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m23s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m27s
CI/CD Pipeline / Unit Tests (push) Failing after 5m54s
CI/CD Pipeline / Integration Tests (push) Successful in 2m26s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m3s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1269h30m20s
CI/CD Pipeline / Deploy Production (push) Failing after 1269h45m45s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1269h45m50s
CI/CD Pipeline / Build Production API Image (push) Failing after 1269h45m54s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1269h47m30s
CI/CD Pipeline / PR Build API Image (push) Failing after 1269h47m32s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1269h47m34s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1270h18m27s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1270h20m7s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1270h18m18s
2026-07-25 17:38:17 +08:00
xiaoxia 32868e54ad refactor(assets): Phase 1 - extract types, constants and utils (#892)
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m44s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 57s
CI/CD Pipeline / Unit Tests (push) Failing after 3m28s
CI/CD Pipeline / Integration Tests (push) Successful in 2m13s
CI/CD Pipeline / Frontend Lint (push) Successful in 38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m0s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m44s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m33s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 7s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1269h47m34s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1269h58m52s
CI/CD Pipeline / Deploy Production (push) Failing after 1270h2m23s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1270h6m44s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1270h9m7s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1270h9m9s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1270h6m46s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1270h10m8s
CI/CD Pipeline / Build Production API Image (push) Failing after 1270h39m23s
CI/CD Pipeline / PR Build API Image (push) Failing after 1270h41m46s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-25 17:18:27 +08:00
xiaoxia d50edcb2e6 refactor(voices): Phase 3 - extract 5 business hooks (#877)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m1s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 58s
CI/CD Pipeline / Frontend Lint (push) Successful in 42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m0s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m34s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m35s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m9s
CI/CD Pipeline / Integration Tests (push) Successful in 1m46s
CI/CD Pipeline / Unit Tests (push) Failing after 3m22s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1271h1m39s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1271h21m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1271h21m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1271h22m43s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1272h15m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1272h15m58s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1272h16m16s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1272h16m18s
CI/CD Pipeline / Build Production API Image (push) Failing after 1272h16m20s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1272h18m8s
CI/CD Pipeline / PR Build API Image (push) Failing after 1272h18m10s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1272h19m14s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1271h54m3s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1272h50m44s
2026-07-25 15:09:05 +08:00
xiaoxia a9aface0b8 feat(ci): 金丝雀发布流水线(#450)
CI/CD Pipeline / Frontend Lint (push) Successful in 48s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m47s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m8s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m29s
CI/CD Pipeline / Unit Tests (push) Failing after 4m59s
CI/CD Pipeline / Build Staging API Image (push) Failing after 6m35s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 6m7s
CI/CD Pipeline / Integration Tests (push) Successful in 2m20s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1272h37m52s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1272h37m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1272h37m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1272h38m2s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1272h38m3s
CI/CD Pipeline / Deploy Production (push) Failing after 1272h38m53s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1272h42m47s
CI/CD Pipeline / Build Production API Image (push) Failing after 1272h42m51s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1272h44m41s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1272h45m1s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1272h44m41s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1273h10m32s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1273h15m23s
CI/CD Pipeline / PR Build API Image (push) Failing after 1273h17m17s
金丝雀发布流水线:5%→20%→50%→100%分阶段灰度,自动健康检查+异常回滚,main分支push自动触发。
2026-07-25 14:43:37 +08:00
xiaoxia dc62884c5f test(unit): 第70波 - 去重算法 + 字幕渲染 + TTS引擎纯逻辑 (+73) (#868)
CI/CD Pipeline / Frontend Lint (push) Successful in 57s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m30s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m38s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m59s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m49s
CI/CD Pipeline / Unit Tests (push) Failing after 4m29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m16s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 15s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m13s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m38s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1272h59m43s
CI/CD Pipeline / Deploy Production (push) Failing after 1273h1m49s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1273h8m27s
CI/CD Pipeline / Build Production API Image (push) Failing after 1273h8m31s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1273h10m12s
CI/CD Pipeline / PR Build API Image (push) Failing after 1273h10m14s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1273h10m16s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1273h41m3s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1273h42m48s
2026-07-25 14:18:22 +08:00
xiaoxia 9b06d814bd refactor(voices): Phase 2 - extract 7 UI components (#875)
CI/CD Pipeline / Frontend Lint (push) Successful in 37s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m1s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 58s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m31s
CI/CD Pipeline / Integration Tests (push) Successful in 2m43s
CI/CD Pipeline / Unit Tests (push) Failing after 5m37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m12s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 29s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m45s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m17s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1273h26m53s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1273h28m12s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1273h28m14s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1273h30m22s
CI/CD Pipeline / Build Production API Image (push) Failing after 1273h28m16s
CI/CD Pipeline / PR Build API Image (push) Failing after 1273h30m24s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1273h31m17s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1274h2m54s
CI/CD Pipeline / Deploy Production (push) Failing after 1273h59m43s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-25 13:57:19 +08:00
xiaoxia 248eb3266d feat(ci): 添加Renovate依赖自动更新(#453) (#876)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 57s
CI/CD Pipeline / Frontend Lint (push) Successful in 30s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m43s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m16s
CI/CD Pipeline / Unit Tests (push) Failing after 5m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m57s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m55s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1274h5m11s
CI/CD Pipeline / Deploy Production (push) Failing after 1274h5m39s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1274h9m49s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1274h10m20s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1274h9m51s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1274h10m22s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1274h11m21s
CI/CD Pipeline / PR Build API Image (push) Failing after 1274h42m55s
CI/CD Pipeline / Build Production API Image (push) Failing after 1274h42m26s
2026-07-25 13:16:49 +08:00
xiaoxia c575a4b835 test(unit): 第56波 worker层更多引擎纯逻辑单测(+108)(#848)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m21s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m55s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m52s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m27s
CI/CD Pipeline / Unit Tests (push) Failing after 6m39s
CI/CD Pipeline / Integration Tests (push) Successful in 2m41s
CI/CD Pipeline / Frontend Lint (push) Successful in 44s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m0s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m11s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 5s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m11s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m28s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1274h18m59s
CI/CD Pipeline / Deploy Production (push) Failing after 1274h19m39s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1274h26m28s
CI/CD Pipeline / Build Production API Image (push) Failing after 1274h26m32s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1274h26m49s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1274h28m34s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1274h26m51s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1274h59m3s
CI/CD Pipeline / PR Build API Image (push) Failing after 1274h59m26s
2026-07-25 12:56:26 +08:00
CI Bot 22a34aebf1 style: auto-format with black + isort + prettier
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 24s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m32s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m48s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m48s
CI/CD Pipeline / Unit Tests (push) Failing after 6m5s
CI/CD Pipeline / Integration Tests (push) Successful in 2m13s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m50s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m56s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m56s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1274h43m0s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1274h44m15s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1274h44m15s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1274h46m2s
CI/CD Pipeline / Build Production API Image (push) Failing after 1274h44m15s
CI/CD Pipeline / PR Build API Image (push) Failing after 1274h46m2s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1274h46m4s
CI/CD Pipeline / Deploy Production (push) Failing after 1275h15m36s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1275h18m33s
2026-07-25 04:32:40 +00:00
xiaoxia 40776cd4fa refactor(voices): Phase 1 - extract types, constants and utils (#873)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m36s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 4m40s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m22s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m21s
CI/CD Pipeline / Unit Tests (push) Failing after 6m50s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Frontend Lint (push) Successful in 42s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m22s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 32s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 6m9s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 6m45s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1274h54m16s
CI/CD Pipeline / Deploy Production (push) Failing after 1274h57m46s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1274h59m11s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1275h0m4s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1275h0m6s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1274h59m12s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1275h0m36s
CI/CD Pipeline / Build Production API Image (push) Failing after 1275h31m47s
CI/CD Pipeline / PR Build API Image (push) Failing after 1275h32m40s
2026-07-25 12:25:14 +08:00
xiaoxia e9bc340612 feat(ci): ACR镜像自动清理增强版(#524)
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m39s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m11s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 58s
CI/CD Pipeline / Unit Tests (push) Failing after 5m46s
CI/CD Pipeline / Integration Tests (push) Successful in 3m19s
CI/CD Pipeline / Frontend Lint (push) Successful in 41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m9s
CI/CD Pipeline / Build Staging API Image (push) Failing after 14m39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m44s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m0s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1274h59m8s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1274h59m9s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1274h59m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1275h0m38s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1275h2m30s
CI/CD Pipeline / Deploy Production (push) Failing after 1275h5m54s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1275h11m30s
CI/CD Pipeline / Build Production API Image (push) Failing after 1275h11m34s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1275h15m34s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1275h16m50s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1275h15m35s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1275h44m6s
CI/CD Pipeline / PR Build API Image (push) Failing after 1275h48m11s
ACR镜像自动清理增强版:
- 3种执行模式:PR-SHA清理/cron全量/手动预览
- 生产镜像保护:main/v*版本tag/固定tag永不删除
- develop镜像保留最近20个commit hash tag
- PR镜像关闭后自动清理(Gitea PR状态检查 + 7天降级保留)
- 安全兜底:staging运行中镜像白名单
- workflow触发:cron每天03:00 + PR关闭事件 + 手动触发
2026-07-25 12:07:17 +08:00
CI Bot 71faf2ba44 style: auto-format with black + isort + prettier
CI/CD Pipeline / Frontend Lint (push) Successful in 44s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m11s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m48s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m52s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m28s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Unit Tests (push) Failing after 6m22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m15s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m2s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1275h36m23s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1275h40m49s
CI/CD Pipeline / Build Production API Image (push) Failing after 1275h40m51s
CI/CD Pipeline / Deploy Production (push) Failing after 1275h39m14s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1275h41m58s
CI/CD Pipeline / PR Build API Image (push) Failing after 1275h42m0s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1275h42m2s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1276h13m20s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1276h14m29s
2026-07-25 03:33:12 +00:00
xiaoxia 26db4ac44d test: 第73波 url_security + pagination + text_splitter 单测补充 (+68)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-25 11:32:38 +08:00
xiaoxia aa0b0de90f test(unit): 第72波 - TTSJob状态机 + GeneratedVideo + VerificationCode (+76)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-25 11:32:35 +08:00
xiaoxia 3204bf2d1f test(unit): 第71波 - 领域实体扩展测试 Project+Asset+IngestJob+User+AssetLibrary (+57)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m29s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m44s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m11s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m13s
CI/CD Pipeline / Unit Tests (push) Failing after 5m59s
CI/CD Pipeline / Integration Tests (push) Successful in 2m31s
CI/CD Pipeline / Frontend Lint (push) Successful in 39s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m9s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m27s
CI/CD Pipeline / Deploy Production (push) Failing after 1275h53m47s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1275h53m45s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1275h55m8s
CI/CD Pipeline / Build Production API Image (push) Failing after 1275h55m12s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1275h56m9s
CI/CD Pipeline / PR Build API Image (push) Failing after 1275h56m13s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1275h57m12s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1276h27m43s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1276h28m44s
2026-07-25 11:31:28 +08:00
xiaoxia ee2cc0e7a1 refactor(voice-materials): phase 3 extract hooks (#866)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m11s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m12s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m14s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m36s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m56s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m37s
CI/CD Pipeline / Unit Tests (push) Failing after 5m50s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m55s
CI/CD Pipeline / Integration Tests (push) Successful in 2m7s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m9s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m12s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m33s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1277h9m46s
CI/CD Pipeline / Deploy Production (push) Failing after 1277h15m45s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1277h22m14s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1277h22m43s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1277h22m16s
CI/CD Pipeline / PR Build API Image (push) Failing after 1277h22m44s
CI/CD Pipeline / Build Production API Image (push) Failing after 1277h22m18s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1277h23m56s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1277h55m14s
2026-07-25 10:04:02 +08:00
xiaoxia be0b4f4dac fix: 状态枚举添加_missing_兼容历史脏数据,修复Staging模板生成接口500 #809 (#835)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-25 10:04:00 +08:00
xiaoxia 1e16e81344 chore(ci): 升级migration验证,新增4项检查 (#451)
CI/CD Pipeline / Production Browser E2E (push) Failing after 1277h35m35s
CI/CD Pipeline / Deploy Production (push) Failing after 1277h37m19s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1277h42m2s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1277h44m23s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1277h44m25s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1277h42m4s
CI/CD Pipeline / PR Build API Image (push) Failing after 1277h44m27s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1277h45m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m2s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m19s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m39s
CI/CD Pipeline / Build Production API Image (push) Failing after 1278h14m38s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m9s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 53s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 53s
CI/CD Pipeline / Unit Tests (push) Failing after 6m4s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 44s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m5s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
#451 数据库迁移检查升级

新增5项完整检查:
1. 命名规范检查(文件名格式/编号连续/revision一致性)
2. 链完整性检查(revision/down_revision链)
3. upgrade head(原有逻辑)
4. downgrade -1 + re-upgrade(双向一致性)
5. alembic check(未生成migration的model变更检测,警告模式)
2026-07-25 09:43:26 +08:00
CI Bot 7a72cfd709 style: auto-format with black + isort + prettier
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m9s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m19s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m7s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m37s
CI/CD Pipeline / Integration Tests (push) Successful in 2m18s
CI/CD Pipeline / Unit Tests (push) Failing after 4m59s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m25s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 27m2s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 30m15s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1278h57m2s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1278h58m2s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1278h58m4s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1279h0m13s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1279h0m14s
CI/CD Pipeline / PR Build API Image (push) Failing after 1279h0m14s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1279h0m15s
CI/CD Pipeline / Build Production API Image (push) Failing after 1279h30m38s
CI/CD Pipeline / Deploy Production (push) Failing after 1279h29m36s
2026-07-25 00:16:10 +00:00
xiaoxia b82b303d6a refactor: VoiceMaterialLibrary Phase 2 - 抽离4个子组件 (#858)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-25 08:13:23 +08:00
xiaoxia baa3bf6300 test(unit): 第69波 - 统一渲染+音频+适配器纯逻辑测试 (+53) (#865)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-25 08:13:19 +08:00
xiaoxia d7788013de test(unit): 第68波 - ffmpeg纯函数 + 模板编辑器工具 + OSS助手纯逻辑 (+83) (#864)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-25 08:13:16 +08:00
xiaoxia ff3038a291 test(unit): 第67波 - trim引擎 + concat引擎 + subtitle_render引擎配置解析 (+112) (#863)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-25 08:13:12 +08:00
xiaoxia 10f9e67c8e test(unit): 第66波 - multi_track + bgm_mixer + sticker 配置 (+52) (#862)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-25 08:13:08 +08:00
xiaoxia c192398db6 test(unit): 第65波 - intro_outro + transition + pip 引擎配置 (+82) (#861)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-25 08:13:03 +08:00
xiaoxia 17cf970ae6 test(unit): 第64波 - speed + chroma_key + color_grade 引擎配置 (+96) (#860)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-25 08:13:00 +08:00
xiaoxia 5b21763aea test(unit): 第63波 - watermark + noise_reduction + thumbnail 纯逻辑 (+89) (#859)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-25 08:12:57 +08:00
xiaoxia c90b4819c0 test(unit): 第62波 - module_registry + asr_service_factory + sms_service (+58) (#856)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m40s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m21s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m42s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m42s
CI/CD Pipeline / Unit Tests (push) Failing after 5m59s
CI/CD Pipeline / Integration Tests (push) Successful in 3m26s
CI/CD Pipeline / Frontend Lint (push) Successful in 57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m29s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m4s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 35m30s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 39m55s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1279h11m55s
CI/CD Pipeline / Deploy Production (push) Failing after 1279h12m25s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1279h14m4s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1279h14m6s
CI/CD Pipeline / Build Production API Image (push) Failing after 1279h14m6s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1279h15m46s
CI/CD Pipeline / PR Build API Image (push) Failing after 1279h15m48s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1279h15m48s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1279h48m18s
2026-07-25 08:12:53 +08:00
CI Bot 464f6ea155 style: auto-format with black + isort + prettier
CI/CD Pipeline / Frontend Lint (push) Successful in 41s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m2s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m25s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m7s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m15s
CI/CD Pipeline / Unit Tests (push) Failing after 4m43s
CI/CD Pipeline / Integration Tests (push) Successful in 2m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 27s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m29s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m31s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1286h40m0s
CI/CD Pipeline / Deploy Production (push) Failing after 1286h55m17s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1287h5m54s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1287h6m9s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1287h5m56s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1287h6m11s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1287h7m3s
CI/CD Pipeline / Build Production API Image (push) Failing after 1287h38m28s
CI/CD Pipeline / PR Build API Image (push) Failing after 1287h38m43s
2026-07-24 16:00:04 +00:00
xiaoxia d2bdb462c6 test(P3-1): 第61波 mock ASR/TTS + TTS工厂单测(+52) (#854)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 23:57:35 +08:00
xiaoxia 745c851758 refactor: VoiceMaterialLibrary Phase 1 - 抽离类型/常量/工具函数 (#846)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m29s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m42s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m41s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m39s
CI/CD Pipeline / Unit Tests (push) Failing after 6m57s
CI/CD Pipeline / Integration Tests (push) Successful in 3m6s
CI/CD Pipeline / Frontend Lint (push) Successful in 46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m50s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m51s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m35s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 37s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m8s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1287h29m34s
CI/CD Pipeline / Deploy Production (push) Failing after 1287h29m36s
CI/CD Pipeline / Build Production API Image (push) Failing after 1287h29m40s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1287h29m38s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1287h31m14s
CI/CD Pipeline / PR Build API Image (push) Failing after 1287h31m18s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1287h31m20s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1288h2m8s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1288h3m46s
2026-07-24 23:57:23 +08:00
CI Bot 18bd0de3fa style: auto-format with black + isort + prettier
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m42s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m44s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m42s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m26s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m22s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m16s
CI/CD Pipeline / Unit Tests (push) Failing after 5m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m18s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m58s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1289h2m32s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1289h5m6s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1289h5m7s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1289h6m49s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1289h6m50s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1289h6m52s
CI/CD Pipeline / Deploy Production (push) Failing after 1289h35m22s
CI/CD Pipeline / Build Production API Image (push) Failing after 1289h37m37s
CI/CD Pipeline / PR Build API Image (push) Failing after 1289h39m20s
2026-07-24 14:07:12 +00:00
xiaoxia 04bcd2e6f9 test(P3-1): 第60波 reverse引擎+in-memory素材+task_enqueue单测(+85) (#852)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 22:06:07 +08:00
xiaoxia f7151ea2be test(P3-1): 第59波 Schema验证+FeatureFlags单测(+72) (#851)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m36s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m44s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m12s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 56s
CI/CD Pipeline / Unit Tests (push) Failing after 6m55s
CI/CD Pipeline / Integration Tests (push) Successful in 3m8s
CI/CD Pipeline / Frontend Lint (push) Successful in 41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m10s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m41s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1289h16m24s
CI/CD Pipeline / Deploy Production (push) Failing after 1289h19m44s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1289h20m39s
CI/CD Pipeline / Build Production API Image (push) Failing after 1289h20m43s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1289h21m56s
CI/CD Pipeline / PR Build API Image (push) Failing after 1289h22m0s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1289h23m16s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1289h53m10s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1289h54m27s
2026-07-24 22:04:50 +08:00
xiaoxia cea83329c8 test(P3-1): 第58波 in-memory仓储层单测(+57)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 22:03:50 +08:00
CI Bot 2df7bc9dc8 style: auto-format with black + isort + prettier
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m45s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m54s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m34s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m49s
CI/CD Pipeline / Frontend Lint (push) Successful in 6m0s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m27s
CI/CD Pipeline / Integration Tests (push) Successful in 2m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m11s
CI/CD Pipeline / Unit Tests (push) Failing after 5m52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m10s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1289h38m58s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1289h42m13s
CI/CD Pipeline / Deploy Production (push) Failing after 1289h40m1s
CI/CD Pipeline / Build Production API Image (push) Failing after 1289h42m15s
CI/CD Pipeline / PR Build API Image (push) Failing after 1289h43m56s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1289h43m55s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1289h43m57s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1290h14m40s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1290h16m23s
2026-07-24 13:35:05 +00:00
xiaoxia f6a1798124 test(P3-1): 第54波 domain 单测 - 剩余12个小模块(+62)domain层扫完 (#844)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m39s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m38s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m28s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m27s
CI/CD Pipeline / Unit Tests (push) Failing after 6m21s
CI/CD Pipeline / Integration Tests (push) Successful in 3m3s
CI/CD Pipeline / Frontend Lint (push) Successful in 44s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m20s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m17s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1289h53m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1289h53m58s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1289h54m2s
CI/CD Pipeline / Build Production API Image (push) Failing after 1289h54m6s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1289h55m41s
CI/CD Pipeline / PR Build API Image (push) Failing after 1289h55m45s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1289h56m16s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1290h26m33s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1290h28m12s
2026-07-24 21:30:59 +08:00
xiaoxia 3ca32f3532 test(P3-1): 第53波 domain 单测 - tts_job/voice_clone_profile/video_share(+133) (#843)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 21:30:57 +08:00
xiaoxia 03b42e1561 test(P3-1): 第52波 domain 单测 - config_schemas/filter_presets/transition_presets(+129) (#842)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 21:30:54 +08:00
xiaoxia 972ff89990 test(P3-1): 第51波 domain 单测 - quota/job/entities(+183) (#840)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 21:30:52 +08:00
xiaoxia 37d111dcd5 test(P3-1): 第57波 worker层小工具+配置单测(+60) (#849)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 21:29:40 +08:00
xiaoxia 0e86062f17 test(P3-1): 第55波 worker层引擎配置验证单测(+120) (#847)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 21:29:37 +08:00
xiaoxia 6e70a7ad29 chore(ci): 端口变量命名统一 (#799) (#845)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 21:29:35 +08:00
xiaoxia c9c7da0c74 docs: 新增统一端口分配清单文档 (#798) (#841)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 21:29:32 +08:00
xiaoxia cf544881fd refactor: GeneratePage Phase 3 - Step内部子组件拆分 (#816)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m9s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m11s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m45s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m55s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m5s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m14s
CI/CD Pipeline / Integration Tests (push) Successful in 3m8s
CI/CD Pipeline / Unit Tests (push) Successful in 7m24s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m22s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m8s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1291h7m30s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1291h7m57s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1291h7m59s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1291h10m11s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1291h10m12s
CI/CD Pipeline / PR Build API Image (push) Failing after 1291h10m12s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1291h10m13s
CI/CD Pipeline / Deploy Production (push) Failing after 1291h40m1s
CI/CD Pipeline / Build Production API Image (push) Failing after 1291h40m30s
2026-07-24 20:18:32 +08:00
xiaoxia 42f92017de fix(ci): Preview Deploy npm ci参数错误修复 (#834)
CI/CD Pipeline / Frontend Lint (push) Successful in 44s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m7s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m49s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m43s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 42s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m36s
CI/CD Pipeline / Integration Tests (push) Successful in 3m51s
CI/CD Pipeline / Unit Tests (push) Successful in 8m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m9s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m39s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1291h50m26s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1291h55m23s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1291h55m25s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1291h57m15s
CI/CD Pipeline / Build Production API Image (push) Failing after 1291h55m27s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1291h56m25s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1291h56m27s
CI/CD Pipeline / Deploy Production (push) Failing after 1292h24m35s
CI/CD Pipeline / PR Build API Image (push) Failing after 1292h28m58s
2026-07-24 19:19:52 +08:00
xiaoxia 58fe71c483 PR #832: Worker基础镜像三级缓存策略优化 + 测试环境变量污染修复
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m48s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 55s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 48s
CI/CD Pipeline / Unit Tests (push) Successful in 6m16s
CI/CD Pipeline / Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Frontend Lint (push) Successful in 35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 14m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 23s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m50s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m10s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1292h5m15s
CI/CD Pipeline / Build Production API Image (push) Failing after 1292h11m7s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1292h11m20s
CI/CD Pipeline / PR Build API Image (push) Failing after 1292h11m22s
CI/CD Pipeline / Deploy Production (push) Failing after 1292h5m46s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1292h13m26s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1292h11m3s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1292h43m34s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1292h43m49s
perf(ci): Worker基础镜像三级缓存策略优化 + 单元测试环境变量污染修复

- 三级缓存策略:L1本地daemon / L2 Gitea Registry / L3本地构建+推送
- 修复pre-build镜像tag与Dockerfile FROM地址不一致的bug
- worker.Dockerfile层顺序优化,变化少的文件放前面
- 修复test_api_settings.py autouse fixture环境变量污染
  (JWT_SECRET_KEY/DATABASE_URL被pop后影响后续测试模块)
2026-07-24 19:04:19 +08:00
xiaoxia 3ae15eb4fc refactor(#778): 统一Storage接口,定义StoragePort+SharedStorageService作为唯一入口 (#794)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 19:00:13 +08:00
CI Bot 68e55df83b style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-24 10:59:46 +00:00
xiaoxia 78afa40458 test: P3-1 第50波单元测试(generation_task/edit_plan/edit_template/edit_plan_clip,+86) (#838)
CI/CD Pipeline / Production Browser E2E (push) Failing after 1292h14m30s
CI/CD Pipeline / Deploy Production (push) Failing after 1292h21m58s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1292h28m31s
CI/CD Pipeline / Build Production API Image (push) Failing after 1292h28m35s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1292h29m16s
CI/CD Pipeline / PR Build API Image (push) Failing after 1292h29m20s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1292h30m21s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m19s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 55s
CI/CD Pipeline / Unit Tests (push) Failing after 6m24s
CI/CD Pipeline / Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m45s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m59s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m16s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m53s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1293h1m1s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1293h1m46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 34s
2026-07-24 18:58:21 +08:00
xiaoxia e38f13eb96 test: P3-1 第49波单元测试(generated_video/duplication/tts_config/classification,+88) (#837)
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1292h30m27s
2026-07-24 18:58:17 +08:00
xiaoxia 42335eff42 test: P3-1 第48波单元测试(tag/verification_code/subtitle/voice_presets,+59) (#836)
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1292h30m30s
2026-07-24 18:58:14 +08:00
xiaoxia 9a51cad137 refactor: GeneratePage Phase 2 — 7个Step组件化 + 自定义Hook(2682→485行) (#803)
CI/CD Pipeline / Frontend Lint (push) Successful in 48s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m24s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m25s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m27s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m8s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m20s
CI/CD Pipeline / Integration Tests (push) Successful in 3m53s
CI/CD Pipeline / Unit Tests (push) Failing after 7m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 26s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m3s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m15s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1294h4m36s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1294h5m22s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1294h5m22s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1294h7m45s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1294h7m47s
CI/CD Pipeline / PR Build API Image (push) Failing after 1294h7m47s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1294h7m49s
CI/CD Pipeline / Deploy Production (push) Failing after 1294h37m5s
CI/CD Pipeline / Build Production API Image (push) Failing after 1294h37m51s
2026-07-24 17:20:59 +08:00
239 changed files with 43159 additions and 13723 deletions
+162
View File
@@ -0,0 +1,162 @@
name: ACR Cleanup
on:
schedule:
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00
workflow_dispatch:
inputs:
pr_sha:
description: "PR commit SHA(仅清理指定PR镜像,留空则全量清理)"
required: false
default: ""
dry_run:
description: "预览模式(dry-run),不实际删除"
required: false
default: "true"
pull_request_target:
types: [closed]
branches: [develop, main]
concurrency:
group: acr-cleanup-${{ gitea.ref }}
cancel-in-progress: false
jobs:
cleanup:
name: ACR Image Cleanup
runs-on: ci-l2
timeout-minutes: 20
permissions:
contents: read
env:
ACR_REGISTRY: xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com
ACR_NAMESPACE: xiaoxiakeji
ACR_SERVICE: registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa
GITEA_URL: https://git.xiaoxiajianji.com
GITEA_REPO: xiaoxia/xiaoxia-saas
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
- name: Get staging running images (whitelist)
id: protected_images
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
env:
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set +e
echo "获取staging服务器运行中镜像作为白名单..."
mkdir -p ~/.ssh
echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
staging_port="${STAGING_SSH_PORT:-22222}"
ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null
# 获取所有运行容器的镜像,提取tag部分
IMAGES=$(ssh -p "$staging_port" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no \
"root@$staging_host" "docker ps --format '{{.Image}}' 2>/dev/null" 2>/dev/null | grep -v "^$" | sort -u)
PROTECTED_TAGS=""
if [ -n "$IMAGES" ]; then
while IFS= read -r img; do
# 从完整镜像名中提取tag(最后一个冒号后)
tag=$(echo "$img" | rev | cut -d: -f1 | rev)
if [ -n "$tag" ] && [ "$tag" != "latest" ] && [ ${#tag} -gt 5 ]; then
if [ -z "$PROTECTED_TAGS" ]; then
PROTECTED_TAGS="$tag"
else
PROTECTED_TAGS="$PROTECTED_TAGS,$tag"
fi
fi
done <<< "$IMAGES"
fi
echo "staging运行中镜像tag: ${PROTECTED_TAGS:-(无)}"
echo "protected_tags=$PROTECTED_TAGS" >> $GITEA_OUTPUT
# ====== Docker登录 ======
- name: Docker login to ACR
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
run: |
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_REGISTRY" -u "$ACR_USERNAME" --password-stdin
# ====== 模式1:PR关闭时清理 ======
- name: Cleanup PR images (PR closed)
if: gitea.event_name == 'pull_request_target'
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PR_SHA: ${{ gitea.event.pull_request.head.sha }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
run: |
echo "============================================"
echo " PR #$PR_NUMBER 已关闭,清理对应镜像"
echo " Head SHA: ${PR_SHA::12}"
echo "============================================"
echo ""
python3 scripts/ci/acr_cleanup.py \
--pr-sha "$PR_SHA" \
--execute
# ====== 模式2:Cron全量清理 ======
- name: Full cleanup (cron / manual)
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PROTECTED_TAGS: ${{ steps.protected_images.outputs.protected_tags }}
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
run: |
echo "============================================"
echo " ACR 全量清理(${{ gitea.event_name }})"
echo "============================================"
echo ""
# 决定是否dry-run
DRY_RUN_FLAG=""
if [ "$DRY_RUN_INPUT" = "true" ]; then
DRY_RUN_FLAG="--dry-run"
echo "模式: 预览模式 (dry-run)"
else
echo "模式: 执行模式"
fi
echo ""
python3 scripts/ci/acr_cleanup.py \
--keep 20 \
--protected-tags "$PROTECTED_TAGS" \
$DRY_RUN_FLAG
# ====== 模式3:手动指定PR SHA清理 ======
- name: Cleanup specific PR image (manual)
if: gitea.event_name == 'workflow_dispatch' && gitea.event.inputs.pr_sha
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
PR_SHA: ${{ gitea.event.inputs.pr_sha }}
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
run: |
echo "手动清理PR镜像: ${PR_SHA::12}"
echo ""
DRY_RUN_FLAG=""
if [ "$DRY_RUN_INPUT" = "true" ]; then
DRY_RUN_FLAG="--dry-run"
fi
python3 scripts/ci/acr_cleanup.py \
--pr-sha "$PR_SHA" \
$DRY_RUN_FLAG
+98 -7
View File
@@ -26,7 +26,7 @@ concurrency:
cancel-in-progress: ${{ gitea.event_name == 'pull_request' }}
env:
CI_PG_HOST: host.docker.internal
CI_PG_PORT: "5432"
CI_LOCAL_PG_PORT: "5432"
CI_PG_USER: postgres
CI_PG_PASSWORD: postgres
CI_PG_DB: xiaoxia_saas
@@ -262,7 +262,7 @@ jobs:
permissions:
contents: read
env:
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_PG_PORT }}/${{ env.CI_PG_DB }}
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }}
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'true'
steps:
@@ -411,7 +411,7 @@ jobs:
- validate-type-check
- validate-migration
env:
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_PG_PORT }}/${{ env.CI_PG_DB }}
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }}
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'true'
OSS_ACCESS_KEY_ID: placeholder
@@ -1230,7 +1230,7 @@ jobs:
runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }}
needs:
if: startsWith(github.ref, 'refs/tags/v')
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
strategy:
fail-fast: false
matrix:
@@ -1307,10 +1307,16 @@ jobs:
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_REF_NAME}"
# 根据ref类型设置镜像标签:tag用版本号,分支用分支名+sha
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
TAG_NAME="${GITHUB_REF_NAME}"
else
TAG_NAME="${GITHUB_REF_NAME}-${GITHUB_SHA::8}"
fi
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_REF_NAME}\""
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf"
fi
@@ -1608,4 +1614,89 @@ jobs:
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
canary-release:
name: Canary Release to Production
runs-on: runtime-builder
timeout-minutes: 120
concurrency:
group: canary-release-production
cancel-in-progress: false
if: github.event_name == 'push' && github.ref_name == 'main'
needs:
- build-production
- staging-api-tests
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Notify canary release start
continue-on-error: true
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=start JOB_NAME="Canary Release" python3 scripts/ci_notify.py
- name: Install SSH client
shell: sh
run: |
set -eu
apt-get update -qq && apt-get install -y -qq openssh-client curl >/dev/null 2>&1
echo "openssh-client installed"
- name: Run canary release
shell: bash
env:
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
PRODUCTION_SSH_PORT: ${{ secrets.PRODUCTION_SSH_PORT }}
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set -eu
IMAGE_TAG="main-${GITHUB_SHA::8}"
export IMAGE_TAG
echo "Canary release version: $IMAGE_TAG"
bash scripts/ci/canary_release.sh
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- name: Notify on success
continue-on-error: true
if: success()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=success JOB_NAME="Canary Release" python3 scripts/ci_notify.py
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Canary Release" python3 scripts/ci_notify.py
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+2 -2
View File
@@ -115,11 +115,11 @@ jobs:
fi
if [ "$CACHE_VALID" = "false" ]; then
echo "Cache miss or invalid: running npm ci..."
if ! npm ci --include=dev; then
if ! npm ci; then
echo "npm ci failed, cleaning node_modules and retrying..."
rm -rf node_modules
mkdir -p node_modules
npm ci --include=dev
npm ci
fi
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
echo "Dependencies installed, cache updated"
+64
View File
@@ -0,0 +1,64 @@
"""#642 - 生成任务新增 bgm_config 字段
Revision ID: 052_generation_task_bgm_config
Revises: 051_generation_task_resolution
Create Date: 2026-07-25
Changes:
1. generation_tasks 表新增 bgm_config 字段(JSON类型),存储用户自定义BGM配置
2. 为空时使用默认空字典
背景:
#642 一键生成支持自定义BGM 功能在 SQLAlchemy 模型中加了 bgm_config 字段,
但遗漏了 alembic migration,导致 staging 环境数据库没有该列,
创建生成任务时直接 500。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "052_generation_task_bgm_config"
down_revision = "051_generation_task_resolution"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否已存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
)
)
if result.scalar() is not None:
return
op.add_column(
"generation_tasks",
sa.Column(
"bgm_config",
sa.JSON,
nullable=False,
server_default=sa.text("'{}'::json"),
),
)
def downgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
)
)
if result.scalar() is None:
return
op.drop_column("generation_tasks", "bgm_config")
+6117
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+15 -1
View File
@@ -57,7 +57,21 @@ apiClient.interceptors.response.use(
}
// 401 → 尝试刷新 Token
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
// 排除 auth 端点:登录/注册/找回密码的 401 是正常业务响应(如密码错误),
// 不应触发 token 刷新或登出跳转,走后面的错误提示逻辑即可
const requestUrl = originalRequest?.url || ""
const isAuthEndpoint =
requestUrl.includes("/auth/login") ||
requestUrl.includes("/auth/register") ||
requestUrl.includes("/auth/forgot-password") ||
requestUrl.includes("/auth/reset-password")
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry &&
!isAuthEndpoint
) {
const refreshToken = useAuthStore.getState().refreshToken
// 无 refresh_token → 直接登出
Regular → Executable
+2 -2
View File
@@ -31,8 +31,8 @@ export const useLogin = () => {
const user = await authApi.getCurrentUser()
setAuth(user, data.access_token, refreshToken)
// 跳转到登录前页面或首页
const redirect = localStorage.getItem("login_redirect") || "/"
// 跳转到登录前页面或仪表盘(与 Login.tsx onFinish 保持一致)
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
return data
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
import React from "react"
import {
PlayCircleOutlined,
CheckOutlined,
DeleteOutlined,
ExperimentOutlined,
LoadingOutlined,
CloseCircleOutlined,
} from "@ant-design/icons"
import { Popconfirm } from "antd"
import type { AssetItem } from "@/pages/assets/types"
import { thumbGradient } from "@/pages/assets/utils/asset"
import { kindIcon } from "@/pages/assets/utils/kindIcon"
import { StatusPill } from "./AssetSkeleton"
/* ============================================================
* AssetCard — 素材卡片(网格视图)
* ============================================================ */
export interface AssetCardProps {
asset: AssetItem
selected: boolean
diagnosing?: boolean
onToggle: () => void
onDiagnose: () => void
onPlay: () => void
onDelete: () => void
}
const AssetCard: React.FC<AssetCardProps> = ({
asset,
selected,
diagnosing,
onToggle,
onDiagnose,
onPlay,
onDelete,
}) => (
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
{/* 缩略图区 */}
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
{asset.thumbUrl ? (
<img src={asset.thumbUrl} alt={asset.name} />
) : (
<span className="xx-asset-thumb-placeholder">
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
</span>
)}
{/* 处理中遮罩 */}
{asset.loading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined />
<span>处理中</span>
</div>
)}
{/* 失败状态标识 */}
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
<CloseCircleOutlined />
<span>处理失败</span>
</div>
)}
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
<span
className="xx-asset-play"
onClick={(e) => {
e.stopPropagation()
onPlay()
}}
>
<PlayCircleOutlined />
</span>
)}
{/* 删除按钮 */}
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除这个素材吗?"
onConfirm={(e) => {
e?.stopPropagation()
onDelete()
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
<DeleteOutlined />
</span>
</Popconfirm>
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
<CheckOutlined />
</span>
)}
</div>
{/* 信息区 */}
<div className="xx-asset-info">
<p className="xx-asset-name" title={asset.name}>
{asset.name}
</p>
<div className="xx-asset-meta">
<StatusPill status={asset.status} label={asset.statusLabel} />
{asset.duration && <span>{asset.duration}</span>}
</div>
<button
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
disabled={diagnosing || asset.loading || asset.status === "bad"}
onClick={(e) => {
e.stopPropagation()
onDiagnose()
}}
>
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
{diagnosing ? "诊断中..." : "诊断"}
</button>
</div>
</div>
)
export default AssetCard
@@ -0,0 +1,62 @@
import React from "react"
import { SearchOutlined } from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import { TYPE_FILTER_OPTIONS, TIME_FILTER_OPTIONS } from "@/pages/assets/constants"
/* ============================================================
* AssetFilterBar — 筛选栏(搜索/类型/时间 + 全选/计数)
* ============================================================ */
export interface AssetFilterBarProps {
searchText: string
onSearchChange: (value: string) => void
filterType: string
onFilterTypeChange: (value: string) => void
filterTime: string
onFilterTimeChange: (value: string) => void
onSelectAll: () => void
totalCount: number
}
const AssetFilterBar: React.FC<AssetFilterBarProps> = ({
searchText,
onSearchChange,
filterType,
onFilterTypeChange,
filterTime,
onFilterTimeChange,
onSelectAll,
totalCount,
}) => (
<div className="xx-assets-filters">
<div className="xx-assets-filters-left">
<Input
placeholder="搜索素材名称..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
value={filterType}
onChange={onFilterTypeChange}
style={{ width: 120 }}
options={TYPE_FILTER_OPTIONS}
/>
<Select
value={filterTime}
onChange={onFilterTimeChange}
style={{ width: 120 }}
options={TIME_FILTER_OPTIONS}
/>
</div>
<div className="xx-assets-filters-right">
<Button buttonType="ghost" buttonSize="sm" onClick={onSelectAll}>
全选
</Button>
<span className="xx-assets-filter-count">共 {totalCount} 个素材</span>
</div>
</div>
)
export default AssetFilterBar
@@ -0,0 +1,36 @@
import React from "react"
import type { StatusType } from "@/pages/assets/types"
/* ============================================================
* StatusPill — 状态标签
* ============================================================ */
export interface StatusPillProps {
status: StatusType
label: string
}
export const StatusPill: React.FC<StatusPillProps> = ({ status, label }) => (
<span className={`xx-status-pill xx-status-pill-${status}`}>{label}</span>
)
/* ============================================================
* SkeletonCard — 骨架屏卡片(素材列表加载时占位)
* ============================================================ */
export const SkeletonCard: React.FC = () => (
<div className="xx-asset-card xx-asset-skeleton">
<div className="xx-asset-thumb xx-skeleton-pulse" />
<div className="xx-asset-info">
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "70%" }} />
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "40%", marginTop: 8 }} />
<div
className="xx-skeleton-line xx-skeleton-pulse"
style={{
width: "100%",
height: 28,
marginTop: 8,
borderRadius: "var(--radius-xs)",
}}
/>
</div>
</div>
)
@@ -0,0 +1,49 @@
import React from "react"
import { Modal as AntModal, Select as AntSelect } from "antd"
import { CATEGORY_OPTIONS } from "@/pages/assets/constants"
/* ============================================================
* BatchClassifyModal — 批量改分类弹窗
* ============================================================ */
export interface BatchClassifyModalProps {
open: boolean
selectedCount: number
onCancel: () => void
onOk: () => void
category: string
onCategoryChange: (value: string) => void
confirmLoading?: boolean
}
const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
open,
selectedCount,
onCancel,
onOk,
category,
onCategoryChange,
confirmLoading,
}) => (
<AntModal
title={`批量改分类(${selectedCount} 个素材)`}
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认修改"
cancelText="取消"
>
<div className="xx-batch-classify-modal">
<p className="xx-batch-classify-hint">将选中的 {selectedCount} 个素材统一修改为以下分类:</p>
<AntSelect
value={category || undefined}
onChange={(v) => onCategoryChange(v)}
placeholder="请选择分类"
style={{ width: "100%" }}
options={CATEGORY_OPTIONS}
/>
</div>
</AntModal>
)
export default BatchClassifyModal
@@ -0,0 +1,67 @@
import React from "react"
import { Modal as AntModal, Tag, Radio } from "antd"
/* ============================================================
* BatchMarkModal — 批量智能标记弹窗
* ============================================================ */
export type SmartViewType = "recommended" | "caution" | "high_risk"
export interface BatchMarkModalProps {
open: boolean
selectedCount: number
onCancel: () => void
onOk: () => void
smartView: SmartViewType
onSmartViewChange: (value: SmartViewType) => void
confirmLoading?: boolean
}
const BatchMarkModal: React.FC<BatchMarkModalProps> = ({
open,
selectedCount,
onCancel,
onOk,
smartView,
onSmartViewChange,
confirmLoading,
}) => (
<AntModal
title={`批量智能标记(${selectedCount} 个素材)`}
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认标记"
cancelText="取消"
>
<div className="xx-batch-mark-modal">
<p className="xx-batch-mark-hint">将选中的 {selectedCount} 个素材标记为:</p>
<Radio.Group
value={smartView}
onChange={(e) => onSmartViewChange(e.target.value)}
className="xx-batch-mark-options"
>
<div className="xx-batch-mark-option">
<Radio value="recommended">
<Tag color="success">推荐</Tag>
<span className="xx-batch-mark-desc">质量优良,可直接用于生产</span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="caution">
<Tag color="warning">慎用</Tag>
<span className="xx-batch-mark-desc">存在一定问题,需人工审核后再使用</span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="high_risk">
<Tag color="error">高风险</Tag>
<span className="xx-batch-mark-desc">存在严重问题,不建议使用</span>
</Radio>
</div>
</Radio.Group>
</div>
</AntModal>
)
export default BatchMarkModal
@@ -0,0 +1,58 @@
import React from "react"
import {
TagsOutlined,
FolderOutlined,
ThunderboltOutlined,
DeleteOutlined,
} from "@ant-design/icons"
import { Popconfirm } from "antd"
import { Button } from "@/components/ui"
/* ============================================================
* BatchOperationBar — 批量操作栏
* ============================================================ */
export interface BatchOperationBarProps {
selectedCount: number
onDeselectAll: () => void
onTagClick: () => void
onClassifyClick: () => void
onMarkClick: () => void
onBatchDelete: () => void
}
const BatchOperationBar: React.FC<BatchOperationBarProps> = ({
selectedCount,
onDeselectAll,
onTagClick,
onClassifyClick,
onMarkClick,
onBatchDelete,
}) => (
<div className="xx-assets-batch-bar">
<span className="xx-assets-batch-count">已选 {selectedCount} 项</span>
<Button buttonType="ghost" buttonSize="sm" onClick={onDeselectAll}>
取消选择
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />} onClick={onTagClick}>
打标签
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<FolderOutlined />} onClick={onClassifyClick}>
改分类
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<ThunderboltOutlined />} onClick={onMarkClick}>
智能标记
</Button>
<Popconfirm
title={`确定删除 ${selectedCount} 个素材?`}
onConfirm={onBatchDelete}
okText="删除"
cancelText="取消"
>
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
批量删除
</Button>
</Popconfirm>
</div>
)
export default BatchOperationBar
@@ -0,0 +1,81 @@
import React from "react"
import { Modal as AntModal, Tag, Radio, Input as AntInput } from "antd"
import { ExclamationCircleOutlined } from "@ant-design/icons"
/* ============================================================
* BatchTagModal — 批量打标签弹窗
* ============================================================ */
export interface BatchTagModalProps {
open: boolean
selectedCount: number
onCancel: () => void
onOk: () => void
tags: string[]
onTagInputChange: (value: string) => void
onTagInputKeyDown: (e: React.KeyboardEvent) => void
onRemoveTag: (tag: string) => void
tagInput: string
tagMode: "add" | "replace"
onTagModeChange: (mode: "add" | "replace") => void
confirmLoading?: boolean
}
const BatchTagModal: React.FC<BatchTagModalProps> = ({
open,
selectedCount,
onCancel,
onOk,
tags,
onTagInputChange,
onTagInputKeyDown,
onRemoveTag,
tagInput,
tagMode,
onTagModeChange,
confirmLoading,
}) => (
<AntModal
title={`批量打标签(${selectedCount} 个素材)`}
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认打标签"
cancelText="取消"
>
<div className="xx-batch-tag-modal">
<div className="xx-batch-tag-mode">
<span className="xx-batch-tag-mode-label">模式:</span>
<Radio.Group value={tagMode} onChange={(e) => onTagModeChange(e.target.value)}>
<Radio value="add">追加标签</Radio>
<Radio value="replace">替换全部标签</Radio>
</Radio.Group>
</div>
<div className="xx-batch-tag-input-row">
<AntInput
placeholder="输入标签后按 Enter 添加"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={onTagInputKeyDown}
style={{ flex: 1 }}
/>
</div>
{tags.length > 0 && (
<div className="xx-batch-tag-list">
{tags.map((tag) => (
<Tag key={tag} closable onClose={() => onRemoveTag(tag)} color="blue">
{tag}
</Tag>
))}
</div>
)}
{tagMode === "replace" && tags.length > 0 && (
<div className="xx-batch-tag-warning">
<ExclamationCircleOutlined /> 替换模式将清除素材原有全部标签
</div>
)}
</div>
</AntModal>
)
export default BatchTagModal
@@ -0,0 +1,64 @@
import React from "react"
import { Modal as AntModal } from "antd"
import { Input, Select } from "@/components/ui"
import type { AssetKind } from "@/pages/assets/types"
import { LIBRARY_KIND_OPTIONS } from "@/pages/assets/constants"
/* ============================================================
* CreateLibraryModal — 新建视频库弹窗
* ============================================================ */
export interface CreateLibraryModalProps {
open: boolean
onCancel: () => void
onOk: () => void
name: string
onNameChange: (value: string) => void
kind: AssetKind
onKindChange: (value: AssetKind) => void
confirmLoading?: boolean
}
const CreateLibraryModal: React.FC<CreateLibraryModalProps> = ({
open,
onCancel,
onOk,
name,
onNameChange,
kind,
onKindChange,
confirmLoading,
}) => (
<AntModal
title="新建视频库"
open={open}
onCancel={onCancel}
onOk={onOk}
okText="创建"
cancelText="取消"
destroyOnClose
confirmLoading={confirmLoading}
>
<div className="xx-asset-form-body">
<div>
<div className="xx-asset-form-label">名称</div>
<Input
placeholder="请输入视频库名称"
value={name}
onChange={(e) => onNameChange(e.target.value)}
maxLength={50}
/>
</div>
<div>
<div className="xx-asset-form-label">类型</div>
<Select
value={kind}
onChange={(v) => onKindChange(v)}
style={{ width: "100%" }}
options={LIBRARY_KIND_OPTIONS}
/>
</div>
</div>
</AntModal>
)
export default CreateLibraryModal
@@ -0,0 +1,70 @@
import React from "react"
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"
import { Popconfirm } from "antd"
import type { LibraryItem } from "@/pages/assets/types"
import { kindLabel } from "@/pages/assets/utils/asset"
import { kindIcon } from "@/pages/assets/utils/kindIcon"
/* ============================================================
* LibrarySidebar — 左侧素材库列表
* ============================================================ */
export interface LibrarySidebarProps {
libraries: LibraryItem[]
activeLibId: string
onSelect: (id: string) => void
onDelete: (id: string) => void
onCreateClick: () => void
}
const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
libraries,
activeLibId,
onSelect,
onDelete,
onCreateClick,
}) => (
<div className="xx-asset-library-list">
{libraries.map((lib) => (
<div
key={lib.id}
className={`xx-asset-library-item${lib.id === activeLibId ? " active" : ""}`}
onClick={() => onSelect(lib.id)}
>
<div className="xx-asset-library-header">
<h4>
{kindIcon(lib.kind)} {lib.name}
</h4>
<Popconfirm
title={`确定删除视频库 "${lib.name}"?`}
onConfirm={(e) => {
e?.stopPropagation()
onDelete(lib.id)
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
>
<button
className="xx-asset-library-delete"
onClick={(e) => e.stopPropagation()}
title="删除视频库"
>
<DeleteOutlined />
</button>
</Popconfirm>
</div>
<span>
{kindLabel(lib.kind)} · {lib.count} 个素材
</span>
</div>
))}
{/* 新建视频库 */}
<div className="xx-asset-library-add" onClick={onCreateClick}>
<PlusOutlined />
新建视频库
</div>
</div>
)
export default LibrarySidebar
@@ -0,0 +1,34 @@
import React from "react"
import { Modal as AntModal } from "antd"
import type { AssetItem } from "@/pages/assets/types"
/* ============================================================
* PlayModal — 视频/音频播放弹窗
* ============================================================ */
export interface PlayModalProps {
open: boolean
asset: AssetItem | null
onClose: () => void
}
const PlayModal: React.FC<PlayModalProps> = ({ open, asset, onClose }) => (
<AntModal
title={asset?.name ?? "播放"}
open={open}
onCancel={onClose}
footer={null}
width={640}
destroyOnClose
>
{asset?.fileUrl ? (
<video src={asset.fileUrl} controls autoPlay className="xx-asset-video-player" />
) : (
<div className="xx-asset-empty-fallback">
<p>暂无可播放的文件地址</p>
<p className="xx-asset-empty-fallback-id">素材 ID: {asset?.id}</p>
</div>
)}
</AntModal>
)
export default PlayModal
@@ -0,0 +1,70 @@
import React from "react"
import { Drawer } from "antd"
import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
import type { BatchOperationResult } from "@/api/assets"
/* ============================================================
* ResultDrawer — 操作结果抽屉
* ============================================================ */
export interface ResultDrawerProps {
open: boolean
title: string
result: BatchOperationResult | null
onClose: () => void
}
const ResultDrawer: React.FC<ResultDrawerProps> = ({ open, title, result, onClose }) => (
<Drawer title={`${title} — 操作结果`} open={open} onClose={onClose} width={420}>
{result && (
<div className="xx-batch-result">
<div className="xx-batch-result-summary">
<div className="xx-batch-result-stat">
<span className="xx-batch-result-total">总计 {result.total} 个</span>
</div>
<div className="xx-batch-result-stat success">
<CheckCircleOutlined />
<span>成功 {result.success_count} 个</span>
</div>
{result.failure_count > 0 && (
<div className="xx-batch-result-stat fail">
<CloseCircleOutlined />
<span>失败 {result.failure_count} 个</span>
</div>
)}
</div>
{(result?.succeeded?.length ?? 0) > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title success">
<CheckCircleOutlined /> 成功列表
</h4>
<div className="xx-batch-result-ids">
{result?.succeeded?.map((id) => (
<div key={id} className="xx-batch-result-id">
{id}
</div>
))}
</div>
</div>
)}
{(result?.failed?.length ?? 0) > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title fail">
<CloseCircleOutlined /> 失败列表
</h4>
<div className="xx-batch-result-ids">
{result?.failed?.map((id) => (
<div key={id} className="xx-batch-result-id fail">
{id}
</div>
))}
</div>
</div>
)}
</div>
)}
</Drawer>
)
export default ResultDrawer
@@ -0,0 +1,56 @@
import React from "react"
import { Modal as AntModal } from "antd"
/* ============================================================
* UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比)
* ============================================================ */
export interface UploadProgressModalProps {
open: boolean
progress: number
}
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({ open, progress }) => (
<AntModal
open={open}
footer={null}
closable={false}
centered
width={260}
maskClosable={false}
className="xx-upload-progress-modal"
>
<div className="xx-upload-progress-body">
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
{/* 背景圆环 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--border-primary, #e5e7eb)"
strokeWidth="8"
/>
{/* 进度圆弧 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--primary-color, #6366f1)"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 52}`}
strokeDashoffset={`${2 * Math.PI * 52 * (1 - progress / 100)}`}
transform="rotate(-90 60 60)"
style={{ transition: "stroke-dashoffset 0.3s ease" }}
/>
</svg>
<div className="xx-upload-progress-text">
<span className="xx-upload-progress-pct">{progress}%</span>
<span className="xx-upload-progress-label">上传中…</span>
</div>
</div>
</AntModal>
)
export default UploadProgressModal
+66
View File
@@ -0,0 +1,66 @@
/**
* 素材库常量
*/
import type { AssetKind } from "./types"
/** 文件大小限制 */
export const MAX_FILE_SIZE = 2048 * 1024 * 1024
export const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024
/** 素材类型标签 */
export const KIND_LABELS: Record<AssetKind, string> = {
video: "视频",
image: "图片",
voice: "配音",
}
/** 类型筛选选项 */
export const TYPE_FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: "all", label: "全部类型" },
{ value: "video", label: "视频" },
{ value: "image", label: "图片" },
]
/** 时间筛选选项 */
export const TIME_FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: "all", label: "全部时间" },
{ value: "today", label: "今天" },
{ value: "week", label: "近一周" },
{ value: "month", label: "近一月" },
]
/** 新建库类型选项(当前仅支持视频和图片) */
export const LIBRARY_KIND_OPTIONS: { value: AssetKind; label: string }[] = [
{ value: "video", label: "视频" },
{ value: "image", label: "图片" },
]
/** 分类选项 */
export const CATEGORY_OPTIONS: { value: string; label: string }[] = [
{ value: "person", label: "人物" },
{ value: "scenic", label: "风景" },
{ value: "product", label: "产品" },
{ value: "food", label: "美食" },
{ value: "animal", label: "动物" },
{ value: "architecture", label: "建筑" },
{ value: "other", label: "其他" },
]
/** 智能标记视图 */
export const SMART_VIEW_OPTIONS: {
value: "recommended" | "caution" | "high_risk"
label: string
color: string
}[] = [
{ value: "recommended", label: "推荐", color: "success" },
{ value: "caution", label: "慎用", color: "warning" },
{ value: "high_risk", label: "高风险", color: "error" },
]
/** 状态配置 */
export const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
ok: { label: "合格", className: "xx-status-pill xx-status-pill-ok" },
warn: { label: "待优化", className: "xx-status-pill xx-status-pill-warn" },
bad: { label: "不合格", className: "xx-status-pill xx-status-pill-bad" },
info: { label: "待诊断", className: "xx-status-pill xx-status-pill-info" },
}
@@ -0,0 +1,301 @@
import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
deleteAsset,
getAssetDiagnosis,
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type BatchOperationResult,
} from "@/api/assets"
import type { AssetItem } from "../types"
import type { SmartViewType } from "../components/BatchMarkModal"
/**
* 素材操作 Hook
* 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作,
* 以及相关弹窗和结果展示的状态管理
*/
interface UseAssetOperationsProps {
selectedIds: Set<string>
setSelectedIds: (ids: Set<string>) => void
}
export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOperationsProps) {
const queryClient = useQueryClient()
/* ── 诊断状态 ── */
const [diagnosingId, setDiagnosingId] = useState<string | null>(null)
/* ── 批量操作弹窗状态 ── */
const [tagModalOpen, setTagModalOpen] = useState(false)
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [markModalOpen, setMarkModalOpen] = useState(false)
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
/* ── 批量打标签表单 ── */
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
/* ── 批量改分类表单 ── */
const [batchCategory, setBatchCategory] = useState("")
/* ── 批量智能标记表单 ── */
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
/* ── 操作结果 ── */
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
const [operationTitle, setOperationTitle] = useState("")
/* ── 批量操作 loading ── */
const [batchLoading, setBatchLoading] = useState(false)
/* ── 刷新数据辅助函数 ── */
const invalidateAssets = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
}, [queryClient])
/* ── 诊断 ── */
const handleDiagnose = useCallback(
async (asset: AssetItem) => {
setDiagnosingId(asset.id)
try {
const result = await getAssetDiagnosis(asset.id)
const score = result.readiness_score ?? "-"
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
queryClient.invalidateQueries({ queryKey: ["assets"] })
} catch {
message.error(`"${asset.name}" 诊断失败`)
} finally {
setDiagnosingId(null)
}
},
[queryClient],
)
/* ── 单个素材删除 ── */
const handleSingleDelete = useCallback(
async (assetId: string) => {
try {
await deleteAsset(assetId)
invalidateAssets()
// 从选中集合中移除
setSelectedIds(
(() => {
const next = new Set(selectedIds)
next.delete(assetId)
return next
})(),
)
message.success("素材已删除")
} catch {
message.error("删除失败,请重试")
}
},
[invalidateAssets, selectedIds, setSelectedIds],
)
/* ── 显示操作结果 ── */
const showOperationResult = useCallback(
(result: BatchOperationResult, title: string, clearSelection = true) => {
setOperationResult(result)
setOperationTitle(title)
setResultDrawerOpen(true)
if (clearSelection) setSelectedIds(new Set())
},
[setSelectedIds],
)
/* ── 批量删除 ── */
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showOperationResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showOperationResult])
/* ── 批量打标签 ── */
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showOperationResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showOperationResult])
/* ── 标签输入处理 ── */
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
/* ── 批量改分类 ── */
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showOperationResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showOperationResult])
/* ── 批量智能标记 ── */
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showOperationResult(result, "批量智能标记")
setMarkModalOpen(false)
const labelMap: Record<SmartViewType, string> = {
recommended: "推荐",
caution: "慎用",
high_risk: "高风险",
}
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showOperationResult])
/* ── 关闭结果 Drawer ── */
const handleResultDrawerClose = useCallback(() => {
setResultDrawerOpen(false)
setOperationResult(null)
}, [])
return {
// 诊断
diagnosingId,
handleDiagnose,
// 单个操作
handleSingleDelete,
// 批量操作 loading
batchLoading,
// 批量打标签
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
// 批量改分类
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
handleBatchClassify,
// 批量智能标记
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
handleBatchMark,
// 批量删除
handleBatchDelete,
// 操作结果
resultDrawerOpen,
operationResult,
operationTitle,
handleResultDrawerClose,
}
}
@@ -0,0 +1,40 @@
import { useState, useCallback } from "react"
import type { AssetItem } from "../types"
/**
* 素材选中态管理 Hook
* 封装单选、全选、取消全选等选中逻辑
*/
interface UseAssetSelectionProps {
filteredAssets: AssetItem[]
}
export function useAssetSelection({ filteredAssets }: UseAssetSelectionProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const selectAll = useCallback(() => {
setSelectedIds(new Set(filteredAssets.map((a) => a.id)))
}, [filteredAssets])
const deselectAll = useCallback(() => {
setSelectedIds(new Set())
}, [])
return {
selectedIds,
setSelectedIds,
toggleSelect,
selectAll,
deselectAll,
selectedCount: selectedIds.size,
}
}
@@ -0,0 +1,65 @@
import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { uploadAssetDirect } from "@/api/assets"
import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants"
/**
* 素材上传 Hook
* 封装上传状态、进度管理和上传逻辑
*/
interface UseAssetUploadProps {
effectiveLibId: string
}
export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) {
const queryClient = useQueryClient()
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const handleUpload = useCallback(
async (file: File) => {
if (file.size > MAX_FILE_SIZE) {
message.error(`文件 "${file.name}" 超过 2GB 限制`)
return
}
if (!effectiveLibId) {
message.warning("请先选择或创建一个视频库")
return
}
setUploading(true)
setUploadProgress(0)
try {
if (file.size > LARGE_FILE_THRESHOLD) {
message.info(`大文件 "${file.name}" 将使用直传上传`)
}
await uploadAssetDirect({
file,
library_id: effectiveLibId,
onProgress: (pct) => setUploadProgress(pct),
})
message.success(`"${file.name}" 上传成功`)
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : ""
console.error("[handleUpload] 上传失败:", err)
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`)
// 错误时延迟关闭弹窗,让用户能看到错误提示
await new Promise((r) => setTimeout(r, 1500))
} finally {
setUploading(false)
setUploadProgress(0)
}
},
[effectiveLibId, queryClient],
)
return {
uploading,
uploadProgress,
handleUpload,
}
}
@@ -0,0 +1,118 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import {
getAssetLibraries,
getAssets,
type AssetLibraryItem,
type AssetItem as ApiAssetItem,
} from "@/api/assets"
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
/**
* 素材库数据 Hook
* 封装视频库列表、素材列表的数据查询,以及筛选、搜索状态管理
*/
export function useAssetsData() {
/* ── 视频库列表查询 ── */
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
staleTime: 60_000,
})
const libraries: LibraryItem[] = useMemo(
() =>
(Array.isArray(apiLibraries) ? apiLibraries : [])
.map(mapLibrary)
.filter((lib) => lib.kind === "video"),
[apiLibraries],
)
/* ── 当前选中的视频库 ── */
const [activeLibId, setActiveLibId] = useState<string>("")
// 当库列表加载完成后,自动选中第一个
const effectiveLibId = activeLibId || libraries[0]?.id || ""
/* ── 当前库的素材列表查询 ── */
const {
data: apiAssets = { items: [], total: 0 },
isLoading: assetsLoading,
isError: assetsError,
error: assetsErrorObj,
refetch: refetchAssets,
} = useQuery<{ items: ApiAssetItem[]; total: number }, Error>({
queryKey: ["assets", effectiveLibId],
queryFn: () =>
getAssets(effectiveLibId, {
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
status: "ready,uploading,ingesting,processing,pending,error,failed",
}),
enabled: !!effectiveLibId,
staleTime: 30_000,
})
const assets: AssetItem[] = useMemo(
() => (Array.isArray(apiAssets?.items) ? apiAssets.items : []).map(mapAsset),
[apiAssets],
)
/* ── 筛选状态 ── */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterTime, setFilterTime] = useState<string>("all")
/* ── 筛选后的素材列表 ── */
const filteredAssets = useMemo(() => {
let list = assets
/* 按素材类型过滤 */
if (filterType !== "all") {
list = list.filter((a) => a.kind === filterType)
}
/* 按时间筛选 */
if (filterTime !== "all") {
const now = new Date()
list = list.filter((a) => {
const d = new Date(a.createdAt)
const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
if (filterTime === "today") return diffDays < 1
if (filterTime === "week") return diffDays < 7
if (filterTime === "month") return diffDays < 30
return true
})
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((a) => a.name.toLowerCase().includes(q))
}
return list
}, [assets, filterType, filterTime, searchText])
return {
// 视频库
libraries,
libLoading,
activeLibId,
setActiveLibId,
effectiveLibId,
// 素材列表
assets,
assetsLoading,
assetsError,
assetsErrorObj,
refetchAssets,
// 筛选
searchText,
setSearchText,
filterType,
setFilterType,
filterTime,
setFilterTime,
filteredAssets,
}
}
@@ -0,0 +1,106 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { createAssetLibrary, deleteAssetLibrary } from "@/api/assets"
import type { AssetKind, LibraryItem } from "../types"
/**
* 视频库管理 Hook
* 封装视频库的创建、删除操作,以及新建弹窗的表单状态
*/
interface UseLibraryManagementProps {
libraries: LibraryItem[]
activeLibId: string
setActiveLibId: (id: string) => void
effectiveLibId: string
}
export function useLibraryManagement({
libraries,
setActiveLibId,
effectiveLibId,
}: UseLibraryManagementProps) {
const queryClient = useQueryClient()
/* ── 状态 ── */
const [createModalOpen, setCreateModalOpen] = useState(false)
const [newLibName, setNewLibName] = useState("")
const [newLibKind, setNewLibKind] = useState<AssetKind>("video")
/* ── Mutations ── */
const createLibMutation = useMutation({
mutationFn: createAssetLibrary,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
message.success("视频库创建成功")
},
onError: () => {
message.error("创建视频库失败")
},
})
const deleteLibMutation = useMutation({
mutationFn: deleteAssetLibrary,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
message.success("视频库已删除")
},
onError: () => {
message.error("删除视频库失败")
},
})
/* ── 新建视频库 ── */
const handleCreateLibrary = useCallback(async () => {
if (!newLibName.trim()) {
message.warning("请输入视频库名称")
return
}
try {
const newLib = await createLibMutation.mutateAsync({
name: newLibName.trim(),
kind: newLibKind,
})
setActiveLibId(newLib.id)
setCreateModalOpen(false)
setNewLibName("")
setNewLibKind("video")
} catch {
// error handled in mutation
}
}, [newLibName, newLibKind, createLibMutation, setActiveLibId])
/* ── 删除视频库 ── */
const handleDeleteLibrary = useCallback(
async (id: string) => {
try {
await deleteLibMutation.mutateAsync(id)
if (effectiveLibId === id) {
const remaining = libraries.filter((l) => l.id !== id)
if (remaining.length > 0) setActiveLibId(remaining[0].id)
else setActiveLibId("")
}
} catch {
// error handled in mutation
}
},
[deleteLibMutation, effectiveLibId, libraries, setActiveLibId],
)
return {
// 弹窗状态
createModalOpen,
setCreateModalOpen,
// 表单状态
newLibName,
setNewLibName,
newLibKind,
setNewLibKind,
// Mutations
isCreating: createLibMutation.isPending,
isDeleting: deleteLibMutation.isPending,
// Handlers
handleCreateLibrary,
handleDeleteLibrary,
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* 素材库类型定义
*/
import type { AssetLibraryItem, AssetItem as ApiAssetItem } from "@/api/assets"
import { formatDuration } from "./utils/format"
export type AssetKind = "video" | "image" | "voice"
export type StatusType = "ok" | "warn" | "bad" | "info"
export interface LibraryItem {
id: string
name: string
kind: AssetKind
count: number
}
export interface AssetItem {
id: string
name: string
kind: AssetKind
thumbUrl?: string
fileUrl?: string
status: StatusType
statusLabel: string
/** 是否处于处理中状态(上传中/入库中/诊断中) */
loading?: boolean
duration?: string
size: number
createdAt: string
}
/** 根据 mime_type 推断前端 AssetKind */
export const inferKind = (mimeType: string): AssetKind => {
if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("audio/")) return "voice"
return "image"
}
/** 根据 quality_score / classification_status / asset status 推断前端状态 */
export const inferStatus = (
score?: number,
classificationStatus?: string,
assetStatus?: string,
): { status: StatusType; label: string; loading?: boolean } => {
// 已删除素材(正常情况列表已过滤,这里是防御性处理)
if (assetStatus === "deleted") {
return { status: "bad", label: "已删除" }
}
// 处理中状态:上传中 / 入库中 / 处理中
if (
assetStatus === "uploading" ||
assetStatus === "ingesting" ||
assetStatus === "processing" ||
assetStatus === "pending"
) {
return { status: "info", label: "处理中", loading: true }
}
// 失败状态
if (assetStatus === "error" || assetStatus === "failed") {
return { status: "bad", label: "处理失败" }
}
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
if (assetStatus === "ready") {
if (score == null) return { status: "info", label: "待诊断" }
if (score >= 70) return { status: "ok", label: "合格" }
if (score >= 40) return { status: "warn", label: "待优化" }
return { status: "bad", label: "不合格" }
}
// 素材未就绪:classification 正在处理中
if (classificationStatus === "processing" || classificationStatus === "pending") {
return { status: "info", label: "处理中", loading: true }
}
if (score == null) return { status: "info", label: "待诊断" }
if (score >= 70) return { status: "ok", label: "合格" }
if (score >= 40) return { status: "warn", label: "待优化" }
return { status: "bad", label: "不合格" }
}
/** 将后端 AssetLibraryItem 映射为前端 LibraryItem */
export const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
id: item.id,
name: item.name,
kind: item.kind || inferKind("video"),
count: item.asset_count ?? 0,
})
/** 将后端 ApiAssetItem 映射为前端 AssetItem */
export const mapAsset = (item: ApiAssetItem): AssetItem => {
const { status, label, loading } = inferStatus(
item.quality_score ?? undefined,
item.classification_status ?? undefined,
item.status ?? undefined,
)
const metadata = item.metadata || {}
const kind = inferKind(item.mime_type || "")
return {
id: item.id,
name: item.name,
kind,
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
// 处理中的素材没有缩略图,显示占位符
thumbUrl: loading
? undefined
: (item.thumbnail_url as string | undefined) ||
(metadata.thumbnail_url as string | undefined) ||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
fileUrl: (item.file_url as string | undefined) || (metadata.file_url as string | undefined),
status,
statusLabel: label,
loading,
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* 素材相关工具函数
*/
import type { AssetKind } from "../types"
import { KIND_LABELS } from "../constants"
export const kindLabel = (kind: AssetKind): string => KIND_LABELS[kind] ?? kind
/** 根据素材类型返回渐变背景 */
export const thumbGradient = (kind: AssetKind): string => {
switch (kind) {
case "video":
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"
case "image":
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"
case "voice":
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* 格式化工具函数
*/
export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
}
export const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export const formatDate = (iso: string): string =>
new Date(iso).toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
@@ -0,0 +1,15 @@
import React from "react"
import { VideoCameraOutlined, PictureOutlined, AudioOutlined } from "@ant-design/icons"
import type { AssetKind } from "../types"
/** 根据素材类型返回对应图标 */
export const kindIcon = (kind: AssetKind): React.ReactNode => {
switch (kind) {
case "video":
return <VideoCameraOutlined />
case "image":
return <PictureOutlined />
case "voice":
return <AudioOutlined />
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,304 @@
/**
* 所有弹窗和 Drawer 组件的集合
* 纯渲染层,业务逻辑和 state 留在父组件
*/
import React from "react"
import type { TemplateCategory } from "@/api/editing-planner"
import type {
ClipData,
TransitionConfig,
SpeedConfig,
TtsConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
} from "../types"
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import SaveModal from "./SaveModal"
import BgmSelector from "./BgmSelector"
import SubtitleStylePanel from "./SubtitleStylePanel"
import TransitionSelector from "./TransitionSelector"
import SpeedPanel from "./SpeedPanel"
import TtsPanel from "./TtsPanel"
import WatermarkPanel from "./WatermarkPanel"
import IntroOutroPanel from "./IntroOutroPanel"
import PipConfigPanel from "./PipConfigPanel"
import FilterPanel from "./FilterPanel"
import GreenScreenPanel from "./GreenScreenPanel"
import StickerPanel from "./StickerPanel"
interface EditingDrawersProps {
/* 保存弹窗 */
saveModalOpen: boolean
saveLoading: boolean
isUpdate: boolean
draftName: string
draftCategory: string
draftTags: string
categories: TemplateCategory[]
estimatedDuration: number
onNameChange: (name: string) => void
onCategoryChange: (cat: string) => void
onTagsChange: (tags: string) => void
onSave: () => Promise<void>
onCancelSave: () => void
/* BGM */
bgmDrawerOpen: boolean
bgmSettings: BgmMixConfig
onCloseBgmDrawer: () => void
onChangeBgmSettings: (config: BgmMixConfig) => void
/* 字幕 */
subtitleDrawerOpen: boolean
subtitleSettings: SubtitleStyleConfig
onCloseSubtitleDrawer: () => void
onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void
/* 转场 */
transitionDrawerOpen: boolean
transitionTargetClipId: string | null
onCloseTransitionDrawer: () => void
onTransitionChange: (config: TransitionConfig) => void
/* 调速 */
speedDrawerOpen: boolean
speedTargetClipId: string | null
onCloseSpeedDrawer: () => void
onSpeedChange: (config: SpeedConfig) => void
onApplySpeedAll: (config: SpeedConfig) => void
/* TTS 配音 */
ttsDrawerOpen: boolean
ttsTargetClipId: string | null
onCloseTtsDrawer: () => void
onTtsChange: (config: TtsConfig) => void
/* 水印 */
watermarkDrawerOpen: boolean
watermarkSettings: WatermarkConfig
onCloseWatermarkDrawer: () => void
onWatermarkChange: (config: WatermarkConfig) => void
/* 片头片尾 */
introOutroDrawerOpen: boolean
introOutroSettings: IntroOutroConfig
onCloseIntroOutroDrawer: () => void
onIntroOutroChange: (config: IntroOutroConfig) => void
/* 混剪 */
pipDrawerOpen: boolean
pipSettings: PipConfig
onClosePipDrawer: () => void
onPipChange: (config: PipConfig) => void
/* 滤镜调色 */
filterDrawerOpen: boolean
filterSettings: FilterConfig
onCloseFilterDrawer: () => void
onFilterChange: (config: FilterConfig) => void
/* 绿幕抠像 */
chromaKeyDrawerOpen: boolean
chromaKeySettings: ChromaKeyConfig
onCloseChromaKeyDrawer: () => void
onChromaKeyChange: (config: ChromaKeyConfig) => void
/* 贴纸 */
stickerDrawerOpen: boolean
stickerSettings: StickerConfig
onCloseStickerDrawer: () => void
onStickerChange: (config: StickerConfig) => void
/* 共享数据 */
clips: ClipData[]
totalDuration: number
}
const EditingDrawers: React.FC<EditingDrawersProps> = ({
saveModalOpen,
saveLoading,
isUpdate,
draftName,
draftCategory,
draftTags,
categories,
estimatedDuration,
onNameChange,
onCategoryChange,
onTagsChange,
onSave,
onCancelSave,
bgmDrawerOpen,
bgmSettings,
onCloseBgmDrawer,
onChangeBgmSettings,
subtitleDrawerOpen,
subtitleSettings,
onCloseSubtitleDrawer,
onChangeSubtitleSettings,
transitionDrawerOpen,
transitionTargetClipId,
onCloseTransitionDrawer,
onTransitionChange,
speedDrawerOpen,
speedTargetClipId,
onCloseSpeedDrawer,
onSpeedChange,
onApplySpeedAll,
ttsDrawerOpen,
ttsTargetClipId,
onCloseTtsDrawer,
onTtsChange,
watermarkDrawerOpen,
watermarkSettings,
onCloseWatermarkDrawer,
onWatermarkChange,
introOutroDrawerOpen,
introOutroSettings,
onCloseIntroOutroDrawer,
onIntroOutroChange,
pipDrawerOpen,
pipSettings,
onClosePipDrawer,
onPipChange,
filterDrawerOpen,
filterSettings,
onCloseFilterDrawer,
onFilterChange,
chromaKeyDrawerOpen,
chromaKeySettings,
onCloseChromaKeyDrawer,
onChromaKeyChange,
stickerDrawerOpen,
stickerSettings,
onCloseStickerDrawer,
onStickerChange,
clips,
totalDuration,
}) => {
const transitionConfig = transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
: DEFAULT_TRANSITION
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
const speedConfig = speedTargetClipId
? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED)
: DEFAULT_SPEED
const ttsConfig = ttsTargetClipId
? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG)
: DEFAULT_TTS_CONFIG
return (
<>
{/* ═══ 保存弹窗 ═══ */}
<SaveModal
open={saveModalOpen}
loading={saveLoading}
isUpdate={isUpdate}
draftName={draftName}
draftCategory={draftCategory}
draftTags={draftTags}
categories={categories}
estimatedDuration={estimatedDuration}
onNameChange={onNameChange}
onCategoryChange={onCategoryChange}
onTagsChange={onTagsChange}
onSave={onSave}
onCancel={onCancelSave}
/>
{/* ═══ BGM 选择器 Drawer ═══ */}
<BgmSelector
open={bgmDrawerOpen}
onClose={onCloseBgmDrawer}
config={bgmSettings}
onChange={onChangeBgmSettings}
/>
{/* ═══ 字幕样式配置 Drawer ═══ */}
<SubtitleStylePanel
open={subtitleDrawerOpen}
onClose={onCloseSubtitleDrawer}
config={subtitleSettings}
onChange={onChangeSubtitleSettings}
/>
{/* ═══ 转场特效选择器 Drawer ═══ */}
<TransitionSelector
open={transitionDrawerOpen}
onClose={onCloseTransitionDrawer}
config={transitionConfig}
onChange={onTransitionChange}
title={transitionTitle}
/>
{/* ═══ 片段调速面板 Drawer ═══ */}
{speedTargetClipId && (
<SpeedPanel
open={speedDrawerOpen}
onClose={onCloseSpeedDrawer}
config={speedConfig}
onChange={onSpeedChange}
onApplyAll={onApplySpeedAll}
/>
)}
{/* ═══ TTS 配音面板 Drawer ═══ */}
{ttsTargetClipId && (
<TtsPanel
open={ttsDrawerOpen}
onClose={onCloseTtsDrawer}
config={ttsConfig}
onChange={onTtsChange}
/>
)}
{/* ═══ 水印配置面板 ═══ */}
<WatermarkPanel
open={watermarkDrawerOpen}
onClose={onCloseWatermarkDrawer}
config={watermarkSettings}
onChange={onWatermarkChange}
/>
{/* ═══ 片头片尾配置面板 ═══ */}
<IntroOutroPanel
open={introOutroDrawerOpen}
onClose={onCloseIntroOutroDrawer}
config={introOutroSettings}
onChange={onIntroOutroChange}
/>
{/* ═══ 混剪配置面板 ═══ */}
<PipConfigPanel
open={pipDrawerOpen}
onClose={onClosePipDrawer}
config={pipSettings}
onChange={onPipChange}
totalDuration={totalDuration}
/>
{/* ═══ 滤镜调色面板 ═══ */}
<FilterPanel
open={filterDrawerOpen}
onClose={onCloseFilterDrawer}
config={filterSettings}
onChange={onFilterChange}
/>
{/* ═══ 绿幕抠像面板 ═══ */}
<GreenScreenPanel
open={chromaKeyDrawerOpen}
onClose={onCloseChromaKeyDrawer}
config={chromaKeySettings}
onChange={onChromaKeyChange}
/>
{/* ═══ 贴纸面板 ═══ */}
<StickerPanel
open={stickerDrawerOpen}
onClose={onCloseStickerDrawer}
config={stickerSettings}
onChange={onStickerChange}
totalDuration={totalDuration}
/>
</>
)
}
export default EditingDrawers
@@ -0,0 +1,246 @@
import React from "react"
import BgmSelector from "./BgmSelector"
import SubtitleStylePanel from "./SubtitleStylePanel"
import TransitionSelector from "./TransitionSelector"
import SpeedPanel from "./SpeedPanel"
import TtsPanel from "./TtsPanel"
import WatermarkPanel from "./WatermarkPanel"
import IntroOutroPanel from "./IntroOutroPanel"
import PipConfigPanel from "./PipConfigPanel"
import FilterPanel from "./FilterPanel"
import GreenScreenPanel from "./GreenScreenPanel"
import StickerPanel from "./StickerPanel"
import type {
ClipData,
TransitionConfig,
SpeedConfig,
TtsConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
interface EditorDrawersProps {
// BGM
bgmDrawerOpen: boolean
bgmSettings: BgmMixConfig
onBgmSettingsChange: (config: BgmMixConfig) => void
onCloseBgmDrawer: () => void
// 字幕
subtitleDrawerOpen: boolean
subtitleSettings: SubtitleStyleConfig
onSubtitleSettingsChange: (config: SubtitleStyleConfig) => void
onCloseSubtitleDrawer: () => void
// 转场
transitionDrawerOpen: boolean
transitionTargetClipId: string | null
clips: ClipData[]
onTransitionChange: (config: TransitionConfig) => void
onCloseTransitionDrawer: () => void
// 调速
speedDrawerOpen: boolean
speedTargetClipId: string | null
onSpeedChange: (config: SpeedConfig) => void
onApplySpeedAll: (config: SpeedConfig) => void
onCloseSpeedDrawer: () => void
// TTS
ttsDrawerOpen: boolean
ttsTargetClipId: string | null
onTtsChange: (config: TtsConfig) => void
onCloseTtsDrawer: () => void
// 水印
watermarkDrawerOpen: boolean
watermarkSettings: WatermarkConfig
onWatermarkChange: (config: WatermarkConfig) => void
onCloseWatermarkDrawer: () => void
// 片头片尾
introOutroDrawerOpen: boolean
introOutroSettings: IntroOutroConfig
onIntroOutroChange: (config: IntroOutroConfig) => void
onCloseIntroOutroDrawer: () => void
// 混剪
pipDrawerOpen: boolean
pipSettings: PipConfig
totalDuration: number
onPipChange: (config: PipConfig) => void
onClosePipDrawer: () => void
// 滤镜
filterDrawerOpen: boolean
filterSettings: FilterConfig
onFilterChange: (config: FilterConfig) => void
onCloseFilterDrawer: () => void
// 绿幕
chromaKeyDrawerOpen: boolean
chromaKeySettings: ChromaKeyConfig
onChromaKeyChange: (config: ChromaKeyConfig) => void
onCloseChromaKeyDrawer: () => void
// 贴纸
stickerDrawerOpen: boolean
stickerSettings: StickerConfig
onStickerChange: (config: StickerConfig) => void
onCloseStickerDrawer: () => void
}
const EditorDrawers: React.FC<EditorDrawersProps> = ({
bgmDrawerOpen,
bgmSettings,
onBgmSettingsChange,
onCloseBgmDrawer,
subtitleDrawerOpen,
subtitleSettings,
onSubtitleSettingsChange,
onCloseSubtitleDrawer,
transitionDrawerOpen,
transitionTargetClipId,
clips,
onTransitionChange,
onCloseTransitionDrawer,
speedDrawerOpen,
speedTargetClipId,
onSpeedChange,
onApplySpeedAll,
onCloseSpeedDrawer,
ttsDrawerOpen,
ttsTargetClipId,
onTtsChange,
onCloseTtsDrawer,
watermarkDrawerOpen,
watermarkSettings,
onWatermarkChange,
onCloseWatermarkDrawer,
introOutroDrawerOpen,
introOutroSettings,
onIntroOutroChange,
onCloseIntroOutroDrawer,
pipDrawerOpen,
pipSettings,
totalDuration,
onPipChange,
onClosePipDrawer,
filterDrawerOpen,
filterSettings,
onFilterChange,
onCloseFilterDrawer,
chromaKeyDrawerOpen,
chromaKeySettings,
onChromaKeyChange,
onCloseChromaKeyDrawer,
stickerDrawerOpen,
stickerSettings,
onStickerChange,
onCloseStickerDrawer,
}) => {
const transitionConfig = transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
: DEFAULT_TRANSITION
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
return (
<>
{/* BGM 选择器 Drawer */}
<BgmSelector
open={bgmDrawerOpen}
onClose={onCloseBgmDrawer}
config={bgmSettings}
onChange={onBgmSettingsChange}
/>
{/* 字幕样式配置 Drawer */}
<SubtitleStylePanel
open={subtitleDrawerOpen}
onClose={onCloseSubtitleDrawer}
config={subtitleSettings}
onChange={onSubtitleSettingsChange}
/>
{/* 转场特效选择器 Drawer */}
<TransitionSelector
open={transitionDrawerOpen}
onClose={onCloseTransitionDrawer}
config={transitionConfig}
onChange={onTransitionChange}
title={transitionTitle}
/>
{/* 片段调速面板 Drawer */}
{speedTargetClipId && (
<SpeedPanel
open={speedDrawerOpen}
onClose={onCloseSpeedDrawer}
config={clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED}
onChange={onSpeedChange}
onApplyAll={onApplySpeedAll}
/>
)}
{/* TTS 配音面板 Drawer */}
{ttsTargetClipId && (
<TtsPanel
open={ttsDrawerOpen}
onClose={onCloseTtsDrawer}
config={clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG}
onChange={onTtsChange}
/>
)}
{/* 水印配置面板 */}
<WatermarkPanel
open={watermarkDrawerOpen}
onClose={onCloseWatermarkDrawer}
config={watermarkSettings}
onChange={onWatermarkChange}
/>
{/* 片头片尾配置面板 */}
<IntroOutroPanel
open={introOutroDrawerOpen}
onClose={onCloseIntroOutroDrawer}
config={introOutroSettings}
onChange={onIntroOutroChange}
/>
{/* 混剪配置面板 */}
<PipConfigPanel
open={pipDrawerOpen}
onClose={onClosePipDrawer}
config={pipSettings}
onChange={onPipChange}
totalDuration={totalDuration}
/>
{/* 滤镜调色面板 */}
<FilterPanel
open={filterDrawerOpen}
onClose={onCloseFilterDrawer}
config={filterSettings}
onChange={onFilterChange}
/>
{/* 绿幕抠像面板 */}
<GreenScreenPanel
open={chromaKeyDrawerOpen}
onClose={onCloseChromaKeyDrawer}
config={chromaKeySettings}
onChange={onChromaKeyChange}
/>
{/* 贴纸面板 */}
<StickerPanel
open={stickerDrawerOpen}
onClose={onCloseStickerDrawer}
config={stickerSettings}
onChange={onStickerChange}
totalDuration={totalDuration}
/>
</>
)
}
export default EditorDrawers
@@ -0,0 +1,28 @@
import React from "react"
import type { TemplateMode } from "@/api/editing-planner"
interface ModeBarProps {
modeList: { key: TemplateMode; label: string; icon: string }[]
currentMode: TemplateMode
onModeChange: (mode: TemplateMode) => void
}
const ModeBar: React.FC<ModeBarProps> = ({ modeList, currentMode, onModeChange }) => {
return (
<div className="ep-mode-bar">
<span className="ep-mode-bar-label">剪辑模式:</span>
{modeList.map((m) => (
<button
key={m.key}
className={`ep-mode-btn ${currentMode === m.key ? "active" : ""}`}
onClick={() => onModeChange(m.key)}
>
<span className="ep-mode-icon">{m.icon}</span>
<span className="ep-mode-label">{m.label}</span>
</button>
))}
</div>
)
}
export default ModeBar
@@ -0,0 +1,169 @@
import React from "react"
import ClipPropertiesPanel from "./ClipPropertiesPanel"
import EditorClipList from "./EditorClipList"
import type { ClipData } from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import type { TemplateMode } from "@/api/editing-planner"
import type { AssetItem } from "@/api/assets"
interface RightPanelProps {
rightTab: "properties" | "clips"
onTabChange: (tab: "properties" | "clips") => void
// 属性 tab
selectedClip: ClipData | null
subtitleSettings: SubtitleStyleConfig
bgmSettings: BgmMixConfig
clipsCount: number
totalDuration: number
currentMode: TemplateMode
onSubtitleSettingsChange: (partial: Partial<SubtitleStyleConfig>) => void
onBgmSettingsChange: (partial: Partial<BgmMixConfig>) => void
onClipUpdate: (clipId: string, updates: Partial<ClipData>) => void
onOpenBgmDrawer: () => void
onOpenSubtitleDrawer: () => void
voiceMaterials: AssetItem[]
voiceMaterialsLoading: boolean
onRefreshVoiceMaterials: () => void
onClipVoiceSelect: (clipId: string, asset: AssetItem | null) => void
onOpenTransitionDrawer: (clipId?: string) => void
onOpenSpeedDrawer: (clipId: string) => void
onOpenTtsDrawer: (clipId: string) => void
onOpenWatermarkDrawer: () => void
onOpenIntroOutroDrawer: () => void
onOpenPipDrawer: () => void
onOpenFilterDrawer: () => void
onOpenGreenScreenDrawer: () => void
onOpenStickerDrawer: () => void
// 片段 tab
clips: ClipData[]
selectedClipId: string | null
onClipSelect: (clipId: string) => void
onClipMoveUp: (clipId: string) => void
onClipMoveDown: (clipId: string) => void
onClipRemove: (clipId: string) => void
onClipAdd: () => void
}
const RightPanel: React.FC<RightPanelProps> = ({
rightTab,
onTabChange,
selectedClip,
subtitleSettings,
bgmSettings,
clipsCount,
totalDuration,
currentMode,
onSubtitleSettingsChange,
onBgmSettingsChange,
onClipUpdate,
onOpenBgmDrawer,
onOpenSubtitleDrawer,
voiceMaterials,
voiceMaterialsLoading,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
onOpenWatermarkDrawer,
onOpenIntroOutroDrawer,
onOpenPipDrawer,
onOpenFilterDrawer,
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
clips,
selectedClipId,
onClipSelect,
onClipMoveUp,
onClipMoveDown,
onClipRemove,
onClipAdd,
}) => {
return (
<div className="ep-right-panel">
{/* Tab 切换 */}
<div className="ep-right-tabs">
<button
className={`ep-right-tab${rightTab === "properties" ? " active" : ""}`}
onClick={() => onTabChange("properties")}
>
属性
</button>
<button
className={`ep-right-tab${rightTab === "clips" ? " active" : ""}`}
onClick={() => onTabChange("clips")}
>
片段
</button>
</div>
{/* 属性 Tab */}
{rightTab === "properties" && (
<div className="ep-right-tab-content">
{(() => {
// ClipPropertiesPanel 内部类型与主文件类型结构一致但字段细节不同
// 使用 unknown 作为中间类型避免 any 警告
const sub = subtitleSettings as unknown as React.ComponentProps<
typeof ClipPropertiesPanel
>["subtitleSettings"]
const bgm = bgmSettings as unknown as React.ComponentProps<
typeof ClipPropertiesPanel
>["bgmSettings"]
const onSubChange = onSubtitleSettingsChange as unknown as React.ComponentProps<
typeof ClipPropertiesPanel
>["onSubtitleSettingsChange"]
const onBgmChange = onBgmSettingsChange as unknown as React.ComponentProps<
typeof ClipPropertiesPanel
>["onBgmSettingsChange"]
return (
<ClipPropertiesPanel
selectedClip={selectedClip}
subtitleSettings={sub}
bgmSettings={bgm}
clipsCount={clipsCount}
totalDuration={totalDuration}
currentMode={currentMode}
onSubtitleSettingsChange={onSubChange}
onBgmSettingsChange={onBgmChange}
onClipUpdate={onClipUpdate}
onOpenBgmDrawer={onOpenBgmDrawer}
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsLoading}
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
onClipVoiceSelect={onClipVoiceSelect}
onOpenTransitionDrawer={onOpenTransitionDrawer}
onOpenSpeedDrawer={onOpenSpeedDrawer}
onOpenTtsDrawer={onOpenTtsDrawer}
onOpenWatermarkDrawer={onOpenWatermarkDrawer}
onOpenIntroOutroDrawer={onOpenIntroOutroDrawer}
onOpenPipDrawer={onOpenPipDrawer}
onOpenFilterDrawer={onOpenFilterDrawer}
onOpenGreenScreenDrawer={onOpenGreenScreenDrawer}
onOpenStickerDrawer={onOpenStickerDrawer}
/>
)
})()}
</div>
)}
{/* 片段 Tab */}
{rightTab === "clips" && (
<div className="ep-right-tab-content">
<EditorClipList
clips={clips}
selectedClipId={selectedClipId}
onSelect={onClipSelect}
onMoveUp={onClipMoveUp}
onMoveDown={onClipMoveDown}
onRemove={onClipRemove}
onAdd={onClipAdd}
/>
</div>
)}
</div>
)
}
export default RightPanel
@@ -0,0 +1,32 @@
import React from "react"
interface StatusBarProps {
clipsCount: number
totalDuration: number
currentModeLabel: string
templateSegments: number
}
const StatusBar: React.FC<StatusBarProps> = ({
clipsCount,
totalDuration,
currentModeLabel,
templateSegments,
}) => {
return (
<div className="ep-status-bar">
<div className="ep-status-left">
<span>📋 片段: {clipsCount}</span>
<span className="ep-status-sep">|</span>
<span>⏱️ 总时长: {totalDuration.toFixed(1)}s</span>
</div>
<div className="ep-status-right">
<span>🎬 {currentModeLabel}</span>
<span className="ep-status-sep">|</span>
<span>📐 模板片段: {templateSegments}</span>
</div>
</div>
)
}
export default StatusBar
@@ -11,6 +11,24 @@
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
import type { ClipData, ClipType, TrimConfig } from "../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import {
CLIP_TYPE_ICONS,
CLIP_TYPE_LABELS,
DEFAULT_PIXELS_PER_SECOND,
MIN_PIXELS_PER_SECOND,
MAX_PIXELS_PER_SECOND,
ZOOM_STEP,
MIN_CLIP_WIDTH,
ADD_PICKER_WIDTH,
TRACK_GAP,
MIN_TRIM_DURATION,
DEFAULT_ADD_DURATION,
MIN_ADD_DURATION,
MAX_ADD_DURATION,
MIN_TRACK_WIDTH,
getRulerStep,
} from "../constants/timeline"
import { formatTime, formatTrimTime, generateRulerMarks } from "../utils/timeline"
interface TimelinePanelProps {
clips: ClipData[]
@@ -38,18 +56,6 @@ interface TimelinePanelProps {
totalDuration?: number
}
/** 片段类型图标 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
}
/** 片段类型标签 */
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
/** 裁剪拖拽方向 */
type TrimDirection = "left" | "right"
@@ -135,7 +141,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>(defaultAddType)
const [addDuration, setAddDuration] = useState<number>(5)
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
@@ -144,21 +150,17 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
}
}, [currentMode, addType, availableTypes, defaultAddType])
/* ── 面板尺寸 ── */
const PICKER_W = 240
const GAP = 6
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - GAP - roughHeight
let top = rect.top - TRACK_GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - PICKER_W < 8) {
right = vw - PICKER_W - 8
if (rect.right - ADD_PICKER_WIDTH < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [])
@@ -181,9 +183,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
const pickerH = pickerEl.offsetHeight
const vh = window.innerHeight
const vw = window.innerWidth
let top = addRect.top - GAP - pickerH
let top = addRect.top - TRACK_GAP - pickerH
if (top < 8) {
top = addRect.bottom + GAP
top = addRect.bottom + TRACK_GAP
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH
if (top < 8) top = 8
@@ -192,7 +194,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
let right = vw - addRect.right
const pickerRect = pickerEl.getBoundingClientRect()
if (pickerRect.left < 8) {
right = vw - PICKER_W - 8
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [showAddPicker])
@@ -224,7 +226,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
}, [contextMenu])
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? 40
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
/* ── 播放头拖拽全局 mousemove/mouseup ── */
@@ -344,11 +346,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
useEffect(() => {
if (!trimDrag) return
const PX_PER_SECOND = pixelsPerSecond ?? 40 // 与缩放级别同步
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX
const dtSec = dx / PX_PER_SECOND
const dtSec = dx / pps
const clip = clips.find((c) => c.id === trimDrag.clipId)
if (!clip) return
@@ -359,10 +359,13 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
if (trimDrag.direction === "left") {
// 左手柄:调整入点
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - 1))
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
} else {
// 右手柄:调整出点
newEnd = Math.max(origTrim.start_time + 1, Math.min(origTrim.end_time + dtSec, origDur))
newEnd = Math.max(
origTrim.start_time + MIN_TRIM_DURATION,
Math.min(origTrim.end_time + dtSec, origDur),
)
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10
@@ -396,7 +399,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond])
}, [trimDrag, trimPreview, clips, onClipTrim, pps])
/* ── 右键菜单 ── */
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
@@ -429,23 +432,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
}, [contextMenu, onClipRemove])
/* ── 时间标尺 ── */
const trackWidth = Math.max(totalDuration * pps, 300)
const rulerMarks: number[] = []
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15
for (let t = 0; t <= totalDuration + step; t += step) {
rulerMarks.push(t)
}
const formatTime = (sec: number) => {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, "0")}`
}
/** 格式化裁剪时间(精确到0.1秒) */
const formatTrimTime = (sec: number) => {
return `${sec.toFixed(1)}s`
}
const trackWidth = Math.max(totalDuration * pps, MIN_TRACK_WIDTH)
const step = getRulerStep(totalDuration)
const rulerMarks = generateRulerMarks(totalDuration, step)
return (
<div className="ep-timeline-area">
@@ -460,7 +449,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<div className="ep-timeline-zoom">
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
onClick={() => onZoomChange?.(Math.max(MIN_PIXELS_PER_SECOND, pps - ZOOM_STEP))}
title="缩小"
>
−
@@ -468,15 +457,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<input
type="range"
className="ep-zoom-slider"
min={10}
max={120}
min={MIN_PIXELS_PER_SECOND}
max={MAX_PIXELS_PER_SECOND}
step={5}
value={pps}
onChange={(e) => onZoomChange?.(Number(e.target.value))}
/>
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
onClick={() => onZoomChange?.(Math.min(MAX_PIXELS_PER_SECOND, pps + ZOOM_STEP))}
title="放大"
>
+
@@ -567,7 +556,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<div
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
style={{ width: Math.max(clip.duration * pps, 60) }}
style={{ width: Math.max(clip.duration * pps, MIN_CLIP_WIDTH) }}
draggable={!trimDrag}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
@@ -751,11 +740,16 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<input
type="number"
className="ep-duration-input"
min={1}
max={120}
min={MIN_ADD_DURATION}
max={MAX_ADD_DURATION}
value={addDuration}
onChange={(e) =>
setAddDuration(Math.max(1, Math.min(120, Number(e.target.value) || 1)))
setAddDuration(
Math.max(
MIN_ADD_DURATION,
Math.min(MAX_ADD_DURATION, Number(e.target.value) || MIN_ADD_DURATION),
),
)
}
/>
<span className="ep-add-clip-duration-unit">秒</span>
@@ -0,0 +1,54 @@
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
interface TopBarProps {
currentTemplate: EditingTemplate | null
canUndo: boolean
canRedo: boolean
onUndo: () => void
onRedo: () => void
onOpenSaveModal: () => void
}
const TopBar: React.FC<TopBarProps> = ({
currentTemplate,
canUndo,
canRedo,
onUndo,
onRedo,
onOpenSaveModal,
}) => {
return (
<div className="ep-top-bar">
<div className="ep-top-bar-left">
<span className="ep-logo">✂️</span>
<span className="ep-app-title">模板制作</span>
<span className="ep-divider">|</span>
<span className="ep-template-name">{currentTemplate?.name || "未选择模板"}</span>
</div>
<div className="ep-top-bar-right">
<button
className="ep-btn ep-btn-secondary"
onClick={onUndo}
disabled={!canUndo}
title="撤销 (Ctrl+Z)"
>
⬅️ 撤销
</button>
<button
className="ep-btn ep-btn-secondary"
onClick={onRedo}
disabled={!canRedo}
title="重做 (Ctrl+Shift+Z)"
>
➡️ 重做
</button>
<button className="ep-btn ep-btn-secondary" onClick={onOpenSaveModal}>
💾 保存模板
</button>
</div>
</div>
)
}
export default TopBar
@@ -0,0 +1,12 @@
import type { TemplateMode } from "@/api/editing-planner"
/* ──────────── 常量 ──────────── */
export const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
{ key: "pip", label: "混剪", icon: "🖼️" },
{ key: "voice_over", label: "人物口播", icon: "🎙️" },
{ key: "one_take", label: "一镜到底", icon: "🎥" },
{ key: "voice_pip", label: "口播+混剪", icon: "🎭" },
]
export const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"]
@@ -0,0 +1,56 @@
import type { ClipType } from "../types"
/** 片段类型图标 */
export const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
}
/** 片段类型标签 */
export const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
/** 默认缩放:每秒像素数 */
export const DEFAULT_PIXELS_PER_SECOND = 40
/** 最小缩放 */
export const MIN_PIXELS_PER_SECOND = 10
/** 最大缩放 */
export const MAX_PIXELS_PER_SECOND = 120
/** 缩放步长 */
export const ZOOM_STEP = 10
/** 片段卡片最小宽度(px) */
export const MIN_CLIP_WIDTH = 60
/** 添加面板宽度(px) */
export const ADD_PICKER_WIDTH = 240
/** 轨道间距(px) */
export const TRACK_GAP = 6
/** 最小裁剪时长(秒) */
export const MIN_TRIM_DURATION = 1
/** 默认添加时长(秒) */
export const DEFAULT_ADD_DURATION = 5
/** 最小添加时长(秒) */
export const MIN_ADD_DURATION = 1
/** 最大添加时长(秒) */
export const MAX_ADD_DURATION = 120
/** 轨道最小宽度(px) */
export const MIN_TRACK_WIDTH = 300
/** 时间标尺刻度计算:根据总时长返回刻度步长(秒) */
export const getRulerStep = (totalDuration: number): number => {
if (totalDuration <= 30) return 5
if (totalDuration <= 60) return 10
return 15
}
@@ -0,0 +1,238 @@
import { useState, useCallback, useMemo } from "react"
import { Modal, message } from "antd"
import type {
ClipData,
ClipType,
TransitionConfig,
SpeedConfig,
TtsConfig,
TrimConfig,
} from "../types"
import type { AssetItem } from "@/api/assets"
interface UseClipOperationsParams {
clips: ClipData[]
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
}
/**
* 片段操作 Hook
* 片段增删改查、排序、裁剪、分割、转场、调速、TTS、配音选择
*/
export const useClipOperations = ({ clips, setClips }: UseClipOperationsParams) => {
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
const selectedClip = useMemo(
() => clips.find((c) => c.id === selectedClipId) || null,
[clips, selectedClipId],
)
/* ── 选中 / 重排 / 删除 ── */
const handleClipSelect = useCallback((clipId: string) => {
setSelectedClipId(clipId)
}, [])
const handleClipReorder = useCallback(
(fromIdx: number, toIdx: number) => {
setClips((prev) => {
const updated = [...prev]
const [moved] = updated.splice(fromIdx, 1)
updated.splice(toIdx, 0, moved)
return updated.map((c, i) => ({ ...c, order: i }))
})
},
[setClips],
)
const handleClipRemove = useCallback(
(clipId: string) => {
Modal.confirm({
title: "删除片段",
content: "确定要删除这个片段吗?此操作可通过撤销恢复。",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: () => {
setClips((prev) => prev.filter((c) => c.id !== clipId))
if (selectedClipId === clipId) setSelectedClipId(null)
},
})
},
[setClips, selectedClipId],
)
const handleClipUpdate = useCallback(
(clipId: string, data: Partial<ClipData>) => {
setClips((prev) => prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)))
},
[setClips],
)
/**
* 添加片段(不绑定任何素材)
* 片段 = 时间规划 + 类型标记
*/
const handleAddClip = useCallback(
(type: ClipType, duration: number) => {
const newClip: ClipData = {
id: `clip-${Date.now()}`,
type,
duration,
startOffset: 0,
order: clips.length,
}
setClips((prev) => [...prev, newClip])
},
[clips.length, setClips],
)
/* ── 裁剪 / 分割 / 重置 ── */
const handleClipTrim = useCallback(
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId ? { ...c, trim_config: trimConfig, duration: newDuration } : c,
),
)
},
[setClips],
)
const handleClipSplit = useCallback(
(clipId: string, splitRatio: number) => {
setClips((prev) => {
const idx = prev.findIndex((c) => c.id === clipId)
if (idx === -1) return prev
const clip = prev[idx]
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev
// 前半段
const firstHalf: ClipData = {
...clip,
duration: splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
end_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
}
// 后半段
const secondHalf: ClipData = {
...clip,
id: `clip-${Date.now()}`,
duration: clip.duration - splitPoint,
startOffset: clip.startOffset + splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
start_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
order: (clip.order ?? idx) + 1,
}
const updated = [...prev]
updated[idx] = firstHalf
updated.splice(idx + 1, 0, secondHalf)
return updated.map((c, i) => ({ ...c, order: i }))
})
},
[setClips],
)
const handleClipResetTrim = useCallback(
(clipId: string) => {
setClips((prev) =>
prev.map((c) => {
if (c.id !== clipId || !c.trim_config) return c
const originalDuration = c.trim_config.original_duration ?? c.duration
return {
...c,
duration: originalDuration,
trim_config: undefined,
}
}),
)
},
[setClips],
)
/* ── 转场 / 调速 / TTS ── */
const handleTransitionChange = useCallback(
(targetClipId: string | null, config: TransitionConfig) => {
if (targetClipId) {
handleClipUpdate(targetClipId, { transition: config })
}
// 同时更新全局默认转场(供新片段使用)—— 暂未实现全局默认
},
[handleClipUpdate],
)
const handleSpeedChange = useCallback(
(targetClipId: string | null, config: SpeedConfig) => {
if (targetClipId) {
handleClipUpdate(targetClipId, { speed: config })
}
},
[handleClipUpdate],
)
const handleApplySpeedAll = useCallback(
(config: SpeedConfig) => {
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })))
message.success("已应用到所有片段")
},
[setClips],
)
const handleTtsChange = useCallback(
(targetClipId: string | null, ttsConfig: TtsConfig) => {
if (!targetClipId) return
handleClipUpdate(targetClipId, { tts_config: ttsConfig })
},
[handleClipUpdate],
)
/** 为片段选择配音素材 */
const handleClipVoiceSelect = useCallback(
(clipId: string, asset: AssetItem | null) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId
? {
...c,
voice_asset_id: asset?.id ?? undefined,
voice_file_url: asset?.file_url ?? undefined,
}
: c,
),
)
},
[setClips],
)
return {
selectedClipId,
setSelectedClipId,
selectedClip,
handleClipSelect,
handleClipReorder,
handleClipRemove,
handleClipUpdate,
handleAddClip,
handleClipTrim,
handleClipSplit,
handleClipResetTrim,
handleTransitionChange,
handleSpeedChange,
handleApplySpeedAll,
handleTtsChange,
handleClipVoiceSelect,
}
}
@@ -0,0 +1,78 @@
import { useState, useCallback } from "react"
/**
* 编辑器 Drawer 开关管理
* 集中管理 11 个抽屉的开关状态 + 3 个目标片段 ID + 快捷打开方法
*/
export const useEditorDrawers = () => {
/* ── 抽屉开关 ── */
const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false)
const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false)
const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false)
const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false)
const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false)
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false)
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false)
const [pipDrawerOpen, setPipDrawerOpen] = useState(false)
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false)
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false)
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false)
/* ── 目标片段 ID ── */
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
const [transitionTargetClipId, setTransitionTargetClipId] = useState<string | null>(null)
/** 当前正在调速的片段 ID */
const [speedTargetClipId, setSpeedTargetClipId] = useState<string | null>(null)
/** 当前正在配置 TTS 的片段 ID */
const [ttsTargetClipId, setTtsTargetClipId] = useState<string | null>(null)
/* ── 快捷打开 ── */
const openTransitionDrawer = useCallback((clipId?: string) => {
setTransitionTargetClipId(clipId ?? null)
setTransitionDrawerOpen(true)
}, [])
const openSpeedDrawer = useCallback((clipId: string) => {
setSpeedTargetClipId(clipId)
setSpeedDrawerOpen(true)
}, [])
const openTtsDrawer = useCallback((clipId: string) => {
setTtsTargetClipId(clipId)
setTtsDrawerOpen(true)
}, [])
return {
// 开关 state
bgmDrawerOpen,
setBgmDrawerOpen,
subtitleDrawerOpen,
setSubtitleDrawerOpen,
transitionDrawerOpen,
setTransitionDrawerOpen,
speedDrawerOpen,
setSpeedDrawerOpen,
ttsDrawerOpen,
setTtsDrawerOpen,
watermarkDrawerOpen,
setWatermarkDrawerOpen,
introOutroDrawerOpen,
setIntroOutroDrawerOpen,
pipDrawerOpen,
setPipDrawerOpen,
filterDrawerOpen,
setFilterDrawerOpen,
chromaKeyDrawerOpen,
setChromaKeyDrawerOpen,
stickerDrawerOpen,
setStickerDrawerOpen,
// 目标 ID
transitionTargetClipId,
speedTargetClipId,
ttsTargetClipId,
// 快捷方法
openTransitionDrawer,
openSpeedDrawer,
openTtsDrawer,
}
}
@@ -0,0 +1,56 @@
import { useState, useCallback, useEffect, useRef } from "react"
/**
* 播放控制 Hook
* 播放/暂停、rAF 帧推进、时间线缩放、seek
*/
export const usePlaybackControl = (totalDuration: number) => {
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [pixelsPerSecond, setPixelsPerSecond] = useState(40)
const prevFrameTimeRef = useRef<number | null>(null)
/** 播放头跳转 */
const handleSeek = useCallback((time: number) => {
setCurrentTime(Math.max(0, time))
}, [])
/** 轨道缩放 */
const handleZoomChange = useCallback((pps: number) => {
setPixelsPerSecond(pps)
}, [])
/** rAF 帧推进 — 播放时平滑更新播放头位置 */
useEffect(() => {
if (!isPlaying) {
prevFrameTimeRef.current = null
return
}
let rafId: number
const tick = (timestamp: number) => {
if (prevFrameTimeRef.current !== null) {
const delta = (timestamp - prevFrameTimeRef.current) / 1000
setCurrentTime((prev) => {
const next = prev + delta
return next >= totalDuration ? totalDuration : next
})
}
prevFrameTimeRef.current = timestamp
rafId = requestAnimationFrame(tick)
}
rafId = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(rafId)
prevFrameTimeRef.current = null
}
}, [isPlaying, totalDuration])
return {
isPlaying,
setIsPlaying,
currentTime,
pixelsPerSecond,
handleSeek,
handleZoomChange,
}
}
@@ -0,0 +1,466 @@
import { useState, useCallback, useEffect, type Dispatch, type SetStateAction } from "react"
import { FILTER_CATEGORIES } from "../constants"
import { message } from "antd"
import type {
EditingTemplate,
TemplateCategory,
TemplateMode,
SaveTemplatePayload,
} from "@/api/editing-planner"
import {
getEditingTemplates,
getEditingTemplate,
createEditingTemplate,
updateEditingTemplate,
getTemplateCategories,
} from "@/api/editing-planner"
import type { MediaAsset, TitleConfig, TransitionEffect } from "@/api/template-editor"
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/template-editor"
import type {
ClipData,
ClipType,
TtsConfig,
TtsMode,
TrimConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
interface UseTemplateManagementParams {
urlTemplateId: string
urlPlanId: string
resetClips: (clips: ClipData[]) => void
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
setSelectedClipId: (id: string | null) => void
setMediaAssets: (assets: MediaAsset[]) => void
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
// 保存时需要的配置
clips: ClipData[]
totalDuration: number
titleConfig: TitleConfig
subtitleSettings: SubtitleStyleConfig
bgmSettings: BgmMixConfig
watermarkSettings: WatermarkConfig
introOutroSettings: IntroOutroConfig
pipSettings: PipConfig
filterSettings: FilterConfig
chromaKeySettings: ChromaKeyConfig
stickerSettings: StickerConfig
coverConfig: CoverConfig
}
/**
* 模板管理 Hook
* 模板列表/分类/加载/保存/模式切换/筛选搜索 + 3 个 useEffect
*/
export const useTemplateManagement = (params: UseTemplateManagementParams) => {
const {
urlTemplateId,
urlPlanId,
resetClips,
setClips,
setSelectedClipId,
setMediaAssets,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
clips,
totalDuration,
titleConfig,
subtitleSettings,
bgmSettings,
watermarkSettings,
introOutroSettings,
pipSettings,
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
} = params
/* ── 模板列表 ── */
const [templates, setTemplates] = useState<EditingTemplate[]>([])
const [categories, setCategories] = useState<TemplateCategory[]>([])
const [loadingTemplates, setLoadingTemplates] = useState(false)
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(urlTemplateId || null)
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip")
/* ── 左栏筛选 ── */
const [currentFilter, setCurrentFilter] = useState("全部")
const [searchQuery, setSearchQuery] = useState("")
/* ── 保存弹窗 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false)
const [draftName, setDraftName] = useState("")
const [draftCategory, setDraftCategory] = useState("")
const [draftTags, setDraftTags] = useState("")
const [saveLoading, setSaveLoading] = useState(false)
/* ── 计划 ID(从 URL 传入,不变) ── */
const [loadedPlanId] = useState<string | null>(urlPlanId || null)
/* ── 计算 ── */
const filteredTemplates = templates.filter((t) => {
if (currentFilter !== "全部" && t.category !== currentFilter) return false
if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
return true
})
const currentTemplate = templates.find((t) => t.id === loadedTemplateId) || null
/* ──────────── 加载 ──────────── */
/**
* 并行加载模板列表、分类、素材库
* 首次挂载时调用,三个接口无依赖关系,用 Promise.all 并发
*/
const loadTemplates = useCallback(async () => {
setLoadingTemplates(true)
try {
const [tpls, cats, assets] = await Promise.all([
getEditingTemplates(),
getTemplateCategories(),
getMediaAssets(),
])
setTemplates(tpls)
setCategories(cats)
setMediaAssets(assets)
} catch {
message.error("加载模板失败")
} finally {
setLoadingTemplates(false)
}
}, [setMediaAssets])
useEffect(() => {
loadTemplates()
}, [loadTemplates])
/**
* 加载模板详情并初始化片段列表
* 将后端 segments 映射为前端 ClipData,取 duration_min/max 均值作为默认时长
* 同时还原标题/字幕/BGM 配置
*/
useEffect(() => {
if (!loadedTemplateId) return
getEditingTemplate(loadedTemplateId)
.then((tpl) => {
if (!tpl) return
setCurrentMode(tpl.mode)
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
id: seg.id || `seg-${idx}`,
template_segment_id: seg.id || `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
}))
resetClips(mapped)
setTitleConfig({
ai_auto_select: tpl.title_config.ai_auto_select,
content: tpl.title_config.content,
position: tpl.title_config.position,
font_preset: tpl.title_config.font_preset,
font_size: tpl.title_config.font_size,
font_color: tpl.title_config.font_color || "#ffffff",
})
setSubtitleSettings((prev) => ({
...prev,
enabled: tpl.subtitle_config.enabled,
position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"],
font: tpl.subtitle_config.font,
fontSize: tpl.subtitle_config.size,
fontColor: tpl.subtitle_config.color || "#ffffff",
animation: tpl.subtitle_config.animation,
}))
setBgmSettings((prev) => ({
...prev,
enabled: tpl.bgm_config.enabled,
music_id: tpl.bgm_config.music_id || "",
}))
setDraftName(tpl.name)
setDraftCategory(tpl.category)
setDraftTags(tpl.tags.join(", "))
})
.catch(() => message.error("加载模板详情失败"))
}, [loadedTemplateId, resetClips, setTitleConfig, setSubtitleSettings, setBgmSettings])
/**
* 加载已有模板草稿数据到编辑器
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
*/
useEffect(() => {
if (!loadedPlanId) return
// 并行加载计划基本信息 + 片段列表
Promise.all([
getEditPlan(loadedPlanId),
getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({
items: [],
total: 0,
})),
])
.then(([plan, clipsRes]) => {
// 设置关联的模板(触发模板加载 effect)
setLoadedTemplateId(plan.template_id)
// 还原基本信息
setDraftName(plan.name)
// 还原 config 中的编辑器状态
const cfg = plan.config
if (cfg.title_config) {
setTitleConfig({
ai_auto_select: cfg.title_config!.ai_auto_select,
content: cfg.title_config!.content,
position: cfg.title_config!.position,
font_preset: cfg.title_config!.font_preset,
font_size: cfg.title_config!.font_size,
font_color: cfg.title_config!.font_color || "#ffffff",
})
}
if (cfg.subtitle_config) {
setSubtitleSettings((prev) => ({
...prev,
enabled: cfg.subtitle_config!.enabled,
position: (cfg.subtitle_config!.position ||
"bottom") as SubtitleStyleConfig["position"],
font: cfg.subtitle_config!.font,
fontSize: cfg.subtitle_config!.size,
fontColor: cfg.subtitle_config!.color || "#ffffff",
animation: cfg.subtitle_config!.animation,
}))
}
if (cfg.bgm_config) {
setBgmSettings((prev) => ({
...prev,
enabled: cfg.bgm_config!.enabled,
music_id: cfg.bgm_config!.music_id || "",
}))
}
// 还原封面配置
if (cfg.cover_config) {
setCoverConfig((prev) => ({
...prev,
enabled: cfg.cover_config!.enabled ?? prev.enabled,
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
}))
}
// 还原片段:优先从后端 clips 表,其次从 config.segments 兜底
const backendClips = clipsRes?.items || []
if (backendClips.length > 0) {
// 从后端 clips 表还原
const sorted = [...backendClips].sort((a, b) => a.order - b.order)
const mapped: ClipData[] = sorted.map((clip) => ({
id: clip.id,
template_segment_id: (clip.config?.template_segment_id as string) || "",
type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: clip.duration || 3,
startOffset: 0,
script_text: clip.text_content || "",
order: clip.order,
media_asset_id: clip.asset_id || undefined,
transition:
clip.transition_effect && clip.transition_effect !== "none"
? {
type: clip.transition_effect as TransitionEffect["type"],
duration: clip.transition_duration || 0.3,
}
: undefined,
speed: clip.playback_speed
? { rate: clip.playback_speed, pitchCorrection: true }
: undefined,
tts_config: (clip.config?.tts_config as TtsConfig) || undefined,
trim_config: (clip.config?.trim_config as TrimConfig) || undefined,
}))
setTimeout(() => resetClips(mapped), 100)
} else if (cfg.segments && cfg.segments.length > 0) {
// 兜底:从 config.segments 还原(老数据兼容)
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
id: `seg-${idx}`,
template_segment_id: `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
transition: seg.transition
? {
type: seg.transition.type as TransitionEffect["type"],
duration: seg.transition.duration,
}
: undefined,
speed: seg.playback_speed
? { rate: seg.playback_speed, pitchCorrection: true }
: undefined,
tts_config: seg.tts_config
? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode }
: undefined,
trim_config: seg.trim_config || undefined,
}))
setTimeout(() => resetClips(mapped), 100)
}
})
.catch(() => message.error("加载模板草稿失败"))
}, [
loadedPlanId,
resetClips,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
])
/* ──────────── 事件 ──────────── */
const handleLoadTemplate = (templateId: string) => {
setLoadedTemplateId(templateId)
setSelectedClipId(null)
}
const handleModeChange = (mode: TemplateMode) => {
setCurrentMode(mode)
// 切换纯单类型模式时,自动转换所有已有片段的类型
if (mode === "voice_over") {
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })))
} else if (mode === "pip") {
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })))
}
// 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型
}
const handleOpenSaveModal = () => {
setSaveModalOpen(true)
}
const handleSave = async () => {
if (!draftName.trim()) {
message.warning("请输入模板名称")
return
}
setSaveLoading(true)
try {
const payload: SaveTemplatePayload = {
name: draftName,
mode: currentMode,
category: draftCategory,
tags: draftTags
.split(",")
.map((t) => t.trim())
.filter(Boolean),
title_config: titleConfig,
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
},
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.type === "voice" ? "voiceover" : "video",
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverConfig },
}
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload)
} else {
await createEditingTemplate(payload)
}
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功")
setSaveModalOpen(false)
loadTemplates()
} catch {
message.error("保存失败")
} finally {
setSaveLoading(false)
}
}
return {
// state
templates,
categories,
loadingTemplates,
loadedTemplateId,
setLoadedTemplateId,
currentMode,
setCurrentMode,
currentFilter,
setCurrentFilter,
searchQuery,
setSearchQuery,
saveModalOpen,
setSaveModalOpen,
draftName,
setDraftName,
draftCategory,
setDraftCategory,
draftTags,
setDraftTags,
saveLoading,
filteredTemplates,
currentTemplate,
// methods
loadTemplates,
handleLoadTemplate,
handleModeChange,
handleOpenSaveModal,
handleSave,
}
}
export { FILTER_CATEGORIES }
@@ -0,0 +1,41 @@
import type { EditingTemplate } from "@/api/editing-planner"
import type { ClipData } from "../types"
/**
* 计算所有片段的总时长
*/
export function calculateTotalDuration(clips: ClipData[]): number {
return clips.reduce((sum, c) => sum + c.duration, 0)
}
/**
* 获取当前选中的模板
*/
export function getCurrentTemplate(
templates: EditingTemplate[],
loadedTemplateId: string | null,
): EditingTemplate | undefined {
return templates.find((t) => t.id === loadedTemplateId)
}
/**
* 根据分类和搜索词筛选模板
*/
export function getFilteredTemplates(
templates: EditingTemplate[],
currentFilter: string,
searchQuery: string,
): EditingTemplate[] {
return templates.filter((t) => {
if (currentFilter !== "全部" && t.category !== currentFilter) return false
if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
return true
})
}
/**
* 获取当前选中的片段
*/
export function getSelectedClip(clips: ClipData[], selectedClipId: string | null): ClipData | null {
return clips.find((c) => c.id === selectedClipId) || null
}
@@ -0,0 +1,20 @@
/** 格式化时间为 mm:ss */
export const formatTime = (sec: number): string => {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, "0")}`
}
/** 格式化裁剪时间(精确到 0.1 秒) */
export const formatTrimTime = (sec: number): string => {
return `${sec.toFixed(1)}s`
}
/** 生成时间标尺刻度 */
export const generateRulerMarks = (totalDuration: number, step: number): number[] => {
const marks: number[] = []
for (let t = 0; t <= totalDuration + step; t += step) {
marks.push(t)
}
return marks
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
/**
* Step 1 模板选择组件
*/
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
import { MODE_GRADIENTS } from "../constants"
import { useStep1Template } from "../hooks/useStep1Template"
interface Step1TemplateSelectProps {
templates: EditingTemplate[]
selectedTemplate: string
onSelectTemplate: (id: string) => void
}
const Step1TemplateSelect: React.FC<Step1TemplateSelectProps> = (props) => {
const { templates, selectedTemplate, handleSelect, handleKeySelect } = useStep1Template(props)
return (
<div className="xx-form-section">
<h3>🎨 选择模板</h3>
{templates.length === 0 ? (
<div className="xx-empty-state">
<p>暂无可用模板</p>
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
请先在「模板编辑器」中创建模板
</p>
</div>
) : (
<div className="xx-choice-list">
{templates.map((tpl) => (
<div
key={tpl.id}
className={`xx-choice-item ${selectedTemplate === tpl.id ? "selected" : ""}`}
onClick={() => handleSelect(tpl.id)}
role="button"
tabIndex={0}
aria-pressed={selectedTemplate === tpl.id}
onKeyDown={(e) => handleKeySelect(e, tpl.id)}
>
<span className="xx-choice-check">✓</span>
<div
className="xx-choice-thumb"
style={{
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
}}
>
🎬
</div>
<h4>{tpl.name}</h4>
<p>
{tpl.estimated_duration}s · {tpl.segments.length}片段
</p>
{tpl.tags.length > 0 && (
<div
style={{
display: "flex",
gap: 4,
flexWrap: "wrap",
marginTop: 4,
}}
>
{tpl.tags.map((tag) => (
<span
key={tag}
style={{
fontSize: 11,
padding: "1px 6px",
borderRadius: 6,
background: "var(--bg-secondary)",
color: "var(--text-secondary)",
}}
>
{tag}
</span>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
export default Step1TemplateSelect
@@ -0,0 +1,95 @@
/**
* Step 2 素材选择组件
*/
import React from "react"
import { useStep2Materials } from "../hooks/useStep2Materials"
import MaterialModeTabs from "./material/MaterialModeTabs"
import ManualMaterialList from "./material/ManualMaterialList"
import SmartMatchInput from "./material/SmartMatchInput"
import SmartMatchResults from "./material/SmartMatchResults"
interface Step2MaterialSelectProps {
materialMode: "manual" | "auto"
onMaterialModeChange: (mode: "manual" | "auto") => void
selectedMaterials: string[]
onSelectedMaterialsChange: (ids: string[]) => void
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
}
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
const m = useStep2Materials(props)
return (
<div className="xx-form-section">
<h3>📦 选择素材</h3>
<MaterialModeTabs mode={m.materialMode} onModeChange={m.onMaterialModeChange} />
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label>选择视频库</label>
<select
value={m.selectedLibraryId}
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
>
{m.libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
</div>
{m.materialMode === "manual" && (
<>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span className="xx-pill xx-pill-ok">已选 {m.selectedMaterials.length} 个素材</span>
</div>
<ManualMaterialList
materials={m.materials}
materialsLoading={m.materialsLoading}
selectedMaterials={m.selectedMaterials}
onToggle={m.handleToggleMaterial}
/>
</>
)}
{m.materialMode === "auto" && (
<div className="xx-smart-match-section">
<SmartMatchInput
inputValue={m.smartMatchInput}
onInputChange={m.setSmartMatchInput}
matching={m.smartMatching}
onMatch={m.handleSmartMatch}
hasMatched={m.hasMatched}
onRefresh={m.handleRefreshMatch}
materialsCount={m.materials.items.length}
loading={m.materialsLoading}
/>
<SmartMatchResults
results={m.smartMatchedResults}
selectedIds={m.smartSelectedIds}
matching={m.smartMatching}
hasMatched={m.hasMatched}
onToggle={m.handleToggleSmartSelect}
onSelectAll={m.handleSelectAllMatched}
onClear={m.handleClearSmartSelect}
formatDuration={m.formatDuration}
selectedTotalDuration={m.smartSelectedTotalDuration}
/>
</div>
)}
</div>
)
}
export default Step2MaterialSelect
@@ -0,0 +1,57 @@
/**
* Step 3 生成预览组件
*/
import React from "react"
import { CheckCircleFilled } from "@ant-design/icons"
import type { EditingTemplate } from "@/api/editing-planner"
import { useStep3Preview } from "../hooks/useStep3Preview"
interface Step3GeneratePreviewProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
duration: number
videoRatio: string
}
const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = (props) => {
const { templateName, materialCount, duration, videoRatio } = useStep3Preview(props)
return (
<div className="xx-form-section">
<h3>🎬 生成预览</h3>
<div className="xx-preview-tip">
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
<span>素材已选好,AI 将为您智能匹配剪辑方案</span>
</div>
<div className="xx-preview-plan-card">
<div className="xx-preview-plan-title">模板草稿预览</div>
<div className="xx-preview-plan-info">
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">模板</span>
<span className="xx-preview-plan-value">{templateName}</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">素材数量</span>
<span className="xx-preview-plan-value">{materialCount}</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">预计时长</span>
<span className="xx-preview-plan-value">{duration} 秒</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">视频比例</span>
<span className="xx-preview-plan-value">{videoRatio}</span>
</div>
</div>
<div className="xx-preview-plan-hint">
💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题
</div>
</div>
</div>
)
}
export default Step3GeneratePreview
@@ -0,0 +1,108 @@
/**
* Step 4 标题设置组件
*/
import React from "react"
import { Select } from "antd"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
import type { TitleSettings } from "../types"
import { useStep4Title } from "../hooks/useStep4Title"
import AiTitleGenerator from "./title/AiTitleGenerator"
import TitleStylePanel from "./title/TitleStylePanel"
interface Step4TitleSettingsProps {
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
}
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
const t = useStep4Title(props)
return (
<div className="xx-form-section">
<h3>📝 选择标题</h3>
<AiTitleGenerator
inputValue={t.aiTitleInput}
onInputChange={t.setAiTitleInput}
generating={t.aiTitleGenerating}
onGenerate={t.handleGenerateAiTitles}
results={t.aiTitleResults}
hasGenerated={t.hasGeneratedTitles}
onSelect={t.handleSelectAiTitle}
selectedTitle={t.titleSettings.title}
onRefresh={t.handleRefreshAiTitles}
/>
<div className="xx-divider">
<span>或手动选择</span>
</div>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI 自动选择标题</span>
<div
className={`xx-switch ${t.titleSettings.aiAutoSelect ? "active" : ""}`}
onClick={t.toggleAiAutoSelect}
>
<div className="xx-switch-knob" />
</div>
</div>
{!t.titleSettings.aiAutoSelect && (
<>
<div className="xx-form-field">
<label>从标题库选择</label>
<Select
placeholder="请选择标题…"
allowClear
showSearch
style={{ width: "100%" }}
value={t.titleSettings.title || undefined}
onChange={(val) => t.updateTitle(val || "")}
options={t.userTitles.map((ut) => ({
label: ut.content,
value: ut.content,
}))}
filterOption={(input, option) =>
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
}
notFoundContent={
t.userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
标题库为空,请前往「标题管理」添加
</span>
) : null
}
/>
</div>
<div className="xx-form-field" style={{ marginTop: 14 }}>
<label>或手动输入</label>
<input
placeholder="输入自定义标题…"
value={t.titleSettings.title}
onChange={(e) => t.updateTitle(e.target.value)}
maxLength={50}
/>
</div>
<TitleStylePanel
settings={t.titleSettings}
onUpdatePosition={t.updatePosition}
onUpdateFont={t.updateFont}
onUpdateSize={t.updateSize}
onToggleBold={t.toggleBold}
onToggleItalic={t.toggleItalic}
onToggleStroke={t.toggleStroke}
onToggleShadow={t.toggleShadow}
onApplyPreset={t.applyPreset}
activePreset={t.activePreset}
titlePresets={t.titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
/>
</>
)}
</div>
)
}
export default Step4TitleSettings
@@ -0,0 +1,137 @@
/**
* Step 5 配音选择组件
*/
import React from "react"
import type { VoiceClone } from "@/api/voice-clone"
import { useStep5Voice } from "../hooks/useStep5Voice"
import VoiceRecommendSection from "./voice/VoiceRecommendSection"
import VoiceChoiceCard from "./voice/VoiceChoiceCard"
import PresetVoiceDetail from "./voice/PresetVoiceDetail"
import CustomVoicePanel from "./voice/CustomVoicePanel"
import SaveVoiceModal from "./voice/SaveVoiceModal"
import CloneVoiceSection from "./voice/CloneVoiceSection"
interface Step5VoiceSelectProps {
selectedVoice: string
onSelectedVoiceChange: (voiceId: string) => void
voiceMode: "preset" | "custom" | "clone"
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
onSelectedClonedVoiceChange: (voiceId: string) => void
clonedVoices: VoiceClone[]
addClone: (voice: VoiceClone) => void
hasProcessing: boolean
cloneModalOpen: boolean
onCloneModalOpenChange: (open: boolean) => void
titleText: string
}
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = (props) => {
const v = useStep5Voice(props)
return (
<div className="xx-form-section">
<h3>🎙️ 选择配音</h3>
<VoiceRecommendSection
presetVoices={v.presetVoices}
voiceRecommendLoading={v.voiceRecommendLoading}
voiceRecommendations={v.voiceRecommendations}
hasVoiceRecommend={v.hasVoiceRecommend}
onRecommend={v.handleVoiceRecommend}
onSelectVoice={v.handleSelectRecommendedVoice}
selectedVoiceId={v.selectedVoice}
voiceMode={v.voiceMode}
VOICE_GENDER_ICON={v.VOICE_GENDER_ICON}
/>
<div className="xx-divider">
<span>全部音色</span>
</div>
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
{v.presetVoices.slice(0, 3).map((pv) => (
<VoiceChoiceCard
key={pv.voice_id}
selected={v.voiceMode === "preset" && v.selectedVoice === pv.voice_id}
onClick={() => v.handleSelectPresetVoice(pv.voice_id)}
avatar={v.VOICE_GENDER_ICON[pv.gender] ?? "✨"}
title={pv.name}
description={pv.description}
/>
))}
{v.presetVoicesLoading && (
<VoiceChoiceCard
selected={false}
onClick={() => {}}
avatar="⏳"
title="加载中…"
loading
/>
)}
<VoiceChoiceCard
selected={v.voiceMode === "clone"}
onClick={v.handleSelectCloneVoice}
avatar="🎤"
title="克隆我的声音"
description="上传语音样本克隆"
avatarStyle={{ background: "linear-gradient(135deg, #10b981, #059669)" }}
/>
</div>
{v.voiceMode === "preset" && (
<PresetVoiceDetail
presetVoices={v.presetVoices}
selectedVoice={v.selectedVoice}
onSelect={v.handleSelectPresetVoice}
playingVoice={v.playingVoice}
onTogglePlay={v.toggleVoicePlay}
presetVoicesLoading={v.presetVoicesLoading}
/>
)}
{v.voiceMode === "custom" && (
<>
<CustomVoicePanel
customVoiceText={v.customVoiceText}
onTextChange={v.setCustomVoiceText}
synthesizePending={v.synthesizeMutation.isPending}
onSynthesize={v.handleSynthesizeVoice}
ttsError={v.ttsError}
customAudioUrl={v.customAudioUrl}
completedTtsJobId={v.completedTtsJobId}
onOpenSaveModal={v.handleOpenSaveModal}
/>
<SaveVoiceModal
open={v.saveModalOpen}
onClose={() => v.setSaveModalOpen(false)}
saveName={v.saveName}
onNameChange={v.setSaveName}
saveTagIds={v.saveTagIds}
onTagIdsChange={v.setSaveTagIds}
saveNewTag={v.saveNewTag}
onNewTagChange={v.setSaveNewTag}
onAddTag={v.handleAddTagInModal}
allTags={v.allTags}
savePending={v.saveToLibraryMutation.isPending}
onConfirm={v.handleConfirmSave}
/>
</>
)}
{v.voiceMode === "clone" && (
<CloneVoiceSection
clonedVoices={v.clonedVoices}
hasProcessing={v.hasProcessing}
selectedClonedVoice={v.selectedClonedVoice}
onSelect={v.handleSelectClonedVoice}
onOpenCloneModal={v.handleOpenCloneModal}
CLONE_STATUS_CONFIG={v.CLONE_STATUS_CONFIG}
formatDuration={v.formatDuration}
/>
)}
</div>
)
}
export default Step5VoiceSelect
@@ -0,0 +1,187 @@
/**
* Step 6 封面设置组件
*/
import React from "react"
import type { CoverConfig } from "../../editing-planner/types"
import { useStep6Cover } from "../hooks/useStep6Cover"
interface Step6CoverSettingsProps {
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
duration: number
}
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
const {
coverSettings,
formatTime,
toggleEnabled,
setMode,
setFrameTime,
handleUpload,
totalDuration,
COVER_MODE_LABELS,
COVER_MODE_ICONS,
} = useStep6Cover(props)
return (
<div className="xx-form-section">
<h3>🖼️ 选择封面</h3>
{/* 启用开关 */}
<div className="xx-cover-header">
<span className="xx-cover-header-label">启用自定义封面</span>
<label className="xx-switch">
<input
type="checkbox"
checked={coverSettings.enabled}
onChange={(e) => toggleEnabled(e.target.checked)}
/>
<span className="xx-switch-slider" />
</label>
</div>
{coverSettings.enabled && (
<>
{/* 模式选择 */}
<div className="xx-section-title">封面来源</div>
<div className="xx-cover-mode-tabs">
{(["auto", "frame", "upload"] as const).map((m) => (
<button
key={m}
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
onClick={() => setMode(m)}
>
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
</button>
))}
</div>
{/* 智能封面 */}
{coverSettings.mode === "auto" && (
<div className="xx-cover-auto">
<div className="xx-cover-auto-desc">
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
</div>
<div className="xx-cover-auto-badge">
<span style={{ fontSize: 24 }}>🤖</span>
<span>AI 智能选帧</span>
</div>
</div>
)}
{/* 抽帧选封面 */}
{coverSettings.mode === "frame" && (
<div className="xx-cover-frame">
<div className="xx-cover-frame-preview">
<div className="xx-cover-frame-placeholder">
<span className="xx-cover-frame-icon">🎞️</span>
<span className="xx-cover-frame-time">
{formatTime(coverSettings.frame_time)}
</span>
</div>
</div>
<div className="xx-cover-frame-slider">
<div className="xx-cover-frame-slider-header">
<span>拖动选择封面帧</span>
<span className="xx-cover-frame-value">
{formatTime(coverSettings.frame_time)}
</span>
</div>
<input
type="range"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={coverSettings.frame_time}
onChange={(e) => setFrameTime(Number(e.target.value))}
className="xx-cover-range"
/>
<div className="xx-cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="xx-cover-frame-quick">
<span className="xx-cover-quick-label">快捷选帧:</span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button
key={ratio}
className="xx-cover-quick-btn"
onClick={() => setFrameTime(t)}
>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)}
{/* 上传封面 */}
{coverSettings.mode === "upload" && (
<div className="xx-cover-upload">
<div
className="xx-cover-upload-area"
onClick={() => {
const input = document.getElementById("cover-upload-input")
input?.click()
}}
>
{coverSettings.upload_url ? (
<div className="xx-cover-upload-preview">
<img src={coverSettings.upload_url} alt="封面预览" />
<div className="xx-cover-upload-overlay">点击更换</div>
</div>
) : (
<div className="xx-cover-upload-placeholder">
<span style={{ fontSize: 32 }}>📤</span>
<span className="xx-cover-upload-text">点击上传封面图片</span>
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
</div>
)}
<input
id="cover-upload-input"
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
}
}}
/>
</div>
</div>
)}
{/* 封面预览 */}
<div className="xx-section-title">封面预览</div>
<div className="xx-cover-preview-box">
{coverSettings.upload_url ? (
<img src={coverSettings.upload_url} alt="封面预览" className="xx-cover-preview-img" />
) : (
<div className="xx-cover-preview-placeholder">
<span style={{ fontSize: 28 }}>🖼️</span>
<span>
{coverSettings.mode === "auto"
? "AI 智能选择"
: coverSettings.mode === "frame"
? `帧 ${formatTime(coverSettings.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="xx-cover-preview-ratio">16:9</div>
</div>
</>
)}
</div>
)
}
export default Step6CoverSettings
@@ -0,0 +1,194 @@
/**
* Step 7 确认生成组件
*/
import React from "react"
import {
LoadingOutlined,
CheckCircleFilled,
CloseCircleOutlined,
MinusOutlined,
PlusOutlined,
} from "@ant-design/icons"
import type { EditingTemplate } from "@/api/editing-planner"
import type { GeneratedVideo } from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import type { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices"
import { useStep7Generate } from "../hooks/useStep7Generate"
interface Step7ConfirmGenerateProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
title: string
voiceMode: "preset" | "custom" | "clone"
selectedVoice: string
selectedClonedVoice: string
presetVoices: PresetVoiceItem[]
clonedVoices: VoiceClone[]
coverSettings: CoverConfig
generateCount: number
onGenerateCountChange: (count: number) => void
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
onRetry: () => void
onDismissError: () => void
}
const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
const {
templateName,
materialSummary,
title,
voiceName,
coverSummary,
generateCount,
handleDecrement,
handleIncrement,
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
handleScrollToPreview,
} = useStep7Generate(props)
const { onRetry, onDismissError } = props
return (
<div className="xx-form-section">
<h3>✨ 确认生成</h3>
<div className="xx-summary-card">
<div className="xx-summary-row">
<span className="xx-summary-label">模板</span>
<span className="xx-summary-value">{templateName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">素材</span>
<span className="xx-summary-value">{materialSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">标题</span>
<span className="xx-summary-value">{title || "未选择"}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">配音</span>
<span className="xx-summary-value">{voiceName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">封面</span>
<span className="xx-summary-value">{coverSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">生成数量</span>
<span className="xx-summary-value">
<div className="xx-count-stepper">
<button
className="xx-count-stepper-btn"
disabled={generateCount <= 1 || generating}
onClick={handleDecrement}
>
<MinusOutlined />
</button>
<span className="xx-count-stepper-value">{generateCount}</span>
<button
className="xx-count-stepper-btn"
disabled={generateCount >= 10 || generating}
onClick={handleIncrement}
>
<PlusOutlined />
</button>
<span className="xx-count-stepper-hint">条视频</span>
</div>
</span>
</div>
</div>
{/* 生成进度 / 结果反馈 */}
{(generating || generated || generateError) && (
<div style={{ marginTop: 16 }}>
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">
<LoadingOutlined />
</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
</div>
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
</div>
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<div className="xx-gen-progress-tip">
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
</div>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-icon">
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
</div>
<div className="xx-gen-success-info">
<div className="xx-gen-success-title">视频生成完成!</div>
<div className="xx-gen-success-sub">
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
</div>
</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={handleScrollToPreview}
>
查看结果
</button>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card">
<div className="xx-gen-error-icon">
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
</div>
<div className="xx-gen-error-info">
<div className="xx-gen-error-title">生成失败</div>
<div className="xx-gen-error-msg">
{typeof generateError === "string"
? generateError
: JSON.stringify(generateError)}
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
🔄 重试
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={onDismissError}
>
知道了
</button>
</div>
</div>
)}
</div>
)}
</div>
)
}
export default Step7ConfirmGenerate
@@ -0,0 +1,84 @@
/**
* 手动选择素材列表
*/
import React from "react"
import { Typography } from "antd"
import type { AssetItem } from "@/api/assets"
const { Text } = Typography
interface ManualMaterialListProps {
materials: { items: AssetItem[]; total: number }
materialsLoading: boolean
selectedMaterials: string[]
onToggle: (materialId: string) => void
}
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
materials,
materialsLoading,
selectedMaterials,
onToggle,
}) => {
return (
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>加载素材中…</Text>
) : materials.items.length === 0 ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
暂无素材,请先在视频库中上传
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{materials.items.map((m) => {
const checked = selectedMaterials.includes(m.id)
return (
<label
key={m.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
borderRadius: 10,
cursor: "pointer",
border: checked
? "1px solid var(--primary-color, #4f46e5)"
: "1px solid transparent",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(m.id)}
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
/>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
}}
>
{m.name}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
}}
>
{m.mime_type.split("/")[1].toUpperCase()}
</span>
</label>
)
})}
</div>
)}
</div>
)
}
export default ManualMaterialList
@@ -0,0 +1,32 @@
/**
* 素材模式切换 Tab
*/
import React from "react"
interface MaterialModeTabsProps {
mode: "manual" | "auto"
onModeChange: (mode: "manual" | "auto") => void
}
const MaterialModeTabs: React.FC<MaterialModeTabsProps> = ({ mode, onModeChange }) => {
return (
<div className="xx-material-mode-tabs">
<button
className={`xx-material-mode-tab ${mode === "manual" ? "active" : ""}`}
onClick={() => onModeChange("manual")}
type="button"
>
手动选择素材
</button>
<button
className={`xx-material-mode-tab ${mode === "auto" ? "active" : ""}`}
onClick={() => onModeChange("auto")}
type="button"
>
选择视频库自动匹配
</button>
</div>
)
}
export default MaterialModeTabs
@@ -0,0 +1,60 @@
/**
* 单个智能匹配卡片
*/
import React from "react"
import { PlayCircleOutlined, CheckCircleFilled } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
interface SmartMatchResultItem {
asset: AssetItem
matchScore: number
matchReason: string
}
interface SmartMatchCardProps {
result: SmartMatchResultItem
selected: boolean
onClick: () => void
formatDuration: (seconds: number) => string
}
const SmartMatchCard: React.FC<SmartMatchCardProps> = ({
result,
selected,
onClick,
formatDuration,
}) => {
const { asset, matchScore, matchReason } = result
return (
<div className={`xx-smart-match-card ${selected ? "selected" : ""}`} onClick={onClick}>
{/* 缩略图 */}
<div className="xx-smart-match-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} />
) : (
<div className="xx-smart-match-thumb-placeholder">
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
</div>
)}
<div className="xx-smart-match-score">{matchScore}%</div>
{selected && (
<div className="xx-smart-match-check">
<CheckCircleFilled style={{ fontSize: 20, color: "#fff" }} />
</div>
)}
{asset.duration && (
<div className="xx-smart-match-duration">{formatDuration(asset.duration)}</div>
)}
</div>
{/* 信息区 */}
<div className="xx-smart-match-info">
<div className="xx-smart-match-name" title={asset.name}>
{asset.name}
</div>
<div className="xx-smart-match-reason">🎯 {matchReason}</div>
</div>
</div>
)
}
export default SmartMatchCard
@@ -0,0 +1,80 @@
/**
* 智能匹配输入区
* textarea + 提示 + 按钮组
*/
import React from "react"
import { LoadingOutlined } from "@ant-design/icons"
interface SmartMatchInputProps {
inputValue: string
onInputChange: (value: string) => void
matching: boolean
onMatch: () => void
hasMatched: boolean
onRefresh: () => void
materialsCount: number
loading: boolean
}
const SmartMatchInput: React.FC<SmartMatchInputProps> = ({
inputValue,
onInputChange,
matching,
onMatch,
hasMatched,
onRefresh,
materialsCount,
loading,
}) => {
return (
<div className="xx-smart-match-input-area">
<label className="xx-smart-match-label">🤖 描述你想要的视频内容</label>
<textarea
className="xx-smart-match-input"
placeholder="例如:一个科技感十足的产品宣传视频,画面要有现代办公场景、团队协作、数据分析图表…"
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
rows={3}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
onMatch()
}
}}
/>
<div className="xx-smart-match-input-footer">
<span className="xx-smart-match-tip">
{loading ? "扫描视频库中…" : `当前视频库共 ${materialsCount} 个素材可供匹配`}
</span>
<div style={{ display: "flex", gap: 8 }}>
{hasMatched && (
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={onRefresh}
disabled={matching || loading}
>
🔄 换一批
</button>
)}
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={onMatch}
disabled={matching || loading || !inputValue.trim()}
>
{matching ? (
<>
<LoadingOutlined style={{ marginRight: 6 }} />
匹配中…
</>
) : (
"✨ 智能匹配"
)}
</button>
</div>
</div>
</div>
)
}
export default SmartMatchInput
@@ -0,0 +1,114 @@
/**
* 智能匹配结果区(含加载/空状态/已选汇总)
*/
import React from "react"
import { LoadingOutlined } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import SmartMatchCard from "./SmartMatchCard"
interface SmartMatchResultItem {
asset: AssetItem
matchScore: number
matchReason: string
}
interface SmartMatchResultsProps {
results: SmartMatchResultItem[]
selectedIds: string[]
matching: boolean
hasMatched: boolean
onToggle: (assetId: string) => void
onSelectAll: () => void
onClear: () => void
formatDuration: (seconds: number) => string
selectedTotalDuration: number
}
const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
results,
selectedIds,
matching,
hasMatched,
onToggle,
onSelectAll,
onClear,
formatDuration,
selectedTotalDuration,
}) => {
// 匹配中状态
if (matching) {
return (
<div className="xx-smart-match-loading">
<LoadingOutlined
style={{ fontSize: 32, color: "var(--primary-color)", marginBottom: 12 }}
/>
<div style={{ color: "var(--text-primary)", fontSize: 14 }}>AI 正在分析素材…</div>
<div style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
正在根据描述从视频库中匹配最合适的素材
</div>
</div>
)
}
// 未匹配状态提示
if (!hasMatched) {
return (
<div className="xx-smart-match-empty">
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
输入视频内容描述,点击「智能匹配」让 AI 帮你选素材
</div>
</div>
)
}
// 无结果
if (results.length === 0) return null
return (
<>
<div className="xx-smart-match-results">
<div className="xx-smart-match-results-header">
<span className="xx-smart-match-results-title">推荐素材 ({results.length}个)</span>
<div className="xx-smart-match-results-actions">
<button type="button" className="xx-link-btn" onClick={onSelectAll}>
全选
</button>
<span style={{ color: "var(--border-color)" }}>|</span>
<button type="button" className="xx-link-btn" onClick={onClear}>
清空
</button>
</div>
</div>
<div className="xx-smart-match-grid">
{results.map((result) => {
const isSelected = selectedIds.includes(result.asset.id)
return (
<SmartMatchCard
key={result.asset.id}
result={result}
selected={isSelected}
onClick={() => onToggle(result.asset.id)}
formatDuration={formatDuration}
/>
)
})}
</div>
</div>
{/* 已选素材汇总 */}
{selectedIds.length > 0 && (
<div className="xx-smart-match-summary">
<div className="xx-smart-match-summary-header">
<span className="xx-pill xx-pill-ok">已选 {selectedIds.length} 个素材</span>
<span style={{ color: "var(--text-tertiary)", fontSize: 12 }}>
预计总时长约 {selectedTotalDuration.toFixed(0)} 秒
</span>
</div>
</div>
)}
</>
)
}
export default SmartMatchResults
@@ -0,0 +1,35 @@
/**
* 单个 AI 标题卡片
*/
import React from "react"
import { CheckCircleFilled } from "@ant-design/icons"
interface AiTitleCardProps {
title: string
highlight: string
style: "catchy" | "emotional" | "informative"
selected: boolean
onClick: () => void
}
const AiTitleCard: React.FC<AiTitleCardProps> = ({
title,
highlight,
style,
selected,
onClick,
}) => {
return (
<div className={`xx-ai-title-card ${selected ? "selected" : ""} ${style}`} onClick={onClick}>
<div className="xx-ai-title-card-text">{title}</div>
<div className="xx-ai-title-card-tag">{highlight}</div>
{selected && (
<div className="xx-ai-title-card-check">
<CheckCircleFilled style={{ color: "#fff", fontSize: 14 }} />
</div>
)}
</div>
)
}
export default AiTitleCard
@@ -0,0 +1,105 @@
/**
* AI 智能生成标题
* 输入框 + 生成按钮 + 结果列表 + 加载状态
*/
import React from "react"
import { LoadingOutlined } from "@ant-design/icons"
import AiTitleCard from "./AiTitleCard"
interface AiTitleItem {
title: string
highlight: string
style: "catchy" | "emotional" | "informative"
}
interface AiTitleGeneratorProps {
inputValue: string
onInputChange: (value: string) => void
generating: boolean
onGenerate: () => void
results: AiTitleItem[]
hasGenerated: boolean
onSelect: (title: string) => void
selectedTitle: string
onRefresh: () => void
}
const AiTitleGenerator: React.FC<AiTitleGeneratorProps> = ({
inputValue,
onInputChange,
generating,
onGenerate,
results,
hasGenerated,
onSelect,
selectedTitle,
onRefresh,
}) => {
return (
<div className="xx-ai-title-section">
<div className="xx-ai-title-header">
<span className="xx-ai-title-label">✨ AI 智能生成标题</span>
</div>
<div className="xx-ai-title-input-row">
<input
className="xx-ai-title-input"
placeholder="输入视频内容描述或关键词,如:职场成长、副业赚钱…"
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onGenerate()
}}
/>
<button
type="button"
className="xx-btn xx-btn-primary"
onClick={onGenerate}
disabled={generating || !inputValue.trim()}
>
{generating ? (
<>
<LoadingOutlined style={{ marginRight: 6 }} />
生成中
</>
) : (
"生成标题"
)}
</button>
</div>
{/* 生成结果 */}
{hasGenerated && !generating && results.length > 0 && (
<div className="xx-ai-title-results">
<div className="xx-ai-title-results-header">
<span className="xx-ai-title-results-count">为你生成 {results.length} 个标题</span>
<button type="button" className="xx-link-btn" onClick={onRefresh} disabled={generating}>
🔄 换一批
</button>
</div>
<div className="xx-ai-title-list">
{results.map((item, idx) => (
<AiTitleCard
key={idx}
title={item.title}
highlight={item.highlight}
style={item.style}
selected={selectedTitle === item.title}
onClick={() => onSelect(item.title)}
/>
))}
</div>
</div>
)}
{/* 生成中 */}
{generating && (
<div className="xx-ai-title-loading">
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
AI 正在为你创作标题…
</div>
)}
</div>
)
}
export default AiTitleGenerator
@@ -0,0 +1,41 @@
/**
* 标题预设样式网格
*/
import React from "react"
interface TitlePresetItem {
key: string
label: string
previewStyle: React.CSSProperties
}
interface TitlePresetsGridProps {
presets: TitlePresetItem[]
activePreset: string | null
onApply: (presetKey: string) => void
}
const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({ presets, activePreset, onApply }) => {
return (
<div className="xx-title-presets-grid">
{presets.map((p) => {
const isActive = activePreset === p.key
return (
<button
key={p.key}
className={`xx-title-preset-card${isActive ? " active" : ""}`}
onClick={() => onApply(p.key)}
title={p.label}
>
<span className="xx-title-preset-preview-text" style={p.previewStyle}>
标题
</span>
<span className="xx-title-preset-card-label">{p.label}</span>
</button>
)
})}
</div>
)
}
export default TitlePresetsGrid
@@ -0,0 +1,151 @@
/**
* 标题样式设置区
* 位置/字体/字号/样式按钮/预设
*/
import React from "react"
import type { TitleSettings } from "../../types"
import TitlePresetsGrid from "./TitlePresetsGrid"
interface PositionOption {
value: string
label: string
}
interface TitlePresetItem {
key: string
label: string
previewStyle: React.CSSProperties
}
interface TitleStylePanelProps {
settings: TitleSettings
onUpdatePosition: (position: string) => void
onUpdateFont: (font: string) => void
onUpdateSize: (size: number) => void
onToggleBold: () => void
onToggleItalic: () => void
onToggleStroke: () => void
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
activePreset: string | null
titlePresets: TitlePresetItem[]
POSITION_OPTIONS: PositionOption[]
FONT_OPTIONS: string[]
}
const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
settings,
onUpdatePosition,
onUpdateFont,
onUpdateSize,
onToggleBold,
onToggleItalic,
onToggleStroke,
onToggleShadow,
onApplyPreset,
activePreset,
titlePresets,
POSITION_OPTIONS,
FONT_OPTIONS,
}) => {
return (
<div className="xx-title-style-section">
<h4 className="xx-section-subtitle">标题样式</h4>
{/* 位置 + 字体 一行 */}
<div className="xx-title-style-row">
<div className="xx-form-field xx-half-field">
<label>位置</label>
<select
className="xx-form-select"
value={settings.position}
onChange={(e) => onUpdatePosition(e.target.value)}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="xx-form-field xx-half-field">
<label>字体</label>
<select
className="xx-form-select"
value={settings.font}
onChange={(e) => onUpdateFont(e.target.value)}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
</div>
{/* 字号滑块 */}
<div className="xx-form-field">
<div className="xx-field-label-row">
<label>字号</label>
<span className="xx-field-value">{settings.size}px</span>
</div>
<input
className="xx-slider"
type="range"
min={12}
max={48}
value={settings.size}
onChange={(e) => onUpdateSize(Number(e.target.value))}
/>
</div>
{/* 预设样式 */}
<div className="xx-form-field">
<label>预设样式</label>
<TitlePresetsGrid
presets={titlePresets}
activePreset={activePreset}
onApply={onApplyPreset}
/>
</div>
{/* 样式按钮:粗体/斜体/描边/阴影 */}
<div className="xx-form-field">
<label>样式</label>
<div className="xx-style-btns">
<button
className={`xx-style-btn ${settings.bold ? "active" : ""}`}
onClick={onToggleBold}
title="粗体"
>
<b>B</b>
</button>
<button
className={`xx-style-btn ${settings.italic ? "active" : ""}`}
onClick={onToggleItalic}
title="斜体"
>
<i>I</i>
</button>
<button
className={`xx-style-btn ${settings.stroke ? "active" : ""}`}
onClick={onToggleStroke}
title="描边"
>
S
</button>
<button
className={`xx-style-btn ${settings.shadow ? "active" : ""}`}
onClick={onToggleShadow}
title="阴影"
>
☁
</button>
</div>
</div>
</div>
)
}
export default TitleStylePanel
@@ -0,0 +1,132 @@
/**
* 克隆声音展开区域
* 克隆按钮、轮询提示、已克隆列表、空状态
*/
import React from "react"
import { Typography } from "antd"
import { ThunderboltOutlined, AudioOutlined, CheckCircleFilled } from "@ant-design/icons"
import type { VoiceClone } from "@/api/voice-clone"
const { Text } = Typography
interface CloneVoiceSectionProps {
clonedVoices: VoiceClone[]
hasProcessing: boolean
selectedClonedVoice: string
onSelect: (voiceId: string) => void
onOpenCloneModal: () => void
CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }>
formatDuration: (seconds: number) => string
}
const CloneVoiceSection: React.FC<CloneVoiceSectionProps> = ({
clonedVoices,
hasProcessing,
selectedClonedVoice,
onSelect,
onOpenCloneModal,
CLONE_STATUS_CONFIG,
formatDuration,
}) => {
return (
<div className="xx-clone-section">
<p className="xx-clone-section-title">克隆我的声音</p>
<Text style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)" }}>
上传一段您的语音样本,AI 将克隆您的声音用于视频配音
</Text>
<div style={{ marginTop: 12 }}>
<button
className="xx-btn xx-btn-primary"
style={{ height: 36, fontSize: 13 }}
onClick={onOpenCloneModal}
>
<ThunderboltOutlined /> 克隆新声音
</button>
</div>
{/* 轮询提示 */}
{hasProcessing && (
<div className="xx-clone-polling-hint" style={{ marginTop: 10 }}>
<span className="xx-clone-polling-dot" />
正在同步克隆进度...
</div>
)}
{/* 已克隆声音列表 */}
{clonedVoices.length > 0 && (
<div className="xx-clone-voices-list">
{clonedVoices.map((cv) => {
const statusCfg = CLONE_STATUS_CONFIG[cv.status]
const isReady = cv.status === "ready"
const selected = selectedClonedVoice === cv.id
return (
<div
key={cv.id}
className={`xx-clone-voice-row ${selected ? "selected" : ""} ${
!isReady ? "disabled" : ""
}`}
onClick={() => {
if (isReady) onSelect(cv.id)
}}
role="button"
tabIndex={isReady ? 0 : -1}
aria-pressed={selected}
>
<div className={`xx-clone-avatar ${cv.status}`}>
<AudioOutlined />
</div>
<div className="xx-clone-info">
<div className="xx-clone-name">{cv.name}</div>
<div className="xx-clone-status">
<span className="xx-clone-status-dot" style={{ background: statusCfg.color }} />
<span style={{ color: statusCfg.color }}>{statusCfg.label}</span>
{isReady && (
<span
style={{
color: "var(--text-secondary)",
marginLeft: 8,
}}
>
{formatDuration(cv.duration_seconds)}
</span>
)}
</div>
</div>
{selected && isReady && (
<CheckCircleFilled style={{ color: "var(--primary-color, #4f46e5)" }} />
)}
</div>
)
})}
</div>
)}
{clonedVoices.length === 0 && (
<Text
style={{
color: "var(--text-secondary)",
display: "block",
textAlign: "center",
padding: "16px 0",
}}
>
暂无克隆音色,点击「克隆新声音」开始
</Text>
)}
<Text
style={{
fontSize: 12,
color: "var(--text-tertiary, #94a3b8)",
marginTop: 10,
display: "block",
}}
>
💡 提示:录音环境越安静,克隆效果越好。
</Text>
</div>
)
}
export default CloneVoiceSection
@@ -0,0 +1,93 @@
/**
* 自定义录制面板
* textarea + 合成按钮 + 结果区 + 存为素材按钮
*/
import React from "react"
import { Typography } from "antd"
import { AudioOutlined, SaveOutlined } from "@ant-design/icons"
const { Text } = Typography
interface CustomVoicePanelProps {
customVoiceText: string
onTextChange: (text: string) => void
synthesizePending: boolean
onSynthesize: () => void
ttsError: string | null
customAudioUrl: string | null
completedTtsJobId: string | null
onOpenSaveModal: () => void
}
const CustomVoicePanel: React.FC<CustomVoicePanelProps> = ({
customVoiceText,
onTextChange,
synthesizePending,
onSynthesize,
ttsError,
customAudioUrl,
completedTtsJobId,
onOpenSaveModal,
}) => {
return (
<div>
<textarea
placeholder="输入配音文案,点击合成按钮生成语音…"
value={customVoiceText}
onChange={(e) => onTextChange(e.target.value)}
maxLength={500}
style={{
width: "100%",
minHeight: 100,
border: "1px solid var(--border-color, #e2e8f0)",
borderRadius: "var(--radius-sm, 10px)",
padding: 12,
fontSize: 14,
resize: "vertical",
outline: "none",
}}
/>
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
<button
className="xx-btn xx-btn-ghost"
disabled={!customVoiceText.trim() || synthesizePending}
onClick={onSynthesize}
>
<AudioOutlined /> {synthesizePending ? "合成中…" : "合成语音"}
</button>
</div>
{ttsError && (
<Text
style={{
color: "var(--error, #ef4444)",
marginTop: 8,
display: "block",
}}
>
{ttsError}
</Text>
)}
{customAudioUrl && completedTtsJobId && (
<div
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<Text style={{ color: "var(--success, #10b981)" }}>✓ 语音合成完成</Text>
<button
className="xx-btn xx-btn-primary"
style={{ height: 30, padding: "0 14px", fontSize: 12 }}
onClick={onOpenSaveModal}
>
<SaveOutlined /> 存为素材
</button>
</div>
)}
</div>
)
}
export default CustomVoicePanel
@@ -0,0 +1,77 @@
/**
* 预设音色下拉选择 + 试听按钮
* voiceMode === "preset" 时显示的详情区
*/
import React from "react"
import { PlayCircleOutlined, PauseCircleOutlined } from "@ant-design/icons"
import type { PresetVoiceItem } from "@/api/voices"
interface PresetVoiceDetailProps {
presetVoices: PresetVoiceItem[]
selectedVoice: string
onSelect: (voiceId: string) => void
playingVoice: string | null
onTogglePlay: (voiceId: string, previewUrl: string | null) => void
presetVoicesLoading: boolean
}
const PresetVoiceDetail: React.FC<PresetVoiceDetailProps> = ({
presetVoices,
selectedVoice,
onSelect,
playingVoice,
onTogglePlay,
presetVoicesLoading,
}) => {
return (
<div>
<div className="xx-form-field">
<label>从配音库选择</label>
<select value={selectedVoice} onChange={(e) => onSelect(e.target.value)}>
<option value="">请选择配音…</option>
{presetVoicesLoading ? (
<option disabled>加载中…</option>
) : (
presetVoices.map((v) => (
<option key={v.voice_id} value={v.voice_id}>
{v.name} — {v.description}
</option>
))
)}
</select>
</div>
{/* 试听按钮 */}
{presetVoices.length > 0 && (
<div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
marginTop: 8,
}}
>
{presetVoices.slice(0, 4).map((v) => (
<button
key={v.voice_id}
className="xx-btn xx-btn-ghost"
style={{ height: 32, padding: "0 12px", fontSize: 12 }}
onClick={() => onTogglePlay(v.voice_id, v.preview_url)}
>
{playingVoice === v.voice_id ? (
<>
<PauseCircleOutlined /> 停止
</>
) : (
<>
<PlayCircleOutlined /> {v.name}
</>
)}
</button>
))}
</div>
)}
</div>
)
}
export default PresetVoiceDetail
@@ -0,0 +1,125 @@
/**
* 保存到配音库弹窗
*/
import React from "react"
import { CloseOutlined, PlusOutlined } from "@ant-design/icons"
import type { TagItem } from "@/api/tags"
interface SaveVoiceModalProps {
open: boolean
onClose: () => void
saveName: string
onNameChange: (name: string) => void
saveTagIds: string[]
onTagIdsChange: (tags: string[] | ((prev: string[]) => string[])) => void
saveNewTag: string
onNewTagChange: (tag: string) => void
onAddTag: (tagName: string) => void
allTags: TagItem[]
savePending: boolean
onConfirm: () => void
}
const SaveVoiceModal: React.FC<SaveVoiceModalProps> = ({
open,
onClose,
saveName,
onNameChange,
saveTagIds,
onTagIdsChange,
saveNewTag,
onNewTagChange,
onAddTag,
allTags,
savePending,
onConfirm,
}) => {
if (!open) return null
return (
<div className="xx-save-modal-overlay" onClick={onClose}>
<div className="xx-save-modal" onClick={(e) => e.stopPropagation()}>
<div className="xx-save-modal-header">
<span>保存到配音库</span>
<button className="xx-save-modal-close" onClick={onClose}>
<CloseOutlined />
</button>
</div>
<div className="xx-save-modal-body">
<label className="xx-save-modal-label">素材名称</label>
<input
className="xx-save-modal-input"
placeholder="留空则自动生成名称"
value={saveName}
onChange={(e) => onNameChange(e.target.value)}
maxLength={50}
/>
<label className="xx-save-modal-label">
标签
<span
style={{
fontWeight: 400,
color: "var(--text-tertiary, #94a3b8)",
}}
>
(可选)
</span>
</label>
<div className="xx-save-modal-tags">
{saveTagIds.map((id) => {
const tag = allTags.find((t) => t.id === id)
return tag ? (
<span key={id} className="xx-save-modal-tag active">
{tag.name}
<CloseOutlined
className="xx-save-modal-tag-remove"
onClick={() => onTagIdsChange((prev: string[]) => prev.filter((x) => x !== id))}
/>
</span>
) : null
})}
<input
className="xx-save-modal-tag-input"
placeholder="输入标签名回车添加"
value={saveNewTag}
onChange={(e) => onNewTagChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
onAddTag(saveNewTag)
}
}}
/>
</div>
{allTags.length > 0 && (
<div className="xx-save-modal-tag-presets">
{allTags
.filter((t) => !saveTagIds.includes(t.id))
.slice(0, 12)
.map((t) => (
<button
key={t.id}
className="xx-save-modal-tag-preset"
onClick={() => onTagIdsChange((prev: string[]) => [...prev, t.id])}
>
{t.name}
<PlusOutlined style={{ fontSize: 10, marginLeft: 4 }} />
</button>
))}
</div>
)}
</div>
<div className="xx-save-modal-footer">
<button className="xx-btn xx-btn-ghost" onClick={onClose}>
取消
</button>
<button className="xx-btn xx-btn-primary" disabled={savePending} onClick={onConfirm}>
{savePending ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
)
}
export default SaveVoiceModal
@@ -0,0 +1,53 @@
/**
* 音色选择卡片
* 用于顶部预设音色卡片和克隆入口卡片
*/
import React from "react"
interface VoiceChoiceCardProps {
selected: boolean
onClick: () => void
avatar: React.ReactNode
title: string
description?: string
avatarStyle?: React.CSSProperties
loading?: boolean
}
const VoiceChoiceCard: React.FC<VoiceChoiceCardProps> = ({
selected,
onClick,
avatar,
title,
description,
avatarStyle,
loading = false,
}) => {
return (
<div
className={`xx-voice-choice-item ${selected ? "selected" : ""}`}
onClick={onClick}
role="button"
tabIndex={0}
aria-pressed={selected}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onClick()
}
}}
style={loading ? { opacity: 0.5 } : undefined}
>
<span className="xx-voice-choice-check">✓</span>
<div className="xx-voice-choice-avatar" style={avatarStyle}>
{avatar}
</div>
<div className="xx-voice-choice-info">
<h4>{title}</h4>
{description && <p>{description}</p>}
</div>
</div>
)
}
export default VoiceChoiceCard
@@ -0,0 +1,100 @@
/**
* AI 智能推荐配音区域
*/
import React from "react"
import { LoadingOutlined, CheckCircleFilled } from "@ant-design/icons"
import type { PresetVoiceItem } from "@/api/voices"
interface VoiceRecommendSectionProps {
presetVoices: PresetVoiceItem[]
voiceRecommendLoading: boolean
voiceRecommendations: string[]
hasVoiceRecommend: boolean
onRecommend: () => void
onSelectVoice: (voiceId: string) => void
selectedVoiceId: string
voiceMode: "preset" | "custom" | "clone"
VOICE_GENDER_ICON: Record<string, string>
}
const VoiceRecommendSection: React.FC<VoiceRecommendSectionProps> = ({
presetVoices,
voiceRecommendLoading,
voiceRecommendations,
hasVoiceRecommend,
onRecommend,
onSelectVoice,
selectedVoiceId,
voiceMode,
VOICE_GENDER_ICON,
}) => {
return (
<div className="xx-voice-recommend-section">
<div className="xx-voice-recommend-header">
<span className="xx-voice-recommend-label">✨ AI 智能推荐</span>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={onRecommend}
disabled={voiceRecommendLoading}
>
{voiceRecommendLoading ? (
<>
<LoadingOutlined style={{ marginRight: 6 }} />
推荐中
</>
) : hasVoiceRecommend ? (
"换一批"
) : (
"智能推荐"
)}
</button>
</div>
{voiceRecommendLoading && (
<div className="xx-voice-recommend-loading">
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
根据视频内容为你匹配最合适的音色…
</div>
)}
{!voiceRecommendLoading && hasVoiceRecommend && voiceRecommendations.length > 0 && (
<div className="xx-voice-recommend-list">
{voiceRecommendations.map((voiceId) => {
const v = presetVoices.find((pv) => pv.voice_id === voiceId)
if (!v) return null
const isSelected = voiceMode === "preset" && selectedVoiceId === v.voice_id
return (
<div
key={v.voice_id}
className={`xx-voice-recommend-card ${isSelected ? "selected" : ""}`}
onClick={() => onSelectVoice(v.voice_id)}
>
<div className="xx-voice-recommend-avatar">
{VOICE_GENDER_ICON[v.gender] ?? "✨"}
</div>
<div className="xx-voice-recommend-info">
<div className="xx-voice-recommend-name">{v.name}</div>
<div className="xx-voice-recommend-desc">{v.description}</div>
</div>
{isSelected && (
<div className="xx-voice-recommend-check">
<CheckCircleFilled style={{ color: "#fff", fontSize: 16 }} />
</div>
)}
</div>
)
})}
</div>
)}
{!voiceRecommendLoading && !hasVoiceRecommend && (
<div className="xx-voice-recommend-empty">
<span>点击「智能推荐」,AI 根据视频内容匹配音色</span>
</div>
)}
</div>
)
}
export default VoiceRecommendSection
@@ -0,0 +1,370 @@
/**
* 视频生成 Hook
* 封装视频生成的核心逻辑、状态管理、轮询等
*/
import { useState, useRef, useCallback } from "react"
import { message } from "antd"
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
import {
generateEditPlan,
updateEditPlan,
getGenerationTaskResults,
getGenerationStatus,
getEditPlan,
} from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import type { TitleSettings } from "../types"
interface UseGenerateVideoProps {
titleSettings: TitleSettings
selectedTemplate: string
selectedMaterials: string[]
materialMode: "manual" | "auto"
smartSelectedIds: string[]
voiceMode: "preset" | "custom" | "clone"
selectedVoice: string
selectedClonedVoice: string
coverSettings: CoverConfig
videoRatio: string
style: string
duration: number
autoSubtitles: boolean
bgm: boolean
generateCount: number
}
export function useGenerateVideo({
titleSettings,
selectedTemplate,
selectedMaterials,
materialMode,
smartSelectedIds,
voiceMode,
selectedVoice,
selectedClonedVoice,
coverSettings,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
generateCount,
}: UseGenerateVideoProps) {
/* ── 生成状态 ── */
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const [generated, setGenerated] = useState(false)
const [generateError, setGenerateError] = useState<string | null>(null)
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined)
/* ── 生成阶段映射 ── */
const getGenerationPhase = (p: number) => {
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
return { label: "即将完成", icon: "✨" }
}
/* ── 生成视频 ── */
const generate = useCallback(async () => {
console.log("[handleGenerate] 开始生成, 参数:", {
titleSettings,
selectedTemplate,
selectedMaterials,
voiceMode,
})
if (!titleSettings.title.trim()) {
message.warning("请先选择或输入标题")
return
}
if (materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材")
return
}
if (voiceMode === "clone" && !selectedClonedVoice) {
message.warning("请先选择一个克隆音色")
return
}
setGenerating(true)
setProgress(0)
setGenerated(false)
setGenerateError(null)
try {
const voiceConfig: Pick<
EditPlanConfig,
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
> = {}
if (voiceMode === "preset") {
voiceConfig.voice_id = selectedVoice || undefined
} else if (voiceMode === "clone") {
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined
} else if (voiceMode === "custom") {
voiceConfig.voice_id = selectedVoice || undefined
// 注意:customAudioUrl / customVoiceText 在 step5 hook 中,
// 自定义配音模式需从 step5 组件传回
}
// 获取或创建草稿
await getEditPlan(selectedTemplate)
// 更新草稿内容 + 切换到 editing 状态
await updateEditPlan(selectedTemplate, {
name: titleSettings.title.trim(),
config: {
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
},
cover_config: coverSettings,
...voiceConfig,
ratio: videoRatio,
style,
duration,
auto_subtitles: autoSubtitles,
bgm,
generate_count: generateCount,
material_mode: materialMode,
},
total_duration: duration,
status: "editing",
})
await generateEditPlan(selectedTemplate)
const poll = async () => {
try {
const data = await getGenerationStatus(selectedTemplate)
if (data.plan_status === "completed") {
setProgress(100)
setGenerating(false)
setGenerated(true)
// 获取生成的视频结果
if (data.generation_task_id) {
try {
const videos = await getGenerationTaskResults(data.generation_task_id)
setGeneratedVideos(videos)
} catch (err) {
console.error("[获取生成结果失败]", err)
}
}
message.success("视频生成完成!")
return
}
if (data.plan_status === "failed") {
setGenerating(false)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
const dataAny = data as Record<string, any>
const rawMsg =
dataAny.error_message ||
dataAny.error ||
dataAny.message ||
(data.clips || []).find((c: { status: string }) => c.status === "failed")
?.error_message ||
"视频生成失败,请联系管理员或重试"
const safeExtract = (val: unknown): string => {
if (typeof val === "string") return val
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
const obj = val as Record<string, any>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
if (obj.message && typeof obj.message === "object") return safeExtract(obj.message)
return JSON.stringify(val)
}
return String(val ?? "")
}
const errorMsg = safeExtract(rawMsg)
console.error("[生成失败] templateId:", selectedTemplate, "响应:", data)
setGenerateError(errorMsg)
message.error(errorMsg)
return
}
const clips = data.clips || []
const total = clips.length || 1
const done = clips.filter((c: { status: string }) => c.status === "completed").length
setProgress(Math.round((done / total) * 100))
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<
typeof setInterval
>
} catch (pollErr) {
console.error("[轮询出错] templateId:", selectedTemplate, pollErr)
progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType<
typeof setInterval
>
}
}
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<typeof setInterval>
} catch (err: unknown) {
console.error("[handleGenerate] 生成失败:", err)
setGenerating(false)
const axiosErr = err as {
response?: {
data?: {
message?: string | object
error?: string | object
detail?: string | object
msg?: string | object
}
}
message?: string
}
const extractString = (val: unknown): string => {
if (typeof val === "string") return val
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
const obj = val as Record<string, any>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
if (typeof obj.message === "object" && obj.message !== null)
return extractString(obj.message)
if (typeof obj.msg === "object" && obj.msg !== null) return extractString(obj.msg)
return JSON.stringify(val)
}
return ""
}
const backendMsg =
extractString(axiosErr.response?.data?.message) ||
extractString(axiosErr.response?.data?.error) ||
extractString(axiosErr.response?.data?.detail) ||
extractString(axiosErr.response?.data?.msg) ||
axiosErr.message ||
""
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", axiosErr)
const safeExtractErr = (val: unknown): string => {
if (typeof val === "string") return val
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
const obj = val as Record<string, any>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
if (typeof obj.message === "object") return safeExtractErr(obj.message)
return JSON.stringify(val)
}
return String(val ?? "")
}
const rawError = safeExtractErr(backendMsg)
const translateError = (msg: string): string => {
if (!msg) return "生成失败,请检查网络后重试或联系管理员"
if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) {
return "正在准备生成,请稍候再试"
}
if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) {
return "所选模板或素材不可用,请重新选择"
}
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
return "素材数据异常,请返回视频库重新检查"
}
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
return "网络连接超时,请检查网络后重试"
}
if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) {
return "已达到生成次数上限,请稍后再试或联系客服"
}
if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg
return "生成失败,请稍后重试或联系管理员"
}
const finalMsg = translateError(rawError)
setGenerateError(finalMsg)
message.error(finalMsg)
}
}, [
titleSettings,
selectedMaterials,
selectedVoice,
voiceMode,
selectedClonedVoice,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
selectedTemplate,
generateCount,
materialMode,
coverSettings,
smartSelectedIds,
])
/* 重新生成(失败后重试) */
const retry = useCallback(() => {
setGenerateError(null)
generate()
}, [generate])
/* 清除错误 */
const dismissError = useCallback(() => {
setGenerateError(null)
}, [])
/* ── 下载视频 ── */
const download = useCallback(async () => {
if (!generatedVideos.length) return
const video = generatedVideos[0]
try {
const url = video.download_url || video.file_url
if (url) {
const a = document.createElement("a")
a.href = url
a.download = video.name || "generated-video.mp4"
a.target = "_blank"
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
} catch (err) {
console.error("[下载失败]", err)
message.error("下载失败,请重试")
}
}, [generatedVideos])
/* ── 分享视频 ── */
const share = useCallback(async () => {
if (!generatedVideos.length) return
const video = generatedVideos[0]
const shareUrl = video.file_url || window.location.href
try {
await navigator.clipboard.writeText(shareUrl)
message.success("视频链接已复制到剪贴板")
} catch {
message.info(`视频链接: ${shareUrl}`)
}
}, [generatedVideos])
return {
// 状态
generating,
progress,
generated,
generateError,
generatedVideos,
// 操作
generate,
retry,
dismissError,
download,
share,
// 工具
getGenerationPhase,
}
}
export default useGenerateVideo
@@ -0,0 +1,44 @@
/**
* Step 1 模板选择 Hook
* 封装模板选择的交互逻辑
*/
import { useCallback } from "react"
import type { EditingTemplate } from "@/api/editing-planner"
interface UseStep1TemplateProps {
templates: EditingTemplate[]
selectedTemplate: string
onSelectTemplate: (id: string) => void
}
export function useStep1Template({
templates,
selectedTemplate,
onSelectTemplate,
}: UseStep1TemplateProps) {
const handleSelect = useCallback(
(id: string) => {
onSelectTemplate(id)
},
[onSelectTemplate],
)
const handleKeySelect = useCallback(
(e: React.KeyboardEvent, id: string) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onSelectTemplate(id)
}
},
[onSelectTemplate],
)
return {
templates,
selectedTemplate,
handleSelect,
handleKeySelect,
}
}
export default useStep1Template
@@ -0,0 +1,205 @@
/**
* Step 2 素材选择 Hook
* 封装素材库加载、手动选择、智能匹配等逻辑
*/
import { useState, useCallback, useEffect, useMemo } from "react"
import { message } from "antd"
import { useQuery } from "@tanstack/react-query"
import { getAssets, getAssetLibraries } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
import { formatDuration } from "../utils/formatDuration"
import { SMART_MATCH_REASONS } from "../constants"
interface SmartMatchedResult {
asset: AssetItem
matchScore: number
matchReason: string
}
interface UseStep2MaterialsProps {
materialMode: "manual" | "auto"
onMaterialModeChange: (mode: "manual" | "auto") => void
selectedMaterials: string[]
onSelectedMaterialsChange: (ids: string[]) => void
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
}
export function useStep2Materials({
materialMode,
onMaterialModeChange,
selectedMaterials,
onSelectedMaterialsChange,
smartSelectedIds,
onSmartSelectedIdsChange,
}: UseStep2MaterialsProps) {
/* ── 素材库数据 API ── */
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
// 自动选中第一个视频库
useEffect(() => {
if (libraries.length > 0 && !selectedLibraryId) {
setSelectedLibraryId(libraries[0].id)
}
}, [libraries, selectedLibraryId])
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
items: AssetItem[]
total: number
}>({
queryKey: ["generate-assets", selectedLibraryId],
queryFn: () => getAssets(selectedLibraryId),
enabled: !!selectedLibraryId,
})
/* ── 智能素材匹配状态 ── */
const [smartMatchInput, setSmartMatchInput] = useState("")
const [smartMatching, setSmartMatching] = useState(false)
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
const [hasMatched, setHasMatched] = useState(false)
/* ── 手动选择素材 ── */
const handleToggleMaterial = useCallback(
(materialId: string) => {
onSelectedMaterialsChange(
selectedMaterials.includes(materialId)
? selectedMaterials.filter((id) => id !== materialId)
: [...selectedMaterials, materialId],
)
},
[selectedMaterials, onSelectedMaterialsChange],
)
/* ── 智能素材匹配 ── */
const handleSmartMatch = useCallback(async () => {
if (!smartMatchInput.trim()) {
message.warning("请先输入视频内容描述")
return
}
if (materials.items.length === 0) {
message.warning("当前视频库暂无素材")
return
}
setSmartMatching(true)
setHasMatched(true)
// 模拟 AI 匹配延迟
await new Promise((resolve) => setTimeout(resolve, 1500))
// 从素材库中随机选取 5-8 个作为推荐结果
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
matchReason:
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
}))
// 按匹配度从高到低排序
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
// 默认选中匹配度 >= 90 的素材
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
onSmartSelectedIdsChange(
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
)
setSmartMatching(false)
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
const handleToggleSmartSelect = useCallback(
(assetId: string) => {
onSmartSelectedIdsChange(
smartSelectedIds.includes(assetId)
? smartSelectedIds.filter((id) => id !== assetId)
: [...smartSelectedIds, assetId],
)
},
[smartSelectedIds, onSmartSelectedIdsChange],
)
const handleRefreshMatch = useCallback(async () => {
if (materials.items.length <= 5) {
message.info("视频库素材较少,无法换一批")
return
}
setSmartMatching(true)
await new Promise((resolve) => setTimeout(resolve, 800))
const remaining = materials.items.filter(
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
)
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(80 + Math.random() * 19),
matchReason:
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",节奏明快" : ""),
}))
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
onSmartSelectedIdsChange([])
setSmartMatching(false)
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
const handleSelectAllMatched = useCallback(() => {
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
}, [smartMatchedResults, onSmartSelectedIdsChange])
const handleClearSmartSelect = useCallback(() => {
onSmartSelectedIdsChange([])
}, [onSmartSelectedIdsChange])
/* ── 计算已选智能匹配素材的总时长 ── */
const smartSelectedTotalDuration = useMemo(() => {
return smartMatchedResults
.filter((r) => smartSelectedIds.includes(r.asset.id))
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
}, [smartMatchedResults, smartSelectedIds])
return {
// 素材库
libraries,
selectedLibraryId,
setSelectedLibraryId,
materials,
materialsLoading,
// 模式
materialMode,
onMaterialModeChange,
// 手动选择
selectedMaterials,
handleToggleMaterial,
// 智能匹配
smartMatchInput,
setSmartMatchInput,
smartMatching,
smartMatchedResults,
hasMatched,
smartSelectedIds,
handleSmartMatch,
handleToggleSmartSelect,
handleRefreshMatch,
handleSelectAllMatched,
handleClearSmartSelect,
smartSelectedTotalDuration,
// utils
formatDuration,
}
}
export default useStep2Materials
@@ -0,0 +1,47 @@
/**
* Step 3 生成预览 Hook
* 封装预览信息的计算逻辑
*/
import { useMemo } from "react"
import type { EditingTemplate } from "@/api/editing-planner"
interface UseStep3PreviewProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
duration: number
videoRatio: string
}
export function useStep3Preview({
templates,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
duration,
videoRatio,
}: UseStep3PreviewProps) {
const templateName = useMemo(
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
[templates, selectedTemplate],
)
const materialCount = useMemo(() => {
if (materialMode === "auto") {
return `${smartSelectedIds.length} 个素材(智能匹配)`
}
return `${selectedMaterials.length} 个素材`
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
return {
templateName,
materialCount,
duration,
videoRatio,
}
}
export default useStep3Preview
@@ -0,0 +1,251 @@
/**
* Step 4 标题设置 Hook
* 封装 AI 标题生成、标题样式设置等逻辑
*/
import { useState, useCallback, useMemo } from "react"
import { message } from "antd"
import { useQuery } from "@tanstack/react-query"
import { getTitles } from "@/api/titles"
import { TITLE_PRESETS, AI_TITLE_TEMPLATES } from "../constants"
import type { TitleSettings } from "../types"
interface UseStep4TitleProps {
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
}
interface AiTitleItem {
title: string
highlight: string
style: "catchy" | "emotional" | "informative"
}
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
/* ── 标题库 API ── */
const { data: userTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: () => getTitles(),
staleTime: 30_000,
})
/* ── AI 标题生成状态 ── */
const [aiTitleInput, setAiTitleInput] = useState("")
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
/* ── 辅助函数 ── */
const extractTopic = (text: string): string => {
const keywords = text
.replace(/[,。!?、,.!?]/g, " ")
.split(/\s+/)
.filter(Boolean)
if (keywords.length === 0) return "这个话题"
// 取前3个关键词组合
return keywords.slice(0, 3).join("")
}
const getActivePreset = (settings: TitleSettings): string | null => {
for (const p of TITLE_PRESETS) {
if (
settings.size === p.style.size &&
settings.color === p.style.color &&
settings.bold === p.style.bold &&
settings.italic === p.style.italic &&
settings.stroke === p.style.stroke &&
settings.shadow === p.style.shadow
) {
return p.key
}
}
return null
}
const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings])
/* ── AI 标题生成 ── */
const handleGenerateAiTitles = useCallback(async () => {
if (!aiTitleInput.trim()) {
message.warning("请先输入视频描述或关键词")
return
}
setAiTitleGenerating(true)
setHasGeneratedTitles(true)
// 模拟 AI 生成延迟
await new Promise((resolve) => setTimeout(resolve, 1200))
const topic = extractTopic(aiTitleInput)
const results: AiTitleItem[] = []
const styles: Array<"catchy" | "emotional" | "informative"> = [
"catchy",
"emotional",
"informative",
]
styles.forEach((style) => {
const templates = AI_TITLE_TEMPLATES[style]
// 每种风格随机选2个
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
shuffled.forEach((tpl) => {
const title = tpl.replace(/\{topic\}/g, topic)
const highlights = {
catchy: "吸睛标题",
emotional: "情感共鸣",
informative: "知识干货",
}
results.push({
title,
highlight: highlights[style],
style,
})
})
})
// 打乱顺序
results.sort(() => Math.random() - 0.5)
setAiTitleResults(results)
setAiTitleGenerating(false)
}, [aiTitleInput])
const handleSelectAiTitle = useCallback(
(title: string) => {
onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false })
message.success("已选用此标题")
},
[titleSettings, onTitleSettingsChange],
)
const handleRefreshAiTitles = useCallback(async () => {
if (!aiTitleInput.trim()) return
setAiTitleGenerating(true)
await new Promise((resolve) => setTimeout(resolve, 800))
// 重新生成一批
const topic = extractTopic(aiTitleInput)
const results: AiTitleItem[] = []
const styles: Array<"catchy" | "emotional" | "informative"> = [
"catchy",
"emotional",
"informative",
]
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
styles.forEach((style) => {
const templates = AI_TITLE_TEMPLATES[style]
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
shuffled.forEach((tpl) => {
results.push({
title: tpl.replace(/\{topic\}/g, topic),
highlight: highlights[style],
style,
})
})
})
results.sort(() => Math.random() - 0.5)
setAiTitleResults(results)
setAiTitleGenerating(false)
}, [aiTitleInput])
/* ── 标题设置更新 ── */
const updateTitle = useCallback(
(title: string) => {
onTitleSettingsChange({ ...titleSettings, title })
},
[titleSettings, onTitleSettingsChange],
)
const toggleAiAutoSelect = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, aiAutoSelect: !titleSettings.aiAutoSelect })
}, [titleSettings, onTitleSettingsChange])
const updatePosition = useCallback(
(position: string) => {
onTitleSettingsChange({ ...titleSettings, position })
},
[titleSettings, onTitleSettingsChange],
)
const updateFont = useCallback(
(font: string) => {
onTitleSettingsChange({ ...titleSettings, font })
},
[titleSettings, onTitleSettingsChange],
)
const updateSize = useCallback(
(size: number) => {
onTitleSettingsChange({ ...titleSettings, size })
},
[titleSettings, onTitleSettingsChange],
)
const updateColor = useCallback(
(color: string) => {
onTitleSettingsChange({ ...titleSettings, color })
},
[titleSettings, onTitleSettingsChange],
)
const toggleBold = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold })
}, [titleSettings, onTitleSettingsChange])
const toggleItalic = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic })
}, [titleSettings, onTitleSettingsChange])
const toggleStroke = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke })
}, [titleSettings, onTitleSettingsChange])
const toggleShadow = useCallback(() => {
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
}, [titleSettings, onTitleSettingsChange])
const applyPreset = useCallback(
(presetKey: string) => {
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return
onTitleSettingsChange({
...titleSettings,
size: preset.style.size,
color: preset.style.color,
bold: preset.style.bold,
italic: preset.style.italic,
stroke: preset.style.stroke,
shadow: preset.style.shadow,
})
},
[titleSettings, onTitleSettingsChange],
)
return {
// 数据
userTitles,
titleSettings,
aiTitleInput,
setAiTitleInput,
aiTitleGenerating,
aiTitleResults,
hasGeneratedTitles,
activePreset,
titlePresets: TITLE_PRESETS,
// AI 标题操作
handleGenerateAiTitles,
handleSelectAiTitle,
handleRefreshAiTitles,
// 标题设置操作
updateTitle,
toggleAiAutoSelect,
updatePosition,
updateFont,
updateSize,
updateColor,
toggleBold,
toggleItalic,
toggleStroke,
toggleShadow,
applyPreset,
}
}
export default useStep4Title
@@ -0,0 +1,397 @@
/**
* Step 5 配音选择 Hook
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
*/
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
import { useNavigate } from "react-router-dom"
import { message } from "antd"
import { useQuery, useMutation } from "@tanstack/react-query"
import type { PresetVoiceItem } from "@/api/voices"
import { fetchPresetVoices } from "@/api/voices"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import { getTags, createTag } from "@/api/tags"
import { formatDuration } from "../utils/formatDuration"
import type { VoiceClone } from "@/api/voice-clone"
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
interface UseStep5VoiceProps {
selectedVoice: string
onSelectedVoiceChange: (voiceId: string) => void
voiceMode: "preset" | "custom" | "clone"
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
onSelectedClonedVoiceChange: (voiceId: string) => void
clonedVoices: VoiceClone[]
addClone: (voice: VoiceClone) => void
hasProcessing: boolean
cloneModalOpen: boolean
onCloneModalOpenChange: (open: boolean) => void
titleText: string
}
export function useStep5Voice({
selectedVoice,
onSelectedVoiceChange,
voiceMode,
onVoiceModeChange,
selectedClonedVoice,
onSelectedClonedVoiceChange,
clonedVoices,
addClone,
hasProcessing,
cloneModalOpen,
onCloneModalOpenChange,
titleText,
}: UseStep5VoiceProps) {
const navigate = useNavigate()
/* ── 预置音色 API ── */
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
/* ── 音频播放 ── */
const audioRef = useRef<HTMLAudioElement | null>(null)
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
const toggleVoicePlay = useCallback(
(voiceId: string, previewUrl: string | null) => {
if (playingVoice === voiceId) {
audioRef.current?.pause()
audioRef.current = null
setPlayingVoice(null)
return
}
audioRef.current?.pause()
if (!previewUrl) {
message.warning("该音色暂无试听音频")
return
}
const audio = new Audio(previewUrl)
audioRef.current = audio
audio.play().catch(() => {
message.error("播放失败,请检查网络")
})
audio.onended = () => {
setPlayingVoice(null)
audioRef.current = null
}
setPlayingVoice(voiceId)
},
[playingVoice],
)
/* ── 智能配音推荐 ── */
const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false)
const [voiceRecommendations, setVoiceRecommendations] = useState<string[]>([])
const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false)
const handleVoiceRecommend = useCallback(async () => {
if (presetVoices.length === 0) return
setVoiceRecommendLoading(true)
setHasVoiceRecommend(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
// 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年
const title = titleText.toLowerCase()
let recommended: string[] = []
const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id)
const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id)
const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id)
if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) {
recommended = femaleVoices.slice(0, 3)
} else if (/教程|知识|科普|干货|讲解|分析/.test(title)) {
recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1))
} else if (/活力|热血|运动|搞笑|有趣/.test(title)) {
recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1))
} else {
// 默认推荐前3个
recommended = presetVoices.slice(0, 3).map((v) => v.voice_id)
}
// 不足3个时补足
if (recommended.length < 3) {
const others = presetVoices
.filter((v) => !recommended.includes(v.voice_id))
.map((v) => v.voice_id)
recommended = recommended.concat(others.slice(0, 3 - recommended.length))
}
setVoiceRecommendations(recommended)
setVoiceRecommendLoading(false)
}, [presetVoices, titleText])
const handleSelectRecommendedVoice = useCallback(
(voiceId: string) => {
onVoiceModeChange("preset")
onSelectedVoiceChange(voiceId)
},
[onVoiceModeChange, onSelectedVoiceChange],
)
/* ── TTS 自定义合成状态 ── */
const [customVoiceText, setCustomVoiceText] = useState("")
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
const [ttsError, setTtsError] = useState<string | null>(null)
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
/** 合成完成后保留的 job ID,用于"存为素材" */
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(null)
/* ── TTS mutation ── */
const synthesizeMutation = useMutation({
mutationFn: synthesizeSpeech,
onSuccess: (data) => {
setTtsJobId(data.job_id)
message.info("语音合成已提交,等待处理…")
},
onError: () => {
setTtsError("语音合成请求失败,请重试")
},
})
/** 轮询 TTS 任务状态 */
useEffect(() => {
if (!ttsJobId) return
let cancelled = false
let timer: ReturnType<typeof setTimeout>
const poll = async () => {
try {
const status = await getTTSJobStatus(ttsJobId)
if (cancelled) return
if (status.status === "completed") {
setCustomAudioUrl(status.output_audio_url)
setCompletedTtsJobId(ttsJobId)
setTtsJobId(null)
setTtsError(null)
message.success("语音合成完成!")
return
}
if (status.status === "failed" || status.status === "cancelled") {
setTtsError(status.error_message || "语音合成失败")
setTtsJobId(null)
return
}
timer = setTimeout(poll, 2000)
} catch {
if (!cancelled) {
setTtsError("查询合成状态失败")
setTtsJobId(null)
}
}
}
timer = setTimeout(poll, 2000)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [ttsJobId])
/** 触发自定义文本 TTS 合成 */
const handleSynthesizeVoice = useCallback(() => {
if (!customVoiceText.trim()) {
message.warning("请先输入配音文案")
return
}
setTtsError(null)
setCustomAudioUrl(null)
synthesizeMutation.mutate({
text: customVoiceText.trim(),
voice_id: selectedVoice || undefined,
language: "zh-CN",
})
}, [customVoiceText, selectedVoice, synthesizeMutation])
/* ── 存为素材弹窗状态 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false)
const [saveName, setSaveName] = useState("")
const [saveTagIds, setSaveTagIds] = useState<string[]>([])
const [saveNewTag, setSaveNewTag] = useState("")
/* ── 标签列表(用于存为素材弹窗) ── */
const { data: allTags = [] } = useQuery({
queryKey: ["generate-save-tags"],
queryFn: getTags,
staleTime: 30_000,
})
/* ── 存为素材 mutation ── */
const saveToLibraryMutation = useMutation({
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
saveTtsToLibrary(completedTtsJobId!, params),
onSuccess: () => {
message.success({
content: (
<span>
已保存到配音库!{" "}
<a
onClick={handleGoToLibrary}
style={{
color: "var(--primary-500, #6366f1)",
cursor: "pointer",
}}
>
去视频库查看
</a>
</span>
),
duration: 5,
})
setSaveModalOpen(false)
setSaveName("")
setSaveTagIds([])
setSaveNewTag("")
setCompletedTtsJobId(null)
setCustomAudioUrl(null)
},
onError: (err: Error) => {
message.error(`保存失败:${err.message || "请重试"}`)
},
})
/** 打开存为素材弹窗 */
const handleOpenSaveModal = useCallback(() => {
setSaveName("")
setSaveTagIds([])
setSaveNewTag("")
setSaveModalOpen(true)
}, [])
/** 确认保存 */
const handleConfirmSave = useCallback(() => {
if (!completedTtsJobId) return
saveToLibraryMutation.mutate({
name: saveName.trim() || undefined,
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
})
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation])
/** 在弹窗中新增标签(先创建再选中) */
const handleAddTagInModal = useCallback(
async (tagName: string) => {
const trimmed = tagName.trim()
if (!trimmed) return
/* 已在选中列表则跳过 */
const existing = allTags.find((t) => t.name === trimmed)
if (existing) {
if (!saveTagIds.includes(existing.id)) {
setSaveTagIds((prev) => [...prev, existing.id])
}
return
}
try {
const created = await createTag(trimmed)
setSaveTagIds((prev) => [...prev, created.id])
setSaveNewTag("")
} catch {
message.error(`创建标签"${trimmed}"失败`)
}
},
[allTags, saveTagIds],
)
/** 保存成功后跳转到视频库 */
const handleGoToLibrary = useCallback(() => {
navigate("/app/voice-materials")
}, [navigate])
/* ── 克隆成功回调 ── */
const handleCloneSuccess = useCallback(
(voice: VoiceClone) => {
addClone(voice)
onCloneModalOpenChange(false)
message.success("音色克隆成功!")
},
[addClone, onCloneModalOpenChange],
)
/* ── 预设音色选择操作 ── */
const handleSelectPresetVoice = useCallback(
(voiceId: string) => {
onVoiceModeChange("preset")
onSelectedVoiceChange(voiceId)
},
[onVoiceModeChange, onSelectedVoiceChange],
)
const handleSelectCloneVoice = useCallback(() => {
onVoiceModeChange("clone")
}, [onVoiceModeChange])
const handleSelectClonedVoice = useCallback(
(voiceId: string) => {
onSelectedClonedVoiceChange(voiceId)
},
[onSelectedClonedVoiceChange],
)
const handleOpenCloneModal = useCallback(() => {
onCloneModalOpenChange(true)
}, [onCloneModalOpenChange])
return {
// 数据
presetVoices,
presetVoicesLoading,
clonedVoices,
hasProcessing,
// 模式 & 选择
voiceMode,
selectedVoice,
selectedClonedVoice,
// AI 推荐
voiceRecommendLoading,
voiceRecommendations,
hasVoiceRecommend,
handleVoiceRecommend,
handleSelectRecommendedVoice,
// 音频播放
playingVoice,
toggleVoicePlay,
// 预设音色操作
handleSelectPresetVoice,
handleSelectCloneVoice,
// 自定义 TTS
customVoiceText,
setCustomVoiceText,
customAudioUrl,
ttsError,
ttsJobId,
completedTtsJobId,
synthesizeMutation,
handleSynthesizeVoice,
// 存为素材
saveModalOpen,
setSaveModalOpen,
saveName,
setSaveName,
saveTagIds,
setSaveTagIds,
saveNewTag,
setSaveNewTag,
allTags,
saveToLibraryMutation,
handleOpenSaveModal,
handleConfirmSave,
handleAddTagInModal,
// 克隆
cloneModalOpen,
handleOpenCloneModal,
handleCloneSuccess,
handleSelectClonedVoice,
// utils
VOICE_GENDER_ICON,
CLONE_STATUS_CONFIG,
formatDuration,
}
}
export default useStep5Voice
@@ -0,0 +1,81 @@
/**
* Step 6 封面设置 Hook
* 封装封面设置的交互逻辑
*/
import { useCallback } from "react"
import type { CoverConfig } from "../../editing-planner/types"
import { COVER_MODE_LABELS, COVER_MODE_ICONS, DEFAULT_COVER_SETTINGS } from "../constants"
interface UseStep6CoverProps {
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
duration: number
}
export function useStep6Cover({
coverSettings,
onCoverSettingsChange,
duration,
}: UseStep6CoverProps) {
const formatTime = useCallback((seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 10)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
}, [])
const toggleEnabled = useCallback(
(enabled: boolean) => {
onCoverSettingsChange({ ...coverSettings, enabled })
},
[coverSettings, onCoverSettingsChange],
)
const setMode = useCallback(
(mode: CoverConfig["mode"]) => {
onCoverSettingsChange({ ...coverSettings, mode })
},
[coverSettings, onCoverSettingsChange],
)
const setFrameTime = useCallback(
(frameTime: number) => {
onCoverSettingsChange({ ...coverSettings, frame_time: frameTime })
},
[coverSettings, onCoverSettingsChange],
)
const handleUpload = useCallback(
(file: File) => {
const reader = new FileReader()
reader.onload = (ev) => {
const url = ev.target?.result as string
onCoverSettingsChange({
...coverSettings,
upload_url: url,
thumbnail_url: url,
mode: "upload",
})
}
reader.readAsDataURL(file)
},
[coverSettings, onCoverSettingsChange],
)
const totalDuration = duration || 30
return {
coverSettings,
formatTime,
toggleEnabled,
setMode,
setFrameTime,
handleUpload,
totalDuration,
COVER_MODE_LABELS,
COVER_MODE_ICONS,
DEFAULT_COVER_SETTINGS,
}
}
export default useStep6Cover
@@ -0,0 +1,121 @@
/**
* Step 7 确认生成 Hook
* 封装生成确认页的展示逻辑
*/
import { useMemo } from "react"
import type { EditingTemplate } from "@/api/editing-planner"
import type { GeneratedVideo } from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import type { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices"
import { COVER_MODE_LABELS } from "../constants"
interface UseStep7GenerateProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
title: string
voiceMode: "preset" | "custom" | "clone"
selectedVoice: string
selectedClonedVoice: string
presetVoices: PresetVoiceItem[]
clonedVoices: VoiceClone[]
coverSettings: CoverConfig
generateCount: number
onGenerateCountChange: (count: number) => void
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
}
export function useStep7Generate({
templates,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
title,
voiceMode,
selectedVoice,
selectedClonedVoice,
presetVoices,
clonedVoices,
coverSettings,
generateCount,
onGenerateCountChange,
generating,
generated,
generateError,
progress,
generatedVideos,
}: UseStep7GenerateProps) {
const templateName = useMemo(
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
[templates, selectedTemplate],
)
const materialSummary = useMemo(() => {
if (materialMode === "auto") {
return `${smartSelectedIds.length} 个素材(智能匹配)`
}
return `${selectedMaterials.length} 个素材`
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
const voiceName = useMemo(() => {
if (voiceMode === "clone") {
const cv = clonedVoices.find((v) => v.id === selectedClonedVoice)
return cv ? cv.name : "未选择"
}
const pv = presetVoices.find((v) => v.voice_id === selectedVoice)
return pv ? pv.name : "未选择"
}, [voiceMode, selectedVoice, selectedClonedVoice, presetVoices, clonedVoices])
const coverSummary = useMemo(() => {
if (!coverSettings.enabled) return "不使用"
return COVER_MODE_LABELS[coverSettings.mode] || "智能封面"
}, [coverSettings])
const getGenerationPhase = (p: number) => {
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
return { label: "即将完成", icon: "✨" }
}
const handleDecrement = () => {
onGenerateCountChange(Math.max(1, generateCount - 1))
}
const handleIncrement = () => {
onGenerateCountChange(Math.min(10, generateCount + 1))
}
const handleScrollToPreview = () => {
const el = document.querySelector(".xx-preview-section")
el?.scrollIntoView({ behavior: "smooth", block: "start" })
}
return {
templateName,
materialSummary,
title,
voiceName,
coverSummary,
generateCount,
handleDecrement,
handleIncrement,
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
handleScrollToPreview,
}
}
export default useStep7Generate
@@ -0,0 +1,9 @@
/**
* 时长格式化工具
* 秒数转分秒格式,如 65 -> "1:05"
*/
export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${String(s).padStart(2, "0")}`
}
+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]
}
+96 -1459
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
import React, { useState, useRef } from "react"
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
import { Button, Input } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial } from "../types"
import { GENDER_OPTIONS } from "../constants"
import { genderClass, formatFileSize } from "../utils/format"
import TagSelector from "./TagSelector"
export interface MaterialFormProps {
initial?: VoiceMaterial
onSubmit: (data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => void
onCancel: () => void
loading?: boolean
uploadProgress?: number | null
tags?: TagItem[]
tagMap?: Map<string, TagItem>
onCreateTag?: (name: string) => Promise<TagItem>
}
const MaterialForm: React.FC<MaterialFormProps> = ({
initial,
onSubmit,
onCancel,
loading,
uploadProgress,
tags = [],
tagMap = new Map(),
onCreateTag,
}) => {
const [name, setName] = useState(initial?.name ?? "")
const [description, setDescription] = useState(initial?.description ?? "")
const [gender, setGender] = useState<VoiceGender>(initial?.gender ?? "female")
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(initial?.tagIds ?? [])
const [file, setFile] = useState<File | undefined>(undefined)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleSubmit = () => {
if (!name.trim()) return
if (!initial && !file) return
onSubmit({
name: name.trim(),
description: description.trim(),
gender,
tagIds: selectedTagIds,
fileName: file?.name ?? initial?.fileName ?? "",
fileSize: file?.size ?? initial?.fileSize ?? 0,
duration: initial?.duration ?? 0,
mimeType: file?.type ?? initial?.mimeType ?? "audio/mpeg",
file,
})
}
return (
<div className="vmat-form">
{/* 音频文件上传(编辑模式不显示) */}
{!initial && (
<div className="vmat-form-field">
<label className="vmat-form-label">音频文件 *</label>
<div
className="vmat-upload-zone"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const f = e.dataTransfer.files[0]
if (f?.type.startsWith("audio/")) setFile(f)
}}
>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) setFile(f)
}}
/>
{file ? (
<div className="vmat-upload-selected">
<SoundOutlined className="vmat-upload-icon" />
<span className="vmat-upload-filename">{file.name}</span>
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
<button
type="button"
className="vmat-upload-clear"
onClick={(e) => {
e.stopPropagation()
setFile(undefined)
}}
>
<CloseOutlined />
</button>
</div>
) : (
<div className="vmat-upload-placeholder">
<UploadOutlined className="vmat-upload-icon" />
<p>点击或拖拽音频文件到此处</p>
<span>支持 MP3、WAV、AAC、FLAC 等格式</span>
</div>
)}
</div>
{/* 上传进度条 */}
{uploadProgress !== null && uploadProgress !== undefined && (
<div className="vmat-upload-progress">
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
</div>
)}
</div>
)}
{/* 名称 */}
<div className="vmat-form-field">
<label className="vmat-form-label">名称 *</label>
<Input
placeholder="输入配音素材名称"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={50}
/>
</div>
{/* 音色描述 */}
<div className="vmat-form-field">
<label className="vmat-form-label">音色描述</label>
<Input.TextArea
placeholder="描述音色特点,如:适合产品宣传的男声配音..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
maxLength={200}
/>
</div>
{/* 性别 */}
<div className="vmat-form-field">
<label className="vmat-form-label">性别</label>
<div className="vmat-gender-group">
{GENDER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`vmat-gender-btn${gender === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
onClick={() => setGender(opt.value)}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
{/* 风格标签 */}
<div className="vmat-form-field">
<label className="vmat-form-label">风格标签</label>
<TagSelector
value={selectedTagIds}
onChange={setSelectedTagIds}
tags={tags}
tagMap={tagMap}
onCreateTag={onCreateTag ?? (async () => ({ id: "", name: "" }))}
/>
</div>
{/* 操作按钮 */}
<div className="vmat-form-actions">
<Button buttonType="ghost" buttonSize="md" onClick={onCancel}>
取消
</Button>
<Button
buttonType="primary"
buttonSize="md"
onClick={handleSubmit}
loading={loading}
disabled={!name.trim() || (!initial && !file)}
>
{initial ? "保存修改" : "上传"}
</Button>
</div>
</div>
)
}
export default MaterialForm
@@ -0,0 +1,163 @@
import React, { useState, useRef, useCallback, useMemo } from "react"
import { CheckOutlined } from "@ant-design/icons"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
export interface TagSelectorProps {
/** 已选标签 ID 列表 */
value: string[]
onChange: (tagIds: string[]) => void
/** 所有可用标签(来自 API) */
tags: TagItem[]
/** 标签 ID → TagItem 映射 */
tagMap: Map<string, TagItem>
/** 创建新标签,返回带 ID 的 TagItem */
onCreateTag: (name: string) => Promise<TagItem>
placeholder?: string
}
const TagSelector: React.FC<TagSelectorProps> = ({
value,
onChange,
tags,
tagMap,
onCreateTag,
placeholder = "输入标签后回车添加",
}) => {
const [inputVal, setInputVal] = useState("")
const [showSuggestions, setShowSuggestions] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
/** 按名称查找已有标签(大小写不敏感) */
const findTagByName = useCallback(
(name: string) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase()),
[tags],
)
/** 去重添加标签(按 ID) */
const addTagId = useCallback(
(tagId: string) => {
if (value.includes(tagId)) return
onChange([...value, tagId])
setInputVal("")
setShowSuggestions(false)
},
[value, onChange],
)
/** 输入自定义标签名:若已存在则直接选,否则创建新标签 */
const addTagByName = useCallback(
async (name: string) => {
const trimmed = name.trim()
if (!trimmed) return
const existing = findTagByName(trimmed)
if (existing) {
addTagId(existing.id)
} else {
try {
const created = await onCreateTag(trimmed)
addTagId(created.id)
} catch {
/* 创建失败静默忽略 */
}
}
},
[findTagByName, addTagId, onCreateTag],
)
const removeTagId = useCallback(
(tagId: string) => {
onChange(value.filter((t) => t !== tagId))
},
[value, onChange],
)
/** 输入补全建议(排除已选) */
const suggestions = useMemo(() => {
if (!inputVal.trim()) return []
const lower = inputVal.toLowerCase()
return tags.filter((t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id))
}, [inputVal, tags, value])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
if (suggestions.length > 0) {
addTagId(suggestions[0].id)
} else {
addTagByName(inputVal)
}
} else if (e.key === "Backspace" && !inputVal && value.length > 0) {
removeTagId(value[value.length - 1])
}
}
return (
<div className="vmat-tag-selector-wrapper">
<div className="vmat-tag-selector" onClick={() => inputRef.current?.focus()}>
{value.map((tagId) => (
<Tag key={tagId} variant="info" closable onClose={() => removeTagId(tagId)}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
<input
ref={inputRef}
className="vmat-tag-selector-input"
value={inputVal}
onChange={(e) => {
setInputVal(e.target.value)
setShowSuggestions(true)
}}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
/>
</div>
{/* 自动补全下拉 */}
{showSuggestions && suggestions.length > 0 && (
<div className="vmat-tag-suggestions">
{suggestions.slice(0, 6).map((tag) => (
<button
key={tag.id}
type="button"
className="vmat-tag-suggestion-item"
onMouseDown={(e) => {
e.preventDefault()
addTagId(tag.id)
}}
>
{tag.name}
</button>
))}
</div>
)}
{/* 已有标签快捷选择 */}
{tags.length > 0 && (
<div className="vmat-tag-selector-presets">
{tags.map((tag) => {
const isSelected = value.includes(tag.id)
return (
<button
key={tag.id}
type="button"
className={`vmat-tag-selector-preset${isSelected ? " selected" : ""}`}
onClick={() => {
if (isSelected) removeTagId(tag.id)
else addTagId(tag.id)
}}
>
{isSelected && <CheckOutlined style={{ fontSize: 10, marginRight: 2 }} />}
{tag.name}
</button>
)
})}
</div>
)}
</div>
)
}
export default TagSelector
@@ -0,0 +1,245 @@
import React, { useRef } from "react"
import {
AudioOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
EditOutlined,
DeleteOutlined,
CheckOutlined,
SoundOutlined,
MutedOutlined,
} from "@ant-design/icons"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { type VoiceMaterial } from "../types"
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../constants"
import {
genderClass,
genderIcon,
genderLabel,
formatDuration,
formatFileSize,
formatDate,
} from "../utils/format"
export interface VoiceCardProps {
material: VoiceMaterial
isPlaying: boolean
currentTime: number
isSelected: boolean
batchMode: boolean
volume: number
tagMap: Map<string, TagItem>
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onEdit: () => void
onDelete: () => void
onToggleSelect: (id: string) => void
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onToggleMute: () => void
}
const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
material,
isPlaying,
currentTime,
isSelected,
batchMode,
volume,
tagMap,
onPlay,
onPause,
onSeek,
onEdit,
onDelete,
onToggleSelect,
onVolumeChange,
onToggleMute,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * material.duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
}
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
const handleCardClick = () => {
if (batchMode) {
onToggleSelect(material.id)
}
}
return (
<div
className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
onClick={handleCardClick}
>
{/* 批量选择 checkbox */}
{(batchMode || isSelected) && (
<div
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(material.id)
}}
>
{isSelected && <CheckOutlined />}
</div>
)}
{/* 操作按钮 */}
<div className="vmat-card-actions">
<button
type="button"
className="vmat-card-action-btn"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="编辑"
>
<EditOutlined />
</button>
<button
type="button"
className="vmat-card-action-btn vmat-card-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="删除"
>
<DeleteOutlined />
</button>
</div>
{/* 头部:图标 + 名称 + 性别 */}
<div className="vmat-card-header">
<div className="vmat-card-avatar">
<AudioOutlined />
</div>
<div className="vmat-card-title-area">
<h4 className="vmat-card-name" title={material.name}>
{material.name}
</h4>
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)}
{genderLabel(material.gender)}
</span>
</div>
</div>
{/* 描述 */}
{material.description && <p className="vmat-card-desc">{material.description}</p>}
{/* 标签 */}
<div className="vmat-card-tags">
{material.tagIds.length === 0 ? (
<span
className="vmat-tag-empty"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
>
添加标签
</span>
) : (
<>
{material.tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{material.tagIds.length > MAX_CARD_TAGS && (
<Tooltip
title={material.tagIds
.slice(MAX_CARD_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_CARD_TAGS}</Tag>
</Tooltip>
)}
</>
)}
</div>
{/* 元信息 */}
<div className="vmat-card-meta">
<span>{formatDuration(material.duration)}</span>
<span>{formatFileSize(material.fileSize)}</span>
<span>{formatDate(material.createdAt)}</span>
</div>
{/* 播放控制 */}
<div className="vmat-card-player">
<button
type="button"
className="vmat-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
disabled={!material.fileUrl}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div>
<span className="vmat-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
</span>
{/* 音量控制 */}
<div className="vmat-volume">
<button
type="button"
className="vmat-volume-btn"
onClick={(e) => {
e.stopPropagation()
onToggleMute()
}}
title={volume === 0 ? "取消静音" : "静音"}
>
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
</button>
<input
type="range"
className="vmat-volume-slider"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => {
e.stopPropagation()
onVolumeChange(e)
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
</div>
)
}
export default VoiceMaterialCard
@@ -0,0 +1,186 @@
import React, { useRef } from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
EditOutlined,
DeleteOutlined,
CheckOutlined,
} from "@ant-design/icons"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { type VoiceMaterial } from "../types"
import { MAX_ROW_TAGS, TAG_VARIANTS } from "../constants"
import {
genderClass,
genderIcon,
genderLabel,
formatDuration,
formatFileSize,
} from "../utils/format"
export interface VoiceRowProps {
material: VoiceMaterial
isPlaying: boolean
currentTime: number
isSelected: boolean
batchMode: boolean
tagMap: Map<string, TagItem>
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onEdit: () => void
onDelete: () => void
onToggleSelect: (id: string) => void
}
const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
material,
isPlaying,
currentTime,
isSelected,
batchMode,
tagMap,
onPlay,
onPause,
onSeek,
onEdit,
onDelete,
onToggleSelect,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * material.duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
}
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
return (
<div
className={`vmat-row ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
>
{/* 批量选择 checkbox */}
{(batchMode || isSelected) && (
<div
className={`vmat-row-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(material.id)
}}
>
{isSelected && <CheckOutlined />}
</div>
)}
{/* 播放按钮 */}
<button
type="button"
className="vmat-row-play"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
disabled={!material.fileUrl}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
{/* 名称 + 描述 */}
<div className="vmat-row-info">
<h4 className="vmat-row-name">{material.name}</h4>
{material.description && <p className="vmat-row-desc">{material.description}</p>}
</div>
{/* 性别 */}
<span className={`vmat-row-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)}
{genderLabel(material.gender)}
</span>
{/* 标签 */}
<div className="vmat-row-tags">
{material.tagIds.length === 0 ? (
<span className="vmat-tag-empty" onClick={() => onEdit()}>
添加标签
</span>
) : (
<>
{material.tagIds.slice(0, MAX_ROW_TAGS).map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{material.tagIds.length > MAX_ROW_TAGS && (
<Tooltip
title={material.tagIds
.slice(MAX_ROW_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_ROW_TAGS}</Tag>
</Tooltip>
)}
</>
)}
</div>
{/* 进度条(可拖拽) */}
<div ref={progressRef} className="vmat-row-progress" onMouseDown={handleProgressMouseDown}>
<div className="vmat-row-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div>
{/* 时长 */}
<span className="vmat-row-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
</span>
{/* 文件大小 */}
<span className="vmat-row-size">{formatFileSize(material.fileSize)}</span>
{/* 操作 */}
<div className="vmat-row-actions">
<button
type="button"
className="vmat-row-action-btn"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="编辑"
>
<EditOutlined />
</button>
<button
type="button"
className="vmat-row-action-btn vmat-row-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="删除"
>
<DeleteOutlined />
</button>
</div>
</div>
)
}
export default VoiceMaterialRow
@@ -0,0 +1,24 @@
/**
* 配音素材库常量
*/
/* eslint-disable react-refresh/only-export-components */
import React from "react"
import { ManOutlined, WomanOutlined, UserOutlined, SoundOutlined } from "@ant-design/icons"
import type { VoiceGender } from "./types"
/** 标签溢出限制 */
export const MAX_CARD_TAGS = 3
export const MAX_ROW_TAGS = 2
export const TAG_VARIANTS = ["info", "primary", "success", "warning", "error"] as const
/** 性别选项 */
export const GENDER_OPTIONS: {
value: VoiceGender
label: string
icon: React.ReactNode
}[] = [
{ value: "male", label: "男声", icon: <ManOutlined /> },
{ value: "female", label: "女声", icon: <WomanOutlined /> },
{ value: "child", label: "童声", icon: <UserOutlined /> },
{ value: "neutral", label: "中性", icon: <SoundOutlined /> },
]
@@ -0,0 +1,142 @@
import { useState, useRef, useCallback, useEffect } from "react"
import type { VoiceMaterial } from "../types"
/**
* 音频播放控制 Hook
* 封装当前播放音频状态、播放/暂停、进度控制、音量控制
*/
export function useAudioPlayer() {
const [playingId, setPlayingId] = useState<string | null>(null)
const [currentTime, setCurrentTime] = useState(0)
const [volume, setVolume] = useState(0.7)
const [pausedMaterial, setPausedMaterial] = useState<VoiceMaterial | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
/** 停止当前播放并重置状态 */
const stopPlayback = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
setPlayingId(null)
setCurrentTime(0)
setPausedMaterial(null)
}, [])
/** 从头开始播放指定素材 */
const startPlayback = useCallback(
(material: VoiceMaterial) => {
if (!material.fileUrl) return
stopPlayback()
const audio = new Audio(material.fileUrl)
audio.volume = volume
audioRef.current = audio
audio.addEventListener("timeupdate", () => {
setCurrentTime(audio.currentTime)
})
audio.addEventListener("ended", () => {
setPlayingId(null)
setCurrentTime(0)
audioRef.current = null
setPausedMaterial(null)
})
audio.play().catch(() => {
audioRef.current = null
setPlayingId(null)
})
setPlayingId(material.id)
setCurrentTime(0)
setPausedMaterial(null)
},
[stopPlayback, volume],
)
/** 播放素材(若为暂停状态则恢复) */
const handlePlay = useCallback(
(material: VoiceMaterial) => {
if (playingId === material.id) return
// 恢复暂停
if (pausedMaterial?.id === material.id && audioRef.current && audioRef.current.paused) {
audioRef.current.play().catch(() => {})
setPlayingId(material.id)
setPausedMaterial(null)
return
}
startPlayback(material)
},
[playingId, pausedMaterial, startPlayback],
)
/** 暂停播放 */
const handlePause = useCallback((material?: VoiceMaterial) => {
if (audioRef.current) {
audioRef.current.pause()
}
setPlayingId(null)
if (material) setPausedMaterial(material)
}, [])
/** 跳转到指定播放时间 */
const handleSeek = useCallback(
(material: VoiceMaterial, time: number) => {
if (audioRef.current) {
audioRef.current.currentTime = time
setCurrentTime(time)
} else {
startPlayback(material)
setTimeout(() => {
if (audioRef.current) {
audioRef.current.currentTime = time
}
}, 100)
}
},
[startPlayback],
)
/** 音量调节 */
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value)
setVolume(v)
if (audioRef.current) audioRef.current.volume = v
}, [])
/** 静音/取消静音切换 */
const toggleMute = useCallback(() => {
if (volume > 0) {
setVolume(0)
if (audioRef.current) audioRef.current.volume = 0
} else {
setVolume(0.7)
if (audioRef.current) audioRef.current.volume = 0.7
}
}, [volume])
// 组件卸载时清理 audio
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
}
}, [])
return {
playingId,
currentTime,
volume,
pausedMaterial,
stopPlayback,
handlePlay,
handlePause,
handleSeek,
handleVolumeChange,
toggleMute,
}
}
@@ -0,0 +1,132 @@
import { useState, useCallback, useMemo } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { deleteAsset } from "@/api/assets"
import { type TagItem, createTag, tagAsset } from "@/api/tags"
import type { VoiceMaterial } from "../types"
/**
* 批量操作 Hook
* 封装批量选择、批量删除、批量打标签等逻辑
*/
interface UseBatchOperationsProps {
/** 当前筛选后的素材列表 */
filtered: VoiceMaterial[]
/** 标签 ID → TagItem 映射 */
tagMap: Map<string, TagItem>
/** 所有可用标签 */
tags: TagItem[]
/** 当前播放中的素材 ID */
playingId: string | null
/** 停止播放回调 */
stopPlayback: () => void
}
export function useBatchOperations({
filtered,
tagMap,
tags,
playingId,
stopPlayback,
}: UseBatchOperationsProps) {
const queryClient = useQueryClient()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [batchCustomTag, setBatchCustomTag] = useState("")
const batchMode = useMemo(() => selectedIds.size > 0, [selectedIds])
const allSelected = useMemo(
() => filtered.length > 0 && filtered.every((m) => selectedIds.has(m.id)),
[filtered, selectedIds],
)
/** 切换单个素材的选中状态 */
const handleToggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
/** 全选 / 取消全选 */
const handleSelectAll = useCallback(() => {
if (allSelected) setSelectedIds(new Set())
else setSelectedIds(new Set(filtered.map((m) => m.id)))
}, [allSelected, filtered])
/** 批量删除 */
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
let successCount = 0
for (const id of ids) {
try {
await deleteAsset(id)
successCount++
} catch {
/* ignore individual failures */
}
if (playingId === id) stopPlayback()
}
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
setSelectedIds(new Set())
message.success(`已批量删除 ${successCount}/${ids.length} 个素材`)
}, [selectedIds, playingId, stopPlayback, queryClient])
/** 批量打标签(已有标签) */
const handleBatchTag = useCallback(
async (tagId: string) => {
const ids = Array.from(selectedIds)
let successCount = 0
for (const id of ids) {
try {
await tagAsset(id, [tagId])
successCount++
} catch {
/* ignore individual failures */
}
}
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
setSelectedIds(new Set())
const tagName = tagMap.get(tagId)?.name ?? tagId
if (successCount === 0) {
message.error(`批量打标签失败,请重试`)
} else {
message.success(`已为 ${successCount}/${ids.length} 个素材添加标签「${tagName}」`)
}
},
[selectedIds, queryClient, tagMap],
)
/** 批量打标签(自定义输入:按名称查找或创建标签,再批量打标) */
const handleBatchCustomTag = useCallback(
async (name: string) => {
// 先查找同名标签(不区分大小写)
let existing = tags.find((t) => t.name.toLowerCase() === name.toLowerCase())
if (!existing) {
try {
existing = await createTag(name)
} catch {
message.error(`创建标签「${name}」失败`)
return
}
}
await handleBatchTag(existing.id)
},
[tags, handleBatchTag],
)
return {
selectedIds,
batchMode,
allSelected,
batchCustomTag,
setBatchCustomTag,
handleToggleSelect,
handleSelectAll,
handleBatchDelete,
handleBatchTag,
handleBatchCustomTag,
}
}
@@ -0,0 +1,135 @@
import { useState, useRef, useCallback, useEffect } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices"
/**
* TTS 合成 Hook
* 封装合成弹窗状态、合成请求、轮询、保存到素材库等逻辑
*/
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
export function useTtsSynthesize() {
const queryClient = useQueryClient()
const [ttsOpen, setTtsOpen] = useState(false)
const [ttsText, setTtsText] = useState("")
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
const [ttsSpeed, setTtsSpeed] = useState(1.0)
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null)
const [ttsError, setTtsError] = useState<string | null>(null)
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// 预设音色列表
const { data: presetVoicesData } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
staleTime: 60_000,
})
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? []
/** 开始 AI 配音合成 */
const handleTtsSynthesize = useCallback(async () => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本")
return
}
setTtsError(null)
setTtsStatus("synthesizing")
setTtsAudioUrl(null)
setTtsJobId(null)
try {
const resp = await synthesizeSpeech({
text: ttsText.trim(),
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
})
setTtsJobId(resp.job_id)
// 轮询任务状态
ttsTimerRef.current = setInterval(async () => {
try {
const job = await getTTSJobStatus(resp.job_id)
if (job.status === "completed") {
clearInterval(ttsTimerRef.current!)
ttsTimerRef.current = null
setTtsStatus("done")
setTtsAudioUrl(job.output_audio_url)
} else if (job.status === "failed") {
clearInterval(ttsTimerRef.current!)
ttsTimerRef.current = null
setTtsStatus("error")
setTtsError(job.error_message || "合成失败")
}
} catch {
clearInterval(ttsTimerRef.current!)
ttsTimerRef.current = null
setTtsStatus("error")
setTtsError("查询合成状态失败")
}
}, 2000)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "合成请求失败"
setTtsStatus("error")
setTtsError(msg)
}
}, [ttsText, ttsVoiceId, ttsSpeed])
/** 保存 TTS 结果到素材库 */
const handleTtsSave = useCallback(async () => {
if (!ttsJobId) return
try {
await saveTtsToLibrary(ttsJobId, {
name: ttsText.slice(0, 20) || "AI配音",
})
message.success("已保存到配音库")
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
setTtsOpen(false)
} catch {
message.error("保存失败")
}
}, [ttsJobId, ttsText, queryClient])
/** 关闭 TTS 弹窗并清理状态 */
const handleTtsClose = useCallback(() => {
setTtsOpen(false)
if (ttsTimerRef.current) {
clearInterval(ttsTimerRef.current)
ttsTimerRef.current = null
}
setTtsStatus("idle")
setTtsAudioUrl(null)
setTtsError(null)
setTtsJobId(null)
}, [])
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current)
}
}, [])
return {
ttsOpen,
ttsText,
ttsVoiceId,
ttsSpeed,
ttsJobId,
ttsStatus,
ttsAudioUrl,
ttsError,
presetVoices,
setTtsOpen,
setTtsText,
setTtsVoiceId,
setTtsSpeed,
handleTtsSynthesize,
handleTtsSave,
handleTtsClose,
}
}
@@ -0,0 +1,351 @@
import { useState, useMemo, useCallback, useEffect } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
getAssetsByKind,
createAsset,
updateAsset,
deleteAsset,
uploadAssetDirect,
getAssetLibraries,
createAssetLibrary,
} from "@/api/assets"
import { type TagItem, getTags, createTag, tagAsset, untagAsset } from "@/api/tags"
import {
type VoiceGender,
type ViewMode,
type VoiceMaterial,
mapAssetToMaterial,
buildMetadata,
} from "../types"
import { getAudioDuration } from "../utils/audio"
/**
* 配音素材数据 Hook
* 封装素材列表查询、筛选状态管理、增删改等数据操作逻辑
*/
export function useVoiceMaterials() {
const queryClient = useQueryClient()
// ── 获取 voice 类型素材库(用于上传) ──────────────────────
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
staleTime: 60_000,
})
const voiceLibrary = useMemo(() => libraries.find((lib) => lib.kind === "voice"), [libraries])
// 自动创建 voice 素材库(如果不存在)
const createLibMutation = useMutation({
mutationFn: () => createAssetLibrary({ name: "配音库", kind: "voice" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
},
})
useEffect(() => {
if (libraries.length > 0 && !voiceLibrary && !createLibMutation.isPending) {
createLibMutation.mutate()
}
}, [libraries, voiceLibrary, createLibMutation])
// ── 获取标签列表 ───────────────────────────────────────────
const { data: tags = [] } = useQuery({
queryKey: ["tags"],
queryFn: getTags,
staleTime: 60_000,
})
/** 标签 ID → TagItem 映射(用于卡片/行渲染) */
const tagMap = useMemo(() => {
const m = new Map<string, TagItem>()
tags.forEach((t) => m.set(t.id, t))
return m
}, [tags])
/** 创建标签 mutation(供 TagSelector 调用) */
const createTagMutation = useMutation({
mutationFn: (name: string) => createTag(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
})
/** 创建标签并返回 TagItem(供 TagSelector 使用) */
const handleCreateTag = useCallback(
async (name: string): Promise<TagItem> => {
return createTagMutation.mutateAsync(name)
},
[createTagMutation],
)
// ── 视图 & 筛选状态 ────────────────────────────────────────
const [viewMode, setViewMode] = useState<ViewMode>("card")
const [searchText, setSearchText] = useState("")
const [filterGender, setFilterGender] = useState<string>("all")
const [filterTagId, setFilterTagId] = useState<string>("all")
// ── 获取配音素材列表(筛选参数透传后端) ─────────────────
const filterKeyword = searchText.trim() || undefined
const filterGenderParam = filterGender !== "all" ? filterGender : undefined
const filterTagIdsParam = filterTagId !== "all" ? [filterTagId] : undefined
const { data: assets = [], isLoading } = useQuery({
queryKey: [
"assets",
"voice",
{
keyword: filterKeyword,
gender: filterGenderParam,
tag_ids: filterTagIdsParam,
},
],
queryFn: () =>
getAssetsByKind("voice", {
keyword: filterKeyword,
gender: filterGenderParam,
tag_ids: filterTagIdsParam,
}),
staleTime: 30_000,
})
const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets])
// ── 弹窗状态 ──────────────────────────────────────────────
const [uploadOpen, setUploadOpen] = useState(false)
const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null)
// ── 上传进度 ──────────────────────────────────────────────
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
// ── 上传 mutation ─────────────────────────────────────────
const uploadMutation = useMutation({
mutationFn: async (data: {
file: File
name: string
gender: VoiceGender
description: string
tagIds: string[]
}) => {
setUploadProgress(0)
try {
// 1. 获取或等待 voice library
let lib = voiceLibrary
if (!lib) {
if (createLibMutation.isPending) {
await createLibMutation.mutateAsync()
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
lib = libs.find((l) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
}
// 2. 上传文件(带进度)
const { storage_key } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
// 3. 获取音频时长
const duration = await getAudioDuration(data.file)
// 4. 创建素材记录
const asset = await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildMetadata({
gender: data.gender,
description: data.description,
duration,
}),
})
// 5. 打标签(标签走独立 API)
if (data.tagIds.length > 0) {
await tagAsset(asset.id, data.tagIds)
}
} finally {
setUploadProgress(null)
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
onError: (err: Error) => {
message.error(err.message || "上传失败,请重试")
},
})
// ── 编辑 mutation ─────────────────────────────────────────
const editMutation = useMutation({
mutationFn: async (data: {
id: string
name: string
gender: VoiceGender
description: string
tagIds: string[]
}) => {
// 1. 更新基础信息
await updateAsset(data.id, {
name: data.name,
metadata: buildMetadata({
gender: data.gender,
description: data.description,
}),
})
// 2. 对比标签差异,调用 tag/untag API
const currentAsset = materials.find((m) => m.id === data.id)
const oldTagIds = currentAsset?.tagIds ?? []
const newTagIds = data.tagIds
const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id))
const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id))
if (toAdd.length > 0) {
await tagAsset(data.id, toAdd)
}
for (const tagId of toRemove) {
await untagAsset(data.id, tagId)
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
})
// ── 删除 mutation ─────────────────────────────────────────
const deleteMutation = useMutation({
mutationFn: (assetId: string) => deleteAsset(assetId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
},
})
/* ── 前端二次筛选(与后端筛选同时存在) ──────────────────── */
const filtered = useMemo(() => {
let list = materials
if (filterGender !== "all") {
list = list.filter((m) => m.gender === filterGender)
}
if (filterTagId !== "all") {
list = list.filter((m) => m.tagIds.includes(filterTagId))
}
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.description.toLowerCase().includes(q) ||
m.tagIds.some((id) => tagMap.get(id)?.name?.toLowerCase().includes(q)),
)
}
return list
}, [materials, filterGender, filterTagId, searchText, tagMap])
/* ── 标签使用计数(药丸条展示,按 tag ID 统计) ──────────── */
const tagCountMap = useMemo(() => {
const map: Record<string, number> = {}
materials.forEach((m) =>
m.tagIds.forEach((id) => {
map[id] = (map[id] || 0) + 1
}),
)
return map
}, [materials])
/* ── 数据操作 handlers ──────────────────────────────────── */
const handleUpload = useCallback(
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
if (!data.file) return
uploadMutation.mutate(
{
file: data.file,
name: data.name,
gender: data.gender,
description: data.description,
tagIds: data.tagIds,
},
{
onSuccess: () => {
setUploadOpen(false)
},
},
)
},
[uploadMutation],
)
const handleEdit = useCallback(
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
if (!editingMaterial) return
editMutation.mutate({
id: editingMaterial.id,
name: data.name,
gender: data.gender,
description: data.description,
tagIds: data.tagIds,
})
setEditingMaterial(null)
},
[editingMaterial, editMutation],
)
const handleDelete = useCallback(
(id: string, onBeforeDelete?: () => void) => {
const material = materials.find((m) => m.id === id)
if (!material) return
if (onBeforeDelete) onBeforeDelete()
deleteMutation.mutate(id)
},
[materials, deleteMutation],
)
return {
// 数据
libraries,
voiceLibrary,
tags,
tagMap,
materials,
filtered,
tagCountMap,
isLoading,
// 视图 & 筛选状态
viewMode,
searchText,
filterGender,
filterTagId,
// 上传 & 编辑状态
uploadProgress,
isUploading: uploadMutation.isPending,
isEditing: editMutation.isPending,
// 弹窗状态
uploadOpen,
editingMaterial,
// 视图控制
setViewMode,
setSearchText,
setFilterGender,
setFilterTagId,
setUploadOpen,
setEditingMaterial,
// 操作
handleCreateTag,
handleUpload,
handleEdit,
handleDelete,
}
}
@@ -0,0 +1,60 @@
/**
* 配音素材库类型定义
*/
import type { AssetItem } from "@/api/assets"
export type VoiceGender = "male" | "female" | "child" | "neutral"
export type ViewMode = "card" | "list"
/** 前端配音素材数据模型(从 AssetItem 映射) */
export interface VoiceMaterial {
id: string
name: string
description: string
gender: VoiceGender
tagIds: string[]
fileName: string
fileSize: number
duration: number
mimeType: string
createdAt: string
fileUrl?: string
}
/** 配音素材上传元数据(传递给 createAsset 的 metadata) */
export interface VoiceAssetMetadata {
gender: VoiceGender
description: string
duration: number
[key: string]: unknown
}
/** 后端 AssetItem → 前端 VoiceMaterial */
export const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
const meta = asset.metadata || {}
return {
id: asset.id,
name: asset.name,
description: (meta.description as string) || "",
gender: (meta.gender as VoiceGender) || "neutral",
tagIds: Array.isArray(asset.tag_ids) ? asset.tag_ids : [],
fileName: asset.storage_key?.split("/").pop() || asset.name,
fileSize: asset.file_size || 0,
duration: (meta.duration as number) || 0,
mimeType: asset.mime_type || "audio/mpeg",
createdAt: asset.created_at || new Date().toISOString(),
fileUrl: asset.file_url,
}
}
/** 前端表单数据 → 后端 metadata(标签走独立 API,不再写 metadata.style) */
export const buildMetadata = (data: {
gender: VoiceGender
description: string
duration?: number
}): VoiceAssetMetadata => ({
gender: data.gender,
description: data.description,
duration: data.duration || 0,
})
@@ -0,0 +1,20 @@
/**
* 音频相关工具函数
*/
/** 获取音频文件时长(秒) */
export const getAudioDuration = (file: File): Promise<number> => {
return new Promise((resolve) => {
const audio = new Audio()
const url = URL.createObjectURL(file)
audio.addEventListener("loadedmetadata", () => {
resolve(audio.duration)
URL.revokeObjectURL(url)
})
audio.addEventListener("error", () => {
resolve(0)
URL.revokeObjectURL(url)
})
audio.src = url
})
}
@@ -0,0 +1,31 @@
/**
* 格式化工具函数
*/
import { GENDER_OPTIONS } from "../constants"
import type { VoiceGender } from "../types"
export const genderLabel = (g: VoiceGender) => GENDER_OPTIONS.find((o) => o.value === g)?.label ?? g
export const genderIcon = (g: VoiceGender) =>
GENDER_OPTIONS.find((o) => o.value === g)?.icon ?? null
export const genderClass = (g: VoiceGender) => `vmat-gender--${g}`
export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, "0")}`
}
export const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export const formatDate = (iso: string): string =>
new Date(iso).toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
File diff suppressed because it is too large Load Diff

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