Compare commits

...

37 Commits

Author SHA1 Message Date
xiaoxia 862340ace5 fix(ci): 修复CI Gate失败时返回exit 0而非exit 1的P0 Bug
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 37s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m29s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m44s
AI Code Review / AI Code Review (pull_request) Successful in 1m17s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m56s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m24s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 4m18s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m54s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 5s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 37s
2026-07-28 10:13:32 +08:00
xiaoxia b40ae9cec7 refactor(voices): 拆分 TtsModal 为子组件(258→53行, -79%) (#1051)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-28 10:03:16 +08:00
xiaoxia eb636b8fef test(wave136): 多轨混音纯逻辑抽离 + 71单测 (#1050)
CI/CD Pipeline / Check if frontend-only change (push) Waiting to run
CI/CD Pipeline / Validate - Code Quality (push) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (push) Waiting to run
CI/CD Pipeline / Validate - Migration (alembic) (push) Waiting to run
CI/CD Pipeline / Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Waiting to run
CI/CD Pipeline / PR Build Web Image (push) Waiting to run
CI/CD Pipeline / PR Build Worker Image (push) Waiting to run
CI/CD Pipeline / Build Staging API Image (push) Waiting to run
CI/CD Pipeline / Build Staging Web Image (push) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (push) Waiting to run
CI/CD Pipeline / Build Production API Image (push) Waiting to run
CI/CD Pipeline / Build Production Web Image (push) Waiting to run
CI/CD Pipeline / Build Production Worker Image (push) Waiting to run
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Blocked by required conditions
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
- 新增纯逻辑模块 multi_track_mixer_pure.py,0 FFmpeg 依赖
- 71个单元测试全绿
- 向后兼容,原 multi_track_mixer.py 未修改
2026-07-28 09:53:23 +08:00
xiaoxia 09b069a65d refactor(upload-voice-modal): 三阶段重构 UploadVoiceModal(329→87行, -74%)(#1047)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-28 09:43:08 +08:00
xiaoxia 378e4c8751 test(wave135): 视频拼接引擎纯逻辑抽离 + 70单测 (#1049) 2026-07-28 09:42:55 +08:00
xiaoxia f9afdd95ce test(wave133): BGM混音纯逻辑抽离 + 84单测 (#1046) 2026-07-28 09:42:49 +08:00
xiaoxia 456718ad84 test(wave134): 贴纸引擎纯逻辑抽离 + 113单测 (#1048)
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Failing after 13m7s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 13m2s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 12m57s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m27s
CI/CD Pipeline / Unit Tests (push) Failing after 4m22s
CI/CD Pipeline / Integration Tests (push) Successful in 2m29s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m33s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m29s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m8s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 24s
CI/CD Pipeline / CI Gate (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-28 09:34:21 +08:00
xiaoxia edd4b6b1ea feat(ci): 格式自动修复对所有PR开放 + 防循环机制(develop同步) (#1044)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 52s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m35s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m52s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m39s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m44s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m26s
CI/CD Pipeline / Unit Tests (push) Failing after 5m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m52s
CI/CD Pipeline / Integration Tests (push) Successful in 4m7s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 20m44s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-28 09:09:58 +08:00
xiaoxia 4578b65965 refactor(asset-selector): 三阶段重构 AssetSelector(504→168行, -67%) (#937)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m30s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m37s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m18s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m17s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m33s
CI/CD Pipeline / Integration Tests (push) Successful in 2m22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m19s
CI/CD Pipeline / Unit Tests (push) Failing after 6m57s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m31s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 16s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m46s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m59s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-28 01:02:50 +08:00
CI Bot e4b3af78b9 style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m48s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m40s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m41s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m52s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m54s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m15s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 4m17s
CI/CD Pipeline / Integration Tests (push) Successful in 3m21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 7m30s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-07-27 14:39:55 +00:00
xiaoxia ff827aba01 refactor(useGenerateVideo): 拆分 useGenerateVideo Hook(370→221行, -40%) (#969)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m11s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m10s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m56s
CI/CD Pipeline / Unit Tests (push) Failing after 10m47s
CI/CD Pipeline / Integration Tests (push) Successful in 5m29s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m56s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m0s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m58s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 6m14s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m5s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-27 22:36:45 +08:00
xiaoxia a75cce0a3b feat(ci): AI代码审查添加阻塞级判定 + 门禁控制(develop同步) (#1042)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m36s
CI/CD Pipeline / Frontend Lint (push) Failing after 1m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m54s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 4m54s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 4m59s
CI/CD Pipeline / Integration Tests (push) Successful in 3m25s
CI/CD Pipeline / Unit Tests (push) Failing after 9m2s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 9m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-27 22:22:34 +08:00
xiaoxia b051acc8b4 refactor(editing-planner): 拆分 GenerationProgressModal,按阶段拆为 3 个子组件 (#1017)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m6s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m59s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m43s
CI/CD Pipeline / Unit Tests (push) Failing after 6m44s
CI/CD Pipeline / Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m2s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m50s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m25s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-27 21:58:54 +08:00
xiaoxia 271db4e989 refactor(useGenerateVideo): 拆分 useGenerateVideo Hook(370→221行, -40%) (#969)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m48s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m1s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m7s
CI/CD Pipeline / Unit Tests (push) Failing after 5m59s
CI/CD Pipeline / Integration Tests (push) Successful in 3m32s
CI/CD Pipeline / Frontend Lint (push) Failing after 58s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m16s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m46s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m59s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m34s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
- 按职责拆分为 5 个子模块:phase/errorUtils/types/useGenerationPolling/voiceConfig
- 主 Hook 从 370 行精简到 221 行,可读性提升 40%
- 保持原有功能不变,纯重构
2026-07-27 21:41:38 +08:00
CI Bot 9eca947f88 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
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-27 13:33:18 +00:00
xiaoxia 8d3647f414 refactor(voice-materials): 深化拆分 UI 组件(603→338行, -44%) (#935)
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m8s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m12s
CI/CD Pipeline / Unit Tests (push) Failing after 8m7s
CI/CD Pipeline / Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m26s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 13m17s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 3m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 10m20s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-27 21:29:17 +08:00
deploy-bot a258d7f9d1 refactor(voice-materials): 拆分 useVoiceMaterials Hook,按职责拆为 4 个子模块 (#1002)
CI/CD Pipeline / Validate - Code Quality (push) Failing after 0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Unit Tests (push) Failing after 0s
CI/CD Pipeline / Integration Tests (push) Failing after 0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m6s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m24s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 40s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m44s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
- useVoiceTags - 标签管理(查询、映射、创建)
- useVoiceMaterialFilterState - 筛选视图状态
- useVoiceMaterialData - 数据查询
- useVoiceMaterialActions - 增删改操作

主文件 351 → 105 行(-70%),导入路径保持向后兼容
2026-07-27 21:02:50 +08:00
xiaoxia 30713e698c refactor(voice-library): 深化 VoiceLibrary 拆分,抽离 Tab 内容/弹窗/Toast (#988)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Unit Tests (push) Failing after 0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1s
CI/CD Pipeline / Integration Tests (push) Failing after 1s
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-27 20:50:54 +08:00
xiaoxia 2b120f5c65 refactor(generate): 拆分 GeneratePage 主文件,抽离状态/导航/组件 (#987)
CI/CD Pipeline / Validate - Code Quality (push) Failing after 0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Integration Tests (push) Failing after 1s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
CI/CD Pipeline / Unit Tests (push) Failing after 1s
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-27 20:50:00 +08:00
xiaoxia bf55cfc3ca refactor(my-voices): 三阶段重构 MyVoices(381→148行, -61%) (#967)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 0s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Integration Tests (push) Failing after 0s
CI/CD Pipeline / Unit Tests (push) Failing after 0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-27 20:48:50 +08:00
xiaoxia f23b0ceae4 refactor(clone-modal): 三阶段重构 CloneModal(602→82行, -86%) (#934) 2026-07-27 20:48:50 +08:00
xiaoxia 66b6ed9f12 refactor(title-library): 三阶段重构 TitleLibrary(736→137行, -81%) (#929) 2026-07-27 20:48:50 +08:00
xiaoxia 1e49618817 refactor(router): split router config into modular files (#979) 2026-07-27 20:48:50 +08:00
xiaoxia 06716a0678 test(wave128): url_security纯逻辑抽离 + 110单测 (#1031)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
CI/CD Pipeline / Integration Tests (push) Failing after 0s
CI/CD Pipeline / Unit Tests (push) Failing after 1s
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-27 20:20:53 +08:00
xiaoxia 31dc5b382a test(wave127): template_version+数据类单测 + 30测试 (#1030) 2026-07-27 20:20:53 +08:00
xiaoxia 57c7543c7f test(wave126): 小领域模块单测组合包 + 53测试 (#1029) 2026-07-27 20:20:52 +08:00
xiaoxia 796cac249f test(wave125): entities领域实体单测 + 76测试 (#1028) 2026-07-27 20:20:52 +08:00
xiaoxia abe275acc4 test(wave124): quota配额系统单测 + 66测试 (#1027) 2026-07-27 20:20:52 +08:00
xiaoxia 1f11981a95 test(wave123): job领域模型单测 + 69测试 (#1026) 2026-07-27 20:20:52 +08:00
xiaoxia 1b7a7bcb55 refactor(timeline-panel): Phase 3 - extract 3 business hooks (#924)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Unit Tests (push) Failing after 0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
CI/CD Pipeline / Integration Tests (push) Failing after 0s
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-27 19:00:53 +08:00
xiaoxia da3ca4bc46 refactor(assets): split useAssetOperations into sub-modules (#973)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Squash merge PR #973: refactor(assets): split useAssetOperations into sub-modules
2026-07-27 18:38:30 +08:00
xiaoxia 8849f5b358 test(wave132): TTS领域模型单测 +103 (#1036)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-27 18:31:39 +08:00
xiaoxia f246f7d8aa test(wave131): password_hasher密码哈希+验证 + 55单测 (#1034)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-27 18:30:13 +08:00
xiaoxia 602bb388d1 test(wave130): jwt_service JWT服务 + 50单测 (#1033) 2026-07-27 18:30:13 +08:00
xiaoxia fba0ade0ca test(wave129): asset_scoring资产评分 + 64单测 (#1032) 2026-07-27 18:30:13 +08:00
xiaoxia 8882e24fd4 refactor(duplication-detail): 三阶段重构 DuplicationDetail(323→110行, -66%) (#968)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Squash merge of PR #968

## 变更说明
DuplicationDetail 查重详情页三阶段重构,主文件 323→110 行(-66%)

### Phase 1: 常量/工具
- : RISK_DESC / RISK_TAG_VARIANT / RISK_LABEL / ToastState
- : formatTime / formatSize / formatDuration / getRiskLevel

### Phase 2: UI 组件
- : 风险评估卡片(圆环 + 查重率 + 描述)
- : 基本信息卡片(6 行元数据)
- : 重复片段列表区域
- : 单个重复片段卡片

### Phase 3: 业务 Hook
- : 查询 + 重试 + Toast + 导航
2026-07-27 18:21:53 +08:00
xiaoxia a6ebb762e6 test(wave117): 抽离speed_config领域模型 + 57单测 (#1008)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Squashed merge of PR #1008
2026-07-27 18:07:27 +08:00
153 changed files with 18021 additions and 6070 deletions
+1 -1
View File
@@ -1805,7 +1805,7 @@ jobs:
echo "❌ CI Gate: FAILED"
echo "失败项: ${FAILED_ITEMS[*]}"
echo "gate_result=failure" >> $GITHUB_OUTPUT
exit 0
exit 1
fi
- name: Report CI trace
+4 -2
View File
@@ -48,6 +48,7 @@ jobs:
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
REPO_NAME: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
LLM_PROVIDER: "coze"
# 扣子模式配置(默认国内站 api.coze.cn
@@ -60,8 +61,9 @@ jobs:
LLM_TIMEOUT: "120"
run: |
python3 scripts/ci_code_review.py
# 审查脚本异常不影响 CI 通过
continue-on-error: true
# 注意:脚本退出码决定job状态
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open
- name: Report CI trace
if: always()
@@ -0,0 +1,110 @@
import React from "react"
import type { MediaAsset } from "@/api/template-editor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
import { formatSize, formatDuration, getQualityLevel } from "./utils"
interface AssetCardProps {
asset: MediaAsset
isSelected: boolean
isDragging: boolean
isDragOver: boolean
showCheckbox: boolean
compact?: boolean
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
onDragStart: (e: React.DragEvent) => void
onDragOver: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
onDragEnd: () => void
onMouseEnter: (e: React.MouseEvent) => void
onMouseLeave: () => void
}
const AssetCard: React.FC<AssetCardProps> = ({
asset,
isSelected,
isDragging,
isDragOver,
showCheckbox,
onToggleSelect,
onCardClick,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onMouseEnter,
onMouseLeave,
}) => {
const qualityLevel = getQualityLevel(asset.quality_score)
return (
<div
className={[
"as-card",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
showCheckbox ? "has-checkbox" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
onClick={(e) => onCardClick(asset, e)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* 缩略图 */}
<div className="as-card-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
) : (
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)}
{/* Checkbox */}
{showCheckbox && (
<span
data-checkbox
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(asset, e.shiftKey)
}}
/>
)}
{/* 类型角标 */}
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
{/* 时长角标 */}
{asset.duration != null && (
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
)}
{/* 质量分角标 */}
{asset.quality_score != null && (
<span
className={`as-card-quality ${qualityLevel}`}
title={`质量分: ${asset.quality_score}`}
>
{asset.quality_score}
</span>
)}
</div>
{/* 信息 */}
<div className="as-card-info">
<p className="as-card-name" title={asset.name}>
{asset.name}
</p>
<div className="as-card-meta">{formatSize(asset.size)}</div>
</div>
</div>
)
}
export default AssetCard
@@ -0,0 +1,99 @@
import React from "react"
import type { MediaAsset } from "@/api/template-editor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
import { formatSize, formatDuration, getQualityColor } from "./utils"
interface AssetListItemProps {
asset: MediaAsset
isSelected: boolean
isDragging: boolean
isDragOver: boolean
showCheckbox: boolean
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
onDragStart: (e: React.DragEvent) => void
onDragOver: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
onDragEnd: () => void
onMouseEnter: (e: React.MouseEvent) => void
onMouseLeave: () => void
}
const AssetListItem: React.FC<AssetListItemProps> = ({
asset,
isSelected,
isDragging,
isDragOver,
showCheckbox,
onToggleSelect,
onCardClick,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onMouseEnter,
onMouseLeave,
}) => {
return (
<div
className={[
"as-list-item",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
onClick={(e) => onCardClick(asset, e)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* 拖拽手柄 */}
<span className="as-list-item-drag" title="拖拽排序">
</span>
{/* Checkbox */}
{showCheckbox && (
<span
data-checkbox
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(asset, e.shiftKey)
}}
/>
)}
{/* 图标 */}
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
{/* 信息 */}
<div className="as-list-item-info">
<div className="as-list-item-name">{asset.name}</div>
<div className="as-list-item-meta">
{MATERIAL_TYPE_LABELS[asset.type]}
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
{asset.size != null && ` · ${formatSize(asset.size)}`}
</div>
</div>
{/* 质量分 */}
{asset.quality_score != null && (
<span
className="as-list-item-quality"
style={{ color: getQualityColor(asset.quality_score) }}
>
{asset.quality_score}
</span>
)}
</div>
)
}
export default AssetListItem
@@ -9,73 +9,20 @@
* - 筛选增强:类型筛选 + 质量分筛选
* - 视图切换:网格视图 / 列表视图
*/
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
import React, { useCallback } from "react"
import "./AssetSelector.css"
import { Input, Select, Button } from "@/components/ui"
import type { AssetSelectorProps } from "./types"
import useAssetFilter from "./hooks/useAssetFilter"
import useAssetSelection from "./hooks/useAssetSelection"
import useDragReorder from "./hooks/useDragReorder"
import useAssetPreview from "./hooks/useAssetPreview"
import SelectorToolbar from "./SelectorToolbar"
import SelectorBatchBar from "./SelectorBatchBar"
import AssetCard from "./AssetCard"
import AssetListItem from "./AssetListItem"
import PreviewOverlay from "./PreviewOverlay"
import EmptyState from "./EmptyState"
import type { MediaAsset } from "@/api/template-editor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/template-editor"
/* ──────────── 类型 ──────────── */
export interface AssetSelectorProps {
assets: MediaAsset[]
selectedIds?: string[]
onSelectionChange?: (ids: string[]) => void
onAssetDragStart?: (asset: MediaAsset) => void
onReorder?: (fromIdx: number, toIdx: number) => void
showQualityFilter?: boolean
showBatchSelect?: boolean
compact?: boolean
}
type ViewMode = "grid" | "list"
/* ──────────── 工具函数 ──────────── */
/** 格式化文件大小 */
const formatSize = (bytes?: number): string => {
if (!bytes) return ""
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}
/** 格式化时长 */
const formatDuration = (seconds?: number): string => {
if (!seconds) return ""
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
}
/** 获取质量分等级 */
const getQualityLevel = (score?: number): string => {
if (score == null) return "none"
if (score >= 90) return "excellent"
if (score >= 70) return "good"
if (score >= 50) return "fair"
return "poor"
}
/** 质量分颜色 */
const getQualityColor = (score?: number): string => {
if (score == null) return "var(--text-secondary)"
if (score >= 90) return "var(--success-color, #10b981)"
if (score >= 70) return "var(--primary-color, #6366f1)"
if (score >= 50) return "var(--warning-color, #f59e0b)"
return "var(--error-color, #ef4444)"
}
/* ──────────── 类型筛选选项 ──────────── */
const TYPE_OPTIONS = [
{ value: "", label: "全部类型" },
{ value: "video", label: "🎬 视频" },
{ value: "image", label: "🖼️ 图片" },
{ value: "audio", label: "🎵 音频" },
]
/* ──────────── 组件 ──────────── */
const AssetSelector: React.FC<AssetSelectorProps> = ({
assets,
@@ -87,162 +34,41 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
showBatchSelect = true,
compact = false,
}) => {
/* ── 搜索 & 筛选 ── */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState("")
const [filterQuality, setFilterQuality] = useState("")
const [viewMode, setViewMode] = useState<ViewMode>("grid")
// 搜索 & 筛选
const {
searchText,
filterType,
filterQuality,
viewMode,
filteredAssets,
setSearchText,
setFilterType,
setFilterQuality,
setViewMode,
} = useAssetFilter(assets)
/* ── 拖拽状态 ── */
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
// 选中操作
const { selectedSet, toggleSelect, clearSelection } = useAssetSelection({
filteredAssets,
selectedIds,
onSelectionChange,
})
/* ── 悬浮预览 ── */
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// 拖拽排序
const { dragIdx, dragOverIdx, handleDragStart, handleDragOver, handleDrop, handleDragEnd } =
useDragReorder({
filteredAssets,
selectedIds,
onAssetDragStart,
onReorder,
})
/* ── Shift 连选 ── */
const lastClickedIdx = useRef<number | null>(null)
/* ── 过滤后的素材列表 ── */
const filteredAssets = useMemo(() => {
let list = assets
if (searchText) {
const q = searchText.toLowerCase()
list = list.filter(
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
)
}
if (filterType) {
list = list.filter((a) => a.type === filterType)
}
if (filterQuality) {
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
if (opt?.min != null && opt?.max != null) {
list = list.filter(
(a) =>
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
)
}
}
return list
}, [assets, searchText, filterType, filterQuality])
/* ── 选中状态 ── */
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
/* ── 选择操作 ── */
const toggleSelect = useCallback(
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
if (!onSelectionChange) return
if (shiftKey && lastClickedIdx.current !== null) {
// Shift 连选
const start = Math.min(lastClickedIdx.current, idx)
const end = Math.max(lastClickedIdx.current, idx)
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
const newSet = new Set(selectedIds)
rangeIds.forEach((id) => newSet.add(id))
onSelectionChange(Array.from(newSet))
} else {
const newSet = new Set(selectedIds)
if (newSet.has(asset.id)) {
newSet.delete(asset.id)
} else {
newSet.add(asset.id)
}
onSelectionChange(Array.from(newSet))
}
lastClickedIdx.current = idx
},
[onSelectionChange, selectedIds, filteredAssets],
)
const clearSelection = useCallback(() => {
onSelectionChange?.([])
}, [onSelectionChange])
/* ── 拖拽排序 ── */
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
setDragIdx(idx)
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", String(idx))
// 设置素材数据,供 TimelinePanel 接收(P1-2 修复)
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
// 批量拖拽:如果有多个选中素材,一起携带
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
}
// 通知父组件素材拖拽开始
if (onAssetDragStart) {
onAssetDragStart(filteredAssets[idx])
}
},
[filteredAssets, selectedIds, onAssetDragStart],
)
const handleDragOver = useCallback(
(e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
if (dragIdx === null || dragIdx === idx) return
setDragOverIdx(idx)
},
[dragIdx],
)
const handleDrop = useCallback(
(e: React.DragEvent, toIdx: number) => {
e.preventDefault()
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
onReorder(dragIdx, toIdx)
}
setDragIdx(null)
setDragOverIdx(null)
},
[dragIdx, onReorder],
)
const handleDragEnd = useCallback(() => {
setDragIdx(null)
setDragOverIdx(null)
}, [])
/* ── 悬浮预览 ── */
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
if (previewTimer.current) clearTimeout(previewTimer.current)
previewTimer.current = setTimeout(() => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
setPreviewAsset(asset)
setPreviewPos({
x: rect.right + 12,
y: Math.max(8, rect.top - 20),
})
}, 400)
}, [])
const handleMouseLeave = useCallback(() => {
if (previewTimer.current) {
clearTimeout(previewTimer.current)
previewTimer.current = null
}
setPreviewAsset(null)
}, [])
// 清理定时器
useEffect(() => {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current)
}
}, [])
// 悬浮预览
const { previewAsset, previewPos, handleMouseEnter, handleMouseLeave } = useAssetPreview()
/* ── 点击卡片 ── */
const handleCardClick = useCallback(
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
// 如果点击的是 checkbox 区域,不触发卡片点击
const target = e.target as HTMLElement
if (target.closest("[data-checkbox]")) return
toggleSelect(asset, idx, e.shiftKey)
@@ -250,253 +76,82 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
[toggleSelect],
)
/* ──────────── 渲染 ──────────── */
const hasSelection = selectedIds.length > 0
return (
<div className="as-container">
{/* ═══ 工具栏 ═══ */}
<div className="as-toolbar">
<div className="as-toolbar-left">
<div className="as-search">
<Input
placeholder="搜索素材..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
prefix="🔍"
/>
</div>
<Select
value={filterType}
onChange={(v: string) => setFilterType(v)}
options={TYPE_OPTIONS}
/>
{showQualityFilter && (
<Select
value={filterQuality}
onChange={(v: string) => setFilterQuality(v)}
options={QUALITY_OPTIONS}
/>
)}
</div>
<div className="as-toolbar-right">
<button
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
onClick={() => setViewMode("grid")}
title="网格视图"
>
</button>
<button
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
onClick={() => setViewMode("list")}
title="列表视图"
>
</button>
</div>
</div>
{/* 工具栏 */}
<SelectorToolbar
searchText={searchText}
filterType={filterType}
filterQuality={filterQuality}
viewMode={viewMode}
showQualityFilter={showQualityFilter}
onSearchChange={setSearchText}
onTypeChange={setFilterType}
onQualityChange={setFilterQuality}
onViewModeChange={setViewMode}
/>
{/* ═══ 批量操作栏 ═══ */}
{/* 批量操作栏 */}
{showBatchSelect && hasSelection && (
<div className="as-batch-bar">
<span className="as-batch-bar-count"> {selectedIds.length} </span>
<div className="as-batch-bar-actions">
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
</Button>
</div>
</div>
<SelectorBatchBar selectedCount={selectedIds.length} onClear={clearSelection} />
)}
{/* ═══ 素材列表 ═══ */}
{/* 素材列表 */}
<div className="as-body">
{filteredAssets.length === 0 ? (
<div className="as-empty">
<div className="as-empty-icon">📂</div>
<p></p>
</div>
<EmptyState />
) : viewMode === "grid" ? (
/* ── 网格视图 ── */
/* 网格视图 */
<div className={`as-grid${compact ? " compact" : ""}`}>
{filteredAssets.map((asset, idx) => {
const isSelected = selectedSet.has(asset.id)
const isDragging = dragIdx === idx
const isDragOver = dragOverIdx === idx
const qualityLevel = getQualityLevel(asset.quality_score)
return (
<div
key={asset.id}
className={[
"as-card",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
showBatchSelect ? "has-checkbox" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onClick={(e) => handleCardClick(asset, idx, e)}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
>
{/* 缩略图 */}
<div className="as-card-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
) : (
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)}
{/* Checkbox */}
{showBatchSelect && (
<span
data-checkbox
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
toggleSelect(asset, idx, e.shiftKey)
}}
/>
)}
{/* 类型角标 */}
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
{/* 时长角标 */}
{asset.duration != null && (
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
)}
{/* 质量分角标 */}
{asset.quality_score != null && (
<span
className={`as-card-quality ${qualityLevel}`}
title={`质量分: ${asset.quality_score}`}
>
{asset.quality_score}
</span>
)}
</div>
{/* 信息 */}
<div className="as-card-info">
<p className="as-card-name" title={asset.name}>
{asset.name}
</p>
<div className="as-card-meta">{formatSize(asset.size)}</div>
</div>
</div>
)
})}
{filteredAssets.map((asset, idx) => (
<AssetCard
key={asset.id}
asset={asset}
isSelected={selectedSet.has(asset.id)}
isDragging={dragIdx === idx}
isDragOver={dragOverIdx === idx}
showCheckbox={showBatchSelect}
compact={compact}
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
onCardClick={(a, e) => handleCardClick(a, idx, e)}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
/>
))}
</div>
) : (
/* ── 列表视图 ── */
/* 列表视图 */
<div className="as-list">
{filteredAssets.map((asset, idx) => {
const isSelected = selectedSet.has(asset.id)
const isDragging = dragIdx === idx
const isDragOver = dragOverIdx === idx
return (
<div
key={asset.id}
className={[
"as-list-item",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onClick={(e) => handleCardClick(asset, idx, e)}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
>
{/* 拖拽手柄 */}
<span className="as-list-item-drag" title="拖拽排序">
</span>
{/* Checkbox */}
{showBatchSelect && (
<span
data-checkbox
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
toggleSelect(asset, idx, e.shiftKey)
}}
/>
)}
{/* 图标 */}
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
{/* 信息 */}
<div className="as-list-item-info">
<div className="as-list-item-name">{asset.name}</div>
<div className="as-list-item-meta">
{MATERIAL_TYPE_LABELS[asset.type]}
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
{asset.size != null && ` · ${formatSize(asset.size)}`}
</div>
</div>
{/* 质量分 */}
{asset.quality_score != null && (
<span
className="as-list-item-quality"
style={{ color: getQualityColor(asset.quality_score) }}
>
{asset.quality_score}
</span>
)}
</div>
)
})}
{filteredAssets.map((asset, idx) => (
<AssetListItem
key={asset.id}
asset={asset}
isSelected={selectedSet.has(asset.id)}
isDragging={dragIdx === idx}
isDragOver={dragOverIdx === idx}
showCheckbox={showBatchSelect}
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
onCardClick={(a, e) => handleCardClick(a, idx, e)}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
/>
))}
</div>
)}
</div>
{/* ═══ 悬浮预览 ═══ */}
{previewAsset && (
<div className="as-preview-overlay" style={{ left: previewPos.x, top: previewPos.y }}>
<div className="as-preview-overlay-thumb">
{previewAsset.thumbnail_url ? (
<img src={previewAsset.thumbnail_url} alt={previewAsset.name} />
) : (
<span className="as-preview-overlay-thumb-icon">
{MATERIAL_TYPE_ICONS[previewAsset.type]}
</span>
)}
</div>
<p className="as-preview-overlay-name">{previewAsset.name}</p>
<div className="as-preview-overlay-meta">
<span>: {MATERIAL_TYPE_LABELS[previewAsset.type]}</span>
{previewAsset.duration != null && (
<span>: {formatDuration(previewAsset.duration)}</span>
)}
{previewAsset.size != null && <span>: {formatSize(previewAsset.size)}</span>}
{previewAsset.quality_score != null && (
<span>: {previewAsset.quality_score}</span>
)}
{previewAsset.tags.length > 0 && <span>: {previewAsset.tags.join(", ")}</span>}
</div>
</div>
)}
{/* 悬浮预览 */}
{previewAsset && <PreviewOverlay asset={previewAsset} position={previewPos} />}
</div>
)
}
@@ -0,0 +1,12 @@
import React from "react"
const EmptyState: React.FC = () => {
return (
<div className="as-empty">
<div className="as-empty-icon">📂</div>
<p></p>
</div>
)
}
export default EmptyState
@@ -0,0 +1,33 @@
import React from "react"
import type { MediaAsset } from "@/api/template-editor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
import { formatSize, formatDuration } from "./utils"
interface PreviewOverlayProps {
asset: MediaAsset
position: { x: number; y: number }
}
const PreviewOverlay: React.FC<PreviewOverlayProps> = ({ asset, position }) => {
return (
<div className="as-preview-overlay" style={{ left: position.x, top: position.y }}>
<div className="as-preview-overlay-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} />
) : (
<span className="as-preview-overlay-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)}
</div>
<p className="as-preview-overlay-name">{asset.name}</p>
<div className="as-preview-overlay-meta">
<span>: {MATERIAL_TYPE_LABELS[asset.type]}</span>
{asset.duration != null && <span>: {formatDuration(asset.duration)}</span>}
{asset.size != null && <span>: {formatSize(asset.size)}</span>}
{asset.quality_score != null && <span>: {asset.quality_score}</span>}
{asset.tags.length > 0 && <span>: {asset.tags.join(", ")}</span>}
</div>
</div>
)
}
export default PreviewOverlay
@@ -0,0 +1,22 @@
import React from "react"
import { Button } from "@/components/ui"
interface SelectorBatchBarProps {
selectedCount: number
onClear: () => void
}
const SelectorBatchBar: React.FC<SelectorBatchBarProps> = ({ selectedCount, onClear }) => {
return (
<div className="as-batch-bar">
<span className="as-batch-bar-count"> {selectedCount} </span>
<div className="as-batch-bar-actions">
<Button buttonType="ghost" buttonSize="sm" onClick={onClear}>
</Button>
</div>
</div>
)
}
export default SelectorBatchBar
@@ -0,0 +1,74 @@
import React from "react"
import { Input, Select } from "@/components/ui"
import { TYPE_OPTIONS } from "./constants"
import { QUALITY_OPTIONS } from "@/api/template-editor"
import type { ViewMode } from "./types"
interface SelectorToolbarProps {
searchText: string
filterType: string
filterQuality: string
viewMode: ViewMode
showQualityFilter: boolean
onSearchChange: (value: string) => void
onTypeChange: (value: string) => void
onQualityChange: (value: string) => void
onViewModeChange: (mode: ViewMode) => void
}
const SelectorToolbar: React.FC<SelectorToolbarProps> = ({
searchText,
filterType,
filterQuality,
viewMode,
showQualityFilter,
onSearchChange,
onTypeChange,
onQualityChange,
onViewModeChange,
}) => {
return (
<div className="as-toolbar">
<div className="as-toolbar-left">
<div className="as-search">
<Input
placeholder="搜索素材..."
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
prefix="🔍"
/>
</div>
<Select
value={filterType}
onChange={(v: string) => onTypeChange(v)}
options={TYPE_OPTIONS}
/>
{showQualityFilter && (
<Select
value={filterQuality}
onChange={(v: string) => onQualityChange(v)}
options={QUALITY_OPTIONS}
/>
)}
</div>
<div className="as-toolbar-right">
<button
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
onClick={() => onViewModeChange("grid")}
title="网格视图"
>
</button>
<button
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
onClick={() => onViewModeChange("list")}
title="列表视图"
>
</button>
</div>
</div>
)
}
export default SelectorToolbar
@@ -0,0 +1,7 @@
/** 类型筛选选项 */
export const TYPE_OPTIONS = [
{ value: "", label: "全部类型" },
{ value: "video", label: "🎬 视频" },
{ value: "image", label: "🖼️ 图片" },
{ value: "audio", label: "🎵 音频" },
]
@@ -0,0 +1,63 @@
import { useState, useMemo } from "react"
import type { MediaAsset } from "@/api/template-editor"
import { QUALITY_OPTIONS } from "@/api/template-editor"
import type { ViewMode } from "../types"
interface UseAssetFilterReturn {
searchText: string
filterType: string
filterQuality: string
viewMode: ViewMode
filteredAssets: MediaAsset[]
setSearchText: (value: string) => void
setFilterType: (value: string) => void
setFilterQuality: (value: string) => void
setViewMode: (mode: ViewMode) => void
}
/**
* 素材筛选 Hook —— 搜索 + 类型 + 质量分 + 视图切换
*/
const useAssetFilter = (assets: MediaAsset[]): UseAssetFilterReturn => {
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState("")
const [filterQuality, setFilterQuality] = useState("")
const [viewMode, setViewMode] = useState<ViewMode>("grid")
const filteredAssets = useMemo(() => {
let list = assets
if (searchText) {
const q = searchText.toLowerCase()
list = list.filter(
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
)
}
if (filterType) {
list = list.filter((a) => a.type === filterType)
}
if (filterQuality) {
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
if (opt?.min != null && opt?.max != null) {
list = list.filter(
(a) =>
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
)
}
}
return list
}, [assets, searchText, filterType, filterQuality])
return {
searchText,
filterType,
filterQuality,
viewMode,
filteredAssets,
setSearchText,
setFilterType,
setFilterQuality,
setViewMode,
}
}
export default useAssetFilter
@@ -0,0 +1,54 @@
import { useState, useRef, useCallback, useEffect } from "react"
import type { MediaAsset } from "@/api/template-editor"
interface UseAssetPreviewReturn {
previewAsset: MediaAsset | null
previewPos: { x: number; y: number }
handleMouseEnter: (asset: MediaAsset, e: React.MouseEvent) => void
handleMouseLeave: () => void
}
/**
* 悬浮预览 Hook —— 延迟 400ms 显示预览浮层
*/
const useAssetPreview = (): UseAssetPreviewReturn => {
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
if (previewTimer.current) clearTimeout(previewTimer.current)
previewTimer.current = setTimeout(() => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
setPreviewAsset(asset)
setPreviewPos({
x: rect.right + 12,
y: Math.max(8, rect.top - 20),
})
}, 400)
}, [])
const handleMouseLeave = useCallback(() => {
if (previewTimer.current) {
clearTimeout(previewTimer.current)
previewTimer.current = null
}
setPreviewAsset(null)
}, [])
// 清理定时器
useEffect(() => {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current)
}
}, [])
return {
previewAsset,
previewPos,
handleMouseEnter,
handleMouseLeave,
}
}
export default useAssetPreview
@@ -0,0 +1,65 @@
import { useCallback, useRef, useMemo } from "react"
import type { MediaAsset } from "@/api/template-editor"
interface UseAssetSelectionOptions {
filteredAssets: MediaAsset[]
selectedIds: string[]
onSelectionChange?: (ids: string[]) => void
}
interface UseAssetSelectionReturn {
selectedSet: Set<string>
toggleSelect: (asset: MediaAsset, idx: number, shiftKey: boolean) => void
clearSelection: () => void
}
/**
* 素材选择 Hook —— 单选/多选/Shift 连选
*/
const useAssetSelection = ({
filteredAssets,
selectedIds,
onSelectionChange,
}: UseAssetSelectionOptions): UseAssetSelectionReturn => {
const lastClickedIdx = useRef<number | null>(null)
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const toggleSelect = useCallback(
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
if (!onSelectionChange) return
if (shiftKey && lastClickedIdx.current !== null) {
// Shift 连选
const start = Math.min(lastClickedIdx.current, idx)
const end = Math.max(lastClickedIdx.current, idx)
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
const newSet = new Set(selectedIds)
rangeIds.forEach((id) => newSet.add(id))
onSelectionChange(Array.from(newSet))
} else {
const newSet = new Set(selectedIds)
if (newSet.has(asset.id)) {
newSet.delete(asset.id)
} else {
newSet.add(asset.id)
}
onSelectionChange(Array.from(newSet))
}
lastClickedIdx.current = idx
},
[onSelectionChange, selectedIds, filteredAssets],
)
const clearSelection = useCallback(() => {
onSelectionChange?.([])
}, [onSelectionChange])
return {
selectedSet,
toggleSelect,
clearSelection,
}
}
export default useAssetSelection
@@ -0,0 +1,89 @@
import { useState, useCallback } from "react"
import type { MediaAsset } from "@/api/template-editor"
interface UseDragReorderOptions {
filteredAssets: MediaAsset[]
selectedIds: string[]
onAssetDragStart?: (asset: MediaAsset) => void
onReorder?: (fromIdx: number, toIdx: number) => void
}
interface UseDragReorderReturn {
dragIdx: number | null
dragOverIdx: number | null
handleDragStart: (e: React.DragEvent, idx: number) => void
handleDragOver: (e: React.DragEvent, idx: number) => void
handleDrop: (e: React.DragEvent, toIdx: number) => void
handleDragEnd: () => void
}
/**
* 拖拽排序 Hook —— HTML5 DnD,支持批量拖拽携带数据
*/
const useDragReorder = ({
filteredAssets,
selectedIds,
onAssetDragStart,
onReorder,
}: UseDragReorderOptions): UseDragReorderReturn => {
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
setDragIdx(idx)
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", String(idx))
// 设置素材数据,供 TimelinePanel 接收
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
// 批量拖拽:如果有多个选中素材,一起携带
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
}
// 通知父组件素材拖拽开始
if (onAssetDragStart) {
onAssetDragStart(filteredAssets[idx])
}
},
[filteredAssets, selectedIds, onAssetDragStart],
)
const handleDragOver = useCallback(
(e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
if (dragIdx === null || dragIdx === idx) return
setDragOverIdx(idx)
},
[dragIdx],
)
const handleDrop = useCallback(
(e: React.DragEvent, toIdx: number) => {
e.preventDefault()
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
onReorder(dragIdx, toIdx)
}
setDragIdx(null)
setDragOverIdx(null)
},
[dragIdx, onReorder],
)
const handleDragEnd = useCallback(() => {
setDragIdx(null)
setDragOverIdx(null)
}, [])
return {
dragIdx,
dragOverIdx,
handleDragStart,
handleDragOver,
handleDrop,
handleDragEnd,
}
}
export default useDragReorder
@@ -1,2 +1,2 @@
export { default as AssetSelector } from "./AssetSelector"
export type { AssetSelectorProps } from "./AssetSelector"
export type { AssetSelectorProps, ViewMode } from "./types"
@@ -0,0 +1,14 @@
import type { MediaAsset } from "@/api/template-editor"
export interface AssetSelectorProps {
assets: MediaAsset[]
selectedIds?: string[]
onSelectionChange?: (ids: string[]) => void
onAssetDragStart?: (asset: MediaAsset) => void
onReorder?: (fromIdx: number, toIdx: number) => void
showQualityFilter?: boolean
showBatchSelect?: boolean
compact?: boolean
}
export type ViewMode = "grid" | "list"
@@ -0,0 +1,33 @@
/** 格式化文件大小 */
export const formatSize = (bytes?: number): string => {
if (!bytes) return ""
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 formatDuration = (seconds?: number): string => {
if (!seconds) return ""
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
}
/** 获取质量分等级 */
export const getQualityLevel = (score?: number): string => {
if (score == null) return "none"
if (score >= 90) return "excellent"
if (score >= 70) return "good"
if (score >= 50) return "fair"
return "poor"
}
/** 质量分颜色 */
export const getQualityColor = (score?: number): string => {
if (score == null) return "var(--text-secondary)"
if (score >= 90) return "var(--success-color, #10b981)"
if (score >= 70) return "var(--primary-color, #6366f1)"
if (score >= 50) return "var(--warning-color, #f59e0b)"
return "var(--error-color, #ef4444)"
}
+47 -567
View File
@@ -10,357 +10,35 @@
*
* V21 Design System — 零 antd 直接导入
*/
import React, { useState, useCallback, useRef, useEffect } from "react"
import { Modal, Button } from "@/components/ui"
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import type { VoiceClone } from "@/api/voice-clone"
import { uploadAsset } from "@/api/assets"
import React from "react"
import { Modal } from "@/components/ui"
import type { CloneModalProps } from "./types/cloneModal"
import useCloneModal from "./hooks/useCloneModal"
import InputView from "./clone-modal/InputView"
import ProgressView from "./clone-modal/ProgressView"
import "./clone-modal.css"
/* ── 类型定义 ───────────────────────────────────────────── */
type ModalPhase = "input" | "uploading" | "cloning" | "done"
export interface CloneModalProps {
/** 弹窗是否可见 */
open: boolean
/** 关闭弹窗回调 */
onClose: () => void
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void
}
/* ── 进度阶段配置 ─────────────────────────────────────────── */
const PROGRESS_STEPS: { key: string; label: string; icon: string }[] = [
{ key: "uploading", label: "上传中", icon: "📤" },
{ key: "cloning", label: "克隆中", icon: "🧬" },
{ key: "done", label: "完成", icon: "✅" },
]
/* ── 常量 ───────────────────────────────────────────────── */
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
/** 最长录制时长:5 分钟(秒) */
const MAX_RECORD_SECONDS = 5 * 60
/* ── 组件 ───────────────────────────────────────────────── */
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
const [phase, setPhase] = useState<ModalPhase>("input")
const [voiceName, setVoiceName] = useState("")
const [voiceDescription, setVoiceDescription] = useState("")
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dragActive, setDragActive] = useState(false)
const [errorMessage, setErrorMessage] = useState("")
// 录音状态
const [isRecording, setIsRecording] = useState(false)
const [recordTime, setRecordTime] = useState(0)
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioChunksRef = useRef<Blob[]>([])
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
const cloneCounterRef = useRef(1)
/** 生成下一个默认音色名称 */
const getNextDefaultName = useCallback((): string => {
const name = `我的声音 ${cloneCounterRef.current}`
cloneCounterRef.current += 1
return name
}, [])
/** 组件卸载时清理定时器和 MediaRecorder */
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
}
}, [])
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input")
setVoiceName(getNextDefaultName())
setVoiceDescription("")
setSelectedFile(null)
setDragActive(false)
setErrorMessage("")
setIsRecording(false)
setRecordTime(0)
setRecordedBlob(null)
audioChunksRef.current = []
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
mediaRecorderRef.current = null
}, [getNextDefaultName])
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState()
onClose()
}, [resetState, onClose])
/** 弹窗打开时重置状态 */
useEffect(() => {
if (open) {
resetState()
}
}, [open, resetState])
/* ── 文件验证 ──────────────────────────────────────── */
const validateFile = (file: File): string | null => {
const ext = file.name.split(".").pop()?.toLowerCase()
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
}
if (file.size > MAX_FILE_SIZE) {
return "文件大小超过 10MB,请压缩后重试"
}
return null
}
/* ── 文件上传 ──────────────────────────────────────── */
const handleUploadClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const error = validateFile(file)
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
setErrorMessage("")
setSelectedFile(file)
// 清除录音
setRecordedBlob(null)
setIsRecording(false)
setRecordTime(0)
}
}
e.target.value = ""
}
/* ── 拖拽 ──────────────────────────────────────────── */
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true)
} else if (e.type === "dragleave") {
setDragActive(false)
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
const file = e.dataTransfer.files?.[0]
if (file) {
const error = validateFile(file)
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
setErrorMessage("")
setSelectedFile(file)
setRecordedBlob(null)
setIsRecording(false)
setRecordTime(0)
}
}
}
/* ── 录音(真实 MediaRecorder) ───────────────────── */
const handleRecord = async () => {
if (isRecording) {
// 停止录制
setIsRecording(false)
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
} else {
// 开始录制
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
})
const mediaRecorder = new MediaRecorder(stream)
mediaRecorderRef.current = mediaRecorder
audioChunksRef.current = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data)
}
}
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
setRecordedBlob(blob)
// 清除上传的文件
setSelectedFile(null)
// 停止音轨
stream.getTracks().forEach((track) => track.stop())
}
mediaRecorder.start()
setIsRecording(true)
setRecordTime(0)
setRecordedBlob(null)
setErrorMessage("")
recordTimerRef.current = setInterval(() => {
setRecordTime((prev) => {
const next = prev + 1
if (next >= MAX_RECORD_SECONDS) {
// 达到 5 分钟上限,自动停止录制
setTimeout(() => {
setIsRecording(false)
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
setErrorMessage("已达最长录制时长(5分钟),已自动停止")
}, 0)
return MAX_RECORD_SECONDS
}
return next
})
}, 1000)
} catch {
setErrorMessage("无法访问麦克风,请检查浏览器权限设置")
}
}
}
/** 格式化录制时间 mm:ss */
const formatRecordTime = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
}
/* ── 表单验证 ──────────────────────────────────────── */
const hasAudio = selectedFile !== null || recordedBlob !== null
const validateForm = (): string | null => {
const name = voiceName.trim()
if (!name) {
return "请输入音色名称"
}
if (name.length < 2 || name.length > 20) {
return "音色名称需在 2-20 个字符之间"
}
if (!hasAudio) {
return "请上传音频文件或录制一段声音"
}
return null
}
/* ── 提交克隆 ──────────────────────────────────────── */
const handleSubmit = async () => {
const formError = validateForm()
if (formError) {
setErrorMessage(formError)
return
}
setErrorMessage("")
try {
// 阶段 1:上传音频
setPhase("uploading")
let fileToUpload: File
if (selectedFile) {
fileToUpload = selectedFile
} else {
// 将录音 Blob 转为 File
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
type: "audio/webm",
})
}
const formData = new FormData()
formData.append("file", fileToUpload)
const uploadResult = await uploadAsset(formData)
// 阶段 2:克隆
setPhase("cloning")
const result = await createVoiceClone({
name: voiceName.trim(),
description: voiceDescription.trim() || undefined,
audio_url: uploadResult.url,
})
// 阶段 3:完成
setPhase("done")
// 2秒后自动关闭
timerRef.current = setTimeout(() => {
onSuccess?.(toVoiceClone(result))
handleClose()
}, 2000)
} catch (err) {
setPhase("input")
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
}
}
/* ── 计算属性 ──────────────────────────────────────── */
const canSubmit = voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio
const isProcessing = phase === "uploading" || phase === "cloning"
/** 当前进度索引 */
const getProgressIndex = (): number => {
switch (phase) {
case "uploading":
return 0
case "cloning":
return 1
case "done":
return 2
default:
return -1
}
}
const progressIndex = getProgressIndex()
const {
phase,
voiceName,
voiceDescription,
selectedFile,
dragActive,
errorMessage,
isRecording,
recordTime,
recordedBlob,
canSubmit,
isProcessing,
setVoiceName,
setVoiceDescription,
setDragActive,
handleFileSelect,
handleRecordToggle,
handleClose,
handleSubmit,
} = useCloneModal({ open, onClose, onSuccess })
return (
<Modal
@@ -373,228 +51,30 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
maskClosable={!isProcessing}
keyboard={!isProcessing}
>
{/* ── 输入阶段 ──────────────────────────────────── */}
{/* 输入阶段 */}
{phase === "input" && (
<div className="xx-clonemodal-body">
{/* 步骤引导 */}
<div className="xx-clonemodal-steps">
<div className="xx-clonemodal-step xx-clonemodal-step--active">
<div className="xx-clonemodal-step-number">1</div>
<span className="xx-clonemodal-step-label">/</span>
</div>
<div className="xx-clonemodal-step-connector" />
<div className="xx-clonemodal-step">
<div className="xx-clonemodal-step-number">2</div>
<span className="xx-clonemodal-step-label"></span>
</div>
<div className="xx-clonemodal-step-connector" />
<div className="xx-clonemodal-step">
<div className="xx-clonemodal-step-number">3</div>
<span className="xx-clonemodal-step-label"></span>
</div>
</div>
{/* 音色名称 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label">
<span className="xx-clonemodal-required">*</span>
</label>
<input
type="text"
className="xx-clonemodal-input"
value={voiceName}
onChange={(e) => setVoiceName(e.target.value)}
placeholder="输入音色名称(2-20字符)"
maxLength={20}
/>
<div className="xx-clonemodal-char-count">{voiceName.length}/20</div>
</div>
{/* 上传区域 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<div
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
<p className="xx-clonemodal-upload-title">
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
</p>
<p className="xx-clonemodal-upload-hint"> MP3WAVM4A 10MB</p>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_MIME}
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
{/* 或分隔 */}
<div className="xx-clonemodal-divider">
<div className="xx-clonemodal-divider-line" />
<span className="xx-clonemodal-divider-text"></span>
<div className="xx-clonemodal-divider-line" />
</div>
{/* 录制区域 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<div className="xx-clonemodal-record-area">
<div className="xx-clonemodal-record-info">
<p className="xx-clonemodal-record-hint">
{isRecording
? `录制中 ${formatRecordTime(recordTime)}`
: recordedBlob
? `已录制 ${formatRecordTime(recordTime)}`
: "点击按钮开始录制(最长 5 分钟)"}
</p>
{isRecording && (
<div className="xx-clonemodal-record-wave">
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
</div>
)}
</div>
<button
type="button"
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
onClick={handleRecord}
title={isRecording ? "停止录制" : "开始录制"}
>
{isRecording ? "⏹" : "🎙️"}
</button>
</div>
</div>
{/* 音色描述 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<textarea
className="xx-clonemodal-textarea"
value={voiceDescription}
onChange={(e) => setVoiceDescription(e.target.value)}
placeholder="可选,描述这个音色的特点(最多100字符)"
maxLength={100}
rows={3}
/>
<div className="xx-clonemodal-char-count">{voiceDescription.length}/100</div>
</div>
{/* 错误提示 */}
{errorMessage && (
<div className="xx-clonemodal-error">
<span className="xx-clonemodal-error-icon"></span>
<span>{errorMessage}</span>
</div>
)}
{/* 提示 */}
<div className="xx-clonemodal-tip">
<span className="xx-clonemodal-tip-icon">💡</span>
<span> 10 ~ 3 </span>
</div>
{/* 底部按钮 */}
<div className="xx-clonemodal-footer">
<Button buttonType="ghost" onClick={handleClose}>
</Button>
<Button buttonType="primary" disabled={!canSubmit} onClick={handleSubmit}>
🎤
</Button>
</div>
</div>
<InputView
voiceName={voiceName}
voiceDescription={voiceDescription}
selectedFile={selectedFile}
dragActive={dragActive}
isRecording={isRecording}
recordTime={recordTime}
recordedBlob={recordedBlob}
errorMessage={errorMessage}
canSubmit={canSubmit}
onVoiceNameChange={setVoiceName}
onVoiceDescChange={setVoiceDescription}
onDragActiveChange={setDragActive}
onFileSelect={handleFileSelect}
onRecordToggle={handleRecordToggle}
onClose={handleClose}
onSubmit={handleSubmit}
/>
)}
{/* ── 进度阶段(上传中 / 克隆中) ──────────────── */}
{isProcessing && (
<div className="xx-clonemodal-progress-body">
{/* 步骤指示器 */}
<div className="xx-clonemodal-steps-progress">
{PROGRESS_STEPS.map((step, idx) => {
const isActive = idx === progressIndex
const isDone = idx < progressIndex
const stepClass = [
"xx-clonemodal-step-progress",
isActive ? "xx-clonemodal-step-progress--active" : "",
isDone ? "xx-clonemodal-step-progress--done" : "",
]
.filter(Boolean)
.join(" ")
return (
<React.Fragment key={step.key}>
{idx > 0 && (
<div
className={`xx-clonemodal-step-connector${isDone ? " xx-clonemodal-step-connector--done" : ""}`}
/>
)}
<div className={stepClass}>
<div className="xx-clonemodal-step-icon">{isDone ? "✓" : step.icon}</div>
<span className="xx-clonemodal-step-label">{step.label}</span>
</div>
</React.Fragment>
)
})}
</div>
{/* 当前阶段描述 */}
<div className="xx-clonemodal-progress-info">
{phase === "uploading" && (
<>
<div className="xx-clonemodal-progress-spinner" />
<p className="xx-clonemodal-progress-text"></p>
<p className="xx-clonemodal-progress-sub"></p>
</>
)}
{phase === "cloning" && (
<>
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
<p className="xx-clonemodal-progress-text">AI </p>
<p className="xx-clonemodal-progress-sub"></p>
</>
)}
</div>
</div>
)}
{/* ── 完成阶段 ──────────────────────────────────── */}
{phase === "done" && (
<div className="xx-clonemodal-progress-body">
{/* 步骤指示器(全部完成) */}
<div className="xx-clonemodal-steps-progress">
{PROGRESS_STEPS.map((step, idx) => (
<React.Fragment key={step.key}>
{idx > 0 && (
<div className="xx-clonemodal-step-connector xx-clonemodal-step-connector--done" />
)}
<div className="xx-clonemodal-step-progress xx-clonemodal-step-progress--done">
<div className="xx-clonemodal-step-icon"></div>
<span className="xx-clonemodal-step-label">{step.label}</span>
</div>
</React.Fragment>
))}
</div>
<div className="xx-clonemodal-success">
<div className="xx-clonemodal-success-icon">🎉</div>
<h3 className="xx-clonemodal-success-title"></h3>
<p className="xx-clonemodal-success-desc">
</p>
</div>
</div>
)}
{/* 进度 / 完成阶段 */}
{phase !== "input" && <ProgressView phase={phase} />}
</Modal>
)
}
@@ -0,0 +1,142 @@
import React from "react"
import { Button } from "@/components/ui"
import UploadZone from "./UploadZone"
import RecordArea from "./RecordArea"
import StepIndicator from "./StepIndicator"
import { MAX_VOICE_NAME_LENGTH, MAX_VOICE_DESC_LENGTH } from "../constants/cloneModal"
interface InputViewProps {
voiceName: string
voiceDescription: string
selectedFile: File | null
dragActive: boolean
isRecording: boolean
recordTime: number
recordedBlob: Blob | null
errorMessage: string
canSubmit: boolean
onVoiceNameChange: (value: string) => void
onVoiceDescChange: (value: string) => void
onDragActiveChange: (active: boolean) => void
onFileSelect: (file: File | null, error: string) => void
onRecordToggle: () => void
onClose: () => void
onSubmit: () => void
}
const INPUT_STEPS = ["上传/录制音频", "填写信息", "提交克隆"]
const InputView: React.FC<InputViewProps> = ({
voiceName,
voiceDescription,
selectedFile,
dragActive,
isRecording,
recordTime,
recordedBlob,
errorMessage,
canSubmit,
onVoiceNameChange,
onVoiceDescChange,
onDragActiveChange,
onFileSelect,
onRecordToggle,
onClose,
onSubmit,
}) => {
return (
<div className="xx-clonemodal-body">
{/* 步骤引导 */}
<StepIndicator currentStep={0} steps={INPUT_STEPS} />
{/* 音色名称 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label">
<span className="xx-clonemodal-required">*</span>
</label>
<input
type="text"
className="xx-clonemodal-input"
value={voiceName}
onChange={(e) => onVoiceNameChange(e.target.value)}
placeholder="输入音色名称(2-20字符)"
maxLength={MAX_VOICE_NAME_LENGTH}
/>
<div className="xx-clonemodal-char-count">
{voiceName.length}/{MAX_VOICE_NAME_LENGTH}
</div>
</div>
{/* 上传区域 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<UploadZone
selectedFile={selectedFile}
dragActive={dragActive}
onDragActiveChange={onDragActiveChange}
onFileSelect={onFileSelect}
/>
</div>
{/* 或分隔 */}
<div className="xx-clonemodal-divider">
<div className="xx-clonemodal-divider-line" />
<span className="xx-clonemodal-divider-text"></span>
<div className="xx-clonemodal-divider-line" />
</div>
{/* 录制区域 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<RecordArea
isRecording={isRecording}
recordTime={recordTime}
recordedBlob={recordedBlob}
onRecordToggle={onRecordToggle}
/>
</div>
{/* 音色描述 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<textarea
className="xx-clonemodal-textarea"
value={voiceDescription}
onChange={(e) => onVoiceDescChange(e.target.value)}
placeholder="可选,描述这个音色的特点(最多100字符)"
maxLength={MAX_VOICE_DESC_LENGTH}
rows={3}
/>
<div className="xx-clonemodal-char-count">
{voiceDescription.length}/{MAX_VOICE_DESC_LENGTH}
</div>
</div>
{/* 错误提示 */}
{errorMessage && (
<div className="xx-clonemodal-error">
<span className="xx-clonemodal-error-icon"></span>
<span>{errorMessage}</span>
</div>
)}
{/* 提示 */}
<div className="xx-clonemodal-tip">
<span className="xx-clonemodal-tip-icon">💡</span>
<span> 10 ~ 3 </span>
</div>
{/* 底部按钮 */}
<div className="xx-clonemodal-footer">
<Button buttonType="ghost" onClick={onClose}>
</Button>
<Button buttonType="primary" disabled={!canSubmit} onClick={onSubmit}>
🎤
</Button>
</div>
</div>
)
}
export default InputView
@@ -0,0 +1,92 @@
import React from "react"
import { PROGRESS_STEPS } from "../constants/cloneModal"
import type { ProgressStep } from "../types/cloneModal"
import type { ModalPhase } from "../types/cloneModal"
interface ProgressViewProps {
phase: ModalPhase
}
const getProgressIndex = (phase: ModalPhase): number => {
switch (phase) {
case "uploading":
return 0
case "cloning":
return 1
case "done":
return 2
default:
return -1
}
}
const ProgressView: React.FC<ProgressViewProps> = ({ phase }) => {
const progressIndex = getProgressIndex(phase)
const isDone = phase === "done"
return (
<div className="xx-clonemodal-progress-body">
{/* 步骤指示器 */}
<div className="xx-clonemodal-steps-progress">
{PROGRESS_STEPS.map((step: ProgressStep, idx: number) => {
const isActive = idx === progressIndex && !isDone
const stepDone = idx < progressIndex || isDone
const stepClass = [
"xx-clonemodal-step-progress",
isActive ? "xx-clonemodal-step-progress--active" : "",
stepDone ? "xx-clonemodal-step-progress--done" : "",
]
.filter(Boolean)
.join(" ")
return (
<React.Fragment key={step.key}>
{idx > 0 && (
<div
className={`xx-clonemodal-step-connector${stepDone ? " xx-clonemodal-step-connector--done" : ""}`}
/>
)}
<div className={stepClass}>
<div className="xx-clonemodal-step-icon">{stepDone ? "✓" : step.icon}</div>
<span className="xx-clonemodal-step-label">{step.label}</span>
</div>
</React.Fragment>
)
})}
</div>
{/* 完成阶段 */}
{isDone && (
<div className="xx-clonemodal-success">
<div className="xx-clonemodal-success-icon">🎉</div>
<h3 className="xx-clonemodal-success-title"></h3>
<p className="xx-clonemodal-success-desc">
</p>
</div>
)}
{/* 进行中阶段 */}
{!isDone && (
<div className="xx-clonemodal-progress-info">
{phase === "uploading" && (
<>
<div className="xx-clonemodal-progress-spinner" />
<p className="xx-clonemodal-progress-text"></p>
<p className="xx-clonemodal-progress-sub"></p>
</>
)}
{phase === "cloning" && (
<>
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
<p className="xx-clonemodal-progress-text">AI </p>
<p className="xx-clonemodal-progress-sub"></p>
</>
)}
</div>
)}
</div>
)
}
export default ProgressView
@@ -0,0 +1,49 @@
import React from "react"
import { formatRecordTime } from "../utils/cloneModal"
interface RecordAreaProps {
isRecording: boolean
recordTime: number
recordedBlob: Blob | null
onRecordToggle: () => void
}
const RecordArea: React.FC<RecordAreaProps> = ({
isRecording,
recordTime,
recordedBlob,
onRecordToggle,
}) => {
const getHintText = () => {
if (isRecording) return `录制中 ${formatRecordTime(recordTime)}`
if (recordedBlob) return `已录制 ${formatRecordTime(recordTime)}`
return "点击按钮开始录制(最长 5 分钟)"
}
return (
<div className="xx-clonemodal-record-area">
<div className="xx-clonemodal-record-info">
<p className="xx-clonemodal-record-hint">{getHintText()}</p>
{isRecording && (
<div className="xx-clonemodal-record-wave">
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
</div>
)}
</div>
<button
type="button"
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
onClick={onRecordToggle}
title={isRecording ? "停止录制" : "开始录制"}
>
{isRecording ? "⏹" : "🎙️"}
</button>
</div>
)
}
export default RecordArea
@@ -0,0 +1,30 @@
import React from "react"
interface StepIndicatorProps {
currentStep: number
steps: string[]
}
/**
* 输入阶段顶部的步骤引导(数字步骤)
*/
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, steps }) => {
return (
<div className="xx-clonemodal-steps">
{steps.map((label, idx) => {
const isActive = idx <= currentStep
return (
<React.Fragment key={idx}>
{idx > 0 && <div className="xx-clonemodal-step-connector" />}
<div className={`xx-clonemodal-step${isActive ? " xx-clonemodal-step--active" : ""}`}>
<div className="xx-clonemodal-step-number">{idx + 1}</div>
<span className="xx-clonemodal-step-label">{label}</span>
</div>
</React.Fragment>
)
})}
</div>
)
}
export default StepIndicator
@@ -0,0 +1,79 @@
import React, { useRef } from "react"
import { ACCEPTED_MIME } from "../constants/cloneModal"
import { validateFile } from "../utils/cloneModal"
interface UploadZoneProps {
selectedFile: File | null
dragActive: boolean
onDragActiveChange: (active: boolean) => void
onFileSelect: (file: File | null, error: string) => void
}
const UploadZone: React.FC<UploadZoneProps> = ({
selectedFile,
dragActive,
onDragActiveChange,
onFileSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const error = validateFile(file)
onFileSelect(error ? null : file, error || "")
}
e.target.value = ""
}
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.type === "dragenter" || e.type === "dragover") {
onDragActiveChange(true)
} else if (e.type === "dragleave") {
onDragActiveChange(false)
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
onDragActiveChange(false)
const file = e.dataTransfer.files?.[0]
if (file) {
const error = validateFile(file)
onFileSelect(error ? null : file, error || "")
}
}
return (
<div
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
<p className="xx-clonemodal-upload-title">
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
</p>
<p className="xx-clonemodal-upload-hint"> MP3WAVM4A 10MB</p>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_MIME}
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
)
}
export default UploadZone
@@ -0,0 +1,29 @@
import type { ProgressStep } from "../types/cloneModal"
/** 进度阶段配置 */
export const PROGRESS_STEPS: ProgressStep[] = [
{ key: "uploading", label: "上传中", icon: "📤" },
{ key: "cloning", label: "克隆中", icon: "🧬" },
{ key: "done", label: "完成", icon: "✅" },
]
/** 支持的音频扩展名 */
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
/** input accept 属性值 */
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
/** 最大文件大小:10MB */
export const MAX_FILE_SIZE = 10 * 1024 * 1024
/** 最长录制时长(秒):5 分钟 */
export const MAX_RECORD_SECONDS = 5 * 60
/** 音色名称最小长度 */
export const MIN_VOICE_NAME_LENGTH = 2
/** 音色名称最大长度 */
export const MAX_VOICE_NAME_LENGTH = 20
/** 音色描述最大长度 */
export const MAX_VOICE_DESC_LENGTH = 100
@@ -0,0 +1,119 @@
import { useState, useRef, useCallback, useEffect } from "react"
import { MAX_RECORD_SECONDS } from "../constants/cloneModal"
interface UseAudioRecorderReturn {
isRecording: boolean
recordTime: number
recordedBlob: Blob | null
toggleRecording: () => void
resetRecording: () => void
}
/**
* 录音 Hook —— 封装 MediaRecorder 录音逻辑
*/
const useAudioRecorder = (): UseAudioRecorderReturn => {
const [isRecording, setIsRecording] = useState(false)
const [recordTime, setRecordTime] = useState(0)
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioChunksRef = useRef<Blob[]>([])
const stopRecording = useCallback(() => {
setIsRecording(false)
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
}, [])
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const mediaRecorder = new MediaRecorder(stream)
mediaRecorderRef.current = mediaRecorder
audioChunksRef.current = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data)
}
}
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
setRecordedBlob(blob)
stream.getTracks().forEach((track) => track.stop())
}
mediaRecorder.start()
setIsRecording(true)
setRecordTime(0)
setRecordedBlob(null)
recordTimerRef.current = setInterval(() => {
setRecordTime((prev) => {
const next = prev + 1
if (next >= MAX_RECORD_SECONDS) {
setTimeout(() => {
stopRecording()
}, 0)
return MAX_RECORD_SECONDS
}
return next
})
}, 1000)
} catch {
// 错误由调用方通过其他机制提示
setIsRecording(false)
}
}, [stopRecording])
const toggleRecording = useCallback(() => {
if (isRecording) {
stopRecording()
} else {
startRecording()
}
}, [isRecording, startRecording, stopRecording])
const resetRecording = useCallback(() => {
setIsRecording(false)
setRecordTime(0)
setRecordedBlob(null)
audioChunksRef.current = []
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
mediaRecorderRef.current = null
}, [])
// 卸载时清理
useEffect(() => {
return () => {
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
}
}, [])
return {
isRecording,
recordTime,
recordedBlob,
toggleRecording,
resetRecording,
}
}
export default useAudioRecorder
@@ -0,0 +1,215 @@
import { useState, useRef, useCallback, useEffect } from "react"
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import { uploadAsset } from "@/api/assets"
import type { ModalPhase, CloneModalProps } from "../types/cloneModal"
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
import useAudioRecorder from "./useAudioRecorder"
interface UseCloneModalReturn {
phase: ModalPhase
voiceName: string
voiceDescription: string
selectedFile: File | null
dragActive: boolean
errorMessage: string
isRecording: boolean
recordTime: number
recordedBlob: Blob | null
canSubmit: boolean
isProcessing: boolean
setVoiceName: (value: string) => void
setVoiceDescription: (value: string) => void
setDragActive: (active: boolean) => void
handleFileSelect: (file: File | null, error: string) => void
handleRecordToggle: () => void
handleClose: () => void
handleSubmit: () => void
}
/**
* 音色克隆弹窗主业务 Hook
*/
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps): UseCloneModalReturn => {
const [phase, setPhase] = useState<ModalPhase>("input")
const [voiceName, setVoiceName] = useState("")
const [voiceDescription, setVoiceDescription] = useState("")
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dragActive, setDragActive] = useState(false)
const [errorMessage, setErrorMessage] = useState("")
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
useAudioRecorder()
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** 默认音色名称计数器 */
const cloneCounterRef = useRef(1)
const getNextDefaultName = useCallback((): string => {
const name = `我的声音 ${cloneCounterRef.current}`
cloneCounterRef.current += 1
return name
}, [])
const hasAudio = selectedFile !== null || recordedBlob !== null
const canSubmit =
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
hasAudio
const isProcessing = phase === "uploading" || phase === "cloning"
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input")
setVoiceName(getNextDefaultName())
setVoiceDescription("")
setSelectedFile(null)
setDragActive(false)
setErrorMessage("")
resetRecording()
}, [getNextDefaultName, resetRecording])
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState()
onClose()
}, [resetState, onClose])
/** 弹窗打开时重置状态 */
useEffect(() => {
if (open) {
resetState()
}
}, [open, resetState])
/** 组件卸载时清理定时器 */
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
/** 选择文件(来自上传或拖拽) */
const handleFileSelect = useCallback(
(file: File | null, error: string) => {
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
setErrorMessage("")
setSelectedFile(file)
// 清除录音
resetRecording()
}
},
[resetRecording],
)
/** 录音切换 */
const handleRecordToggle = useCallback(() => {
setErrorMessage("")
if (isRecording) {
toggleRecording()
} else {
// 开始录制前清除已选文件
setSelectedFile(null)
toggleRecording()
}
}, [isRecording, toggleRecording])
/** 表单验证 */
const validateForm = useCallback((): string | null => {
const name = voiceName.trim()
if (!name) {
return "请输入音色名称"
}
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
}
if (!hasAudio) {
return "请上传音频文件或录制一段声音"
}
return null
}, [voiceName, hasAudio])
/** 提交克隆 */
const handleSubmit = useCallback(async () => {
const formError = validateForm()
if (formError) {
setErrorMessage(formError)
return
}
setErrorMessage("")
try {
// 阶段 1:上传音频
setPhase("uploading")
let fileToUpload: File
if (selectedFile) {
fileToUpload = selectedFile
} else {
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
type: "audio/webm",
})
}
const formData = new FormData()
formData.append("file", fileToUpload)
const uploadResult = await uploadAsset(formData)
// 阶段 2:克隆
setPhase("cloning")
const result = await createVoiceClone({
name: voiceName.trim(),
description: voiceDescription.trim() || undefined,
audio_url: uploadResult.url,
})
// 阶段 3:完成
setPhase("done")
// 2秒后自动关闭
timerRef.current = setTimeout(() => {
onSuccess?.(toVoiceClone(result))
handleClose()
}, 2000)
} catch (err) {
setPhase("input")
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
}
}, [
validateForm,
selectedFile,
recordedBlob,
voiceName,
voiceDescription,
onSuccess,
handleClose,
])
return {
phase,
voiceName,
voiceDescription,
selectedFile,
dragActive,
errorMessage,
isRecording,
recordTime,
recordedBlob,
canSubmit,
isProcessing,
setVoiceName,
setVoiceDescription,
setDragActive,
handleFileSelect,
handleRecordToggle,
handleClose,
handleSubmit,
}
}
export default useCloneModal
@@ -0,0 +1,20 @@
import type { VoiceClone } from "@/api/voice-clone"
/** 弹窗阶段 */
export type ModalPhase = "input" | "uploading" | "cloning" | "done"
export interface CloneModalProps {
/** 弹窗是否可见 */
open: boolean
/** 关闭弹窗回调 */
onClose: () => void
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void
}
/** 进度步骤项 */
export interface ProgressStep {
key: string
label: string
icon: string
}
@@ -0,0 +1,25 @@
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "../constants/cloneModal"
/**
* 格式化录制时间 mm:ss
*/
export const formatRecordTime = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
}
/**
* 验证上传的音频文件
* @returns 错误信息,null 表示验证通过
*/
export const validateFile = (file: File): string | null => {
const ext = file.name.split(".").pop()?.toLowerCase()
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
}
if (file.size > MAX_FILE_SIZE) {
return "文件大小超过 10MB,请压缩后重试"
}
return null
}
@@ -11,10 +11,7 @@ export interface AssetUploadZoneProps {
onUpload: (file: File) => void
}
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
uploading,
onUpload,
}) => {
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onUpload }) => {
return (
<Upload.Dragger
beforeUpload={(file) => {
@@ -0,0 +1,238 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import {
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type BatchOperationResult,
} from "@/api/assets"
import type { SmartViewType } from "../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "./constants"
/* ── 批量删除 ── */
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
/* ── 批量打标签 ── */
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
/* ── 批量改分类 ── */
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
/* ── 批量智能标记 ── */
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
@@ -0,0 +1,7 @@
import type { SmartViewType } from "../../components/BatchMarkModal"
export const SMART_VIEW_LABELS: Record<SmartViewType, string> = {
recommended: "推荐",
caution: "慎用",
high_risk: "高风险",
}
@@ -0,0 +1,103 @@
import { useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { getAssetDiagnosis, deleteAsset, type BatchOperationResult } from "@/api/assets"
import type { AssetItem } from "../../types"
interface UseSingleOperationsOptions {
selectedIds: Set<string>
setSelectedIds: (ids: Set<string>) => void
}
/**
* 单个素材操作 Hook
* 诊断、单个删除
*/
export const useSingleOperations = ({
selectedIds,
setSelectedIds,
}: UseSingleOperationsOptions) => {
const queryClient = useQueryClient()
/* ── 诊断 ── */
const handleDiagnose = useCallback(
async (asset: AssetItem) => {
// 模拟 loading 状态
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}" 诊断失败`)
}
},
[queryClient],
)
/* ── 单个素材删除 ── */
const handleSingleDelete = useCallback(
async (assetId: string) => {
try {
await deleteAsset(assetId)
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
// 从选中集合中移除
setSelectedIds(
(() => {
const next = new Set(selectedIds)
next.delete(assetId)
return next
})(),
)
message.success("素材已删除")
} catch {
message.error("删除失败,请重试")
}
},
[queryClient, selectedIds, setSelectedIds],
)
return {
handleDiagnose,
handleSingleDelete,
}
}
interface UseBatchHelpersOptions {
queryClient: ReturnType<typeof useQueryClient>
setSelectedIds: (ids: Set<string>) => void
}
/**
* 批量操作辅助函数
* 刷新数据、显示操作结果
*/
export const useBatchHelpers = ({ queryClient, setSelectedIds }: UseBatchHelpersOptions) => {
const invalidateAssets = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
}, [queryClient])
const showOperationResult = useCallback(
(
setResult: (r: BatchOperationResult | null) => void,
setTitle: (t: string) => void,
setDrawerOpen: (v: boolean) => void,
result: BatchOperationResult,
title: string,
clearSelection = true,
) => {
setResult(result)
setTitle(title)
setDrawerOpen(true)
if (clearSelection) setSelectedIds(new Set())
},
[setSelectedIds],
)
return {
invalidateAssets,
showOperationResult,
}
}
@@ -1,23 +1,21 @@
/**
* 素材操作 Hook(入口)
* 组合各子模块,保持导出不变
*
* 子模块位于 ./asset-operations/
*/
import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { useSingleOperations, useBatchHelpers } from "./asset-operations/useSingleOperations"
import {
deleteAsset,
getAssetDiagnosis,
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type BatchOperationResult,
} from "@/api/assets"
import type { AssetItem } from "../types"
useBatchDelete,
useBatchTag,
useBatchClassify,
useBatchMark,
} from "./asset-operations/batchOperations"
import type { BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../components/BatchMarkModal"
/**
* 素材操作 Hook
* 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作,
* 以及相关弹窗和结果展示的状态管理
*/
interface UseAssetOperationsProps {
selectedIds: Set<string>
setSelectedIds: (ids: Set<string>) => void
@@ -29,78 +27,35 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
/* ── 诊断状态 ── */
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 [resultDrawerOpen, setResultDrawerOpen] = useState(false)
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
const [operationTitle, setOperationTitle] = useState("")
/* ── 批量操作 loading ── */
const [batchLoading, setBatchLoading] = useState(false)
/* ── 单个操作 ── */
const { handleDiagnose: handleDiagnoseRaw, handleSingleDelete } = useSingleOperations({
selectedIds,
setSelectedIds,
})
/* ── 刷新数据辅助函数 ── */
const invalidateAssets = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
}, [queryClient])
/* ── 诊断 ── */
// 包装一下,加上 diagnosingId 状态
const handleDiagnose = useCallback(
async (asset: AssetItem) => {
async (asset: Parameters<typeof handleDiagnoseRaw>[0]) => {
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}" 诊断失败`)
await handleDiagnoseRaw(asset)
} finally {
setDiagnosingId(null)
}
},
[queryClient],
[handleDiagnoseRaw],
)
/* ── 单个素材删除 ── */
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 { invalidateAssets } = useBatchHelpers({ queryClient, setSelectedIds })
/* ── 显示操作结果 ── */
const showOperationResult = useCallback(
// 包装 showResult 适配子模块的接口
const showResult = useCallback(
(result: BatchOperationResult, title: string, clearSelection = true) => {
setOperationResult(result)
setOperationTitle(title)
@@ -110,147 +65,23 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
[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 { batchLoading: deleteLoading, handleBatchDelete } = useBatchDelete({
selectedIds,
invalidateAssets,
showResult,
})
/* ── 批量打标签 ── */
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 tagResult = useBatchTag({ selectedIds, queryClient, showResult })
const classifyResult = useBatchClassify({ selectedIds, queryClient, showResult })
const markResult = useBatchMark({ selectedIds, queryClient, showResult })
/* ── 标签输入处理 ── */
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
/* ── 批量改分类 ── */
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])
// 取任一批量操作的 loading 状态(任意一个在加载都算加载中)
const batchLoading =
deleteLoading ||
tagResult.batchLoading ||
classifyResult.batchLoading ||
markResult.batchLoading
/* ── 关闭结果 Drawer ── */
const handleResultDrawerClose = useCallback(() => {
@@ -267,29 +98,29 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
// 批量操作 loading
batchLoading,
// 批量打标签
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
tagModalOpen: tagResult.tagModalOpen,
setTagModalOpen: tagResult.setTagModalOpen,
batchTagInput: tagResult.batchTagInput,
setBatchTagInput: tagResult.setBatchTagInput,
batchTags: tagResult.batchTags,
setBatchTags: tagResult.setBatchTags,
tagMode: tagResult.tagMode,
setTagMode: tagResult.setTagMode,
handleBatchTag: tagResult.handleBatchTag,
handleTagInputKeyDown: tagResult.handleTagInputKeyDown,
removeBatchTag: tagResult.removeBatchTag,
// 批量改分类
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
handleBatchClassify,
classifyModalOpen: classifyResult.classifyModalOpen,
setClassifyModalOpen: classifyResult.setClassifyModalOpen,
batchCategory: classifyResult.batchCategory,
setBatchCategory: classifyResult.setBatchCategory,
handleBatchClassify: classifyResult.handleBatchClassify,
// 批量智能标记
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
handleBatchMark,
markModalOpen: markResult.markModalOpen,
setMarkModalOpen: markResult.setMarkModalOpen,
batchSmartView: markResult.batchSmartView as SmartViewType,
setBatchSmartView: markResult.setBatchSmartView,
handleBatchMark: markResult.handleBatchMark,
// 批量删除
handleBatchDelete,
// 操作结果
+23 -239
View File
@@ -3,137 +3,29 @@
* 风险评估 + 基本信息 + 检测项列表 + 匹配片段
* 零 antd 依赖
*/
import React, { useState, useCallback } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Button, Tag, Tooltip } from "@/components/ui"
import { useParams, useNavigate } from "react-router-dom"
import { getDuplicationDetail, retryDuplication, type DuplicateSegment } from "@/api/duplication"
import "./duplication.css"
import React from "react"
import { Button, Tag } from "@/components/ui"
import PageHead from "@/components/layout/PageHead"
/** 格式化时间(秒 → mm:ss */
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
}
/** 格式化文件大小 */
const formatSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
/** 格式化时长 */
const formatDuration = (seconds?: number) => {
if (!seconds) return "-"
const totalSec = Math.round(seconds)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
return m > 0 ? `${m}${s}` : `${s}`
}
/** 根据查重率获取风险等级 */
const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
if (rate === undefined) return "low"
if (rate <= 10) return "low"
if (rate <= 30) return "medium"
return "high"
}
/** 风险等级描述 */
const RISK_DESC: Record<string, string> = {
low: "查重率较低,内容原创度高",
medium: "存在一定重复,建议修改部分片段",
high: "重复率较高,建议大幅修改或替换",
}
/** 风险等级标签变体 */
const RISK_TAG_VARIANT: Record<string, "success" | "warning" | "error"> = {
low: "success",
medium: "warning",
high: "error",
}
/** 风险等级文字 */
const RISK_LABEL: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
}
/** 简易 toast */
interface ToastState {
message: string
type: "success" | "error" | "warning"
}
/** 单个重复片段卡片 */
const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
segment,
index,
}) => {
const sourceDuration = segment.source_end - segment.source_start
const matchedDuration = segment.matched_end - segment.matched_start
const riskLevel = segment.similarity >= 90 ? "high" : segment.similarity >= 70 ? "medium" : "low"
return (
<div className="dup-check-item">
<div className="dup-check-icon">🎬</div>
<div className="dup-check-body">
<h4>
{index + 1}{segment.matched_video_name}
</h4>
<p>
{formatTime(segment.source_start)} - {formatTime(segment.source_end)}
{sourceDuration.toFixed(0)}s {formatTime(segment.matched_start)} -{" "}
{formatTime(segment.matched_end)}{matchedDuration.toFixed(0)}s
</p>
</div>
<div className={`dup-check-bar`}>
<div
className={`dup-check-bar-fill ${riskLevel}`}
style={{ width: `${Math.min(segment.similarity, 100)}%` }}
/>
</div>
<span className={`dup-check-value ${riskLevel}`}>{segment.similarity.toFixed(1)}%</span>
</div>
)
}
import { RiskCard } from "./components/RiskCard"
import { InfoCard } from "./components/InfoCard"
import { SegmentsSection } from "./components/SegmentsSection"
import { useDuplicationDetail } from "./hooks/useDuplicationDetail"
import { RISK_TAG_VARIANT, RISK_LABEL } from "./constants"
import { formatSize, formatDuration } from "./utils"
import "./duplication.css"
const DuplicationDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [toast, setToast] = useState<ToastState | null>(null)
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}, [])
const {
data: detail,
detail,
isLoading,
isError,
} = useQuery({
queryKey: ["duplication-detail", id],
queryFn: () => getDuplicationDetail(id!),
enabled: !!id,
})
// 重新查重
const retryMutation = useMutation({
mutationFn: retryDuplication,
onSuccess: () => {
showToast("已重新提交查重", "success")
queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] })
},
onError: () => {
showToast("重新查重失败", "error")
},
})
toast,
riskLevel,
similarityPercent,
handleRetry,
handleDownloadReport,
handleBack,
} = useDuplicationDetail()
if (isLoading) {
return (
@@ -155,7 +47,7 @@ const DuplicationDetail: React.FC = () => {
<Button
buttonType="primary"
buttonSize="md"
onClick={() => navigate("/app/duplication/results")}
onClick={handleBack}
style={{ marginTop: 16 }}
>
@@ -165,10 +57,6 @@ const DuplicationDetail: React.FC = () => {
)
}
const riskLevel = getRiskLevel(detail.duplicate_rate)
const similarityPercent =
detail.duplicate_rate !== undefined ? detail.duplicate_rate.toFixed(1) : "—"
return (
<div className="dup-page">
{/* Toast */}
@@ -194,21 +82,11 @@ const DuplicationDetail: React.FC = () => {
}
actions={
<div className="dup-detail-actions" style={{ display: "flex", gap: 8 }}>
<Button
buttonType="secondary"
buttonSize="md"
onClick={() => {
showToast("报告下载功能开发中", "warning")
}}
>
<Button buttonType="secondary" buttonSize="md" onClick={handleDownloadReport}>
📥
</Button>
{detail.status === "failed" && (
<Button
buttonType="primary"
buttonSize="md"
onClick={() => retryMutation.mutate(detail.id)}
>
<Button buttonType="primary" buttonSize="md" onClick={handleRetry}>
🔄
</Button>
)}
@@ -218,103 +96,9 @@ const DuplicationDetail: React.FC = () => {
{/* 内容网格 */}
<div className="dup-detail-grid">
{/* 风险评估卡片 */}
<div className="dup-risk-card">
<h3>📊 </h3>
<div className={`dup-risk-circle ${riskLevel}`}>
<span className="dup-risk-value">{similarityPercent}%</span>
<span className="dup-risk-label"></span>
</div>
<p className="dup-risk-desc">{RISK_DESC[riskLevel]}</p>
</div>
{/* 基本信息卡片 */}
<div className="dup-info-detail-card">
<h3>📋 </h3>
<div className="dup-info-rows">
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<Tooltip title={detail.filename}>
<span
className="dup-info-row-value"
style={{
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{detail.filename}
</span>
</Tooltip>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{formatSize(detail.file_size)}</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{formatDuration(detail.duration_seconds)}</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
<Tag
variant={
detail.status === "completed"
? "success"
: detail.status === "failed"
? "error"
: detail.status === "processing"
? "warning"
: "info"
}
>
{detail.status === "completed"
? "✅ 已完成"
: detail.status === "failed"
? "❌ 失败"
: detail.status === "processing"
? "🔄 查重中"
: "⏳ 等待中"}
</Tag>
</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{detail.duplicate_count ?? 0} </span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
{new Date(detail.created_at).toLocaleString("zh-CN")}
</span>
</div>
</div>
</div>
{/* 检测项列表 */}
<div className="dup-checks-section">
<h3>
🔍
<Tag variant="primary" style={{ marginLeft: 8 }}>
{detail.segments?.length ?? 0}
</Tag>
</h3>
{detail.segments && detail.segments.length > 0 ? (
<div className="dup-checks-list">
{detail.segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} />
))}
</div>
) : (
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
<div className="dup-results-empty-icon">🎉</div>
<p></p>
</div>
)}
</div>
<RiskCard riskLevel={riskLevel} similarityPercent={similarityPercent} />
<InfoCard detail={detail} />
<SegmentsSection segments={detail.segments} />
</div>
</div>
)
@@ -0,0 +1,72 @@
import React from "react"
import { Tag, Tooltip } from "@/components/ui"
import { formatSize, formatDuration } from "../utils"
import type { DuplicationDetail } from "@/api/duplication"
interface InfoCardProps {
detail: DuplicationDetail
}
/**
* 基本信息卡片
*/
export const InfoCard: React.FC<InfoCardProps> = ({ detail }) => {
const statusMap: Record<string, { text: string; variant: string }> = {
completed: { text: "✅ 已完成", variant: "success" },
failed: { text: "❌ 失败", variant: "error" },
processing: { text: "🔄 查重中", variant: "warning" },
pending: { text: "⏳ 等待中", variant: "info" },
}
const status = statusMap[detail.status] || {
text: detail.status,
variant: "info",
}
return (
<div className="dup-info-detail-card">
<h3>📋 </h3>
<div className="dup-info-rows">
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<Tooltip title={detail.filename}>
<span
className="dup-info-row-value"
style={{
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{detail.filename}
</span>
</Tooltip>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{formatSize(detail.file_size)}</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{formatDuration(detail.duration_seconds)}</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
<Tag variant={status.variant as "success"}>{status.text}</Tag>
</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{detail.duplicate_count ?? 0} </span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
{new Date(detail.created_at).toLocaleString("zh-CN")}
</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,21 @@
import React from "react"
import { RISK_DESC } from "../constants"
interface RiskCardProps {
riskLevel: string
similarityPercent: string
}
/**
* 风险评估卡片
*/
export const RiskCard: React.FC<RiskCardProps> = ({ riskLevel, similarityPercent }) => (
<div className="dup-risk-card">
<h3>📊 </h3>
<div className={`dup-risk-circle ${riskLevel}`}>
<span className="dup-risk-value">{similarityPercent}%</span>
<span className="dup-risk-label"></span>
</div>
<p className="dup-risk-desc">{RISK_DESC[riskLevel]}</p>
</div>
)
@@ -0,0 +1,40 @@
import React from "react"
import type { DuplicateSegment } from "@/api/duplication"
import { formatTime } from "../utils"
interface SegmentCardProps {
segment: DuplicateSegment
index: number
}
/**
* 单个重复片段卡片
*/
export const SegmentCard: React.FC<SegmentCardProps> = ({ segment, index }) => {
const sourceDuration = segment.source_end - segment.source_start
const matchedDuration = segment.matched_end - segment.matched_start
const riskLevel = segment.similarity >= 90 ? "high" : segment.similarity >= 70 ? "medium" : "low"
return (
<div className="dup-check-item">
<div className="dup-check-icon">🎬</div>
<div className="dup-check-body">
<h4>
{index + 1}{segment.matched_video_name}
</h4>
<p>
{formatTime(segment.source_start)} - {formatTime(segment.source_end)}
{sourceDuration.toFixed(0)}s {formatTime(segment.matched_start)} -{" "}
{formatTime(segment.matched_end)}{matchedDuration.toFixed(0)}s
</p>
</div>
<div className={`dup-check-bar`}>
<div
className={`dup-check-bar-fill ${riskLevel}`}
style={{ width: `${Math.min(segment.similarity, 100)}%` }}
/>
</div>
<span className={`dup-check-value ${riskLevel}`}>{segment.similarity.toFixed(1)}%</span>
</div>
)
}
@@ -0,0 +1,35 @@
import React from "react"
import { Tag } from "@/components/ui"
import type { DuplicateSegment } from "@/api/duplication"
import { SegmentCard } from "./SegmentCard"
interface SegmentsSectionProps {
segments?: DuplicateSegment[]
}
/**
* 重复片段列表区域
*/
export const SegmentsSection: React.FC<SegmentsSectionProps> = ({ segments = [] }) => (
<div className="dup-checks-section">
<h3>
🔍
<Tag variant="primary" style={{ marginLeft: 8 }}>
{segments.length}
</Tag>
</h3>
{segments.length > 0 ? (
<div className="dup-checks-list">
{segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} />
))}
</div>
) : (
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
<div className="dup-results-empty-icon">🎉</div>
<p></p>
</div>
)}
</div>
)
+28 -1
View File
@@ -16,7 +16,28 @@ export const STATUS_CONFIG: Record<
failed: { variant: "error", text: "失败", icon: "❌" },
}
/** 风险等级标签 */
/** 风险等级描述 */
export const RISK_DESC: Record<string, string> = {
low: "查重率较低,内容原创度高",
medium: "存在一定重复,建议修改部分片段",
high: "重复率较高,建议大幅修改或替换",
}
/** 风险等级标签变体 */
export const RISK_TAG_VARIANT: Record<string, "success" | "warning" | "error"> = {
low: "success",
medium: "warning",
high: "error",
}
/** 风险等级文字(详情页用) */
export const RISK_LABEL: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
}
/** 风险等级标签(列表页用) */
export const RISK_LABELS: Record<string, string> = {
low: "低风险",
medium: "中风险",
@@ -29,3 +50,9 @@ export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
{ key: "medium", label: "中风险" },
{ key: "high", label: "高风险" },
]
/** Toast 类型 */
export interface ToastState {
message: string
type: "success" | "error" | "warning"
}
@@ -0,0 +1,77 @@
import { useState, useCallback } from "react"
import { useParams, useNavigate } from "react-router-dom"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getDuplicationDetail, retryDuplication } from "@/api/duplication"
import type { ToastState } from "../constants"
import { getRiskLevel } from "../utils"
/**
* 查重详情业务 Hook
*/
export const useDuplicationDetail = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [toast, setToast] = useState<ToastState | null>(null)
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}, [])
const {
data: detail,
isLoading,
isError,
} = useQuery({
queryKey: ["duplication-detail", id],
queryFn: () => getDuplicationDetail(id!),
enabled: !!id,
})
// 重新查重
const retryMutation = useMutation({
mutationFn: retryDuplication,
onSuccess: () => {
showToast("已重新提交查重", "success")
queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] })
},
onError: () => {
showToast("重新查重失败", "error")
},
})
const handleRetry = useCallback(() => {
if (!detail) return
retryMutation.mutate(detail.id)
}, [detail, retryMutation])
const handleDownloadReport = useCallback(() => {
showToast("报告下载功能开发中", "warning")
}, [showToast])
const handleBack = useCallback(() => {
navigate("/app/duplication/results")
}, [navigate])
const riskLevel = detail ? getRiskLevel(detail.duplicate_rate) : "low"
const similarityPercent =
detail?.duplicate_rate !== undefined ? detail.duplicate_rate.toFixed(1) : "—"
return {
// 数据
detail,
isLoading,
isError,
// 状态
toast,
riskLevel,
similarityPercent,
retryLoading: retryMutation.isPending,
// 操作
showToast,
handleRetry,
handleDownloadReport,
handleBack,
}
}
+7
View File
@@ -6,6 +6,13 @@ export const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
return "high"
}
/** 格式化时间(秒 → mm:ss */
export const formatTime = (seconds: number) => {
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 formatSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
@@ -1,238 +1,6 @@
/**
* 生成进度弹窗 — 任务 2.17
* 三阶段 UIsetup(配置)→ progress(进度轮询)→ completed / failed(结果)
* V21 设计系统,CSS 类名前缀 ep-gen-
* 生成进度弹窗 — 入口文件(向后兼容)
* 实际实现已移至 ./generation-progress-modal/ 目录
*/
import React, { useEffect, useRef } from "react"
import { Modal, Button } from "@/components/ui"
import type { TaskItem } from "@/api/tasks"
/* ──────────── 类型 ──────────── */
export type GenPhase = "setup" | "progress" | "completed" | "failed"
export interface GenerationProgressModalProps {
open: boolean
phase: GenPhase
/* setup 阶段 */
voiceoverDuration: number | null
estimatedDuration: number
onDurationChange: (v: number | null) => void
onGenerate: () => void
/* progress / 结果阶段 */
task: TaskItem | null
/* 通用 */
submitting: boolean
onCancel: () => void
onRetry?: () => void
onClose?: () => void
}
/* ──────────── 步骤文案映射 ──────────── */
const STEP_LABELS: Record<string, string> = {
queued: "排队中…",
preparing: "准备素材…",
generating_video: "渲染视频中…",
adding_effects: "添加特效…",
composing: "合成中…",
encoding: "编码输出中…",
completed: "生成完成!",
failed: "生成失败",
}
const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
/* ──────────── 状态徽标颜色 ──────────── */
const STATUS_COLOR: Record<string, string> = {
queued: "#6b7280",
pending: "#6b7280",
preparing: "#f59e0b",
generating_video: "#4f46e5",
adding_effects: "#7c3aed",
composing: "#2563eb",
encoding: "#0891b2",
completed: "#10b981",
failed: "#ef4444",
}
/* ──────────── 组件 ──────────── */
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
open,
phase,
voiceoverDuration,
estimatedDuration,
onDurationChange,
onGenerate,
task,
submitting,
onCancel,
onRetry,
onClose,
}) => {
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
const prevOpen = useRef(false)
useEffect(() => {
if (prevOpen.current && !open) {
/* modal just closed — parent handles reset */
}
prevOpen.current = open
}, [open])
const progress = task?.progress ?? 0
const status = task?.status ?? ""
const currentStep = task?.current_step ?? ""
const userMessage = task?.user_message ?? ""
const errorMessage = task?.error_message ?? ""
const retryable = task?.retryable ?? false
/* ── setup 阶段 ── */
if (phase === "setup") {
return (
<Modal
open={open}
title="使用模板生成视频"
confirmLoading={submitting}
onOk={onGenerate}
onCancel={onCancel}
okText="开始生成"
cancelText="取消"
width={440}
>
<div className="ep-gen-setup">
<label className="ep-gen-field-label"></label>
<input
type="number"
className="ep-gen-duration-input"
placeholder="请输入配音时长"
value={voiceoverDuration ?? ""}
onChange={(e) => {
const v = e.target.value ? Number(e.target.value) : null
onDurationChange(v)
}}
min={1}
max={600}
/>
<div className="ep-gen-estimate">
<strong>{estimatedDuration}s</strong>
</div>
</div>
</Modal>
)
}
/* ── progress 阶段 ── */
if (phase === "progress") {
const stepColor = STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
return (
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
<div className="ep-gen-progress">
{/* 进度环 */}
<div className="ep-gen-progress-ring-wrap">
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
<circle
className="ep-gen-progress-ring-fill"
cx="60"
cy="60"
r="52"
style={{
strokeDasharray: `${2 * Math.PI * 52}`,
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
stroke: stepColor,
}}
/>
</svg>
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
{progress}%
</span>
</div>
{/* 当前步骤 */}
<div className="ep-gen-step-text">
{userMessage || getStepLabel(currentStep) || "处理中…"}
</div>
{/* 进度条 */}
<div className="ep-gen-progress-bar">
<div
className="ep-gen-progress-bar-fill"
style={{
width: `${progress}%`,
backgroundColor: stepColor,
}}
/>
</div>
{/* 任务 ID */}
{task?.id && <div className="ep-gen-task-id"> ID: {task.id}</div>}
</div>
</Modal>
)
}
/* ── completed 阶段 ── */
if (phase === "completed") {
return (
<Modal
open={open}
title="✅ 生成完成"
footer={null}
onCancel={onClose || onCancel}
closable
width={440}
>
<div className="ep-gen-result">
<div className="ep-gen-result-icon">🎉</div>
<div className="ep-gen-result-title"></div>
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
<div className="ep-gen-result-actions">
<Button buttonType="primary" onClick={onClose || onCancel}>
</Button>
</div>
</div>
</Modal>
)
}
/* ── failed 阶段 ── */
return (
<Modal
open={open}
title="❌ 生成失败"
footer={null}
onCancel={onClose || onCancel}
closable
width={440}
>
<div className="ep-gen-result ep-gen-result--error">
<div className="ep-gen-result-icon">😥</div>
<div className="ep-gen-result-title"></div>
{(errorMessage || userMessage) && (
<div className="ep-gen-result-msg ep-gen-result-msg--error">
{errorMessage || userMessage}
</div>
)}
<div className="ep-gen-result-actions">
{retryable && onRetry && (
<Button buttonType="primary" onClick={onRetry}>
🔄
</Button>
)}
<Button buttonType="secondary" onClick={onClose || onCancel}>
</Button>
</div>
</div>
</Modal>
)
}
export default GenerationProgressModal
export { default } from "./generation-progress-modal"
export type { GenPhase, GenerationProgressModalProps } from "./generation-progress-modal"
@@ -8,21 +8,13 @@
* - 拖动时实时显示裁剪预览(入点/出点/时长)
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
*/
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
import React, { useState, useRef, useCallback, useEffect } from "react"
import type { ClipData, ClipType, TrimConfig } from "../types"
import {
DEFAULT_PIXELS_PER_SECOND,
MIN_PIXELS_PER_SECOND,
MAX_PIXELS_PER_SECOND,
ZOOM_STEP,
MIN_TRIM_DURATION,
DEFAULT_ADD_DURATION,
MIN_ADD_DURATION,
MAX_ADD_DURATION,
TRACK_GAP,
ADD_PICKER_WIDTH,
} from "../constants/timeline"
import { DEFAULT_PIXELS_PER_SECOND } from "../constants/timeline"
import { formatTime } from "../utils/timeline"
import { useClipDrag } from "../hooks/useClipDrag"
import { useTrimDrag } from "../hooks/useTrimDrag"
import { useTimelineMenus } from "../hooks/useTimelineMenus"
import { ClipCard } from "./timeline/ClipCard"
import { TimeRuler } from "./timeline/TimeRuler"
import { AddClipPicker } from "./timeline/AddClipPicker"
@@ -55,25 +47,6 @@ interface TimelinePanelProps {
totalDuration?: number
}
/** 裁剪拖拽方向 */
type TrimDirection = "left" | "right"
/** 裁剪拖拽状态 */
interface TrimDragState {
clipId: string
direction: TrimDirection
startX: number
originalTrim: TrimConfig
originalDuration: number
}
/** 右键菜单状态 */
interface ContextMenuState {
x: number
y: number
clipId: string
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
clips,
selectedClipId,
@@ -91,143 +64,51 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onSeek,
totalDuration: totalDurationProp,
}) => {
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const dragRef = useRef<number | null>(null)
const [showAddPicker, setShowAddPicker] = useState(false)
const pickerRef = useRef<HTMLDivElement>(null)
const addCardRef = useRef<HTMLDivElement>(null)
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({
top: 0,
right: 0,
})
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
/* ── 裁剪拖拽状态 ── */
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
const [trimPreview, setTrimPreview] = useState<{
clipId: string
startTime: number
endTime: number
duration: number
x: number
y: number
} | null>(null)
/* ── 裁剪拖拽 ── */
const { trimDrag, trimPreview, handleTrimHandleMouseDown } = useTrimDrag(clips, pps, onClipTrim)
/* ── 右键菜单 ── */
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
/* ── 片段拖拽排序 ── */
const {
dragIdx,
dragOverIdx,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDrop,
handleEmptyDragOver,
} = useClipDrag(onClipReorder, !!trimDrag)
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
/* ── 菜单 & 面板 ── */
const {
contextMenu,
contextMenuRef,
handleContextMenu,
handleContextSplit,
handleContextResetTrim,
handleContextDelete,
showAddPicker,
pickerRef,
addCardRef,
pickerPos,
availableTypes,
addType,
addDuration,
setAddType,
setAddDuration,
handleTogglePicker,
handleConfirmAdd,
hoveredClipId,
setHoveredClipId,
} = useTimelineMenus(clips, currentMode, onAddClip, onClipSplit, onClipResetTrim, onClipRemove)
/* ── 播放头拖拽状态 ── */
const [playheadDragging, setPlayheadDragging] = useState(false)
const trackRef = useRef<HTMLDivElement>(null)
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"], // voice_pip / one_take / 默认
[currentMode],
)
/* ── 默认添加类型:跟随模式 ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice"
if (currentMode === "pip") return "pip"
return "voice"
}, [currentMode])
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>(defaultAddType)
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
if (!availableTypes.includes(addType)) {
setAddType(defaultAddType)
}
}, [currentMode, addType, availableTypes, defaultAddType])
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - TRACK_GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - ADD_PICKER_WIDTH < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [])
const handleTogglePicker = () => {
if (!showAddPicker) {
const defaultType =
currentMode === "pip" ? "pip" : currentMode === "voice_over" ? "voice" : "voice"
setAddType(defaultType)
updatePickerPosition()
}
setShowAddPicker((v) => !v)
}
/* ── 渲染后精确边界校正 ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
const pickerEl = pickerRef.current
const addRect = addCardRef.current.getBoundingClientRect()
const pickerH = pickerEl.offsetHeight
const vh = window.innerHeight
const vw = window.innerWidth
let top = addRect.top - TRACK_GAP - pickerH
if (top < 8) {
top = addRect.bottom + TRACK_GAP
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH
if (top < 8) top = 8
}
}
let right = vw - addRect.right
const pickerRect = pickerEl.getBoundingClientRect()
if (pickerRect.left < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [showAddPicker])
/* ── 点击外部关闭添加面板 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowAddPicker(false)
}
}
if (showAddPicker) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [showAddPicker])
/* ── 点击外部关闭右键菜单 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(null)
}
}
if (contextMenu) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [contextMenu])
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
/* ── 播放头拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!playheadDragging) return
@@ -271,165 +152,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setPlayheadDragging(true)
}, [])
/* ── 确认添加片段 ── */
const handleConfirmAdd = () => {
onAddClip(addType, addDuration)
setShowAddPicker(false)
}
/* ── 片段拖拽排序 ── */
const handleDragStart = (e: React.DragEvent, idx: number) => {
// 如果正在裁剪拖拽,不允许排序拖拽
if (trimDrag) return
dragRef.current = idx
setDragIdx(idx)
e.dataTransfer.setData("application/x-clip-drag", String(idx))
e.dataTransfer.effectAllowed = "move"
}
const handleDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
setDragOverIdx(idx)
}
const handleDragEnd = () => {
dragRef.current = null
setDragIdx(null)
setDragOverIdx(null)
}
const handleDrop = (e: React.DragEvent, toIdx: number) => {
e.preventDefault()
setDragOverIdx(null)
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
if (fromStr !== "") {
const fromIdx = Number(fromStr)
if (fromIdx !== toIdx) {
onClipReorder(fromIdx, toIdx)
}
}
}
/* ── 空轨道区域不接受素材拖入 ── */
const handleEmptyDragOver = (e: React.DragEvent) => {
e.preventDefault()
}
/* ── 裁剪手柄拖拽 ── */
const handleTrimHandleMouseDown = useCallback(
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
e.preventDefault()
e.stopPropagation()
const clip = clips.find((c) => c.id === clipId)
if (!clip) return
const trim: TrimConfig = clip.trim_config ?? {
start_time: 0,
end_time: clip.duration,
original_duration: clip.duration,
}
setTrimDrag({
clipId,
direction,
startX: e.clientX,
originalTrim: { ...trim },
originalDuration: clip.duration,
})
},
[clips],
)
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!trimDrag) return
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX
const dtSec = dx / pps
const clip = clips.find((c) => c.id === trimDrag.clipId)
if (!clip) return
const origTrim = trimDrag.originalTrim
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
let newStart = origTrim.start_time
let newEnd = origTrim.end_time
if (trimDrag.direction === "left") {
// 左手柄:调整入点
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
} else {
// 右手柄:调整出点
newEnd = Math.max(
origTrim.start_time + MIN_TRIM_DURATION,
Math.min(origTrim.end_time + dtSec, origDur),
)
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10
setTrimPreview({
clipId: trimDrag.clipId,
startTime: Math.round(newStart * 10) / 10,
endTime: Math.round(newEnd * 10) / 10,
duration: newDuration,
x: e.clientX,
y: e.clientY,
})
}
const handleMouseUp = () => {
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
const newTrim: TrimConfig = {
start_time: trimPreview.startTime,
end_time: trimPreview.endTime,
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
}
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
}
setTrimDrag(null)
setTrimPreview(null)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [trimDrag, trimPreview, clips, onClipTrim, pps])
/* ── 右键菜单 ── */
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
}, [])
/* ── 右键菜单操作 ── */
const handleContextSplit = useCallback(() => {
if (!contextMenu) return
if (onClipSplit) {
onClipSplit(contextMenu.clipId, 0.5) // 在中间分割
}
setContextMenu(null)
}, [contextMenu, onClipSplit])
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return
if (onClipResetTrim) {
onClipResetTrim(contextMenu.clipId)
}
setContextMenu(null)
}, [contextMenu, onClipResetTrim])
const handleContextDelete = useCallback(() => {
if (!contextMenu) return
onClipRemove(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipRemove])
return (
<div className="ep-timeline-area">
{/* 时间线头部 */}
@@ -443,7 +165,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<div className="ep-timeline-zoom">
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.max(MIN_PIXELS_PER_SECOND, pps - ZOOM_STEP))}
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
title="缩小"
>
@@ -451,15 +173,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<input
type="range"
className="ep-zoom-slider"
min={MIN_PIXELS_PER_SECOND}
max={MAX_PIXELS_PER_SECOND}
min={10}
max={120}
step={5}
value={pps}
onChange={(e) => onZoomChange?.(Number(e.target.value))}
/>
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.min(MAX_PIXELS_PER_SECOND, pps + ZOOM_STEP))}
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
title="放大"
>
+
@@ -583,8 +305,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
availableTypes={availableTypes}
addType={addType}
addDuration={addDuration}
minDuration={MIN_ADD_DURATION}
maxDuration={MAX_ADD_DURATION}
onTypeChange={setAddType}
onDurationChange={setAddDuration}
onConfirm={handleConfirmAdd}
@@ -0,0 +1,65 @@
import React from "react"
import { Modal } from "@/components/ui"
import type { TaskItem } from "@/api/tasks"
import { getStepLabel, getStatusColor } from "./constants"
interface ProgressPhaseProps {
open: boolean
task: TaskItem | null
onCancel: () => void
}
/** progress(进度轮询)阶段弹窗 */
export const ProgressPhase: React.FC<ProgressPhaseProps> = ({ open, task, onCancel }) => {
const progress = task?.progress ?? 0
const status = task?.status ?? ""
const currentStep = task?.current_step ?? ""
const userMessage = task?.user_message ?? ""
const stepColor = getStatusColor(status, currentStep)
return (
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
<div className="ep-gen-progress">
{/* 进度环 */}
<div className="ep-gen-progress-ring-wrap">
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
<circle
className="ep-gen-progress-ring-fill"
cx="60"
cy="60"
r="52"
style={{
strokeDasharray: `${2 * Math.PI * 52}`,
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
stroke: stepColor,
}}
/>
</svg>
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
{progress}%
</span>
</div>
{/* 当前步骤 */}
<div className="ep-gen-step-text">
{userMessage || getStepLabel(currentStep) || "处理中…"}
</div>
{/* 进度条 */}
<div className="ep-gen-progress-bar">
<div
className="ep-gen-progress-bar-fill"
style={{
width: `${progress}%`,
backgroundColor: stepColor,
}}
/>
</div>
{/* 任务 ID */}
{task?.id && <div className="ep-gen-task-id"> ID: {task.id}</div>}
</div>
</Modal>
)
}
@@ -0,0 +1,82 @@
import React from "react"
import { Modal, Button } from "@/components/ui"
import type { TaskItem } from "@/api/tasks"
interface ResultPhaseProps {
open: boolean
phase: "completed" | "failed"
task: TaskItem | null
onCancel: () => void
onRetry?: () => void
onClose?: () => void
}
/** completed / failed(结果)阶段弹窗 */
export const ResultPhase: React.FC<ResultPhaseProps> = ({
open,
phase,
task,
onCancel,
onRetry,
onClose,
}) => {
const userMessage = task?.user_message ?? ""
const errorMessage = task?.error_message ?? ""
const retryable = task?.retryable ?? false
const handleClose = onClose || onCancel
if (phase === "completed") {
return (
<Modal
open={open}
title="✅ 生成完成"
footer={null}
onCancel={handleClose}
closable
width={440}
>
<div className="ep-gen-result">
<div className="ep-gen-result-icon">🎉</div>
<div className="ep-gen-result-title"></div>
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
<div className="ep-gen-result-actions">
<Button buttonType="primary" onClick={handleClose}>
</Button>
</div>
</div>
</Modal>
)
}
return (
<Modal
open={open}
title="❌ 生成失败"
footer={null}
onCancel={handleClose}
closable
width={440}
>
<div className="ep-gen-result ep-gen-result--error">
<div className="ep-gen-result-icon">😥</div>
<div className="ep-gen-result-title"></div>
{(errorMessage || userMessage) && (
<div className="ep-gen-result-msg ep-gen-result-msg--error">
{errorMessage || userMessage}
</div>
)}
<div className="ep-gen-result-actions">
{retryable && onRetry && (
<Button buttonType="primary" onClick={onRetry}>
🔄
</Button>
)}
<Button buttonType="secondary" onClick={handleClose}>
</Button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,53 @@
import React from "react"
import { Modal } from "@/components/ui"
interface SetupPhaseProps {
open: boolean
voiceoverDuration: number | null
estimatedDuration: number
submitting: boolean
onDurationChange: (v: number | null) => void
onGenerate: () => void
onCancel: () => void
}
/** setup(配置)阶段弹窗 */
export const SetupPhase: React.FC<SetupPhaseProps> = ({
open,
voiceoverDuration,
estimatedDuration,
submitting,
onDurationChange,
onGenerate,
onCancel,
}) => (
<Modal
open={open}
title="使用模板生成视频"
confirmLoading={submitting}
onOk={onGenerate}
onCancel={onCancel}
okText="开始生成"
cancelText="取消"
width={440}
>
<div className="ep-gen-setup">
<label className="ep-gen-field-label"></label>
<input
type="number"
className="ep-gen-duration-input"
placeholder="请输入配音时长"
value={voiceoverDuration ?? ""}
onChange={(e) => {
const v = e.target.value ? Number(e.target.value) : null
onDurationChange(v)
}}
min={1}
max={600}
/>
<div className="ep-gen-estimate">
<strong>{estimatedDuration}s</strong>
</div>
</div>
</Modal>
)
@@ -0,0 +1,31 @@
/* ──────────── 步骤文案映射 ──────────── */
export const STEP_LABELS: Record<string, string> = {
queued: "排队中…",
preparing: "准备素材…",
generating_video: "渲染视频中…",
adding_effects: "添加特效…",
composing: "合成中…",
encoding: "编码输出中…",
completed: "生成完成!",
failed: "生成失败",
}
export const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
/* ──────────── 状态徽标颜色 ──────────── */
export const STATUS_COLOR: Record<string, string> = {
queued: "#6b7280",
pending: "#6b7280",
preparing: "#f59e0b",
generating_video: "#4f46e5",
adding_effects: "#7c3aed",
composing: "#2563eb",
encoding: "#0891b2",
completed: "#10b981",
failed: "#ef4444",
}
export const getStatusColor = (status: string, currentStep: string) =>
STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
@@ -0,0 +1,70 @@
/**
* 生成进度弹窗 — 任务 2.17
* 三阶段 UIsetup(配置)→ progress(进度轮询)→ completed / failed(结果)
* V21 设计系统,CSS 类名前缀 ep-gen-
*/
import React, { useEffect, useRef } from "react"
import type { GenPhase, GenerationProgressModalProps } from "./types"
import { SetupPhase } from "./SetupPhase"
import { ProgressPhase } from "./ProgressPhase"
import { ResultPhase } from "./ResultPhase"
/* 重新导出类型,保持向后兼容 */
export type { GenPhase, GenerationProgressModalProps }
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
open,
phase,
voiceoverDuration,
estimatedDuration,
onDurationChange,
onGenerate,
task,
submitting,
onCancel,
onRetry,
onClose,
}) => {
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
const prevOpen = useRef(false)
useEffect(() => {
if (prevOpen.current && !open) {
/* modal just closed — parent handles reset */
}
prevOpen.current = open
}, [open])
/* setup 阶段 */
if (phase === "setup") {
return (
<SetupPhase
open={open}
voiceoverDuration={voiceoverDuration}
estimatedDuration={estimatedDuration}
submitting={submitting}
onDurationChange={onDurationChange}
onGenerate={onGenerate}
onCancel={onCancel}
/>
)
}
/* progress 阶段 */
if (phase === "progress") {
return <ProgressPhase open={open} task={task} onCancel={onCancel} />
}
/* completed / failed 阶段 */
return (
<ResultPhase
open={open}
phase={phase as "completed" | "failed"}
task={task}
onCancel={onCancel}
onRetry={onRetry}
onClose={onClose}
/>
)
}
export default GenerationProgressModal
@@ -0,0 +1,23 @@
import type { TaskItem } from "@/api/tasks"
export type GenPhase = "setup" | "progress" | "completed" | "failed"
export interface GenerationProgressModalProps {
open: boolean
phase: GenPhase
/* setup 阶段 */
voiceoverDuration: number | null
estimatedDuration: number
onDurationChange: (v: number | null) => void
onGenerate: () => void
/* progress / 结果阶段 */
task: TaskItem | null
/* 通用 */
submitting: boolean
onCancel: () => void
onRetry?: () => void
onClose?: () => void
}
@@ -0,0 +1,64 @@
import { useState, useCallback } from "react"
/**
* 片段拖拽排序 Hook
* 支持 HTML5 原生拖拽,实时高亮拖拽位置
*/
export const useClipDrag = (
onClipReorder: (fromIdx: number, toIdx: number) => void,
disabled?: boolean,
) => {
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
if (disabled) return
setDragIdx(idx)
e.dataTransfer.setData("application/x-clip-drag", String(idx))
e.dataTransfer.effectAllowed = "move"
},
[disabled],
)
const handleDragOver = useCallback((e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
setDragOverIdx(idx)
}, [])
const handleDragEnd = useCallback(() => {
setDragIdx(null)
setDragOverIdx(null)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent, toIdx: number) => {
e.preventDefault()
setDragOverIdx(null)
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
if (fromStr !== "") {
const fromIdx = Number(fromStr)
if (fromIdx !== toIdx) {
onClipReorder(fromIdx, toIdx)
}
}
setDragIdx(null)
},
[onClipReorder],
)
const handleEmptyDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
}, [])
return {
dragIdx,
dragOverIdx,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDrop,
handleEmptyDragOver,
}
}
@@ -0,0 +1,192 @@
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
import type { ClipData, ClipType } from "../types"
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../constants/timeline"
interface ContextMenuState {
x: number
y: number
clipId: string
}
/**
* 时间线菜单 Hook
* 管理右键菜单和添加片段面板的状态与交互
*/
export const useTimelineMenus = (
_clips: ClipData[],
currentMode: string,
onAddClip: (type: ClipType, duration: number) => void,
onClipSplit?: (clipId: string, splitRatio: number) => void,
onClipResetTrim?: (clipId: string) => void,
onClipRemove?: (clipId: string) => void,
) => {
/* ── 右键菜单 ── */
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
/* ── 添加片段面板 ── */
const [showAddPicker, setShowAddPicker] = useState(false)
const pickerRef = useRef<HTMLDivElement>(null)
const addCardRef = useRef<HTMLDivElement>(null)
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
[currentMode],
)
/* ── 默认添加类型:跟随模式 ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice"
if (currentMode === "pip") return "pip"
return "voice"
}, [currentMode])
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>(defaultAddType)
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
if (!availableTypes.includes(addType)) {
setAddType(defaultAddType)
}
}, [currentMode, addType, availableTypes, defaultAddType])
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - TRACK_GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - ADD_PICKER_WIDTH < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [])
const handleTogglePicker = useCallback(() => {
if (!showAddPicker) {
setAddType(defaultAddType)
updatePickerPosition()
}
setShowAddPicker((v) => !v)
}, [showAddPicker, defaultAddType, updatePickerPosition])
/* ── 渲染后精确边界校正 ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
const pickerEl = pickerRef.current
const addRect = addCardRef.current.getBoundingClientRect()
const pickerH = pickerEl.offsetHeight
const vh = window.innerHeight
const vw = window.innerWidth
let top = addRect.top - TRACK_GAP - pickerH
if (top < 8) {
top = addRect.bottom + TRACK_GAP
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH
if (top < 8) top = 8
}
}
let right = vw - addRect.right
const pickerRect = pickerEl.getBoundingClientRect()
if (pickerRect.left < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [showAddPicker])
/* ── 点击外部关闭添加面板 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowAddPicker(false)
}
}
if (showAddPicker) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [showAddPicker])
/* ── 点击外部关闭右键菜单 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(null)
}
}
if (contextMenu) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [contextMenu])
/* ── 确认添加片段 ── */
const handleConfirmAdd = useCallback(() => {
onAddClip(addType, addDuration)
setShowAddPicker(false)
}, [onAddClip, addType, addDuration])
/* ── 右键菜单操作 ── */
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
}, [])
const handleContextSplit = useCallback(() => {
if (!contextMenu) return
onClipSplit?.(contextMenu.clipId, 0.5)
setContextMenu(null)
}, [contextMenu, onClipSplit])
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return
onClipResetTrim?.(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipResetTrim])
const handleContextDelete = useCallback(() => {
if (!contextMenu) return
onClipRemove?.(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipRemove])
return {
// 右键菜单
contextMenu,
contextMenuRef,
handleContextMenu,
handleContextSplit,
handleContextResetTrim,
handleContextDelete,
// 添加面板
showAddPicker,
pickerRef,
addCardRef,
pickerPos,
availableTypes,
addType,
addDuration,
setAddType,
setAddDuration,
handleTogglePicker,
handleConfirmAdd,
// 悬停状态
hoveredClipId,
setHoveredClipId,
}
}
@@ -0,0 +1,121 @@
import { useState, useCallback, useEffect } from "react"
import type { ClipData, TrimConfig } from "../types"
interface TrimDragState {
clipId: string
direction: "left" | "right"
startX: number
originalTrim: TrimConfig
originalDuration: number
}
interface TrimPreviewState {
clipId: string
startTime: number
endTime: number
duration: number
x: number
y: number
}
const MIN_TRIM_DURATION = 1
/**
* 裁剪拖拽 Hook
* 拖拽片段两端手柄调整入点/出点,实时显示预览
*/
export const useTrimDrag = (
clips: ClipData[],
pps: number,
onClipTrim?: (clipId: string, trimConfig: TrimConfig, newDuration: number) => void,
) => {
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
const [trimPreview, setTrimPreview] = useState<TrimPreviewState | null>(null)
const handleTrimHandleMouseDown = useCallback(
(e: React.MouseEvent, clipId: string, direction: "left" | "right") => {
e.preventDefault()
e.stopPropagation()
const clip = clips.find((c) => c.id === clipId)
if (!clip) return
const trim: TrimConfig = clip.trim_config ?? {
start_time: 0,
end_time: clip.duration,
original_duration: clip.duration,
}
setTrimDrag({
clipId,
direction,
startX: e.clientX,
originalTrim: { ...trim },
originalDuration: clip.duration,
})
},
[clips],
)
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!trimDrag) return
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX
const dtSec = dx / pps
const clip = clips.find((c) => c.id === trimDrag.clipId)
if (!clip) return
const origTrim = trimDrag.originalTrim
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
let newStart = origTrim.start_time
let newEnd = origTrim.end_time
if (trimDrag.direction === "left") {
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
} else {
newEnd = Math.max(
origTrim.start_time + MIN_TRIM_DURATION,
Math.min(origTrim.end_time + dtSec, origDur),
)
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10
setTrimPreview({
clipId: trimDrag.clipId,
startTime: Math.round(newStart * 10) / 10,
endTime: Math.round(newEnd * 10) / 10,
duration: newDuration,
x: e.clientX,
y: e.clientY,
})
}
const handleMouseUp = () => {
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
const newTrim: TrimConfig = {
start_time: trimPreview.startTime,
end_time: trimPreview.endTime,
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
}
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
}
setTrimDrag(null)
setTrimPreview(null)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [trimDrag, trimPreview, clips, pps, onClipTrim])
return {
trimDrag,
trimPreview,
handleTrimHandleMouseDown,
}
}
+113 -360
View File
@@ -2,230 +2,93 @@
* 智能剪辑页面 — V21 原型 1:1 还原
* 7 步向导:选择模板 → 选择素材 → 生成预览 → 选择标题 → 选择配音 → 选择封面 → 确认生成
* 左右布局:左侧 generate-form + 右侧 generate-preview
* 主组件仅保留共享状态、步骤切换、整体布局
* 各 Step 的 UI 与业务逻辑拆分至 components/ + hooks/
* 生成核心逻辑封装在 useGenerateVideo hook
* 主组件仅保留整体布局与事件编排
* 状态管理 → hooks/useGenerateFormState
* 步骤导航 → hooks/useStepNavigation
* 步骤内容 → components/GenerateStepContent
* 底部按钮 → components/GenerateStepActions
* 生成核心逻辑 → hooks/useGenerateVideo
*/
import React, { useState, useEffect, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { message, Modal } from "antd"
import { ThunderboltOutlined } from "@ant-design/icons"
import type { GeneratedVideo, TitleConfig } from "@/api/template-editor"
import { getEditPlan } from "@/api/template-editor"
import type { CoverConfig } from "../editing-planner/types"
import { getEditingTemplates } from "@/api/editing-planner"
import type { PresetVoiceItem } from "@/api/voices"
import { fetchPresetVoices } from "@/api/voices"
import React from "react"
import { Modal, message } from "antd"
import { useNavigate } from "react-router-dom"
import type { VoiceClone } from "@/api/voice-clone"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import CloneModal from "@/components/voice/CloneModal"
import { useSearchParams, useNavigate } from "react-router-dom"
import GenerateHeader from "./components/GenerateHeader"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateResultPanel from "./components/GenerateResultPanel"
import Step1TemplateSelect from "./components/Step1TemplateSelect"
import Step2MaterialSelect from "./components/Step2MaterialSelect"
import Step3GeneratePreview from "./components/Step3GeneratePreview"
import Step4TitleSettings from "./components/Step4TitleSettings"
import Step5VoiceSelect from "./components/Step5VoiceSelect"
import Step6CoverSettings from "./components/Step6CoverSettings"
import Step7ConfirmGenerate from "./components/Step7ConfirmGenerate"
import { DEFAULT_COVER_SETTINGS } from "./constants"
import type { TitleSettings } from "./types"
import GenerateStepContent from "./components/GenerateStepContent"
import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import "./generate.css"
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
}
const GeneratePage: React.FC = () => {
const navigate = useNavigate()
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1)
/* ── 表单状态 ── */
const formState = useGenerateFormState()
const {
currentStep,
setCurrentStep,
selectedTemplate,
setSelectedTemplate,
userTemplates,
selectedMaterials,
setSelectedMaterials,
materialMode,
setMaterialMode,
smartSelectedIds,
setSmartSelectedIds,
titleSettings,
setTitleSettings,
coverSettings,
setCoverSettings,
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
cloneModalOpen,
setCloneModalOpen,
generateCount,
setGenerateCount,
videoRatio,
duration,
style,
autoSubtitles,
bgm,
editPlanId,
previewVideo,
setPreviewVideo,
previewModalOpen,
setPreviewModalOpen,
} = formState
/* ── 模板(从 API 加载) ── */
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery({
queryKey: ["generate-templates"],
queryFn: () => getEditingTemplates(),
staleTime: 60_000,
})
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [userTemplates, selectedTemplate])
/* ── 素材(共享:step2 选择、step7 展示、生成使用) ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
/* ── 标题设置(共享:step4 编辑、step7 展示、生成使用) ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
/* ── 封面设置(共享:step6 编辑、step7 展示、生成使用) ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */
useEffect(() => {
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
if (tpl?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: tpl.title_config!.ai_auto_select,
title: tpl.title_config!.content || prev.title,
position: tpl.title_config!.position || prev.position,
font: tpl.title_config!.font_preset || prev.font,
size: tpl.title_config!.font_size || prev.size,
color: tpl.title_config!.font_color || prev.color,
}))
}
if (tpl?.cover_config) {
setCoverSettings((prev) => ({
...prev,
enabled: tpl.cover_config!.enabled ?? prev.enabled,
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
}))
}
}, [selectedTemplate, userTemplates])
/* ── 配音(共享:step5 选择、step7 展示、生成使用) ── */
const [selectedVoice, setSelectedVoice] = useState<string>("")
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("")
/* ── 预置音色 API(共享:step5 选择、step7 展示) ── */
const { data: presetVoicesData } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
/* ── 克隆声音(共享:step5 管理、step7 展示) ── */
const [cloneModalOpen, setCloneModalOpen] = useState(false)
/* ── 克隆声音 ── */
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1)
/* ── 高级设置(隐藏但保留) ── */
const [videoRatio] = useState("16:9")
const [duration] = useState(30)
const [style] = useState("business")
const [autoSubtitles] = useState(true)
const [bgm] = useState(true)
/* ── URL 参数:从模板编辑器跳转过来时携带 edit_plan_id + plan_config ── */
const [searchParams] = useSearchParams()
const editPlanId = searchParams.get("edit_plan_id")
const planConfigStr = searchParams.get("plan_config")
/** 解析 plan_config 并自动填充表单 */
useEffect(() => {
if (!planConfigStr) return
try {
const config = JSON.parse(planConfigStr) as {
title_config?: { content?: string; ai_auto_select?: boolean }
subtitle_config?: { enabled?: boolean }
bgm_config?: { enabled?: boolean; music_id?: string }
mode?: string
total_duration?: number
segments?: Array<{ media_asset_id?: string; material_type?: string }>
}
if (config.title_config) {
const tc = config.title_config as TitleConfig
setTitleSettings((prev) => ({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: tc.position || prev.position,
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
}))
}
if (config.segments && config.segments.length > 0) {
const assetIds = config.segments
.map((s) => s.media_asset_id)
.filter((id): id is string => !!id)
if (assetIds.length > 0) {
setSelectedMaterials(assetIds)
}
}
} catch (err) {
console.warn("解析 plan_config 失败:", err)
}
}, [planConfigStr])
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
useEffect(() => {
if (!editPlanId || planConfigStr) return
const loadPlanConfig = async () => {
try {
const plan = await getEditPlan(editPlanId)
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
const cfg = plan.config
if (cfg?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: cfg.title_config!.position || prev.position,
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
}))
}
if (cfg?.cover_config) {
const cc = cfg.cover_config as CoverConfig
setCoverSettings((prev) => ({
...prev,
enabled: cc.enabled ?? prev.enabled,
mode: cc.mode || prev.mode,
frame_time: cc.frame_time ?? prev.frame_time,
upload_url: cc.upload_url || prev.upload_url,
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
}))
}
if (cfg?.asset_ids) {
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
}
} catch (err) {
console.warn("加载模板草稿配置失败:", err)
}
}
loadPlanConfig()
}, [editPlanId, planConfigStr])
/* ── 克隆成功回调 ── */
const handleCloneSuccess = (voice: VoiceClone) => {
addClone(voice)
setCloneModalOpen(false)
message.success("音色克隆成功!")
}
/* ── 步骤导航 ── */
const { goNext, goPrev } = useStepNavigation({
currentStep,
setCurrentStep,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
titleSettings,
})
/* ── 视频生成核心逻辑 ── */
const {
generating,
@@ -256,136 +119,6 @@ const GeneratePage: React.FC = () => {
generateCount,
})
/* ── 预览弹窗状态 ── */
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
const [previewModalOpen, setPreviewModalOpen] = useState(false)
/* ── 步骤导航 ── */
const goNext = () => {
if (currentStep === 1 && !selectedTemplate) {
message.warning("请先选择一个模板")
return
}
if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材")
return
}
if (currentStep === 2 && materialMode === "auto" && smartSelectedIds.length === 0) {
message.warning("请先进行智能匹配并选择素材")
return
}
if (currentStep === 4 && !titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
}
if (currentStep < 7) {
setCurrentStep((s) => s + 1)
}
}
const goPrev = () => {
if (currentStep > 1) {
setCurrentStep((s) => s - 1)
}
}
/* ── 渲染当前步骤 ── */
const renderCurrentStep = () => {
switch (currentStep) {
case 1:
return (
<Step1TemplateSelect
templates={userTemplates}
selectedTemplate={selectedTemplate}
onSelectTemplate={setSelectedTemplate}
/>
)
case 2:
return (
<Step2MaterialSelect
materialMode={materialMode}
onMaterialModeChange={setMaterialMode}
selectedMaterials={selectedMaterials}
onSelectedMaterialsChange={setSelectedMaterials}
smartSelectedIds={smartSelectedIds}
onSmartSelectedIdsChange={setSmartSelectedIds}
/>
)
case 3:
return (
<Step3GeneratePreview
templates={userTemplates}
selectedTemplate={selectedTemplate}
materialMode={materialMode}
selectedMaterials={selectedMaterials}
smartSelectedIds={smartSelectedIds}
duration={duration}
videoRatio={videoRatio}
/>
)
case 4:
return (
<Step4TitleSettings
titleSettings={titleSettings}
onTitleSettingsChange={setTitleSettings}
/>
)
case 5:
return (
<Step5VoiceSelect
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
voiceMode={voiceMode}
onVoiceModeChange={setVoiceMode}
selectedClonedVoice={selectedClonedVoice}
onSelectedClonedVoiceChange={setSelectedClonedVoice}
clonedVoices={clonedVoices}
addClone={addClone}
hasProcessing={hasProcessing}
cloneModalOpen={cloneModalOpen}
onCloneModalOpenChange={setCloneModalOpen}
titleText={titleSettings.title}
/>
)
case 6:
return (
<Step6CoverSettings
coverSettings={coverSettings}
onCoverSettingsChange={setCoverSettings}
duration={duration}
/>
)
case 7:
return (
<Step7ConfirmGenerate
templates={userTemplates}
selectedTemplate={selectedTemplate}
materialMode={materialMode}
selectedMaterials={selectedMaterials}
smartSelectedIds={smartSelectedIds}
title={titleSettings.title}
voiceMode={voiceMode}
selectedVoice={selectedVoice}
selectedClonedVoice={selectedClonedVoice}
presetVoices={presetVoices}
clonedVoices={clonedVoices}
coverSettings={coverSettings}
generateCount={generateCount}
onGenerateCountChange={setGenerateCount}
generating={generating}
generated={generated}
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
onRetry={handleRetryGenerate}
onDismissError={handleDismissError}
/>
)
default:
return null
}
}
/* ================================================================
渲染 — 主页面
================================================================ */
@@ -402,35 +135,55 @@ const GeneratePage: React.FC = () => {
<div className="xx-generate-layout">
{/* ════ 左侧:表单区 ════ */}
<div className="xx-generate-form">
{/* 当前步骤 */}
{renderCurrentStep()}
<GenerateStepContent
currentStep={currentStep}
userTemplates={userTemplates}
selectedTemplate={selectedTemplate}
onSelectTemplate={setSelectedTemplate}
materialMode={materialMode}
onMaterialModeChange={setMaterialMode}
selectedMaterials={selectedMaterials}
onSelectedMaterialsChange={setSelectedMaterials}
smartSelectedIds={smartSelectedIds}
onSmartSelectedIdsChange={setSmartSelectedIds}
titleSettings={titleSettings}
onTitleSettingsChange={setTitleSettings}
coverSettings={coverSettings}
onCoverSettingsChange={setCoverSettings}
duration={duration}
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
voiceMode={voiceMode}
onVoiceModeChange={setVoiceMode}
selectedClonedVoice={selectedClonedVoice}
onSelectedClonedVoiceChange={setSelectedClonedVoice}
clonedVoices={clonedVoices}
addClone={addClone}
hasProcessing={hasProcessing}
cloneModalOpen={cloneModalOpen}
onCloneModalOpenChange={setCloneModalOpen}
generateCount={generateCount}
onGenerateCountChange={setGenerateCount}
generating={generating}
generated={generated}
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
onRetry={handleRetryGenerate}
onDismissError={handleDismissError}
presetVoices={presetVoices}
videoRatio={videoRatio}
/>
{/* 底部操作按钮 */}
<div className="xx-step-actions">
<button className="xx-btn xx-btn-ghost" onClick={goPrev} disabled={currentStep === 1}>
</button>
{currentStep < 7 ? (
<button className="xx-btn xx-btn-primary" onClick={goNext}>
</button>
) : (
<button
className="xx-btn xx-btn-primary"
onClick={handleGenerate}
disabled={generating || (generated && !generateError)}
>
<ThunderboltOutlined />
{generating
? "生成中…"
: generated && !generateError
? "已生成"
: generateError
? "🔄 重新生成"
: "✨ 确认生成"}
</button>
)}
</div>
<GenerateStepActions
currentStep={currentStep}
onPrev={goPrev}
onNext={goNext}
onGenerate={handleGenerate}
generating={generating}
generated={generated}
generateError={generateError}
/>
</div>
{/* ════ 右侧:生成结果 ════ */}
@@ -0,0 +1,55 @@
/**
* GeneratePage 步骤底部操作按钮
*/
import React from "react"
import { ThunderboltOutlined } from "@ant-design/icons"
export interface GenerateStepActionsProps {
currentStep: number
onPrev: () => void
onNext: () => void
onGenerate: () => void
generating: boolean
generated: boolean
generateError: string | null
}
export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
currentStep,
onPrev,
onNext,
onGenerate,
generating,
generated,
generateError,
}) => {
return (
<div className="xx-step-actions">
<button className="xx-btn xx-btn-ghost" onClick={onPrev} disabled={currentStep === 1}>
</button>
{currentStep < 7 ? (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
</button>
) : (
<button
className="xx-btn xx-btn-primary"
onClick={onGenerate}
disabled={generating || (generated && !generateError)}
>
<ThunderboltOutlined />
{generating
? "生成中…"
: generated && !generateError
? "已生成"
: generateError
? "🔄 重新生成"
: "✨ 确认生成"}
</button>
)}
</div>
)
}
export default GenerateStepActions
@@ -0,0 +1,203 @@
/**
* GeneratePage 步骤内容渲染
* 根据当前步骤渲染对应的 Step 组件
*/
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
import type { PresetVoiceItem } from "@/api/voices"
import type { VoiceClone } from "@/api/voice-clone"
import type { CoverConfig } from "../../editing-planner/types"
import type { TitleSettings } from "../types"
import Step1TemplateSelect from "../components/Step1TemplateSelect"
import Step2MaterialSelect from "../components/Step2MaterialSelect"
import Step3GeneratePreview from "../components/Step3GeneratePreview"
import Step4TitleSettings from "../components/Step4TitleSettings"
import Step5VoiceSelect from "../components/Step5VoiceSelect"
import Step6CoverSettings from "../components/Step6CoverSettings"
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
import type { GeneratedVideo } from "@/api/template-editor"
export interface GenerateStepContentProps {
currentStep: number
/* 模板 */
userTemplates: EditingTemplate[]
selectedTemplate: string
onSelectTemplate: (id: string) => void
/* 素材 */
materialMode: "manual" | "auto"
onMaterialModeChange: (mode: "manual" | "auto") => void
selectedMaterials: string[]
onSelectedMaterialsChange: (ids: string[]) => void
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
/* 标题 */
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
/* 封面 */
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
duration: number
/* 配音 */
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
voiceMode: "preset" | "custom" | "clone"
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
onSelectedClonedVoiceChange: (id: string) => void
clonedVoices: VoiceClone[]
addClone: (voice: VoiceClone) => void
hasProcessing: boolean
cloneModalOpen: boolean
onCloneModalOpenChange: (open: boolean) => void
/* 生成 */
generateCount: number
onGenerateCountChange: (n: number) => void
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
onRetry: () => void
onDismissError: () => void
/* 其他 */
presetVoices: PresetVoiceItem[]
videoRatio: string
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
const {
currentStep,
userTemplates,
selectedTemplate,
onSelectTemplate,
materialMode,
onMaterialModeChange,
selectedMaterials,
onSelectedMaterialsChange,
smartSelectedIds,
onSmartSelectedIdsChange,
titleSettings,
onTitleSettingsChange,
coverSettings,
onCoverSettingsChange,
duration,
selectedVoice,
onSelectedVoiceChange,
voiceMode,
onVoiceModeChange,
selectedClonedVoice,
onSelectedClonedVoiceChange,
clonedVoices,
addClone,
hasProcessing,
cloneModalOpen,
onCloneModalOpenChange,
generateCount,
onGenerateCountChange,
generating,
generated,
generateError,
progress,
generatedVideos,
onRetry,
onDismissError,
presetVoices,
videoRatio,
} = props
switch (currentStep) {
case 1:
return (
<Step1TemplateSelect
templates={userTemplates}
selectedTemplate={selectedTemplate}
onSelectTemplate={onSelectTemplate}
/>
)
case 2:
return (
<Step2MaterialSelect
materialMode={materialMode}
onMaterialModeChange={onMaterialModeChange}
selectedMaterials={selectedMaterials}
onSelectedMaterialsChange={onSelectedMaterialsChange}
smartSelectedIds={smartSelectedIds}
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
/>
)
case 3:
return (
<Step3GeneratePreview
templates={userTemplates}
selectedTemplate={selectedTemplate}
materialMode={materialMode}
selectedMaterials={selectedMaterials}
smartSelectedIds={smartSelectedIds}
duration={duration}
videoRatio={videoRatio}
/>
)
case 4:
return (
<Step4TitleSettings
titleSettings={titleSettings}
onTitleSettingsChange={onTitleSettingsChange}
/>
)
case 5:
return (
<Step5VoiceSelect
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
voiceMode={voiceMode}
onVoiceModeChange={onVoiceModeChange}
selectedClonedVoice={selectedClonedVoice}
onSelectedClonedVoiceChange={onSelectedClonedVoiceChange}
clonedVoices={clonedVoices}
addClone={addClone}
hasProcessing={hasProcessing}
cloneModalOpen={cloneModalOpen}
onCloneModalOpenChange={onCloneModalOpenChange}
titleText={titleSettings.title}
/>
)
case 6:
return (
<Step6CoverSettings
coverSettings={coverSettings}
onCoverSettingsChange={onCoverSettingsChange}
duration={duration}
/>
)
case 7:
return (
<Step7ConfirmGenerate
templates={userTemplates}
selectedTemplate={selectedTemplate}
materialMode={materialMode}
selectedMaterials={selectedMaterials}
smartSelectedIds={smartSelectedIds}
title={titleSettings.title}
voiceMode={voiceMode}
selectedVoice={selectedVoice}
selectedClonedVoice={selectedClonedVoice}
presetVoices={presetVoices}
clonedVoices={clonedVoices}
coverSettings={coverSettings}
generateCount={generateCount}
onGenerateCountChange={onGenerateCountChange}
generating={generating}
generated={generated}
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
onRetry={onRetry}
onDismissError={onDismissError}
/>
)
default:
return null
}
}
export default GenerateStepContent
@@ -0,0 +1,64 @@
/**
* 错误提取工具
* 从各种响应格式中安全提取错误消息
*/
/** 安全提取字符串错误信息 */
export const safeExtractError = (val: unknown): string => {
if (typeof val === "string") return val
if (typeof val === "object" && val !== null) {
const obj = val as Record<string, unknown>
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 safeExtractError(obj.message)
return JSON.stringify(val)
}
return String(val ?? "")
}
/** 错误信息翻译/友好化 */
export 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 "生成失败,请稍后重试或联系管理员"
}
/** 从 axios 错误对象中提取后端消息 */
export const extractBackendError = (err: unknown): string => {
const axiosErr = err as {
response?: {
data?: {
message?: string | object
error?: string | object
detail?: string | object
msg?: string | object
}
}
message?: string
}
return (
safeExtractError(axiosErr.response?.data?.message) ||
safeExtractError(axiosErr.response?.data?.error) ||
safeExtractError(axiosErr.response?.data?.detail) ||
safeExtractError(axiosErr.response?.data?.msg) ||
axiosErr.message ||
""
)
}
@@ -0,0 +1,9 @@
import type { GenerationPhase } from "./types"
/** 生成阶段映射 */
export const getGenerationPhase = (p: number): GenerationPhase => {
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
return { label: "即将完成", icon: "✨" }
}
@@ -0,0 +1,48 @@
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
import type { CoverConfig } from "../../../editing-planner/types"
import type { TitleSettings } from "../../types"
/** useGenerateVideo 入参 */
export 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 interface GenerationPhase {
label: string
icon: string
}
/** useGenerateVideo 返回值 */
export interface UseGenerateVideoResult {
generating: boolean
progress: number
generated: boolean
generateError: string | null
generatedVideos: GeneratedVideo[]
generate: () => Promise<void>
retry: () => void
dismissError: () => void
download: () => Promise<void>
share: () => Promise<void>
getGenerationPhase: (p: number) => GenerationPhase
}
export type EditPlanVoiceConfig = Pick<
EditPlanConfig,
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
>
@@ -0,0 +1,87 @@
import { useRef, useCallback } from "react"
import { message } from "antd"
import { getGenerationStatus, getGenerationTaskResults } from "@/api/template-editor"
import { safeExtractError } from "./errorUtils"
interface UseGenerationPollingOptions {
templateId: string
onProgress: (progress: number) => void
onComplete: (videos: unknown[]) => void
onFailed: (errorMsg: string) => void
}
/**
* 生成状态轮询 Hook
* 轮询生成状态,更新进度,处理完成/失败
*/
export const useGenerationPolling = ({
templateId,
onProgress,
onComplete,
onFailed,
}: UseGenerationPollingOptions) => {
const progressTimer = useRef<ReturnType<typeof setTimeout>>()
const clearTimer = useCallback(() => {
if (progressTimer.current) {
clearTimeout(progressTimer.current)
progressTimer.current = undefined
}
}, [])
const startPolling = useCallback(() => {
const poll = async () => {
try {
const data = await getGenerationStatus(templateId)
if (data.plan_status === "completed") {
onProgress(100)
// 获取生成的视频结果
let videos: unknown[] = []
if (data.generation_task_id) {
try {
videos = await getGenerationTaskResults(data.generation_task_id)
} catch (err) {
console.error("[获取生成结果失败]", err)
}
}
onComplete(videos)
message.success("视频生成完成!")
return
}
if (data.plan_status === "failed") {
const dataAny = data as unknown as Record<string, unknown>
const rawMsg =
dataAny.error_message ||
dataAny.error ||
dataAny.message ||
(Array.isArray(data.clips)
? (data.clips as { status: string; error_message?: string }[]).find(
(c) => c.status === "failed",
)?.error_message
: undefined) ||
"视频生成失败,请联系管理员或重试"
const errorMsg = safeExtractError(rawMsg)
console.error("[生成失败] templateId:", templateId, "响应:", data)
onFailed(errorMsg)
message.error(errorMsg)
return
}
const clips = data.clips || []
const total = clips.length || 1
const done = (clips as { status: string }[]).filter((c) => c.status === "completed").length
onProgress(Math.round((done / total) * 100))
progressTimer.current = setTimeout(poll, 2000)
} catch (pollErr) {
console.error("[轮询出错] templateId:", templateId, pollErr)
progressTimer.current = setTimeout(poll, 3000)
}
}
progressTimer.current = setTimeout(poll, 2000)
}, [templateId, onProgress, onComplete, onFailed])
return { startPolling, clearTimer }
}
@@ -0,0 +1,33 @@
import type { EditPlanConfig } from "@/api/template-editor"
type VoiceConfig = Pick<
EditPlanConfig,
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
>
interface BuildVoiceConfigOptions {
voiceMode: "preset" | "custom" | "clone"
selectedVoice: string
selectedClonedVoice: string
}
/**
* 根据 voiceMode 构建配音配置
*/
export const buildVoiceConfig = ({
voiceMode,
selectedVoice,
selectedClonedVoice,
}: BuildVoiceConfigOptions): VoiceConfig => {
const voiceConfig: VoiceConfig = {}
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 组件传回
}
return voiceConfig
}
@@ -0,0 +1,309 @@
/**
* GeneratePage 表单状态管理
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
*/
import { useState, useEffect, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { useSearchParams } from "react-router-dom"
import type { GeneratedVideo, TitleConfig } from "@/api/template-editor"
import type { EditingTemplate } from "@/api/editing-planner"
import { getEditPlan } from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import { getEditingTemplates } from "@/api/editing-planner"
import type { PresetVoiceItem } from "@/api/voices"
import { fetchPresetVoices } from "@/api/voices"
import { DEFAULT_COVER_SETTINGS } from "../constants"
import type { TitleSettings } from "../types"
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
}
export interface GenerateFormState {
/* 步骤 */
currentStep: number
setCurrentStep: (step: number | ((prev: number) => number)) => void
/* 模板 */
selectedTemplate: string
setSelectedTemplate: (id: string) => void
userTemplates: EditingTemplate[]
/* 素材 */
selectedMaterials: string[]
setSelectedMaterials: (ids: string[]) => void
materialMode: "manual" | "auto"
setMaterialMode: (mode: "manual" | "auto") => void
smartSelectedIds: string[]
setSmartSelectedIds: (ids: string[]) => void
/* 标题 */
titleSettings: TitleSettings
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
/* 封面 */
coverSettings: CoverConfig
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
/* 配音 */
selectedVoice: string
setSelectedVoice: (id: string) => void
voiceMode: "preset" | "custom" | "clone"
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
setSelectedClonedVoice: (id: string) => void
presetVoices: PresetVoiceItem[]
/* 克隆弹窗 */
cloneModalOpen: boolean
setCloneModalOpen: (open: boolean) => void
/* 生成数量 */
generateCount: number
setGenerateCount: (n: number) => void
/* 高级设置 */
videoRatio: string
duration: number
style: string
autoSubtitles: boolean
bgm: boolean
/* URL 参数 */
editPlanId: string | null
planConfigStr: string | null
/* 预览弹窗 */
previewVideo: GeneratedVideo | null
setPreviewVideo: (v: GeneratedVideo | null) => void
previewModalOpen: boolean
setPreviewModalOpen: (open: boolean) => void
}
export const useGenerateFormState = (): GenerateFormState => {
const [searchParams] = useSearchParams()
const editPlanId = searchParams.get("edit_plan_id")
const planConfigStr = searchParams.get("plan_config")
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板(从 API 加载) ── */
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery({
queryKey: ["generate-templates"],
queryFn: () => getEditingTemplates(),
staleTime: 60_000,
})
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [userTemplates, selectedTemplate])
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
/* ── 标题设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
/* ── 封面设置 ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */
useEffect(() => {
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
if (tpl?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: tpl.title_config!.ai_auto_select,
title: tpl.title_config!.content || prev.title,
position: tpl.title_config!.position || prev.position,
font: tpl.title_config!.font_preset || prev.font,
size: tpl.title_config!.font_size || prev.size,
color: tpl.title_config!.font_color || prev.color,
}))
}
if (tpl?.cover_config) {
setCoverSettings((prev) => ({
...prev,
enabled: tpl.cover_config!.enabled ?? prev.enabled,
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
}))
}
}, [selectedTemplate, userTemplates])
/* ── 配音 ── */
const [selectedVoice, setSelectedVoice] = useState("")
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
/* ── 预置音色 API ── */
const { data: presetVoicesData } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
/* ── 克隆声音弹窗 ── */
const [cloneModalOpen, setCloneModalOpen] = useState(false)
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1)
/* ── 高级设置(隐藏但保留) ── */
const [videoRatio] = useState("16:9")
const [duration] = useState(30)
const [style] = useState("business")
const [autoSubtitles] = useState(true)
const [bgm] = useState(true)
/* ── 预览弹窗 ── */
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
const [previewModalOpen, setPreviewModalOpen] = useState(false)
/** 解析 plan_config 并自动填充表单 */
useEffect(() => {
if (!planConfigStr) return
try {
const config = JSON.parse(planConfigStr) as {
title_config?: {
content?: string
ai_auto_select?: boolean
position?: string
font_preset?: string
font_size?: number
font_color?: string
}
subtitle_config?: { enabled?: boolean }
bgm_config?: { enabled?: boolean; music_id?: string }
mode?: string
total_duration?: number
segments?: Array<{ media_asset_id?: string; material_type?: string }>
}
if (config.title_config) {
const tc = config.title_config as TitleConfig
setTitleSettings((prev) => ({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: tc.position || prev.position,
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
}))
}
if (config.segments && config.segments.length > 0) {
const assetIds = config.segments
.map((s) => s.media_asset_id)
.filter((id): id is string => !!id)
if (assetIds.length > 0) {
setSelectedMaterials(assetIds)
}
}
} catch (err) {
console.warn("解析 plan_config 失败:", err)
}
}, [planConfigStr])
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
useEffect(() => {
if (!editPlanId || planConfigStr) return
const loadPlanConfig = async () => {
try {
const plan = await getEditPlan(editPlanId)
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
const cfg = plan.config
if (cfg?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: cfg.title_config!.position || prev.position,
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
}))
}
if (cfg?.cover_config) {
const cc = cfg.cover_config as CoverConfig
setCoverSettings((prev) => ({
...prev,
enabled: cc.enabled ?? prev.enabled,
mode: cc.mode || prev.mode,
frame_time: cc.frame_time ?? prev.frame_time,
upload_url: cc.upload_url || prev.upload_url,
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
}))
}
if (cfg?.asset_ids) {
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
}
} catch (err) {
console.warn("加载模板草稿配置失败:", err)
}
}
loadPlanConfig()
}, [editPlanId, planConfigStr])
return {
currentStep,
setCurrentStep,
selectedTemplate,
setSelectedTemplate,
userTemplates,
selectedMaterials,
setSelectedMaterials,
materialMode,
setMaterialMode,
smartSelectedIds,
setSmartSelectedIds,
titleSettings,
setTitleSettings,
coverSettings,
setCoverSettings,
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
cloneModalOpen,
setCloneModalOpen,
generateCount,
setGenerateCount,
videoRatio,
duration,
style,
autoSubtitles,
bgm,
editPlanId,
planConfigStr,
previewVideo,
setPreviewVideo,
previewModalOpen,
setPreviewModalOpen,
}
}
@@ -2,54 +2,35 @@
* 视频生成 Hook
* 封装视频生成的核心逻辑、状态管理、轮询等
*/
import { useState, useRef, useCallback } from "react"
import { useState, 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"
import type { GeneratedVideo } from "@/api/template-editor"
import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-editor"
import type { UseGenerateVideoProps } from "./generate-video/types"
import { getGenerationPhase } from "./generate-video/phase"
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
import { buildVoiceConfig } from "./generate-video/voiceConfig"
import { extractBackendError, translateError } from "./generate-video/errorUtils"
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(props: UseGenerateVideoProps) {
const {
titleSettings,
selectedTemplate,
selectedMaterials,
materialMode,
smartSelectedIds,
voiceMode,
selectedVoice,
selectedClonedVoice,
coverSettings,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
generateCount,
} = props
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)
@@ -57,24 +38,26 @@ export function useGenerateVideo({
const [generateError, setGenerateError] = useState<string | null>(null)
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined)
const handleProgress = useCallback((p: number) => setProgress(p), [])
const handleComplete = useCallback((videos: unknown[]) => {
setGenerating(false)
setGenerated(true)
setGeneratedVideos(videos as GeneratedVideo[])
}, [])
const handleFailed = useCallback((errorMsg: string) => {
setGenerating(false)
setGenerateError(errorMsg)
}, [])
/* ── 生成阶段映射 ── */
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 { startPolling, clearTimer } = useGenerationPolling({
templateId: selectedTemplate,
onProgress: handleProgress,
onComplete: handleComplete,
onFailed: handleFailed,
})
/* ── 生成视频 ── */
const generate = useCallback(async () => {
console.log("[handleGenerate] 开始生成, 参数:", {
titleSettings,
selectedTemplate,
selectedMaterials,
voiceMode,
})
if (!titleSettings.title.trim()) {
message.warning("请先选择或输入标题")
return
@@ -83,7 +66,6 @@ export function useGenerateVideo({
message.warning("请至少选择一个素材")
return
}
if (voiceMode === "clone" && !selectedClonedVoice) {
message.warning("请先选择一个克隆音色")
return
@@ -93,21 +75,14 @@ export function useGenerateVideo({
setProgress(0)
setGenerated(false)
setGenerateError(null)
clearTimer()
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 组件传回
}
const voiceConfig = buildVoiceConfig({
voiceMode,
selectedVoice,
selectedClonedVoice,
})
// 获取或创建草稿
await getEditPlan(selectedTemplate)
@@ -140,149 +115,13 @@ export function useGenerateVideo({
})
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>
startPolling()
} 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)
const backendMsg = extractBackendError(err)
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", err)
const finalMsg = translateError(backendMsg)
setGenerateError(finalMsg)
message.error(finalMsg)
}
@@ -302,6 +141,8 @@ export function useGenerateVideo({
materialMode,
coverSettings,
smartSelectedIds,
clearTimer,
startPolling,
])
/* 重新生成(失败后重试) */
@@ -0,0 +1,63 @@
/**
* GeneratePage 步骤导航
* 管理步骤切换与各步骤的前置校验
*/
import { message } from "antd"
import type { TitleSettings } from "../types"
export interface UseStepNavigationOptions {
currentStep: number
setCurrentStep: (step: number | ((prev: number) => number)) => void
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
titleSettings: TitleSettings
}
export interface UseStepNavigationReturn {
goNext: () => void
goPrev: () => void
}
export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNavigationReturn => {
const {
currentStep,
setCurrentStep,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
titleSettings,
} = options
const goNext = () => {
if (currentStep === 1 && !selectedTemplate) {
message.warning("请先选择一个模板")
return
}
if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材")
return
}
if (currentStep === 2 && materialMode === "auto" && smartSelectedIds.length === 0) {
message.warning("请先进行智能匹配并选择素材")
return
}
if (currentStep === 4 && !titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
}
if (currentStep < 7) {
setCurrentStep((s) => s + 1)
}
}
const goPrev = () => {
if (currentStep > 1) {
setCurrentStep((s) => s - 1)
}
}
return { goNext, goPrev }
}
+41 -294
View File
@@ -1,259 +1,47 @@
/**
* 我的音色页面 — V21 设计系统(任务 3.12 升级 / 3.15 进度轮询)
* 我的音色页面 — V21 设计系统
*
* 功能:克隆音色卡片列表、试听播放、状态标签、删除、编辑名称、空状态引导
* 使用 useCloneProgress hook 实现 processing 状态自动轮询
*/
import React, { useState, useCallback, useRef } from "react"
import { useNavigate } from "react-router-dom"
import {
PlayCircleOutlined,
PauseCircleOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
SoundOutlined,
ClockCircleOutlined,
} from "@ant-design/icons"
import { Button, Modal, Input, Tooltip } from "@/components/ui"
import React from "react"
import { PlusOutlined } from "@ant-design/icons"
import { Button, Modal, Input } from "@/components/ui"
import type { ButtonProps } from "antd"
import PageHead from "@/components/layout/PageHead"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import { deleteVoiceClone, updateVoiceClone, formatDuration } from "@/api/voice-clone"
import type { VoiceClone, VoiceCloneStatus } from "@/api/voice-clone"
import { VoiceCard } from "./components/VoiceCard"
import {
StatsBar,
EmptyState,
LoadingState,
PollingHint,
ToastContainer,
} from "./components/States"
import { useMyVoices } from "./hooks/useMyVoices"
import "./my-voices.css"
/* ============================================================
* 工具函数
* ============================================================ */
function formatDate(isoStr: string): string {
const d = new Date(isoStr)
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
}
/* ============================================================
* 状态配置
* ============================================================ */
const STATUS_CONFIG: Record<VoiceCloneStatus, { label: string; dotClass: string }> = {
ready: { label: "就绪", dotClass: "xx-mv-status-dot--ready" },
processing: { label: "克隆中", dotClass: "xx-mv-status-dot--processing" },
failed: { label: "失败", dotClass: "xx-mv-status-dot--failed" },
}
/* ============================================================
* Toast 组件
* ============================================================ */
interface ToastItem {
id: number
message: string
type: "success" | "error"
}
let _toastId = 0
/* ============================================================
* 音色卡片组件
* ============================================================ */
interface VoiceCardProps {
voice: VoiceClone
isPlaying: boolean
onTogglePlay: (voice: VoiceClone) => void
onEdit: (voice: VoiceClone) => void
onDelete: (voice: VoiceClone) => void
}
const VoiceCard: React.FC<VoiceCardProps> = ({
voice,
isPlaying,
onTogglePlay,
onEdit,
onDelete,
}) => {
const statusCfg = STATUS_CONFIG[voice.status]
const isReady = voice.status === "ready"
return (
<div className={`xx-mv-card xx-mv-card--${voice.status}`}>
{/* 头部:头像 + 名称 + 状态 */}
<div className="xx-mv-card-header">
<div className={`xx-mv-card-avatar xx-mv-card-avatar--${voice.status}`}>
<SoundOutlined />
</div>
<div className="xx-mv-card-info">
<h4 className="xx-mv-card-name">{voice.name}</h4>
<span className="xx-mv-status">
<span className={`xx-mv-status-dot ${statusCfg.dotClass}`} />
{statusCfg.label}
</span>
</div>
</div>
{/* 元信息 */}
<div className="xx-mv-card-meta">
<span className="xx-mv-card-meta-item">
<ClockCircleOutlined /> {formatDate(voice.created_at)}
</span>
{voice.duration_seconds > 0 && (
<span className="xx-mv-card-meta-item">{formatDuration(voice.duration_seconds)}</span>
)}
</div>
{/* 进度条(克隆中 — indeterminate 条纹流动动画) */}
{voice.status === "processing" && (
<div className="xx-mv-progress xx-mv-progress--indeterminate">
<div className="xx-mv-progress-bar" />
<span className="xx-mv-progress-text"></span>
</div>
)}
{/* 操作区 */}
<div className="xx-mv-card-actions">
{isReady ? (
<Button
buttonType={isPlaying ? "secondary" : "ghost"}
buttonSize="sm"
onClick={() => onTogglePlay(voice)}
>
{isPlaying ? (
<>
<PauseCircleOutlined />
</>
) : (
<>
<PlayCircleOutlined />
</>
)}
</Button>
) : voice.status === "failed" ? (
<Button buttonType="ghost" buttonSize="sm" disabled>
</Button>
) : (
<Button buttonType="ghost" buttonSize="sm" disabled>
...
</Button>
)}
<div className="xx-mv-card-icon-actions">
<Tooltip title="编辑名称">
<button
type="button"
className="xx-mv-icon-btn"
onClick={() => onEdit(voice)}
disabled={!isReady}
>
<EditOutlined />
</button>
</Tooltip>
<Tooltip title="删除">
<button
type="button"
className="xx-mv-icon-btn xx-mv-icon-btn--danger"
onClick={() => onDelete(voice)}
>
<DeleteOutlined />
</button>
</Tooltip>
</div>
</div>
</div>
)
}
/* ============================================================
* 主页面组件
* ============================================================ */
const MyVoices: React.FC = () => {
const navigate = useNavigate()
const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress()
const [playingId, setPlayingId] = useState<string | null>(null)
const [toasts, setToasts] = useState<ToastItem[]>([])
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(null)
const [editName, setEditName] = useState("")
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
// Toast
const showToast = useCallback((message: string, type: ToastItem["type"]) => {
const id = ++_toastId
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000)
}, [])
// 试听播放
const handleTogglePlay = useCallback(
(voice: VoiceClone) => {
if (playingId === voice.id) {
audioRef.current?.pause()
setPlayingId(null)
return
}
if (audioRef.current) {
audioRef.current.pause()
}
if (!voice.sample_url) {
showToast("暂无试听音频", "error")
return
}
const audio = new Audio(voice.sample_url)
audioRef.current = audio
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"))
audio.onended = () => setPlayingId(null)
setPlayingId(voice.id)
},
[playingId, showToast],
)
// 编辑
const handleEdit = (voice: VoiceClone) => {
setEditingVoice(voice)
setEditName(voice.name)
setEditModalOpen(true)
}
const handleEditConfirm = async () => {
if (!editingVoice || !editName.trim()) return
try {
const updated = await updateVoiceClone(editingVoice.id, {
name: editName.trim(),
})
updateClone(updated)
setEditModalOpen(false)
setEditingVoice(null)
showToast("名称已更新", "success")
} catch {
showToast("更新失败,请重试", "error")
}
}
// 删除
const handleDelete = (voice: VoiceClone) => {
setDeleteConfirmId(voice.id)
}
const handleDeleteConfirm = async () => {
if (!deleteConfirmId) return
try {
await deleteVoiceClone(deleteConfirmId)
removeClone(deleteConfirmId)
setDeleteConfirmId(null)
showToast("音色已删除", "success")
} catch {
showToast("删除失败,请重试", "error")
}
}
// 克隆新音色
const handleCloneNew = () => {
navigate("/app/voices")
}
// 统计
const readyCount = clones.filter((v) => v.status === "ready").length
const processingCount = clones.filter((v) => v.status === "processing").length
const {
clones,
loading,
hasProcessing,
playingId,
toasts,
editModalOpen,
editName,
setEditName,
deleteConfirmId,
readyCount,
processingCount,
handleTogglePlay,
handleEdit,
handleEditCancel,
handleEditConfirm,
handleDelete,
handleDeleteCancel,
handleDeleteConfirm,
handleCloneNew,
} = useMyVoices()
return (
<div className="xx-mv-page">
@@ -269,39 +57,15 @@ const MyVoices: React.FC = () => {
/>
{/* 轮询提示 */}
{hasProcessing && (
<div className="xx-mv-polling-hint">
<span className="xx-mv-polling-dot" />
...
</div>
)}
{hasProcessing && <PollingHint />}
{/* 统计栏 */}
{!loading && clones.length > 0 && (
<div className="xx-mv-stats">
<span className="xx-mv-stat">
<strong>{clones.length}</strong>
</span>
<span className="xx-mv-stat xx-mv-stat--ready">
<span className="xx-mv-stat-dot xx-mv-stat-dot--ready" />
{readyCount}
</span>
{processingCount > 0 && (
<span className="xx-mv-stat xx-mv-stat--processing">
<span className="xx-mv-stat-dot xx-mv-stat-dot--processing" />
{processingCount}
</span>
)}
</div>
<StatsBar total={clones.length} readyCount={readyCount} processingCount={processingCount} />
)}
{/* 加载状态 */}
{loading && (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon"></div>
<p className="xx-mv-empty-desc">...</p>
</div>
)}
{loading && <LoadingState />}
{/* 卡片网格 */}
{!loading && clones.length > 0 && (
@@ -320,22 +84,13 @@ const MyVoices: React.FC = () => {
)}
{/* 空状态 */}
{!loading && clones.length === 0 && (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon">🎤</div>
<h3 className="xx-mv-empty-title"></h3>
<p className="xx-mv-empty-desc">AI </p>
<Button buttonType="primary" onClick={handleCloneNew}>
<PlusOutlined />
</Button>
</div>
)}
{!loading && clones.length === 0 && <EmptyState onClone={handleCloneNew} />}
{/* 编辑弹窗 */}
<Modal
open={editModalOpen}
title="编辑音色名称"
onCancel={() => setEditModalOpen(false)}
onCancel={handleEditCancel}
onOk={handleEditConfirm}
okText="保存"
cancelText="取消"
@@ -355,7 +110,7 @@ const MyVoices: React.FC = () => {
<Modal
open={!!deleteConfirmId}
title="确认删除"
onCancel={() => setDeleteConfirmId(null)}
onCancel={handleDeleteCancel}
onOk={handleDeleteConfirm}
okText="删除"
cancelText="取消"
@@ -365,15 +120,7 @@ const MyVoices: React.FC = () => {
</Modal>
{/* Toast */}
{toasts.length > 0 && (
<div className="xx-mv-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`xx-mv-toast xx-mv-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)}
<ToastContainer toasts={toasts} />
</div>
)
}
@@ -0,0 +1,79 @@
import React from "react"
import { PlusOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import type { ToastItem } from "../types"
interface StatsBarProps {
total: number
readyCount: number
processingCount: number
}
/** 统计栏 */
export const StatsBar: React.FC<StatsBarProps> = ({ total, readyCount, processingCount }) => (
<div className="xx-mv-stats">
<span className="xx-mv-stat">
<strong>{total}</strong>
</span>
<span className="xx-mv-stat xx-mv-stat--ready">
<span className="xx-mv-stat-dot xx-mv-stat-dot--ready" />
{readyCount}
</span>
{processingCount > 0 && (
<span className="xx-mv-stat xx-mv-stat--processing">
<span className="xx-mv-stat-dot xx-mv-stat-dot--processing" />
{processingCount}
</span>
)}
</div>
)
interface EmptyStateProps {
onClone: () => void
}
/** 空状态 */
export const EmptyState: React.FC<EmptyStateProps> = ({ onClone }) => (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon">🎤</div>
<h3 className="xx-mv-empty-title"></h3>
<p className="xx-mv-empty-desc">AI </p>
<Button buttonType="primary" onClick={onClone}>
<PlusOutlined />
</Button>
</div>
)
/** 加载状态 */
export const LoadingState: React.FC = () => (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon"></div>
<p className="xx-mv-empty-desc">...</p>
</div>
)
/** 轮询提示 */
export const PollingHint: React.FC = () => (
<div className="xx-mv-polling-hint">
<span className="xx-mv-polling-dot" />
...
</div>
)
interface ToastContainerProps {
toasts: ToastItem[]
}
/** Toast 容器 */
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts }) => {
if (toasts.length === 0) return null
return (
<div className="xx-mv-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`xx-mv-toast xx-mv-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)
}
@@ -0,0 +1,121 @@
import React from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
DeleteOutlined,
EditOutlined,
SoundOutlined,
ClockCircleOutlined,
} from "@ant-design/icons"
import { Button, Tooltip } from "@/components/ui"
import { formatDuration, type VoiceClone } from "@/api/voice-clone"
import { STATUS_CONFIG } from "../types"
import { formatDate } from "../utils"
interface VoiceCardProps {
voice: VoiceClone
isPlaying: boolean
onTogglePlay: (voice: VoiceClone) => void
onEdit: (voice: VoiceClone) => void
onDelete: (voice: VoiceClone) => void
}
/**
* 音色卡片组件
*/
export const VoiceCard: React.FC<VoiceCardProps> = ({
voice,
isPlaying,
onTogglePlay,
onEdit,
onDelete,
}) => {
const statusCfg = STATUS_CONFIG[voice.status]
const isReady = voice.status === "ready"
return (
<div className={`xx-mv-card xx-mv-card--${voice.status}`}>
{/* 头部:头像 + 名称 + 状态 */}
<div className="xx-mv-card-header">
<div className={`xx-mv-card-avatar xx-mv-card-avatar--${voice.status}`}>
<SoundOutlined />
</div>
<div className="xx-mv-card-info">
<h4 className="xx-mv-card-name">{voice.name}</h4>
<span className="xx-mv-status">
<span className={`xx-mv-status-dot ${statusCfg.dotClass}`} />
{statusCfg.label}
</span>
</div>
</div>
{/* 元信息 */}
<div className="xx-mv-card-meta">
<span className="xx-mv-card-meta-item">
<ClockCircleOutlined /> {formatDate(voice.created_at)}
</span>
{voice.duration_seconds > 0 && (
<span className="xx-mv-card-meta-item">{formatDuration(voice.duration_seconds)}</span>
)}
</div>
{/* 进度条(克隆中 — indeterminate 条纹流动动画) */}
{voice.status === "processing" && (
<div className="xx-mv-progress xx-mv-progress--indeterminate">
<div className="xx-mv-progress-bar" />
<span className="xx-mv-progress-text"></span>
</div>
)}
{/* 操作区 */}
<div className="xx-mv-card-actions">
{isReady ? (
<Button
buttonType={isPlaying ? "secondary" : "ghost"}
buttonSize="sm"
onClick={() => onTogglePlay(voice)}
>
{isPlaying ? (
<>
<PauseCircleOutlined />
</>
) : (
<>
<PlayCircleOutlined />
</>
)}
</Button>
) : voice.status === "failed" ? (
<Button buttonType="ghost" buttonSize="sm" disabled>
</Button>
) : (
<Button buttonType="ghost" buttonSize="sm" disabled>
...
</Button>
)}
<div className="xx-mv-card-icon-actions">
<Tooltip title="编辑名称">
<button
type="button"
className="xx-mv-icon-btn"
onClick={() => onEdit(voice)}
disabled={!isReady}
>
<EditOutlined />
</button>
</Tooltip>
<Tooltip title="删除">
<button
type="button"
className="xx-mv-icon-btn xx-mv-icon-btn--danger"
onClick={() => onDelete(voice)}
>
<DeleteOutlined />
</button>
</Tooltip>
</div>
</div>
</div>
)
}
@@ -0,0 +1,140 @@
import { useState, useCallback, useRef } from "react"
import { useNavigate } from "react-router-dom"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import { deleteVoiceClone, updateVoiceClone } from "@/api/voice-clone"
import type { VoiceClone } from "@/api/voice-clone"
import type { ToastItem } from "../types"
let _toastId = 0
/**
* 我的音色业务 Hook
* 封装播放、编辑、删除、Toast 等业务逻辑
*/
export const useMyVoices = () => {
const navigate = useNavigate()
const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress()
const [playingId, setPlayingId] = useState<string | null>(null)
const [toasts, setToasts] = useState<ToastItem[]>([])
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(null)
const [editName, setEditName] = useState("")
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
// Toast
const showToast = useCallback((message: string, type: ToastItem["type"]) => {
const id = ++_toastId
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000)
}, [])
// 试听播放
const handleTogglePlay = useCallback(
(voice: VoiceClone) => {
if (playingId === voice.id) {
audioRef.current?.pause()
setPlayingId(null)
return
}
if (audioRef.current) {
audioRef.current.pause()
}
if (!voice.sample_url) {
showToast("暂无试听音频", "error")
return
}
const audio = new Audio(voice.sample_url)
audioRef.current = audio
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"))
audio.onended = () => setPlayingId(null)
setPlayingId(voice.id)
},
[playingId, showToast],
)
// 编辑
const handleEdit = useCallback((voice: VoiceClone) => {
setEditingVoice(voice)
setEditName(voice.name)
setEditModalOpen(true)
}, [])
const handleEditCancel = useCallback(() => {
setEditModalOpen(false)
setEditingVoice(null)
}, [])
const handleEditConfirm = useCallback(async () => {
if (!editingVoice || !editName.trim()) return
try {
const updated = await updateVoiceClone(editingVoice.id, {
name: editName.trim(),
})
updateClone(updated)
setEditModalOpen(false)
setEditingVoice(null)
showToast("名称已更新", "success")
} catch {
showToast("更新失败,请重试", "error")
}
}, [editingVoice, editName, updateClone, showToast])
// 删除
const handleDelete = useCallback((voice: VoiceClone) => {
setDeleteConfirmId(voice.id)
}, [])
const handleDeleteCancel = useCallback(() => {
setDeleteConfirmId(null)
}, [])
const handleDeleteConfirm = useCallback(async () => {
if (!deleteConfirmId) return
try {
await deleteVoiceClone(deleteConfirmId)
removeClone(deleteConfirmId)
setDeleteConfirmId(null)
showToast("音色已删除", "success")
} catch {
showToast("删除失败,请重试", "error")
}
}, [deleteConfirmId, removeClone, showToast])
// 克隆新音色
const handleCloneNew = useCallback(() => {
navigate("/app/voices")
}, [navigate])
// 统计
const readyCount = clones.filter((v) => v.status === "ready").length
const processingCount = clones.filter((v) => v.status === "processing").length
return {
// 数据
clones,
loading,
hasProcessing,
// 状态
playingId,
toasts,
editModalOpen,
editingVoice,
editName,
setEditName,
deleteConfirmId,
// 统计
readyCount,
processingCount,
// 操作
showToast,
handleTogglePlay,
handleEdit,
handleEditCancel,
handleEditConfirm,
handleDelete,
handleDeleteCancel,
handleDeleteConfirm,
handleCloneNew,
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { VoiceCloneStatus } from "@/api/voice-clone"
/** 状态配置 */
export const STATUS_CONFIG: Record<VoiceCloneStatus, { label: string; dotClass: string }> = {
ready: { label: "就绪", dotClass: "xx-mv-status-dot--ready" },
processing: { label: "克隆中", dotClass: "xx-mv-status-dot--processing" },
failed: { label: "失败", dotClass: "xx-mv-status-dot--failed" },
}
/** Toast 类型 */
export interface ToastItem {
id: number
message: string
type: "success" | "error"
}
+9
View File
@@ -0,0 +1,9 @@
/** 格式化日期 */
export function formatDate(isoStr: string): string {
const d = new Date(isoStr)
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
}
+108 -707
View File
@@ -4,731 +4,132 @@
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
* 对接后端真实 APIGET/POST/PUT/DELETE /titles
*/
import React, { useMemo, useState, useCallback } from "react"
import { Modal as AntModal, message, Popconfirm } from "antd"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
PlusOutlined,
SearchOutlined,
DeleteOutlined,
EditOutlined,
CopyOutlined,
CheckOutlined,
RobotOutlined,
StarOutlined,
StarFilled,
FileTextOutlined,
} from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import { getTitles, createTitle, updateTitle, deleteTitle, type TitleItem } from "@/api/titles"
import React from "react"
import { useTitleLibrary } from "./hooks/useTitleLibrary"
import { useTitleEdit } from "./hooks/useTitleEdit"
import { useTitleAI } from "./hooks/useTitleAI"
import { CategorySidebar } from "./components/title-library/CategorySidebar"
import { FilterBar } from "./components/title-library/FilterBar"
import { TitleGrid } from "./components/title-library/TitleGrid"
import { CreateTitleModal } from "./components/title-library/CreateTitleModal"
import { AIGenerateModal } from "./components/title-library/AIGenerateModal"
import "./titles.css"
/* ============================================================
* 类型
* ============================================================ */
type TitleType = "hot" | "normal" | "creative"
type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel"
type Frequency = "all" | "high" | "medium" | "low"
interface TitleData {
id: string
content: string
type: TitleType
industry: Industry
category: string
usageCount: number
isFavorited: boolean
createdAt: string
}
/** 后端 TitleItem → 前端 TitleData 映射 */
const toTitleData = (item: TitleItem): TitleData => ({
id: item.id,
content: item.content,
type: (item.category as TitleType) || "normal",
industry: "general",
category: item.category || "未分类",
usageCount: 0,
isFavorited: false,
createdAt: item.created_at?.slice(0, 10) || "",
})
/* ============================================================
* 工具函数
* ============================================================ */
const typeLabel = (type: TitleType): string => {
switch (type) {
case "hot":
return "爆款"
case "normal":
return "常规"
case "creative":
return "创意"
}
}
/** 复制文本到剪贴板 */
const copyToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
/* 降级方案 */
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand("copy")
return true
} catch {
return false
} finally {
document.body.removeChild(textarea)
}
}
}
/* ============================================================
* TitleCard 组件
* ============================================================ */
const TitleCard: React.FC<{
title: TitleData
isEditing: boolean
editText: string
onEditChange: (text: string) => void
onStartEdit: () => void
onSaveEdit: () => void
onCancelEdit: () => void
onCopy: () => void
onDelete: () => void
onToggleFavorite: () => void
}> = ({
title,
isEditing,
editText,
onEditChange,
onStartEdit,
onSaveEdit,
onCancelEdit,
onCopy,
onDelete,
onToggleFavorite,
}) => {
return (
<div className="xx-title-card">
{/* 收藏按钮 */}
<button
className="xx-title-fav-btn"
onClick={onToggleFavorite}
title={title.isFavorited ? "取消收藏" : "收藏"}
style={{
position: "absolute",
top: 12,
right: 12,
color: title.isFavorited ? "#f59e0b" : "var(--text-tertiary)",
}}
>
{title.isFavorited ? <StarFilled /> : <StarOutlined />}
</button>
{/* 标题文本 / 编辑区 */}
{isEditing ? (
<textarea
className="xx-title-card-edit"
value={editText}
onChange={(e) => onEditChange(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSaveEdit()
}
if (e.key === "Escape") {
onCancelEdit()
}
}}
/>
) : (
<div className="xx-title-card-text" style={{ paddingRight: 24 }}>
{title.content}
</div>
)}
{/* 底部元信息 */}
<div className="xx-title-card-meta">
<div className="xx-title-card-meta-left">
<span className={`xx-title-type-tag ${title.type}`}>{typeLabel(title.type)}</span>
<span className="xx-title-card-stat">使 {title.usageCount} </span>
<span className="xx-title-card-stat">{title.createdAt}</span>
</div>
<div className="xx-title-card-actions">
{isEditing ? (
<>
<button className="xx-title-card-action-btn" onClick={onSaveEdit} title="保存">
<CheckOutlined />
</button>
<button className="xx-title-card-action-btn" onClick={onCancelEdit} title="取消">
</button>
</>
) : (
<>
<button className="xx-title-card-action-btn" onClick={onCopy} title="复制">
<CopyOutlined />
</button>
<button className="xx-title-card-action-btn" onClick={onStartEdit} title="编辑">
<EditOutlined />
</button>
<Popconfirm
title="确定删除此标题?"
onConfirm={onDelete}
okText="删除"
cancelText="取消"
>
<button className="xx-title-card-action-btn danger" title="删除">
<DeleteOutlined />
</button>
</Popconfirm>
</>
)}
</div>
</div>
</div>
)
}
/* ============================================================
* 主组件
* ============================================================ */
const TitleLibrary: React.FC = () => {
const queryClient = useQueryClient()
const {
categories,
activeCatId,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
createMutation,
updateMutation,
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
handleToggleFavorite,
handleCopy,
handleDelete,
} = useTitleLibrary()
/* 分类数据 — 从真实标题数据动态派生 */
const [activeCatId, setActiveCatId] = useState<string>("cat-all")
const {
editingId,
editText,
setEditText,
createTitleModalOpen,
setCreateTitleModalOpen,
newTitleContent,
setNewTitleContent,
newTitleType,
setNewTitleType,
handleStartEdit,
handleSaveEdit,
handleCancelEdit,
handleCreateTitle,
handleCloseCreateModal,
} = useTitleEdit({ updateMutation, createMutation })
/* 标题数据 — 真实 API */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
})
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
/* 从真实标题数据动态派生分类(无需后端分类 API) */
const categories = useMemo(() => {
const cats = new Map<string, number>()
apiTitles.forEach((t) => {
const cat = t.category || "未分类"
cats.set(cat, (cats.get(cat) || 0) + 1)
})
return [
{ id: "cat-all", name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
]
}, [apiTitles])
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("创建标题失败"),
})
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("更新标题失败"),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("删除标题失败"),
})
/* 筛选 */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterIndustry, setFilterIndustry] = useState<string>("all")
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
/* 编辑状态 */
const [editingId, setEditingId] = useState<string | null>(null)
const [editText, setEditText] = useState("")
/* 新建标题 */
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false)
const [newTitleContent, setNewTitleContent] = useState("")
const [newTitleType, setNewTitleType] = useState<TitleType>("normal")
/* AI 生成 */
const [aiModalOpen, setAiModalOpen] = useState(false)
const [aiKeyword, setAiKeyword] = useState("")
const [aiLoading, setAiLoading] = useState(false)
const [aiResults, setAiResults] = useState<string[]>([])
/* 派生数据 */
const activeCategory = categories.find((c) => c.id === activeCatId)
const filteredTitles = useMemo(() => {
let list = titles
/* 按分类过滤("全部标题" 不过滤)— 直接匹配后端 category 字段 */
if (activeCatId !== "cat-all") {
const catName = activeCategory?.name || ""
if (catName) {
list = list.filter((t) => t.category === catName)
}
}
/* 按类型筛选 */
if (filterType !== "all") {
list = list.filter((t) => t.type === filterType)
}
/* 按行业筛选 */
if (filterIndustry !== "all") {
list = list.filter((t) => t.industry === filterIndustry)
}
/* 按使用频率筛选 */
if (filterFrequency !== "all") {
switch (filterFrequency) {
case "high":
list = list.filter((t) => t.usageCount >= 100)
break
case "medium":
list = list.filter((t) => t.usageCount >= 30 && t.usageCount < 100)
break
case "low":
list = list.filter((t) => t.usageCount < 30)
break
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((t) => t.content.toLowerCase().includes(q))
}
return list
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
/* 收藏切换(暂不支持,待后端 API) */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线")
}, [])
/* 复制 */
const handleCopy = useCallback(async (title: TitleData) => {
const ok = await copyToClipboard(title.content)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 编辑 */
const handleStartEdit = useCallback((title: TitleData) => {
setEditingId(title.id)
setEditText(title.content)
}, [])
const handleSaveEdit = useCallback(() => {
if (!editText.trim()) {
message.warning("标题内容不能为空")
return
}
if (editingId) {
updateMutation.mutate({ id: editingId, content: editText.trim() })
}
setEditingId(null)
setEditText("")
message.success("标题已更新")
}, [editingId, editText, updateMutation])
const handleCancelEdit = useCallback(() => {
setEditingId(null)
setEditText("")
}, [])
/* 删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id)
message.success("标题已删除")
},
[deleteMutation],
)
/* 新建标题 */
const handleCreateTitle = () => {
if (!newTitleContent.trim()) {
message.warning("请输入标题内容")
return
}
createMutation.mutate(newTitleContent.trim(), {
onSuccess: () => {
setCreateTitleModalOpen(false)
setNewTitleContent("")
setNewTitleType("normal")
message.success("标题创建成功")
},
})
}
/* AI 生成标题 */
const handleAIGenerate = () => {
if (!aiKeyword.trim()) {
message.warning("请输入关键词或主题")
return
}
setAiLoading(true)
setAiResults([])
/* Mock AI 生成延迟 */
setTimeout(() => {
const keyword = aiKeyword.trim()
const results = [
`${keyword}:这个方法让我事半功倍!`,
`关于${keyword}99%的人都不知道的事`,
`${keyword}全攻略,看完这篇就够了`,
`我花了 3 个月研究${keyword},总结出这些经验`,
`${keyword}避坑指南,帮你省下 1000 块`,
]
setAiResults(results)
setAiLoading(false)
}, 2000)
}
/* 采纳 AI 生成的标题 */
const handleAdoptAITitle = (text: string) => {
createMutation.mutate(text, {
onSuccess: () => {
message.success("标题已采纳并添加到标题库")
},
})
}
/* 复制 AI 生成的标题 */
const handleCopyAI = async (text: string) => {
const ok = await copyToClipboard(text)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}
const {
aiModalOpen,
setAiModalOpen,
aiKeyword,
setAiKeyword,
aiLoading,
aiResults,
handleAIGenerate,
handleAdoptAITitle,
handleCopyAI,
handleCloseAIModal,
} = useTitleAI({ createMutation })
return (
<div className="xx-titles-page">
{/* 两栏布局 */}
<div className="xx-titles-layout">
{/* ─── 左侧:分类列表 ─── */}
<div className="xx-title-category-list">
{categories.map((cat) => (
<div
key={cat.id}
className={`xx-title-category-item${cat.id === activeCatId ? " active" : ""}`}
onClick={() => setActiveCatId(cat.id)}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flex: 1,
minWidth: 0,
}}
>
<div style={{ minWidth: 0 }}>
<h4 style={{ margin: 0 }}>
<FileTextOutlined /> {cat.name}
</h4>
<span>{cat.count} </span>
</div>
</div>
</div>
))}
{/* 左侧:分类列表 */}
<CategorySidebar
categories={categories}
activeCatId={activeCatId}
onSelect={setActiveCatId}
/>
{/* TODO: 新建分类功能待后端分类 API 就绪后启用 */}
</div>
{/* ─── 右侧:内容区 ─── */}
{/* 右侧:内容区 */}
<div className="xx-titles-content">
{/* 筛选栏 */}
<div className="xx-titles-filters">
<div className="xx-titles-filters-left">
<Input
placeholder="搜索标题关键词..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
value={filterType}
onChange={setFilterType}
style={{ width: 110 }}
options={[
{ value: "all", label: "全部类型" },
{ value: "hot", label: "爆款" },
{ value: "normal", label: "常规" },
{ value: "creative", label: "创意" },
]}
/>
<Select
value={filterIndustry}
onChange={setFilterIndustry}
style={{ width: 110 }}
options={[
{ value: "all", label: "全部行业" },
{ value: "food", label: "美食" },
{ value: "tech", label: "科技" },
{ value: "beauty", label: "美妆" },
{ value: "education", label: "教育" },
{ value: "travel", label: "旅行" },
]}
/>
<Select
value={filterFrequency}
onChange={(v) => setFilterFrequency(v as Frequency)}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部频率" },
{ value: "high", label: "高频使用" },
{ value: "medium", label: "中频使用" },
{ value: "low", label: "低频使用" },
]}
/>
</div>
<div className="xx-titles-filters-right">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<PlusOutlined />}
onClick={() => setCreateTitleModalOpen(true)}
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<RobotOutlined />}
onClick={() => setAiModalOpen(true)}
>
AI
</Button>
</div>
</div>
<FilterBar
searchText={searchText}
onSearchChange={setSearchText}
filterType={filterType}
onFilterTypeChange={setFilterType}
filterIndustry={filterIndustry}
onFilterIndustryChange={setFilterIndustry}
filterFrequency={filterFrequency}
onFilterFrequencyChange={setFilterFrequency}
onCreateClick={() => setCreateTitleModalOpen(true)}
onAIClick={() => setAiModalOpen(true)}
/>
{/* 标题卡片网格 */}
{filteredTitles.length > 0 ? (
<div className="xx-title-grid">
{filteredTitles.map((title) => (
<TitleCard
key={title.id}
title={title}
isEditing={editingId === title.id}
editText={editingId === title.id ? editText : ""}
onEditChange={setEditText}
onStartEdit={() => handleStartEdit(title)}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
onCopy={() => handleCopy(title)}
onDelete={() => handleDelete(title.id)}
onToggleFavorite={() => handleToggleFavorite(title.id)}
/>
))}
</div>
) : (
<div className="xx-titles-empty">
<div className="xx-titles-empty-icon">
<FileTextOutlined />
</div>
<p>
{searchText
? "未找到匹配的标题"
: "暂无标题,点击「新建标题」或「AI 生成标题」开始"}
</p>
</div>
)}
<TitleGrid
titles={filteredTitles}
editingId={editingId}
editText={editText}
searchText={searchText}
onEditChange={setEditText}
onStartEdit={handleStartEdit}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
onCopy={handleCopy}
onDelete={handleDelete}
onToggleFavorite={handleToggleFavorite}
/>
</div>
</div>
{/* ─── 新建标题弹窗 ─── */}
<AntModal
title="新建标题"
{/* 新建标题弹窗 */}
<CreateTitleModal
open={createTitleModalOpen}
onCancel={() => setCreateTitleModalOpen(false)}
onOk={handleCreateTitle}
okText="创建"
cancelText="取消"
destroyOnClose
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Input.TextArea
placeholder="请输入标题内容"
value={newTitleContent}
onChange={(e) => setNewTitleContent(e.target.value)}
rows={3}
maxLength={200}
showCount
/>
</div>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Select
value={newTitleType}
onChange={(v) => setNewTitleType(v)}
style={{ width: "100%" }}
options={[
{ value: "hot", label: "爆款" },
{ value: "normal", label: "常规" },
{ value: "creative", label: "创意" },
]}
/>
</div>
</div>
</AntModal>
newTitleContent={newTitleContent}
newTitleType={newTitleType}
onContentChange={setNewTitleContent}
onTypeChange={setNewTitleType}
onCancel={handleCloseCreateModal}
onSubmit={handleCreateTitle}
/>
{/* ─── AI 生成标题弹窗 ─── */}
<AntModal
title="AI 生成标题"
{/* AI 生成标题弹窗 */}
<AIGenerateModal
open={aiModalOpen}
onCancel={() => {
setAiModalOpen(false)
setAiLoading(false)
setAiResults([])
setAiKeyword("")
}}
onOk={handleAIGenerate}
okText={aiLoading ? "生成中..." : "生成"}
cancelText="关闭"
okButtonProps={{ disabled: aiLoading }}
destroyOnClose
width={640}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Input
placeholder="例如:美食探店、科技评测、旅行攻略..."
value={aiKeyword}
onChange={(e) => setAiKeyword(e.target.value)}
maxLength={100}
onPressEnter={handleAIGenerate}
/>
</div>
{/* AI 加载动画 */}
{aiLoading && (
<div className="xx-ai-loading">
<div className="xx-ai-loading-dots">
<div className="xx-ai-loading-dot" />
<div className="xx-ai-loading-dot" />
<div className="xx-ai-loading-dot" />
</div>
<span>AI ...</span>
</div>
)}
{/* AI 生成结果列表 */}
{aiResults.length > 0 && (
<div className="xx-ai-results">
<div
style={{
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
marginBottom: 4,
}}
>
{aiResults.length}
</div>
{aiResults.map((text, idx) => (
<div key={idx} className="xx-ai-result-item">
<span className="xx-ai-result-text">{text}</span>
<div className="xx-ai-result-actions">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<CopyOutlined />}
onClick={() => handleCopyAI(text)}
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<CheckOutlined />}
onClick={() => handleAdoptAITitle(text)}
>
</Button>
</div>
</div>
))}
</div>
)}
</div>
</AntModal>
aiKeyword={aiKeyword}
aiLoading={aiLoading}
aiResults={aiResults}
onKeywordChange={setAiKeyword}
onGenerate={handleAIGenerate}
onCancel={handleCloseAIModal}
onCopy={handleCopyAI}
onAdopt={handleAdoptAITitle}
/>
</div>
)
}
@@ -0,0 +1,121 @@
import React from "react"
import { Modal as AntModal } from "antd"
import { CopyOutlined, CheckOutlined } from "@ant-design/icons"
import { Button, Input } from "@/components/ui"
import { AI_KEYWORD_MAX_LENGTH } from "../../constants/titleLibrary"
interface AIGenerateModalProps {
open: boolean
aiKeyword: string
aiLoading: boolean
aiResults: string[]
onKeywordChange: (keyword: string) => void
onGenerate: () => void
onCancel: () => void
onCopy: (text: string) => void
onAdopt: (text: string) => void
}
export const AIGenerateModal: React.FC<AIGenerateModalProps> = ({
open,
aiKeyword,
aiLoading,
aiResults,
onKeywordChange,
onGenerate,
onCancel,
onCopy,
onAdopt,
}) => {
return (
<AntModal
title="AI 生成标题"
open={open}
onCancel={onCancel}
onOk={onGenerate}
okText={aiLoading ? "生成中..." : "生成"}
cancelText="关闭"
okButtonProps={{ disabled: aiLoading }}
destroyOnClose
width={640}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Input
placeholder="例如:美食探店、科技评测、旅行攻略..."
value={aiKeyword}
onChange={(e) => onKeywordChange(e.target.value)}
maxLength={AI_KEYWORD_MAX_LENGTH}
onPressEnter={onGenerate}
/>
</div>
{/* AI 加载动画 */}
{aiLoading && (
<div className="xx-ai-loading">
<div className="xx-ai-loading-dots">
<div className="xx-ai-loading-dot" />
<div className="xx-ai-loading-dot" />
<div className="xx-ai-loading-dot" />
</div>
<span>AI ...</span>
</div>
)}
{/* AI 生成结果列表 */}
{aiResults.length > 0 && (
<div className="xx-ai-results">
<div
style={{
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
marginBottom: 4,
}}
>
{aiResults.length}
</div>
{aiResults.map((text, idx) => (
<div key={idx} className="xx-ai-result-item">
<span className="xx-ai-result-text">{text}</span>
<div className="xx-ai-result-actions">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<CopyOutlined />}
onClick={() => onCopy(text)}
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<CheckOutlined />}
onClick={() => onAdopt(text)}
>
</Button>
</div>
</div>
))}
</div>
)}
</div>
</AntModal>
)
}
@@ -0,0 +1,44 @@
import React from "react"
import { FileTextOutlined } from "@ant-design/icons"
import type { CategoryItem } from "../../types/titleLibrary"
interface CategorySidebarProps {
categories: CategoryItem[]
activeCatId: string
onSelect: (catId: string) => void
}
export const CategorySidebar: React.FC<CategorySidebarProps> = ({
categories,
activeCatId,
onSelect,
}) => {
return (
<div className="xx-title-category-list">
{categories.map((cat) => (
<div
key={cat.id}
className={`xx-title-category-item${cat.id === activeCatId ? " active" : ""}`}
onClick={() => onSelect(cat.id)}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flex: 1,
minWidth: 0,
}}
>
<div style={{ minWidth: 0 }}>
<h4 style={{ margin: 0 }}>
<FileTextOutlined /> {cat.name}
</h4>
<span>{cat.count} </span>
</div>
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,89 @@
import React from "react"
import { Modal as AntModal } from "antd"
import { Input, Select } from "@/components/ui"
import type { TitleType } from "../../types/titleLibrary"
import { TITLE_MAX_LENGTH } from "../../constants/titleLibrary"
const TITLE_TYPE_CREATE_OPTIONS: Array<{ value: TitleType; label: string }> = [
{ value: "hot", label: "爆款" },
{ value: "normal", label: "常规" },
{ value: "creative", label: "创意" },
]
interface CreateTitleModalProps {
open: boolean
newTitleContent: string
newTitleType: TitleType
onContentChange: (content: string) => void
onTypeChange: (type: TitleType) => void
onCancel: () => void
onSubmit: () => void
}
export const CreateTitleModal: React.FC<CreateTitleModalProps> = ({
open,
newTitleContent,
newTitleType,
onContentChange,
onTypeChange,
onCancel,
onSubmit,
}) => {
return (
<AntModal
title="新建标题"
open={open}
onCancel={onCancel}
onOk={onSubmit}
okText="创建"
cancelText="取消"
destroyOnClose
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Input.TextArea
placeholder="请输入标题内容"
value={newTitleContent}
onChange={(e) => onContentChange(e.target.value)}
rows={3}
maxLength={TITLE_MAX_LENGTH}
showCount
/>
</div>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Select
value={newTitleType}
onChange={(v) => onTypeChange(v as TitleType)}
style={{ width: "100%" }}
options={TITLE_TYPE_CREATE_OPTIONS}
/>
</div>
</div>
</AntModal>
)
}
@@ -0,0 +1,76 @@
import React from "react"
import { SearchOutlined, PlusOutlined, RobotOutlined } from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import type { Frequency } from "../../types/titleLibrary"
import {
TITLE_TYPE_OPTIONS,
INDUSTRY_OPTIONS,
FREQUENCY_OPTIONS,
} from "../../constants/titleLibrary"
interface FilterBarProps {
searchText: string
onSearchChange: (text: string) => void
filterType: string
onFilterTypeChange: (value: string) => void
filterIndustry: string
onFilterIndustryChange: (value: string) => void
filterFrequency: Frequency
onFilterFrequencyChange: (value: Frequency) => void
onCreateClick: () => void
onAIClick: () => void
}
export const FilterBar: React.FC<FilterBarProps> = ({
searchText,
onSearchChange,
filterType,
onFilterTypeChange,
filterIndustry,
onFilterIndustryChange,
filterFrequency,
onFilterFrequencyChange,
onCreateClick,
onAIClick,
}) => {
return (
<div className="xx-titles-filters">
<div className="xx-titles-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: 110 }}
options={TITLE_TYPE_OPTIONS}
/>
<Select
value={filterIndustry}
onChange={onFilterIndustryChange}
style={{ width: 110 }}
options={INDUSTRY_OPTIONS}
/>
<Select
value={filterFrequency}
onChange={(v) => onFilterFrequencyChange(v as Frequency)}
style={{ width: 120 }}
options={FREQUENCY_OPTIONS}
/>
</div>
<div className="xx-titles-filters-right">
<Button buttonType="ghost" buttonSize="sm" icon={<PlusOutlined />} onClick={onCreateClick}>
</Button>
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />} onClick={onAIClick}>
AI
</Button>
</div>
</div>
)
}
@@ -0,0 +1,121 @@
import React from "react"
import { Popconfirm } from "antd"
import {
StarOutlined,
StarFilled,
EditOutlined,
CopyOutlined,
DeleteOutlined,
CheckOutlined,
} from "@ant-design/icons"
import type { TitleData } from "../../types/titleLibrary"
import { typeLabel } from "../../utils/titleLibrary"
interface TitleCardProps {
title: TitleData
isEditing: boolean
editText: string
onEditChange: (text: string) => void
onStartEdit: () => void
onSaveEdit: () => void
onCancelEdit: () => void
onCopy: () => void
onDelete: () => void
onToggleFavorite: () => void
}
export const TitleCard: React.FC<TitleCardProps> = ({
title,
isEditing,
editText,
onEditChange,
onStartEdit,
onSaveEdit,
onCancelEdit,
onCopy,
onDelete,
onToggleFavorite,
}) => {
return (
<div className="xx-title-card">
{/* 收藏按钮 */}
<button
className="xx-title-fav-btn"
onClick={onToggleFavorite}
title={title.isFavorited ? "取消收藏" : "收藏"}
style={{
position: "absolute",
top: 12,
right: 12,
color: title.isFavorited ? "#f59e0b" : "var(--text-tertiary)",
}}
>
{title.isFavorited ? <StarFilled /> : <StarOutlined />}
</button>
{/* 标题文本 / 编辑区 */}
{isEditing ? (
<textarea
className="xx-title-card-edit"
value={editText}
onChange={(e) => onEditChange(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSaveEdit()
}
if (e.key === "Escape") {
onCancelEdit()
}
}}
/>
) : (
<div className="xx-title-card-text" style={{ paddingRight: 24 }}>
{title.content}
</div>
)}
{/* 底部元信息 */}
<div className="xx-title-card-meta">
<div className="xx-title-card-meta-left">
<span className={`xx-title-type-tag ${title.type}`}>{typeLabel(title.type)}</span>
<span className="xx-title-card-stat">使 {title.usageCount} </span>
<span className="xx-title-card-stat">{title.createdAt}</span>
</div>
<div className="xx-title-card-actions">
{isEditing ? (
<>
<button className="xx-title-card-action-btn" onClick={onSaveEdit} title="保存">
<CheckOutlined />
</button>
<button className="xx-title-card-action-btn" onClick={onCancelEdit} title="取消">
</button>
</>
) : (
<>
<button className="xx-title-card-action-btn" onClick={onCopy} title="复制">
<CopyOutlined />
</button>
<button className="xx-title-card-action-btn" onClick={onStartEdit} title="编辑">
<EditOutlined />
</button>
<Popconfirm
title="确定删除此标题?"
onConfirm={onDelete}
okText="删除"
cancelText="取消"
>
<button className="xx-title-card-action-btn danger" title="删除">
<DeleteOutlined />
</button>
</Popconfirm>
</>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,63 @@
import React from "react"
import { FileTextOutlined } from "@ant-design/icons"
import { TitleCard } from "./TitleCard"
import type { TitleData } from "../../types/titleLibrary"
interface TitleGridProps {
titles: TitleData[]
editingId: string | null
editText: string
searchText: string
onEditChange: (text: string) => void
onStartEdit: (title: TitleData) => void
onSaveEdit: () => void
onCancelEdit: () => void
onCopy: (title: TitleData) => void
onDelete: (id: string) => void
onToggleFavorite: (id: string) => void
}
export const TitleGrid: React.FC<TitleGridProps> = ({
titles,
editingId,
editText,
searchText,
onEditChange,
onStartEdit,
onSaveEdit,
onCancelEdit,
onCopy,
onDelete,
onToggleFavorite,
}) => {
if (titles.length > 0) {
return (
<div className="xx-title-grid">
{titles.map((title) => (
<TitleCard
key={title.id}
title={title}
isEditing={editingId === title.id}
editText={editingId === title.id ? editText : ""}
onEditChange={onEditChange}
onStartEdit={() => onStartEdit(title)}
onSaveEdit={onSaveEdit}
onCancelEdit={onCancelEdit}
onCopy={() => onCopy(title)}
onDelete={() => onDelete(title.id)}
onToggleFavorite={() => onToggleFavorite(title.id)}
/>
))}
</div>
)
}
return (
<div className="xx-titles-empty">
<div className="xx-titles-empty-icon">
<FileTextOutlined />
</div>
<p>{searchText ? "未找到匹配的标题" : "暂无标题,点击「新建标题」或「AI 生成标题」开始"}</p>
</div>
)
}
@@ -0,0 +1,34 @@
import type { TitleType, Industry, Frequency } from "../types/titleLibrary"
export const TITLE_TYPE_OPTIONS: Array<{ value: TitleType | "all"; label: string }> = [
{ value: "all", label: "全部类型" },
{ value: "hot", label: "爆款" },
{ value: "normal", label: "常规" },
{ value: "creative", label: "创意" },
]
export const INDUSTRY_OPTIONS: Array<{ value: Industry | "all"; label: string }> = [
{ value: "all", label: "全部行业" },
{ value: "food", label: "美食" },
{ value: "tech", label: "科技" },
{ value: "beauty", label: "美妆" },
{ value: "education", label: "教育" },
{ value: "travel", label: "旅行" },
]
export const FREQUENCY_OPTIONS: Array<{ value: Frequency; label: string }> = [
{ value: "all", label: "全部频率" },
{ value: "high", label: "高频使用" },
{ value: "medium", label: "中频使用" },
{ value: "low", label: "低频使用" },
]
export const FREQUENCY_THRESHOLDS = {
high: 100,
medium: 30,
} as const
export const AI_GENERATE_DELAY = 2000
export const TITLE_MAX_LENGTH = 200
export const AI_KEYWORD_MAX_LENGTH = 100
export const ALL_CATEGORY_ID = "cat-all"
@@ -0,0 +1,84 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import type { UseMutationResult } from "@tanstack/react-query"
import type { TitleItem } from "@/api/titles"
import { copyToClipboard } from "../utils/titleLibrary"
import { AI_GENERATE_DELAY } from "../constants/titleLibrary"
interface UseTitleAIProps {
createMutation: UseMutationResult<TitleItem, Error, string, unknown>
}
const generateMockTitles = (keyword: string): string[] => [
`${keyword}:这个方法让我事半功倍!`,
`关于${keyword}99%的人都不知道的事`,
`${keyword}全攻略,看完这篇就够了`,
`我花了 3 个月研究${keyword},总结出这些经验`,
`${keyword}避坑指南,帮你省下 1000 块`,
]
export const useTitleAI = ({ createMutation }: UseTitleAIProps) => {
const [aiModalOpen, setAiModalOpen] = useState(false)
const [aiKeyword, setAiKeyword] = useState("")
const [aiLoading, setAiLoading] = useState(false)
const [aiResults, setAiResults] = useState<string[]>([])
/* AI 生成标题 */
const handleAIGenerate = useCallback(() => {
if (!aiKeyword.trim()) {
message.warning("请输入关键词或主题")
return
}
setAiLoading(true)
setAiResults([])
setTimeout(() => {
const results = generateMockTitles(aiKeyword.trim())
setAiResults(results)
setAiLoading(false)
}, AI_GENERATE_DELAY)
}, [aiKeyword])
/* 采纳 AI 生成的标题 */
const handleAdoptAITitle = useCallback(
(text: string) => {
createMutation.mutate(text, {
onSuccess: () => {
message.success("标题已采纳并添加到标题库")
},
})
},
[createMutation],
)
/* 复制 AI 生成的标题 */
const handleCopyAI = useCallback(async (text: string) => {
const ok = await copyToClipboard(text)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 关闭 AI 弹窗 */
const handleCloseAIModal = useCallback(() => {
setAiModalOpen(false)
setAiLoading(false)
setAiResults([])
setAiKeyword("")
}, [])
return {
aiModalOpen,
setAiModalOpen,
aiKeyword,
setAiKeyword,
aiLoading,
aiResults,
handleAIGenerate,
handleAdoptAITitle,
handleCopyAI,
handleCloseAIModal,
}
}
@@ -0,0 +1,87 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import type { TitleData, TitleType } from "../types/titleLibrary"
import type { UseMutationResult } from "@tanstack/react-query"
import type { TitleItem } from "@/api/titles"
interface UseTitleEditProps {
updateMutation: UseMutationResult<TitleItem, Error, { id: string; content: string }, unknown>
createMutation: UseMutationResult<TitleItem, Error, string, unknown>
}
export const useTitleEdit = ({ updateMutation, createMutation }: UseTitleEditProps) => {
/* 编辑状态 */
const [editingId, setEditingId] = useState<string | null>(null)
const [editText, setEditText] = useState("")
/* 新建标题弹窗 */
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false)
const [newTitleContent, setNewTitleContent] = useState("")
const [newTitleType, setNewTitleType] = useState<TitleType>("normal")
/* 开始编辑 */
const handleStartEdit = useCallback((title: TitleData) => {
setEditingId(title.id)
setEditText(title.content)
}, [])
/* 保存编辑 */
const handleSaveEdit = useCallback(() => {
if (!editText.trim()) {
message.warning("标题内容不能为空")
return
}
if (editingId) {
updateMutation.mutate({ id: editingId, content: editText.trim() })
}
setEditingId(null)
setEditText("")
message.success("标题已更新")
}, [editingId, editText, updateMutation])
/* 取消编辑 */
const handleCancelEdit = useCallback(() => {
setEditingId(null)
setEditText("")
}, [])
/* 新建标题提交 */
const handleCreateTitle = useCallback(() => {
if (!newTitleContent.trim()) {
message.warning("请输入标题内容")
return
}
createMutation.mutate(newTitleContent.trim(), {
onSuccess: () => {
setCreateTitleModalOpen(false)
setNewTitleContent("")
setNewTitleType("normal")
message.success("标题创建成功")
},
})
}, [newTitleContent, createMutation])
/* 关闭新建弹窗 */
const handleCloseCreateModal = useCallback(() => {
setCreateTitleModalOpen(false)
setNewTitleContent("")
setNewTitleType("normal")
}, [])
return {
editingId,
editText,
setEditText,
createTitleModalOpen,
setCreateTitleModalOpen,
newTitleContent,
setNewTitleContent,
newTitleType,
setNewTitleType,
handleStartEdit,
handleSaveEdit,
handleCancelEdit,
handleCreateTitle,
handleCloseCreateModal,
}
}
@@ -0,0 +1,174 @@
import { useMemo, useState, useCallback } from "react"
import { message } from "antd"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getTitles, createTitle, updateTitle, deleteTitle } from "@/api/titles"
import type { TitleData, CategoryItem, Frequency } from "../types/titleLibrary"
import { toTitleData, copyToClipboard } from "../utils/titleLibrary"
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../constants/titleLibrary"
export const useTitleLibrary = () => {
const queryClient = useQueryClient()
/* 分类 */
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
/* 数据获取 */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
})
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
/* 动态派生分类 */
const categories: CategoryItem[] = useMemo(() => {
const cats = new Map<string, number>()
apiTitles.forEach((t) => {
const cat = t.category || "未分类"
cats.set(cat, (cats.get(cat) || 0) + 1)
})
return [
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
]
}, [apiTitles])
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("创建标题失败"),
})
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("更新标题失败"),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("删除标题失败"),
})
/* 筛选状态 */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterIndustry, setFilterIndustry] = useState<string>("all")
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
/* 派生:筛选后的标题列表 */
const activeCategory = categories.find((c) => c.id === activeCatId)
const filteredTitles = useMemo(() => {
let list = titles
/* 按分类过滤 */
if (activeCatId !== ALL_CATEGORY_ID) {
const catName = activeCategory?.name || ""
if (catName) {
list = list.filter((t) => t.category === catName)
}
}
/* 按类型筛选 */
if (filterType !== "all") {
list = list.filter((t) => t.type === filterType)
}
/* 按行业筛选 */
if (filterIndustry !== "all") {
list = list.filter((t) => t.industry === filterIndustry)
}
/* 按使用频率筛选 */
if (filterFrequency !== "all") {
switch (filterFrequency) {
case "high":
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
break
case "medium":
list = list.filter(
(t) =>
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
t.usageCount < FREQUENCY_THRESHOLDS.high,
)
break
case "low":
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
break
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((t) => t.content.toLowerCase().includes(q))
}
return list
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
/* 操作:收藏 */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线")
}, [])
/* 操作:复制 */
const handleCopy = useCallback(async (title: TitleData) => {
const ok = await copyToClipboard(title.content)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 操作:删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id)
message.success("标题已删除")
},
[deleteMutation],
)
return {
/* 状态 */
titles,
categories,
activeCatId,
activeCategory,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
/* mutations */
createMutation,
updateMutation,
deleteMutation,
/* setters */
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
/* handlers */
handleToggleFavorite,
handleCopy,
handleDelete,
}
}
@@ -0,0 +1,20 @@
export type TitleType = "hot" | "normal" | "creative"
export type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel"
export type Frequency = "all" | "high" | "medium" | "low"
export interface TitleData {
id: string
content: string
type: TitleType
industry: Industry
category: string
usageCount: number
isFavorited: boolean
createdAt: string
}
export interface CategoryItem {
id: string
name: string
count: number
}
@@ -0,0 +1,49 @@
import type { TitleData, TitleType } from "../types/titleLibrary"
import type { TitleItem } from "@/api/titles"
export const typeLabel = (type: TitleType): string => {
switch (type) {
case "hot":
return "爆款"
case "normal":
return "常规"
case "creative":
return "创意"
}
}
/** 后端 TitleItem → 前端 TitleData 映射 */
export const toTitleData = (item: TitleItem): TitleData => ({
id: item.id,
content: item.content,
type: (item.category as TitleType) || "normal",
industry: "general",
category: item.category || "未分类",
usageCount: 0,
isFavorited: false,
createdAt: item.created_at?.slice(0, 10) || "",
})
/** 复制文本到剪贴板 */
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
/* 降级方案 */
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand("copy")
return true
} catch {
return false
} finally {
document.body.removeChild(textarea)
}
}
}
@@ -9,21 +9,8 @@
* - 删除素材
*/
import React from "react"
import {
AudioOutlined,
SearchOutlined,
PlusOutlined,
DeleteOutlined,
UploadOutlined,
UnorderedListOutlined,
AppstoreOutlined,
CheckOutlined,
TagsOutlined,
RobotOutlined,
LoadingOutlined,
} from "@ant-design/icons"
import { Button, Input, Select, Modal } from "@/components/ui"
import { Popover, Popconfirm } from "antd"
import { PlusOutlined, RobotOutlined } from "@ant-design/icons"
import { Button, Modal } from "@/components/ui"
import PageHead from "@/components/layout/PageHead"
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
import { useAudioPlayer } from "./hooks/useAudioPlayer"
@@ -32,6 +19,11 @@ import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
import MaterialForm from "./components/MaterialForm"
import VoiceMaterialCard from "./components/VoiceMaterialCard"
import VoiceMaterialRow from "./components/VoiceMaterialRow"
import Toolbar from "./components/Toolbar"
import TagFilterBar from "./components/TagFilterBar"
import BatchBar from "./components/BatchBar"
import EmptyState from "./components/EmptyState"
import TtsModal from "./components/TtsModal"
import "./voice-materials.css"
/* ============================================================
@@ -137,7 +129,7 @@ const VoiceMaterialLibrary: React.FC = () => {
})
}
/* ── 渲染 ─────────────────────────────────────────────── */
const hasFilter = !!searchText || filterGender !== "all" || filterTagId !== "all"
const pageActions = (
<div className="vmat-page-actions">
@@ -164,155 +156,47 @@ const VoiceMaterialLibrary: React.FC = () => {
/>
{/* 工具栏:搜索 + 筛选 + 视图切换 */}
<div className="vmat-toolbar">
<div className="vmat-toolbar-left">
<Input
placeholder="搜索配音素材..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 240 }}
/>
<Select
value={filterGender}
onChange={setFilterGender}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部性别" },
{ value: "male", label: "男声" },
{ value: "female", label: "女声" },
{ value: "child", label: "童声" },
{ value: "neutral", label: "中性" },
]}
/>
</div>
<div className="vmat-toolbar-right">
<span className="vmat-result-count"> {filtered.length} </span>
<div className="vmat-view-toggle">
<button
type="button"
className={`vmat-view-btn${viewMode === "card" ? " active" : ""}`}
onClick={() => setViewMode("card")}
title="卡片视图"
>
<AppstoreOutlined />
</button>
<button
type="button"
className={`vmat-view-btn${viewMode === "list" ? " active" : ""}`}
onClick={() => setViewMode("list")}
title="列表视图"
>
<UnorderedListOutlined />
</button>
</div>
</div>
</div>
<Toolbar
searchText={searchText}
filterGender={filterGender}
viewMode={viewMode}
resultCount={filtered.length}
onSearchChange={setSearchText}
onGenderChange={setFilterGender}
onViewModeChange={setViewMode}
/>
{/* ── 标签筛选药丸条 ─────────────────────────────────── */}
{tags.length > 0 && (
<div className="vmat-tag-filter-bar">
<button
type="button"
className={`vmat-filter-pill${filterTagId === "all" ? " active" : ""}`}
onClick={() => setFilterTagId("all")}
tabIndex={0}
>
</button>
{tags.map((tag) => (
<button
key={tag.id}
type="button"
className={`vmat-filter-pill${filterTagId === tag.id ? " active" : ""}`}
onClick={() => setFilterTagId(tag.id)}
tabIndex={0}
>
{tag.name}
<span className="vmat-filter-pill-count">{tagCountMap[tag.id] || 0}</span>
</button>
))}
</div>
)}
{/* 加载中 */}
{isLoading && (
<div className="vmat-empty">
<div className="vmat-empty-icon">
<AudioOutlined />
</div>
<h3>...</h3>
</div>
)}
{/* 标签筛选药丸条 */}
<TagFilterBar
tags={tags}
filterTagId={filterTagId}
tagCountMap={tagCountMap}
onTagSelect={setFilterTagId}
/>
{/* 批量操作栏 */}
{batchMode && (
<div className="vmat-batch-bar">
<div className="vmat-batch-bar-left">
<div
className={`vmat-checkbox${allSelected ? " checked" : ""}`}
onClick={handleSelectAll}
>
{allSelected && <CheckOutlined />}
</div>
<span className="vmat-select-all" onClick={handleSelectAll}>
{allSelected ? "取消全选" : "全选"}
</span>
<span className="vmat-batch-count"> {selectedIds.size} </span>
</div>
<div className="vmat-batch-bar-right">
<Popover
content={
<div className="vmat-tag-popover">
<div className="vmat-tag-pop-input-row">
<Input
placeholder="输入自定义标签后回车"
value={batchCustomTag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setBatchCustomTag(e.target.value)
}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && batchCustomTag.trim()) {
handleBatchCustomTag(batchCustomTag.trim())
setBatchCustomTag("")
}
}}
/>
</div>
{tags.map((tag) => (
<button
key={tag.id}
type="button"
className="vmat-tag-pop-btn"
onClick={() => handleBatchTag(tag.id)}
>
{tag.name}
</button>
))}
</div>
}
title="批量打标签"
trigger="click"
>
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />}>
</Button>
</Popover>
<Popconfirm
title={`确定删除选中的 ${selectedIds.size} 个素材?`}
onConfirm={handleBatchDelete}
okText="删除"
cancelText="取消"
>
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</div>
</div>
<BatchBar
selectedCount={selectedIds.size}
allSelected={allSelected}
batchCustomTag={batchCustomTag}
tags={tags}
onSelectAll={handleSelectAll}
onBatchCustomTagChange={setBatchCustomTag}
onBatchCustomTagSubmit={handleBatchCustomTag}
onBatchTag={handleBatchTag}
onBatchDelete={handleBatchDelete}
/>
)}
{/* 加载/空状态 */}
<EmptyState
isLoading={isLoading}
isEmpty={filtered.length === 0}
hasFilter={hasFilter}
onUploadClick={() => setUploadOpen(true)}
/>
{/* 内容区 — 卡片视图 */}
{!isLoading && filtered.length > 0 && viewMode === "card" && (
<div className="vmat-grid">
@@ -374,31 +258,6 @@ const VoiceMaterialLibrary: React.FC = () => {
</div>
)}
{/* 空状态 */}
{!isLoading && filtered.length === 0 && (
<div className="vmat-empty">
<div className="vmat-empty-icon">
<AudioOutlined />
</div>
<h3></h3>
<p>
{searchText || filterGender !== "all" || filterTagId !== "all"
? "未找到匹配的素材,试试调整筛选条件"
: "上传音频文件,开始管理配音素材"}
</p>
{!searchText && filterGender === "all" && filterTagId === "all" && (
<Button
buttonType="primary"
buttonSize="md"
icon={<UploadOutlined />}
onClick={() => setUploadOpen(true)}
>
</Button>
)}
</div>
)}
{/* 上传弹窗 */}
<Modal
title="上传配音素材"
@@ -450,151 +309,22 @@ const VoiceMaterialLibrary: React.FC = () => {
width={560}
destroyOnClose
>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* 文本输入 */}
<div>
<label
style={{
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
display: "block",
}}
>
</label>
<textarea
rows={4}
placeholder="请输入需要转换为语音的文本内容…"
value={ttsText}
onChange={(e) => setTtsText(e.target.value)}
maxLength={2000}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color, #d9d9d9)",
borderRadius: 6,
fontSize: 13,
resize: "vertical",
fontFamily: "inherit",
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary, #999)",
marginTop: 4,
textAlign: "right",
}}
>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<label
style={{
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
display: "block",
}}
>
</label>
<select
value={ttsVoiceId}
onChange={(e) => setTtsVoiceId(e.target.value)}
style={{
width: "100%",
height: 36,
padding: "0 10px",
border: "1px solid var(--border-color, #d9d9d9)",
borderRadius: 6,
fontSize: 13,
background: "var(--bg-primary, #fff)",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.voice_id} value={v.voice_id}>
{v.name}
</option>
))}
</select>
</div>
{/* 语速调节 */}
<div>
<label
style={{
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
display: "block",
}}
>
{ttsSpeed.toFixed(1)}x
</label>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
style={{ width: "100%" }}
/>
</div>
{/* 合成按钮 */}
<Button
buttonType="primary"
buttonSize="md"
icon={ttsStatus === "synthesizing" ? <LoadingOutlined /> : <RobotOutlined />}
onClick={handleTtsSynthesize}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
>
{ttsStatus === "synthesizing" ? "合成中…" : "开始合成"}
</Button>
{/* 错误提示 */}
{ttsStatus === "error" && ttsError && (
<div
style={{
padding: "8px 12px",
background: "#fff2f0",
borderRadius: 6,
color: "#ff4d4f",
fontSize: 13,
}}
>
{ttsError}
</div>
)}
{/* 合成结果 */}
{ttsStatus === "done" && ttsAudioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-surface, #f5f5f5)",
borderRadius: 8,
}}
>
<audio controls src={ttsAudioUrl} style={{ width: "100%", marginBottom: 12 }} />
<Button
buttonType="primary"
buttonSize="sm"
icon={<PlusOutlined />}
onClick={handleTtsSave}
>
</Button>
</div>
)}
</div>
<TtsModal
open={ttsOpen}
text={ttsText}
voiceId={ttsVoiceId}
speed={ttsSpeed}
status={ttsStatus}
audioUrl={ttsAudioUrl ?? ""}
error={ttsError ?? ""}
presetVoices={presetVoices}
onClose={handleTtsClose}
onTextChange={setTtsText}
onVoiceChange={setTtsVoiceId}
onSpeedChange={setTtsSpeed}
onSynthesize={handleTtsSynthesize}
onSave={handleTtsSave}
/>
</Modal>
</div>
)
@@ -0,0 +1,94 @@
import React from "react"
import { CheckOutlined, TagsOutlined, DeleteOutlined } from "@ant-design/icons"
import { Button, Input } from "@/components/ui"
import { Popover, Popconfirm } from "antd"
import type { TagItem } from "@/api/tags"
interface BatchBarProps {
selectedCount: number
allSelected: boolean
batchCustomTag: string
tags: TagItem[]
onSelectAll: () => void
onBatchCustomTagChange: (value: string) => void
onBatchCustomTagSubmit: (tag: string) => void
onBatchTag: (tagId: string) => void
onBatchDelete: () => void
}
const BatchBar: React.FC<BatchBarProps> = ({
selectedCount,
allSelected,
batchCustomTag,
tags,
onSelectAll,
onBatchCustomTagChange,
onBatchCustomTagSubmit,
onBatchTag,
onBatchDelete,
}) => {
return (
<div className="vmat-batch-bar">
<div className="vmat-batch-bar-left">
<div className={`vmat-checkbox${allSelected ? " checked" : ""}`} onClick={onSelectAll}>
{allSelected && <CheckOutlined />}
</div>
<span className="vmat-select-all" onClick={onSelectAll}>
{allSelected ? "取消全选" : "全选"}
</span>
<span className="vmat-batch-count"> {selectedCount} </span>
</div>
<div className="vmat-batch-bar-right">
<Popover
content={
<div className="vmat-tag-popover">
<div className="vmat-tag-pop-input-row">
<Input
placeholder="输入自定义标签后回车"
value={batchCustomTag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onBatchCustomTagChange(e.target.value)
}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && batchCustomTag.trim()) {
onBatchCustomTagSubmit(batchCustomTag.trim())
onBatchCustomTagChange("")
}
}}
/>
</div>
{tags.map((tag) => (
<button
key={tag.id}
type="button"
className="vmat-tag-pop-btn"
onClick={() => onBatchTag(tag.id)}
>
{tag.name}
</button>
))}
</div>
}
title="批量打标签"
trigger="click"
>
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />}>
</Button>
</Popover>
<Popconfirm
title={`确定删除选中的 ${selectedCount} 个素材?`}
onConfirm={onBatchDelete}
okText="删除"
cancelText="取消"
>
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</div>
</div>
)
}
export default BatchBar
@@ -0,0 +1,52 @@
import React from "react"
import { AudioOutlined, UploadOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
interface EmptyStateProps {
isLoading: boolean
isEmpty: boolean
hasFilter: boolean
onUploadClick: () => void
}
const EmptyState: React.FC<EmptyStateProps> = ({
isLoading,
isEmpty,
hasFilter,
onUploadClick,
}) => {
if (isLoading) {
return (
<div className="vmat-empty">
<div className="vmat-empty-icon">
<AudioOutlined />
</div>
<h3>...</h3>
</div>
)
}
if (!isEmpty) return null
return (
<div className="vmat-empty">
<div className="vmat-empty-icon">
<AudioOutlined />
</div>
<h3></h3>
<p>{hasFilter ? "未找到匹配的素材,试试调整筛选条件" : "上传音频文件,开始管理配音素材"}</p>
{!hasFilter && (
<Button
buttonType="primary"
buttonSize="md"
icon={<UploadOutlined />}
onClick={onUploadClick}
>
</Button>
)}
</div>
)
}
export default EmptyState
@@ -0,0 +1,45 @@
import React from "react"
import type { TagItem } from "@/api/tags"
interface TagFilterBarProps {
tags: TagItem[]
filterTagId: string
tagCountMap: Record<string, number>
onTagSelect: (tagId: string) => void
}
const TagFilterBar: React.FC<TagFilterBarProps> = ({
tags,
filterTagId,
tagCountMap,
onTagSelect,
}) => {
if (tags.length === 0) return null
return (
<div className="vmat-tag-filter-bar">
<button
type="button"
className={`vmat-filter-pill${filterTagId === "all" ? " active" : ""}`}
onClick={() => onTagSelect("all")}
tabIndex={0}
>
</button>
{tags.map((tag) => (
<button
key={tag.id}
type="button"
className={`vmat-filter-pill${filterTagId === tag.id ? " active" : ""}`}
onClick={() => onTagSelect(tag.id)}
tabIndex={0}
>
{tag.name}
<span className="vmat-filter-pill-count">{tagCountMap[tag.id] || 0}</span>
</button>
))}
</div>
)
}
export default TagFilterBar
@@ -0,0 +1,76 @@
import React from "react"
import { SearchOutlined, AppstoreOutlined, UnorderedListOutlined } from "@ant-design/icons"
import { Input, Select } from "@/components/ui"
import type { ViewMode } from "../types"
interface ToolbarProps {
searchText: string
filterGender: string
viewMode: ViewMode
resultCount: number
onSearchChange: (value: string) => void
onGenderChange: (value: string) => void
onViewModeChange: (mode: ViewMode) => void
}
const GENDER_OPTIONS = [
{ value: "all", label: "全部性别" },
{ value: "male", label: "男声" },
{ value: "female", label: "女声" },
{ value: "child", label: "童声" },
{ value: "neutral", label: "中性" },
]
const Toolbar: React.FC<ToolbarProps> = ({
searchText,
filterGender,
viewMode,
resultCount,
onSearchChange,
onGenderChange,
onViewModeChange,
}) => {
return (
<div className="vmat-toolbar">
<div className="vmat-toolbar-left">
<Input
placeholder="搜索配音素材..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
style={{ width: 240 }}
/>
<Select
value={filterGender}
onChange={onGenderChange}
style={{ width: 120 }}
options={GENDER_OPTIONS}
/>
</div>
<div className="vmat-toolbar-right">
<span className="vmat-result-count"> {resultCount} </span>
<div className="vmat-view-toggle">
<button
type="button"
className={`vmat-view-btn${viewMode === "card" ? " active" : ""}`}
onClick={() => onViewModeChange("card")}
title="卡片视图"
>
<AppstoreOutlined />
</button>
<button
type="button"
className={`vmat-view-btn${viewMode === "list" ? " active" : ""}`}
onClick={() => onViewModeChange("list")}
title="列表视图"
>
<UnorderedListOutlined />
</button>
</div>
</div>
</div>
)
}
export default Toolbar
+171
View File
@@ -0,0 +1,171 @@
import React from "react"
import { RobotOutlined, LoadingOutlined, PlusOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
export interface TtsPresetVoice {
voice_id: string
name: string
}
interface TtsModalProps {
open: boolean
text: string
voiceId: string
speed: number
status: TtsStatus
audioUrl: string
error: string
presetVoices: TtsPresetVoice[]
onClose: () => void
onTextChange: (value: string) => void
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onSynthesize: () => void
onSave: () => void
}
const TtsModal: React.FC<TtsModalProps> = ({
open,
text,
voiceId,
speed,
status,
audioUrl,
error,
presetVoices,
onClose: _onClose,
onTextChange,
onVoiceChange,
onSpeedChange,
onSynthesize,
onSave,
}) => {
if (!open) return null
const labelStyle: React.CSSProperties = {
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
display: "block",
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* 文本输入 */}
<div>
<label style={labelStyle}></label>
<textarea
rows={4}
placeholder="请输入需要转换为语音的文本内容…"
value={text}
onChange={(e) => onTextChange(e.target.value)}
maxLength={2000}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color, #d9d9d9)",
borderRadius: 6,
fontSize: 13,
resize: "vertical",
fontFamily: "inherit",
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary, #999)",
marginTop: 4,
textAlign: "right",
}}
>
{text.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<label style={labelStyle}></label>
<select
value={voiceId}
onChange={(e) => onVoiceChange(e.target.value)}
style={{
width: "100%",
height: 36,
padding: "0 10px",
border: "1px solid var(--border-color, #d9d9d9)",
borderRadius: 6,
fontSize: 13,
background: "var(--bg-primary, #fff)",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.voice_id} value={v.voice_id}>
{v.name}
</option>
))}
</select>
</div>
{/* 语速调节 */}
<div>
<label style={labelStyle}>{speed.toFixed(1)}x</label>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={speed}
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
style={{ width: "100%" }}
/>
</div>
{/* 合成按钮 */}
<Button
buttonType="primary"
buttonSize="md"
icon={status === "synthesizing" ? <LoadingOutlined /> : <RobotOutlined />}
onClick={onSynthesize}
disabled={status === "synthesizing" || !text.trim()}
>
{status === "synthesizing" ? "合成中…" : "开始合成"}
</Button>
{/* 错误提示 */}
{status === "error" && error && (
<div
style={{
padding: "8px 12px",
background: "#fff2f0",
borderRadius: 6,
color: "#ff4d4f",
fontSize: 13,
}}
>
{error}
</div>
)}
{/* 合成结果 */}
{status === "done" && audioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-surface, #f5f5f5)",
borderRadius: 8,
}}
>
<audio controls src={audioUrl} style={{ width: "100%", marginBottom: 12 }} />
<Button buttonType="primary" buttonSize="sm" icon={<PlusOutlined />} onClick={onSave}>
</Button>
</div>
)}
</div>
)
}
export default TtsModal
@@ -0,0 +1,107 @@
import { useMemo } from "react"
import type { TagItem } from "@/api/tags"
import { useVoiceTags } from "./useVoiceTags"
import { useVoiceMaterialFilterState } from "./useVoiceMaterialFilterState"
import { useVoiceMaterialData } from "./useVoiceMaterialData"
import { useVoiceMaterialActions } from "./useVoiceMaterialActions"
import { type VoiceMaterial } from "../../types"
/**
* 配音素材数据 Hook
* 封装素材列表查询、筛选状态管理、增删改等数据操作逻辑
*/
export function useVoiceMaterials() {
// 标签管理
const { tags, tagMap, handleCreateTag } = useVoiceTags()
// 筛选视图状态
const filterState = useVoiceMaterialFilterState()
const { searchText, filterGender, filterTagId } = filterState
// 后端查询参数
const keyword = searchText.trim() || undefined
const gender = filterGender !== "all" ? filterGender : undefined
const tagIds = filterTagId !== "all" ? [filterTagId] : undefined
// 数据查询
const { libraries, voiceLibrary, materials, isLoading, createLibMutation } = useVoiceMaterialData(
{ keyword, gender, tagIds },
)
// 操作层
const actions = useVoiceMaterialActions({
voiceLibrary,
materials,
createLibMutation,
})
// 前端二次筛选(与后端筛选同时存在,保证即时响应)
const filtered = useMemo(() => {
let list: VoiceMaterial[] = 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 as Map<string, TagItem>).get(id)?.name?.toLowerCase().includes(q),
),
)
}
return list
}, [materials, filterGender, filterTagId, searchText, tagMap])
// 标签使用计数
const tagCountMap = useMemo(() => {
const map: Record<string, number> = {}
materials.forEach((m) =>
m.tagIds.forEach((id) => {
map[id] = (map[id] || 0) + 1
}),
)
return map
}, [materials])
return {
// 数据
libraries,
voiceLibrary,
tags,
tagMap,
materials,
filtered,
tagCountMap,
isLoading,
// 视图 & 筛选状态
viewMode: filterState.viewMode,
searchText: filterState.searchText,
filterGender: filterState.filterGender,
filterTagId: filterState.filterTagId,
// 上传 & 编辑状态
uploadProgress: actions.uploadProgress,
isUploading: actions.isUploading,
isEditing: actions.isEditing,
// 弹窗状态
uploadOpen: actions.uploadOpen,
editingMaterial: actions.editingMaterial,
// 视图控制
setViewMode: filterState.setViewMode,
setSearchText: filterState.setSearchText,
setFilterGender: filterState.setFilterGender,
setFilterTagId: filterState.setFilterTagId,
setUploadOpen: actions.setUploadOpen,
setEditingMaterial: actions.setEditingMaterial,
// 操作
handleCreateTag,
handleUpload: actions.handleUpload,
handleEdit: actions.handleEdit,
handleDelete: actions.handleDelete,
}
}
@@ -1,117 +1,35 @@
import { useState, useMemo, useCallback, useEffect } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
getAssetsByKind,
createAsset,
updateAsset,
deleteAsset,
uploadAssetDirect,
getAssetLibraries,
createAssetLibrary,
type AssetLibraryItem,
} 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"
import { tagAsset, untagAsset } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../types"
import { getAudioDuration } from "../../utils/audio"
interface UseVoiceMaterialActionsOptions {
voiceLibrary?: { id: string; kind: string }
materials: VoiceMaterial[]
createLibMutation: { mutateAsync: () => Promise<AssetLibraryItem>; isPending: boolean }
}
/**
* Hook
*
* Hook
* UI
*/
export function useVoiceMaterials() {
export function useVoiceMaterialActions({
voiceLibrary,
materials,
createLibMutation,
}: UseVoiceMaterialActionsOptions) {
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)
@@ -140,7 +58,7 @@ export function useVoiceMaterials() {
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
lib = libs.find((l) => l.kind === "voice")
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
}
@@ -231,40 +149,6 @@ export function useVoiceMaterials() {
},
})
/* ── 前端二次筛选(与后端筛选同时存在) ──────────────────── */
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(
@@ -314,20 +198,6 @@ export function useVoiceMaterials() {
)
return {
// 数据
libraries,
voiceLibrary,
tags,
tagMap,
materials,
filtered,
tagCountMap,
isLoading,
// 视图 & 筛选状态
viewMode,
searchText,
filterGender,
filterTagId,
// 上传 & 编辑状态
uploadProgress,
isUploading: uploadMutation.isPending,
@@ -336,14 +206,9 @@ export function useVoiceMaterials() {
uploadOpen,
editingMaterial,
// 视图控制
setViewMode,
setSearchText,
setFilterGender,
setFilterTagId,
setUploadOpen,
setEditingMaterial,
// 操作
handleCreateTag,
handleUpload,
handleEdit,
handleDelete,
@@ -0,0 +1,58 @@
import { useMemo, useEffect } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getAssetsByKind, getAssetLibraries, createAssetLibrary } from "@/api/assets"
import { type VoiceMaterial, mapAssetToMaterial } from "../../types"
interface UseVoiceMaterialDataOptions {
keyword?: string
gender?: string
tagIds?: string[]
}
/**
* 配音素材数据查询 Hook
* 封装素材库获取、自动创建 voice 库、素材列表查询
*/
export function useVoiceMaterialData({ keyword, gender, tagIds }: UseVoiceMaterialDataOptions) {
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: assets = [], isLoading } = useQuery({
queryKey: ["assets", "voice", { keyword, gender, tag_ids: tagIds }],
queryFn: () => getAssetsByKind("voice", { keyword, gender, tag_ids: tagIds }),
staleTime: 30_000,
})
const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets])
return {
libraries,
voiceLibrary,
materials,
isLoading,
createLibMutation,
}
}
@@ -0,0 +1,24 @@
import { useState } from "react"
import { type ViewMode } from "../../types"
/**
* 配音素材筛选视图状态 Hook
* 管理视图模式、搜索、性别/标签筛选的状态
*/
export function useVoiceMaterialFilterState() {
const [viewMode, setViewMode] = useState<ViewMode>("card")
const [searchText, setSearchText] = useState("")
const [filterGender, setFilterGender] = useState<string>("all")
const [filterTagId, setFilterTagId] = useState<string>("all")
return {
viewMode,
searchText,
filterGender,
filterTagId,
setViewMode,
setSearchText,
setFilterGender,
setFilterTagId,
}
}
@@ -0,0 +1,44 @@
import { useMemo, useCallback } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { type TagItem, getTags, createTag } from "@/api/tags"
/**
* 配音素材标签管理 Hook
* 封装标签列表查询、标签映射、创建标签等逻辑
*/
export function useVoiceTags() {
const queryClient = useQueryClient()
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])
const createTagMutation = useMutation({
mutationFn: (name: string) => createTag(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
})
const handleCreateTag = useCallback(
async (name: string): Promise<TagItem> => {
return createTagMutation.mutateAsync(name)
},
[createTagMutation],
)
return {
tags,
tagMap,
handleCreateTag,
}
}
+88 -281
View File
@@ -1,47 +1,39 @@
/**
* 配音库页面 — V21 设计系统
*
* Phase 3: 逻辑抽离为 Hooks
* - useVoicesData: 三 Tab 数据查询 + 筛选
* - useAudioPlayer: 播放控制
* - useCloneOperations: 克隆音色删除/重试/详情
* - useTtsSynthesize: AI 配音合成
* - useVoiceUpload: 上传音频
* 主组件仅保留 Hook 组装与整体布局
* 数据查询 → hooks/useVoicesData
* 播放控制 → hooks/useAudioPlayer
* 克隆操作 → hooks/useCloneOperations
* TTS 合成 → hooks/useTtsSynthesize
* 上传音频 → hooks/useVoiceUpload
* Tab 切换 → components/VoiceTabBar
* 预置音色 → components/PresetVoiceTab
* 克隆音色 → components/ClonedVoiceTab
* 配音素材 → components/MaterialVoiceTab
* 弹窗集合 → components/VoiceModals
* Toast 提示 → components/VoiceToasts
*/
import React, { useCallback, useState } from "react"
import {
SoundOutlined,
PlusOutlined,
RobotOutlined,
UploadOutlined,
AudioOutlined,
UserOutlined,
} from "@ant-design/icons"
import { UploadOutlined, AudioOutlined, RobotOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import PageHead from "@/components/layout/PageHead"
import { type AssetItem } from "@/api/assets"
import { genderLabel, languageLabel } from "@/pages/voices/utils/format"
import CloneModal from "@/components/voice/CloneModal"
import VoiceCard from "@/pages/voices/components/VoiceCard"
import CloneVoiceCard from "@/pages/voices/components/CloneVoiceCard"
import CloneDetailModal from "@/pages/voices/components/CloneDetailModal"
import CloneCardSkeleton from "@/pages/voices/components/CloneCardSkeleton"
import UploadVoiceModal from "@/pages/voices/components/UploadVoiceModal"
import TtsModal from "@/pages/voices/components/TtsModal"
import VoiceFilterBar from "@/pages/voices/components/VoiceFilterBar"
import { useVoicesData } from "@/pages/voices/hooks/useVoicesData"
import { useAudioPlayer } from "@/pages/voices/hooks/useAudioPlayer"
import { useCloneOperations } from "@/pages/voices/hooks/useCloneOperations"
import { useTtsSynthesize } from "@/pages/voices/hooks/useTtsSynthesize"
import { useVoiceUpload } from "@/pages/voices/hooks/useVoiceUpload"
import VoiceTabBar from "./components/VoiceTabBar"
import type { VoiceTabKey } from "./components/VoiceTabBar"
import PresetVoiceTab from "./components/PresetVoiceTab"
import ClonedVoiceTab from "./components/ClonedVoiceTab"
import MaterialVoiceTab from "./components/MaterialVoiceTab"
import VoiceModals from "./components/VoiceModals"
import VoiceToasts from "./components/VoiceToasts"
import type { Toast } from "./components/VoiceToasts"
import { useVoicesData } from "./hooks/useVoicesData"
import { useAudioPlayer } from "./hooks/useAudioPlayer"
import { useCloneOperations } from "./hooks/useCloneOperations"
import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
import { useVoiceUpload } from "./hooks/useVoiceUpload"
import "./voices.css"
interface Toast {
id: number
message: string
type: "success" | "error"
}
let toastIdSeq = 0
const VoiceLibrary: React.FC = () => {
@@ -138,17 +130,9 @@ const VoiceLibrary: React.FC = () => {
handleUploadClose,
} = useVoiceUpload({ showToast })
// ── 克隆音色播放切换 ──────────────────────────────────
const handleClonePlayPause = useCallback(
(voiceId: string, duration: number) => {
handleTogglePlay(voiceId, duration)
},
[handleTogglePlay],
)
// ── 切换 Tab 时停止播放 ───────────────────────────────
const handleTabChange = useCallback(
(tab: typeof activeTab) => {
(tab: VoiceTabKey) => {
setActiveTab(tab)
stopPlayback()
},
@@ -200,254 +184,85 @@ const VoiceLibrary: React.FC = () => {
actions={pageActions}
/>
{/* ── Tabs ──────────────────────────────────────── */}
<div className="xx-voices-tabs">
<button
className={`xx-voices-tab${activeTab === "preset" ? " active" : ""}`}
onClick={() => handleTabChange("preset")}
>
<AudioOutlined />
<span className="xx-voices-tab-count">{presetCount}</span>
</button>
<button
className={`xx-voices-tab${activeTab === "cloned" ? " active" : ""}`}
onClick={() => handleTabChange("cloned")}
>
<UserOutlined />
<span className="xx-voices-tab-count">{cloneCount}</span>
</button>
<button
className={`xx-voices-tab${activeTab === "material" ? " active" : ""}`}
onClick={() => handleTabChange("material")}
>
<SoundOutlined />
<span className="xx-voices-tab-count">{materialCount}</span>
</button>
</div>
{/* ── Tab 切换栏 ────────────────────────────────── */}
<VoiceTabBar
activeTab={activeTab as VoiceTabKey}
presetCount={presetCount}
cloneCount={cloneCount}
materialCount={materialCount}
onTabChange={handleTabChange}
/>
{/* ── 预置音色 ──────────────────────────────────── */}
{activeTab === "preset" && (
<div className="xx-voices-tab-content">
<VoiceFilterBar
searchText={searchText}
filterGender={filterGender}
filterLang={filterLang}
onSearchChange={setSearchText}
onGenderChange={setFilterGender}
onLangChange={setFilterLang}
/>
{presetLoading && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<SoundOutlined />
</div>
<p>...</p>
</div>
)}
{!presetLoading && filteredPreset.length > 0 && (
<div className="xx-voice-grid">
{filteredPreset.map((voice) => (
<VoiceCard
key={voice.id}
id={voice.id}
name={voice.name}
subtitle={`${genderLabel(voice.gender)} · ${languageLabel(voice.language)} · ${voice.description}`}
tags={voice.tags}
duration={voice.duration}
gender={voice.gender}
isPlaying={playingId === voice.id}
isSelected={false}
currentTime={playingId === voice.id ? currentTime : 0}
starred={voice.starred}
onPlay={() => handlePlay(voice.id, voice.duration)}
onPause={handlePause}
onSeek={(time) => handleSeek(voice.id, time, voice.duration)}
onToggleStar={() => {}}
/>
))}
</div>
)}
{!presetLoading && filteredPreset.length === 0 && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<SoundOutlined />
</div>
<p></p>
<Button buttonType="ghost" buttonSize="sm" onClick={handleClearFilters}>
</Button>
</div>
)}
</div>
<PresetVoiceTab
searchText={searchText}
filterGender={filterGender}
filterLang={filterLang}
onSearchChange={setSearchText}
onGenderChange={setFilterGender}
onLangChange={setFilterLang}
loading={presetLoading}
voices={filteredPreset}
playingId={playingId}
currentTime={currentTime}
onPlay={handlePlay}
onPause={handlePause}
onSeek={handleSeek}
onClearFilters={handleClearFilters}
/>
)}
{/* ── 我的克隆 ──────────────────────────────────── */}
{activeTab === "cloned" && (
<div className="xx-voices-tab-content">
{/* 骨架屏加载 */}
{cloneLoading && (
<div className="xx-voice-grid">
{Array.from({ length: 6 }).map((_, i) => (
<CloneCardSkeleton key={i} />
))}
</div>
)}
{/* 卡片列表 */}
{!cloneLoading && clonedVoices.length > 0 && (
<div className="xx-voice-grid">
{clonedVoices.map((voice) => (
<CloneVoiceCard
key={voice.id}
voice={voice}
isPlaying={playingId === voice.id}
currentTime={playingId === voice.id ? currentTime : 0}
onPlay={() => handleClonePlayPause(voice.id, voice.duration)}
onPause={handlePause}
onUse={() => handleCloneUse(voice)}
onDelete={() => handleCloneDelete(voice)}
onRetry={() => handleCloneRetry(voice)}
onShowDetail={() => handleShowDetail(voice)}
/>
))}
</div>
)}
{/* 空状态 */}
{!cloneLoading && clonedVoices.length === 0 && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<UserOutlined />
</div>
<h3></h3>
<p></p>
<Button buttonType="primary" buttonSize="md" onClick={() => setCloneModalOpen(true)}>
<PlusOutlined />
</Button>
</div>
)}
{/* 处理中提示 */}
{!cloneLoading && clonedVoices.some((v) => v.status === "processing") && (
<div className="xx-clone-processing-hint">
<RobotOutlined />
<span></span>
</div>
)}
</div>
<ClonedVoiceTab
loading={cloneLoading}
voices={clonedVoices}
playingId={playingId}
currentTime={currentTime}
onPlay={handleTogglePlay}
onPause={handlePause}
onUse={handleCloneUse}
onDelete={handleCloneDelete}
onRetry={handleCloneRetry}
onShowDetail={handleShowDetail}
onOpenClone={() => setCloneModalOpen(true)}
/>
)}
{/* ── 配音素材 ──────────────────────────────────── */}
{activeTab === "material" && (
<div className="xx-voices-tab-content">
{/* 骨架屏加载 */}
{materialLoading && (
<div className="xx-voice-grid">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="vmat-card vmat-card--skeleton">
<div className="vmat-thumb" />
<div className="vmat-info">
<div className="vmat-skeleton-line vmat-skeleton-title" />
<div className="vmat-skeleton-line" />
</div>
</div>
))}
</div>
)}
{/* 卡片列表 */}
{!materialLoading && materials.length > 0 && (
<div className="xx-voice-grid">
{materials.map((asset: AssetItem) => {
const duration = (asset.metadata?.duration as number) || 0
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
return (
<div key={asset.id} className="vmat-card">
<div className="vmat-thumb">
<AudioOutlined className="vmat-thumb-icon" />
<span className="vmat-duration">
{minutes}:{seconds.toString().padStart(2, "0")}
</span>
</div>
<div className="vmat-info">
<div className="vmat-name" title={asset.name}>
{asset.name}
</div>
<div className="vmat-meta">
<span>
{asset.file_size
? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
: "--"}
</span>
</div>
</div>
</div>
)
})}
</div>
)}
{/* 空状态 */}
{!materialLoading && materials.length === 0 && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<SoundOutlined />
</div>
<h3></h3>
<p></p>
<Button buttonType="primary" buttonSize="md" onClick={() => setUploadOpen(true)}>
<UploadOutlined />
</Button>
</div>
)}
</div>
)}
{/* ── 克隆音色弹窗 ──────────────────────────────── */}
<CloneModal
open={cloneModalOpen}
onClose={() => setCloneModalOpen(false)}
onSuccess={handleCloneSuccess}
/>
{/* ── 详情弹窗 ──────────────────────────────────── */}
{detailVoice && (
<CloneDetailModal
voice={detailVoice}
onClose={handleCloseDetail}
onDelete={() => handleCloneDelete(detailVoice)}
onRetry={() => handleCloneRetry(detailVoice)}
onUse={() => handleCloneUse(detailVoice)}
<MaterialVoiceTab
loading={materialLoading}
materials={materials as AssetItem[]}
onOpenUpload={() => setUploadOpen(true)}
/>
)}
{/* ── 上传音频弹窗 ──────────────────────────────── */}
<UploadVoiceModal
open={uploadOpen}
{/* ── 弹窗集合 ──────────────────────────────────── */}
<VoiceModals
cloneModalOpen={cloneModalOpen}
onCloneClose={() => setCloneModalOpen(false)}
onCloneSuccess={handleCloneSuccess}
detailVoice={detailVoice}
onDetailClose={handleCloseDetail}
onDetailDelete={() => detailVoice && handleCloneDelete(detailVoice)}
onDetailRetry={() => detailVoice && handleCloneRetry(detailVoice)}
onDetailUse={() => detailVoice && handleCloneUse(detailVoice)}
uploadOpen={uploadOpen}
uploadFile={uploadFile}
uploadName={uploadName}
uploadGender={uploadGender}
uploadDesc={uploadDesc}
uploadProgress={uploadProgress}
onClose={handleUploadClose}
onUploadClose={handleUploadClose}
onFileSelect={handleFileSelect}
onFileRemove={handleFileRemove}
onNameChange={setUploadName}
onGenderChange={setUploadGender}
onDescChange={setUploadDesc}
onUpload={handleUpload}
/>
{/* ── AI 配音弹窗 ───────────────────────────────── */}
<TtsModal
open={ttsOpen}
ttsOpen={ttsOpen}
ttsText={ttsText}
ttsVoiceId={ttsVoiceId}
ttsSpeed={ttsSpeed}
@@ -455,24 +270,16 @@ const VoiceLibrary: React.FC = () => {
ttsAudioUrl={ttsAudioUrl}
ttsError={ttsError}
presetVoices={presetVoices}
onClose={handleTtsClose}
onTextChange={setTtsText}
onVoiceChange={setTtsVoiceId}
onSpeedChange={setTtsSpeed}
onSynthesize={handleTtsSynthesize}
onSave={handleTtsSave}
onTtsClose={handleTtsClose}
onTtsTextChange={setTtsText}
onTtsVoiceChange={setTtsVoiceId}
onTtsSpeedChange={setTtsSpeed}
onTtsSynthesize={handleTtsSynthesize}
onTtsSave={handleTtsSave}
/>
{/* ── Toast 提示 ────────────────────────────────── */}
{toasts.length > 0 && (
<div className="vc-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)}
<VoiceToasts toasts={toasts} />
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
/**
* VoiceLibrary 我的克隆 Tab 内容
*/
import React from "react"
import { UserOutlined, PlusOutlined, RobotOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import CloneVoiceCard from "./CloneVoiceCard"
import CloneCardSkeleton from "./CloneCardSkeleton"
import type { ClonedVoiceDisplay } from "../types"
export interface ClonedVoiceTabProps {
loading: boolean
voices: ClonedVoiceDisplay[]
playingId: string | null
currentTime: number
onPlay: (voiceId: string, duration: number) => void
onPause: () => void
onUse: (voice: ClonedVoiceDisplay) => void
onDelete: (voice: ClonedVoiceDisplay) => void
onRetry: (voice: ClonedVoiceDisplay) => void
onShowDetail: (voice: ClonedVoiceDisplay) => void
onOpenClone: () => void
}
export const ClonedVoiceTab: React.FC<ClonedVoiceTabProps> = ({
loading,
voices,
playingId,
currentTime,
onPlay,
onPause,
onUse,
onDelete,
onRetry,
onShowDetail,
onOpenClone,
}) => {
return (
<div className="xx-voices-tab-content">
{/* 骨架屏加载 */}
{loading && (
<div className="xx-voice-grid">
{Array.from({ length: 6 }).map((_, i) => (
<CloneCardSkeleton key={i} />
))}
</div>
)}
{/* 卡片列表 */}
{!loading && voices.length > 0 && (
<div className="xx-voice-grid">
{voices.map((voice) => (
<CloneVoiceCard
key={voice.id}
voice={voice}
isPlaying={playingId === voice.id}
currentTime={playingId === voice.id ? currentTime : 0}
onPlay={() => onPlay(voice.id, voice.duration)}
onPause={onPause}
onUse={() => onUse(voice)}
onDelete={() => onDelete(voice)}
onRetry={() => onRetry(voice)}
onShowDetail={() => onShowDetail(voice)}
/>
))}
</div>
)}
{/* 空状态 */}
{!loading && voices.length === 0 && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<UserOutlined />
</div>
<h3></h3>
<p></p>
<Button buttonType="primary" buttonSize="md" onClick={onOpenClone}>
<PlusOutlined />
</Button>
</div>
)}
{/* 处理中提示 */}
{!loading && voices.some((v) => v.status === "processing") && (
<div className="xx-clone-processing-hint">
<RobotOutlined />
<span></span>
</div>
)}
</div>
)
}
export default ClonedVoiceTab
@@ -0,0 +1,85 @@
/**
* VoiceLibrary 配音素材 Tab 内容
*/
import React from "react"
import { SoundOutlined, UploadOutlined, AudioOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import type { AssetItem } from "@/api/assets"
export interface MaterialVoiceTabProps {
loading: boolean
materials: AssetItem[]
onOpenUpload: () => void
}
export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
loading,
materials,
onOpenUpload,
}) => {
return (
<div className="xx-voices-tab-content">
{/* 骨架屏加载 */}
{loading && (
<div className="xx-voice-grid">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="vmat-card vmat-card--skeleton">
<div className="vmat-thumb" />
<div className="vmat-info">
<div className="vmat-skeleton-line vmat-skeleton-title" />
<div className="vmat-skeleton-line" />
</div>
</div>
))}
</div>
)}
{/* 卡片列表 */}
{!loading && materials.length > 0 && (
<div className="xx-voice-grid">
{materials.map((asset: AssetItem) => {
const duration = (asset.metadata?.duration as number) || 0
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
return (
<div key={asset.id} className="vmat-card">
<div className="vmat-thumb">
<AudioOutlined className="vmat-thumb-icon" />
<span className="vmat-duration">
{minutes}:{seconds.toString().padStart(2, "0")}
</span>
</div>
<div className="vmat-info">
<div className="vmat-name" title={asset.name}>
{asset.name}
</div>
<div className="vmat-meta">
<span>
{asset.file_size ? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB` : "--"}
</span>
</div>
</div>
</div>
)
})}
</div>
)}
{/* 空状态 */}
{!loading && materials.length === 0 && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<SoundOutlined />
</div>
<h3></h3>
<p></p>
<Button buttonType="primary" buttonSize="md" onClick={onOpenUpload}>
<UploadOutlined />
</Button>
</div>
)}
</div>
)
}
export default MaterialVoiceTab
@@ -0,0 +1,104 @@
/**
* VoiceLibrary 预置音色 Tab 内容
*/
import React from "react"
import { SoundOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import VoiceFilterBar from "./VoiceFilterBar"
import VoiceCard from "./VoiceCard"
import { genderLabel, languageLabel } from "../utils/format"
import type { PresetVoiceDisplay } from "../types"
export interface PresetVoiceTabProps {
searchText: string
filterGender: string
filterLang: string
onSearchChange: (text: string) => void
onGenderChange: (gender: string) => void
onLangChange: (lang: string) => void
loading: boolean
voices: PresetVoiceDisplay[]
playingId: string | null
currentTime: number
onPlay: (id: string, duration: number) => void
onPause: () => void
onSeek: (id: string, time: number, duration: number) => void
onClearFilters: () => void
}
export const PresetVoiceTab: React.FC<PresetVoiceTabProps> = ({
searchText,
filterGender,
filterLang,
onSearchChange,
onGenderChange,
onLangChange,
loading,
voices,
playingId,
currentTime,
onPlay,
onPause,
onSeek,
onClearFilters,
}) => {
return (
<div className="xx-voices-tab-content">
<VoiceFilterBar
searchText={searchText}
filterGender={filterGender}
filterLang={filterLang}
onSearchChange={onSearchChange}
onGenderChange={onGenderChange}
onLangChange={onLangChange}
/>
{loading && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<SoundOutlined />
</div>
<p>...</p>
</div>
)}
{!loading && voices.length > 0 && (
<div className="xx-voice-grid">
{voices.map((voice) => (
<VoiceCard
key={voice.id}
id={voice.id}
name={voice.name}
subtitle={`${genderLabel(voice.gender)} · ${languageLabel(voice.language)} · ${voice.description}`}
tags={voice.tags}
duration={voice.duration}
gender={voice.gender}
isPlaying={playingId === voice.id}
isSelected={false}
currentTime={playingId === voice.id ? currentTime : 0}
starred={voice.starred}
onPlay={() => onPlay(voice.id, voice.duration)}
onPause={onPause}
onSeek={(time) => onSeek(voice.id, time, voice.duration)}
onToggleStar={() => {}}
/>
))}
</div>
)}
{!loading && voices.length === 0 && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<SoundOutlined />
</div>
<p></p>
<Button buttonType="ghost" buttonSize="sm" onClick={onClearFilters}>
</Button>
</div>
)}
</div>
)
}
export default PresetVoiceTab
+16 -221
View File
@@ -1,27 +1,12 @@
import React from "react"
import { RobotOutlined } from "@ant-design/icons"
import { Modal, message } from "antd"
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import { genderLabel } from "@/pages/voices/utils/format"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
export interface TtsModalProps {
open: boolean
ttsText: string
ttsVoiceId: string
ttsSpeed: number
ttsStatus: TtsStatus
ttsAudioUrl: string | null
ttsError: string | null
presetVoices: PresetVoiceDisplay[]
onClose: () => void
onTextChange: (text: string) => void
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onSynthesize: () => void
onSave: () => void
}
import { Modal } from "antd"
import { type TtsModalProps, type TtsStatus } from "./tts-modal/types"
import TextInputSection from "./tts-modal/TextInputSection"
import VoiceSelector from "./tts-modal/VoiceSelector"
import SpeedControl from "./tts-modal/SpeedControl"
import SynthesizeButton from "./tts-modal/SynthesizeButton"
import ErrorAlert from "./tts-modal/ErrorAlert"
import ResultPanel from "./tts-modal/ResultPanel"
/** AI 配音弹窗 */
const TtsModal: React.FC<TtsModalProps> = ({
@@ -50,205 +35,13 @@ const TtsModal: React.FC<TtsModalProps> = ({
padding: "8px 0",
}}
>
{/* 文本输入 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={ttsText}
onChange={(e) => onTextChange(e.target.value)}
placeholder="输入要配音的文本内容..."
maxLength={2000}
rows={4}
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
lineHeight: 1.6,
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
textAlign: "right",
marginTop: 4,
}}
>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<select
value={ttsVoiceId}
onChange={(e) => onVoiceChange(e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {genderLabel(v.gender)}
</option>
))}
</select>
</div>
{/* 语速 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
{ttsSpeed.toFixed(1)}x
</div>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--primary-color)" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--text-tertiary)",
}}
>
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 合成按钮 */}
<button
type="button"
onClick={() => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本")
return
}
onSynthesize()
}}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
style={{
width: "100%",
padding: "10px 0",
borderRadius: 8,
border: "none",
background:
ttsStatus === "synthesizing" || !ttsText.trim()
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: ttsStatus === "synthesizing" || !ttsText.trim() ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<RobotOutlined />
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
</button>
{/* 错误提示 */}
{ttsError && (
<div
style={{
padding: "10px 12px",
background: "var(--error-soft, #fff2f0)",
borderRadius: 8,
color: "var(--error-color, #ff4d4f)",
fontSize: 13,
}}
>
{ttsError}
</div>
)}
{/* 合成结果 */}
<TextInputSection value={ttsText} onChange={onTextChange} />
<VoiceSelector value={ttsVoiceId} onChange={onVoiceChange} presetVoices={presetVoices} />
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
{ttsError && <ErrorAlert error={ttsError} />}
{ttsStatus === "done" && ttsAudioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--success-color, #52c41a)",
}}
>
</div>
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
<button
type="button"
onClick={onSave}
style={{
padding: "8px 0",
borderRadius: 8,
border: "1px solid var(--primary-color)",
background: "var(--primary-soft)",
color: "var(--primary-color)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
</button>
</div>
<ResultPanel audioUrl={ttsAudioUrl} onSave={onSave} />
)}
</div>
</Modal>
@@ -256,3 +49,5 @@ const TtsModal: React.FC<TtsModalProps> = ({
}
export default TtsModal
export type { TtsModalProps, TtsStatus }
@@ -1,24 +1,13 @@
import React from "react"
import { UploadOutlined, SoundOutlined } from "@ant-design/icons"
import { Modal, Upload, message } from "antd"
import { type VoiceGender } from "@/pages/voices/types"
import { formatFileSize } from "@/pages/voices/utils/format"
export interface UploadVoiceModalProps {
open: boolean
uploadFile: File | null
uploadName: string
uploadGender: VoiceGender
uploadDesc: string
uploadProgress: number | null
onClose: () => void
onFileSelect: (file: File) => void
onFileRemove: () => void
onNameChange: (name: string) => void
onGenderChange: (gender: VoiceGender) => void
onDescChange: (desc: string) => void
onUpload: () => void
}
import { Modal } from "antd"
import {
FileUploadZone,
FileInfoCard,
UploadProgress,
FormFields,
ActionButtons,
} from "./upload-voice-modal"
import type { UploadVoiceModalProps } from "./upload-voice-modal"
/** 上传音频弹窗 */
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
@@ -36,17 +25,20 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
onDescChange,
onUpload,
}) => {
const uploading = uploadProgress !== null
const canUpload = !!uploadFile && !!uploadName.trim()
return (
<Modal
title="上传音频"
open={open}
onCancel={() => {
if (uploadProgress !== null) return // 上传中不可关闭
if (uploading) return // 上传中不可关闭
onClose()
}}
footer={null}
width={520}
maskClosable={uploadProgress === null}
maskClosable={!uploading}
>
<div
style={{
@@ -57,270 +49,36 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
}}
>
{/* 拖拽上传区 */}
<Upload.Dragger
accept="audio/*"
maxCount={1}
beforeUpload={(file) => {
onFileSelect(file)
return false
}}
onRemove={() => {
onFileRemove()
}}
showUploadList={false}
disabled={uploadProgress !== null}
>
<p
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
}}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
</p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
MP3WAVAACFLAC 200MB
</p>
</Upload.Dragger>
<FileUploadZone
disabled={uploading}
onFileSelect={onFileSelect}
onFileRemove={onFileRemove}
/>
{/* 已选文件信息 */}
{uploadFile && (
<div
style={{
padding: "10px 12px",
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{uploadFile.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{formatFileSize(uploadFile.size)}
</div>
</div>
</div>
)}
{uploadFile && <FileInfoCard file={uploadFile} />}
{/* 上传进度 */}
{uploadProgress !== null && (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--primary-color)",
}}
>
{uploadProgress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{uploadProgress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${uploadProgress}%`,
background: "var(--primary-color)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)}
{uploadProgress !== null && <UploadProgress progress={uploadProgress} />}
{/* 名称 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<input
value={uploadName}
onChange={(e) => onNameChange(e.target.value)}
placeholder="输入素材名称"
maxLength={100}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
/>
</div>
{/* 性别选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<div style={{ display: "flex", gap: 8 }}>
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
<button
key={g}
type="button"
onClick={() => onGenderChange(g)}
disabled={uploadProgress !== null}
style={{
flex: 1,
padding: "6px 0",
borderRadius: 8,
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
background: uploadGender === g ? "var(--primary-soft)" : "transparent",
color: uploadGender === g ? "var(--primary-color)" : "var(--text-secondary)",
fontSize: 13,
fontWeight: uploadGender === g ? 600 : 400,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
transition: "all 0.2s",
}}
>
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
</button>
))}
</div>
</div>
{/* 描述 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={uploadDesc}
onChange={(e) => onDescChange(e.target.value)}
placeholder="描述这个音色的特点..."
maxLength={500}
rows={2}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
}}
/>
</div>
{/* 表单字段 */}
<FormFields
name={uploadName}
gender={uploadGender}
desc={uploadDesc}
disabled={uploading}
onNameChange={onNameChange}
onGenderChange={onGenderChange}
onDescChange={onDescChange}
/>
{/* 操作按钮 */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
paddingTop: 4,
}}
>
<button
type="button"
onClick={onClose}
disabled={uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "1px solid var(--border-color)",
background: "transparent",
fontSize: 13,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
color: "var(--text-secondary)",
}}
>
</button>
<button
type="button"
onClick={() => {
if (!uploadFile) {
message.warning("请先选择音频文件")
return
}
if (!uploadName.trim()) {
message.warning("请输入素材名称")
return
}
onUpload()
}}
disabled={!uploadFile || !uploadName.trim() || uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "none",
background:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "not-allowed"
: "pointer",
}}
>
{uploadProgress !== null ? "上传中..." : "开始上传"}
</button>
</div>
<ActionButtons
uploading={uploading}
canUpload={canUpload}
onCancel={onClose}
onUpload={onUpload}
/>
</div>
</Modal>
)
@@ -0,0 +1,149 @@
/**
* VoiceLibrary 弹窗集合
*/
import React from "react"
import type { ClonedVoiceDisplay, PresetVoiceDisplay, VoiceGender } from "../types"
import type { VoiceClone } from "@/api/voice-clone"
import type { TtsStatus } from "./TtsModal"
import CloneModal from "@/components/voice/CloneModal"
import CloneDetailModal from "./CloneDetailModal"
import UploadVoiceModal from "./UploadVoiceModal"
import TtsModal from "./TtsModal"
export interface VoiceModalsProps {
/* 克隆音色弹窗 */
cloneModalOpen: boolean
onCloneClose: () => void
onCloneSuccess: (voice: VoiceClone) => void
/* 克隆详情弹窗 */
detailVoice: ClonedVoiceDisplay | null
onDetailClose: () => void
onDetailDelete: () => void
onDetailRetry: () => void
onDetailUse: () => void
/* 上传音频弹窗 */
uploadOpen: boolean
uploadFile: File | null
uploadName: string
uploadGender: VoiceGender
uploadDesc: string
uploadProgress: number | null
onUploadClose: () => void
onFileSelect: (file: File) => void
onFileRemove: () => void
onNameChange: (name: string) => void
onGenderChange: (gender: VoiceGender) => void
onDescChange: (desc: string) => void
onUpload: () => void
/* TTS 弹窗 */
ttsOpen: boolean
ttsText: string
ttsVoiceId: string
ttsSpeed: number
ttsStatus: TtsStatus
ttsAudioUrl: string | null
ttsError: string | null
presetVoices: PresetVoiceDisplay[]
onTtsClose: () => void
onTtsTextChange: (text: string) => void
onTtsVoiceChange: (id: string) => void
onTtsSpeedChange: (speed: number) => void
onTtsSynthesize: () => void
onTtsSave: () => void
}
export const VoiceModals: React.FC<VoiceModalsProps> = ({
cloneModalOpen,
onCloneClose,
onCloneSuccess,
detailVoice,
onDetailClose,
onDetailDelete,
onDetailRetry,
onDetailUse,
uploadOpen,
uploadFile,
uploadName,
uploadGender,
uploadDesc,
uploadProgress,
onUploadClose,
onFileSelect,
onFileRemove,
onNameChange,
onGenderChange,
onDescChange,
onUpload,
ttsOpen,
ttsText,
ttsVoiceId,
ttsSpeed,
ttsStatus,
ttsAudioUrl,
ttsError,
presetVoices,
onTtsClose,
onTtsTextChange,
onTtsVoiceChange,
onTtsSpeedChange,
onTtsSynthesize,
onTtsSave,
}) => {
return (
<>
{/* 克隆音色弹窗 */}
<CloneModal open={cloneModalOpen} onClose={onCloneClose} onSuccess={onCloneSuccess} />
{/* 详情弹窗 */}
{detailVoice && (
<CloneDetailModal
voice={detailVoice}
onClose={onDetailClose}
onDelete={onDetailDelete}
onRetry={onDetailRetry}
onUse={onDetailUse}
/>
)}
{/* 上传音频弹窗 */}
<UploadVoiceModal
open={uploadOpen}
uploadFile={uploadFile}
uploadName={uploadName}
uploadGender={uploadGender}
uploadDesc={uploadDesc}
uploadProgress={uploadProgress}
onClose={onUploadClose}
onFileSelect={onFileSelect}
onFileRemove={onFileRemove}
onNameChange={onNameChange}
onGenderChange={onGenderChange}
onDescChange={onDescChange}
onUpload={onUpload}
/>
{/* AI 配音弹窗 */}
<TtsModal
open={ttsOpen}
ttsText={ttsText}
ttsVoiceId={ttsVoiceId}
ttsSpeed={ttsSpeed}
ttsStatus={ttsStatus}
ttsAudioUrl={ttsAudioUrl}
ttsError={ttsError}
presetVoices={presetVoices}
onClose={onTtsClose}
onTextChange={onTtsTextChange}
onVoiceChange={onTtsVoiceChange}
onSpeedChange={onTtsSpeedChange}
onSynthesize={onTtsSynthesize}
onSave={onTtsSave}
/>
</>
)
}
export default VoiceModals

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