Compare commits

...

22 Commits

Author SHA1 Message Date
xiaoxia 201c4ae5a6 fix: 移除不必要的useCallback依赖 newTitleType 2026-07-27 21:34:00 +08:00
xiaoxia 1598da861a refactor(voice-materials): 深化拆分 UI 组件(603→338行, -44%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m0s
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 / Frontend Lint (pull_request) Failing after 56s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m47s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m22s
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 / Frontend Unit Tests (pull_request) Successful in 53s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m28s
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 / PR Build Web Image (pull_request) Failing after 2m39s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m34s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 2m26s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m11s
CI/CD Pipeline / CI Gate (pull_request) Successful in 40s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m22s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 36s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 55s
Phase 4: 继续拆 5 个 UI 组件
- Toolbar — 搜索 + 性别筛选 + 视图切换
- TagFilterBar — 标签筛选药丸条
- BatchBar — 批量操作栏(全选/打标签/删除)
- EmptyState — 加载/空状态
- TtsModal — AI 配音 TTS 合成弹窗

更新 smoke test 新增模块 import
2026-07-27 21:15:53 +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
88 changed files with 10519 additions and 4500 deletions
+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
}
@@ -0,0 +1,230 @@
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,106 @@
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,20 @@ 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 +95,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`
@@ -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,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,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,
}
}
@@ -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)
}
}
}
+59 -329
View File
@@ -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 { AudioOutlined, 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
@@ -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,
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,16 +206,11 @@ 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
@@ -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
@@ -0,0 +1,54 @@
/**
* VoiceLibrary Tab 切换栏
*/
import React from "react"
import { AudioOutlined, UserOutlined, SoundOutlined } from "@ant-design/icons"
export type VoiceTabKey = "preset" | "cloned" | "material"
export interface VoiceTabBarProps {
activeTab: VoiceTabKey
presetCount: number
cloneCount: number
materialCount: number
onTabChange: (tab: VoiceTabKey) => void
}
export const VoiceTabBar: React.FC<VoiceTabBarProps> = ({
activeTab,
presetCount,
cloneCount,
materialCount,
onTabChange,
}) => {
return (
<div className="xx-voices-tabs">
<button
className={`xx-voices-tab${activeTab === "preset" ? " active" : ""}`}
onClick={() => onTabChange("preset")}
>
<AudioOutlined />
<span className="xx-voices-tab-count">{presetCount}</span>
</button>
<button
className={`xx-voices-tab${activeTab === "cloned" ? " active" : ""}`}
onClick={() => onTabChange("cloned")}
>
<UserOutlined />
<span className="xx-voices-tab-count">{cloneCount}</span>
</button>
<button
className={`xx-voices-tab${activeTab === "material" ? " active" : ""}`}
onClick={() => onTabChange("material")}
>
<SoundOutlined />
<span className="xx-voices-tab-count">{materialCount}</span>
</button>
</div>
)
}
export default VoiceTabBar
@@ -0,0 +1,29 @@
/**
* VoiceLibrary Toast 提示
*/
import React from "react"
export interface Toast {
id: number
message: string
type: "success" | "error"
}
export interface VoiceToastsProps {
toasts: Toast[]
}
export const VoiceToasts: React.FC<VoiceToastsProps> = ({ toasts }) => {
if (toasts.length === 0) return null
return (
<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>
)
}
export default VoiceToasts
+16
View File
@@ -0,0 +1,16 @@
import React from "react"
import { Navigate } from "react-router-dom"
import { useAuthStore } from "@/store/authStore"
/** 受保护的路由组件 */
// eslint-disable-next-line react-refresh/only-export-components
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
if (!isAuthenticated || !hasAccessToken) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
+225
View File
@@ -0,0 +1,225 @@
import { Navigate, type RouteObject } from "react-router-dom"
import MainLayout from "@/components/layout/MainLayout"
import { ProtectedRoute } from "./ProtectedRoute"
/**
* 受保护的 /app 子路由
* 所有页面使用 lazy 懒加载
*/
const appChildren: RouteObject[] = [
{
index: true,
element: <Navigate to="/app/dashboard" replace />,
},
{
path: "dashboard",
lazy: () =>
import("@/pages/dashboard/Dashboard").then((m) => ({
Component: m.default,
})),
},
{
path: "assets",
lazy: () =>
import("@/pages/assets/AssetLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "titles",
lazy: () =>
import("@/pages/titles/TitleLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "voices",
lazy: () =>
import("@/pages/voices/VoiceLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "templates",
lazy: () =>
import("@/pages/templates/TemplateLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "generate",
lazy: () =>
import("@/pages/generate/GeneratePage").then((m) => ({
Component: m.default,
})),
},
{
path: "history",
lazy: () =>
import("@/pages/history/TaskHistory").then((m) => ({
Component: m.default,
})),
},
{
path: "products",
lazy: () =>
import("@/pages/products/ProductLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "products/:id",
lazy: () =>
import("@/pages/products/ProductDetail").then((m) => ({
Component: m.default,
})),
},
{
path: "tasks",
lazy: () =>
import("@/pages/tasks/TaskCenter").then((m) => ({
Component: m.default,
})),
},
{
path: "editing-planner",
lazy: () =>
import("@/pages/editing-planner/EditingPlanner").then((m) => ({
Component: m.default,
})),
},
{
path: "my-templates",
lazy: () =>
import("@/pages/my-templates/MyTemplates").then((m) => ({
Component: m.default,
})),
},
{
path: "voice-clone",
lazy: () =>
import("@/pages/voice-clone/VoiceClone").then((m) => ({
Component: m.default,
})),
},
{
path: "voice-materials",
lazy: () =>
import("@/pages/voice-materials/VoiceMaterialLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "my-voices",
lazy: () =>
import("@/pages/my-voices/MyVoices").then((m) => ({
Component: m.default,
})),
},
{
path: "accounts",
lazy: () =>
import("@/pages/accounts/Accounts").then((m) => ({
Component: m.default,
})),
},
{
path: "duplication",
lazy: () =>
import("@/pages/duplication/DuplicationUpload").then((m) => ({
Component: m.default,
})),
},
{
path: "duplication/results",
lazy: () =>
import("@/pages/duplication/DuplicationResults").then((m) => ({
Component: m.default,
})),
},
{
path: "duplication/:id",
lazy: () =>
import("@/pages/duplication/DuplicationDetail").then((m) => ({
Component: m.default,
})),
},
{
path: "subscription",
lazy: () =>
import("@/pages/subscription/Plans").then((m) => ({
Component: m.default,
})),
},
{
path: "subscription/upgrade",
lazy: () =>
import("@/pages/subscription/UpgradeSubscription").then((m) => ({
Component: m.default,
})),
},
{
path: "subscription/billing",
lazy: () =>
import("@/pages/subscription/Billing").then((m) => ({
Component: m.default,
})),
},
{
path: "profile",
lazy: () =>
import("@/pages/profile/Settings").then((m) => ({
Component: m.default,
})),
},
{
path: "admin",
children: [
{
index: true,
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
{
path: "users",
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
{
path: "analytics",
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
{
path: "monitor",
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
{
path: "logs",
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
],
},
]
export const appRoutes: RouteObject = {
path: "/app",
element: (
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
),
children: appChildren,
}
+4 -274
View File
@@ -3,283 +3,13 @@
* 扁平化路由:去掉 Project 层级,所有资源直接归属用户
*/
import { createBrowserRouter, Navigate } from "react-router-dom"
import React from "react"
import MainLayout from "@/components/layout/MainLayout"
import Login from "@/pages/auth/Login"
import Register from "@/pages/auth/Register"
import ForgotPassword from "@/pages/auth/ForgotPassword"
import ResetPassword from "@/pages/auth/ResetPassword"
import WechatCallback from "@/pages/auth/WechatCallback"
import HomePage from "@/pages/home/HomePage"
import { useAuthStore } from "@/store/authStore"
/** 受保护的路由组件 */
// eslint-disable-next-line react-refresh/only-export-components
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
if (!isAuthenticated || !hasAccessToken) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
/** 首页路由组件:已登录跳 dashboard,未登录显示落地页 */
// eslint-disable-next-line react-refresh/only-export-components
const HomeRoute: React.FC = () => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
if (isAuthenticated && hasAccessToken) {
return <Navigate to="/app/dashboard" replace />
}
return <HomePage />
}
import { publicRoutes } from "./publicRoutes"
import { appRoutes } from "./appRoutes"
/** 路由配置 */
export const router = createBrowserRouter([
{
path: "/",
element: <HomeRoute />,
},
{
path: "/login",
element: <Login />,
},
{
path: "/register",
element: <Register />,
},
{
path: "/forgot-password",
element: <ForgotPassword />,
},
{
path: "/reset-password",
element: <ResetPassword />,
},
{
path: "/auth/wechat/callback",
element: <WechatCallback />,
},
{
path: "/app",
element: (
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
),
children: [
{
index: true,
element: <Navigate to="/app/dashboard" replace />,
},
{
path: "dashboard",
lazy: () =>
import("@/pages/dashboard/Dashboard").then((m) => ({
Component: m.default,
})),
},
{
path: "assets",
lazy: () =>
import("@/pages/assets/AssetLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "titles",
lazy: () =>
import("@/pages/titles/TitleLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "voices",
lazy: () =>
import("@/pages/voices/VoiceLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "templates",
lazy: () =>
import("@/pages/templates/TemplateLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "generate",
lazy: () =>
import("@/pages/generate/GeneratePage").then((m) => ({
Component: m.default,
})),
},
{
path: "history",
lazy: () =>
import("@/pages/history/TaskHistory").then((m) => ({
Component: m.default,
})),
},
{
path: "products",
lazy: () =>
import("@/pages/products/ProductLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "products/:id",
lazy: () =>
import("@/pages/products/ProductDetail").then((m) => ({
Component: m.default,
})),
},
{
path: "tasks",
lazy: () =>
import("@/pages/tasks/TaskCenter").then((m) => ({
Component: m.default,
})),
},
{
path: "editing-planner",
lazy: () =>
import("@/pages/editing-planner/EditingPlanner").then((m) => ({
Component: m.default,
})),
},
{
path: "my-templates",
lazy: () =>
import("@/pages/my-templates/MyTemplates").then((m) => ({
Component: m.default,
})),
},
{
path: "voice-clone",
lazy: () =>
import("@/pages/voice-clone/VoiceClone").then((m) => ({
Component: m.default,
})),
},
{
path: "voice-materials",
lazy: () =>
import("@/pages/voice-materials/VoiceMaterialLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "my-voices",
lazy: () =>
import("@/pages/my-voices/MyVoices").then((m) => ({
Component: m.default,
})),
},
{
path: "accounts",
lazy: () =>
import("@/pages/accounts/Accounts").then((m) => ({
Component: m.default,
})),
},
{
path: "duplication",
lazy: () =>
import("@/pages/duplication/DuplicationUpload").then((m) => ({
Component: m.default,
})),
},
{
path: "duplication/results",
lazy: () =>
import("@/pages/duplication/DuplicationResults").then((m) => ({
Component: m.default,
})),
},
{
path: "duplication/:id",
lazy: () =>
import("@/pages/duplication/DuplicationDetail").then((m) => ({
Component: m.default,
})),
},
{
path: "subscription",
lazy: () =>
import("@/pages/subscription/Plans").then((m) => ({
Component: m.default,
})),
},
{
path: "subscription/upgrade",
lazy: () =>
import("@/pages/subscription/UpgradeSubscription").then((m) => ({
Component: m.default,
})),
},
{
path: "subscription/billing",
lazy: () =>
import("@/pages/subscription/Billing").then((m) => ({
Component: m.default,
})),
},
{
path: "profile",
lazy: () =>
import("@/pages/profile/Settings").then((m) => ({
Component: m.default,
})),
},
{
path: "admin",
children: [
{
index: true,
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
{
path: "users",
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
{
path: "analytics",
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
{
path: "monitor",
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
{
path: "logs",
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
},
],
},
],
},
...publicRoutes,
appRoutes,
{
path: "*",
element: <Navigate to="/" replace />,
+48
View File
@@ -0,0 +1,48 @@
import { Navigate, type RouteObject } from "react-router-dom"
import HomePage from "@/pages/home/HomePage"
import Login from "@/pages/auth/Login"
import Register from "@/pages/auth/Register"
import ForgotPassword from "@/pages/auth/ForgotPassword"
import ResetPassword from "@/pages/auth/ResetPassword"
import WechatCallback from "@/pages/auth/WechatCallback"
import { useAuthStore } from "@/store/authStore"
/** 首页路由组件:已登录跳 dashboard,未登录显示落地页 */
// eslint-disable-next-line react-refresh/only-export-components
const HomeRoute: React.FC = () => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
if (isAuthenticated && hasAccessToken) {
return <Navigate to="/app/dashboard" replace />
}
return <HomePage />
}
export const publicRoutes: RouteObject[] = [
{
path: "/",
element: <HomeRoute />,
},
{
path: "/login",
element: <Login />,
},
{
path: "/register",
element: <Register />,
},
{
path: "/forgot-password",
element: <ForgotPassword />,
},
{
path: "/reset-password",
element: <ResetPassword />,
},
{
path: "/auth/wechat/callback",
element: <WechatCallback />,
},
]
@@ -65,6 +65,18 @@ vi.mock("@/store/authStore", () => ({
vi.mock("@/pages/titles/titles.css", () => ({}))
import TitleLibrary from "@/pages/titles/TitleLibrary"
import "@/pages/titles/types/titleLibrary"
import "@/pages/titles/constants/titleLibrary"
import "@/pages/titles/utils/titleLibrary"
import "@/pages/titles/hooks/useTitleLibrary"
import "@/pages/titles/hooks/useTitleEdit"
import "@/pages/titles/hooks/useTitleAI"
import "@/pages/titles/components/title-library/TitleCard"
import "@/pages/titles/components/title-library/CategorySidebar"
import "@/pages/titles/components/title-library/FilterBar"
import "@/pages/titles/components/title-library/TitleGrid"
import "@/pages/titles/components/title-library/CreateTitleModal"
import "@/pages/titles/components/title-library/AIGenerateModal"
describe("TitleLibrary Page", () => {
it("should render without crashing", () => {
@@ -94,6 +94,9 @@ import "@/pages/editing-planner/hooks/usePipLayers"
import "@/pages/editing-planner/hooks/useStickerItems"
import "@/pages/editing-planner/hooks/useTtsPanel"
import "@/pages/editing-planner/hooks/useWatermarkConfig"
import "@/pages/editing-planner/hooks/useTimelineMenus"
import "@/pages/editing-planner/hooks/useTrimDrag"
import "@/pages/editing-planner/hooks/useClipDrag"
describe("EditingPlanner module smoke test", () => {
it("should load all editing-planner modules", () => {
@@ -13,6 +13,11 @@ import "@/pages/voice-materials/components/TagSelector"
import "@/pages/voice-materials/components/MaterialForm"
import "@/pages/voice-materials/components/VoiceMaterialCard"
import "@/pages/voice-materials/components/VoiceMaterialRow"
import "@/pages/voice-materials/components/Toolbar"
import "@/pages/voice-materials/components/TagFilterBar"
import "@/pages/voice-materials/components/BatchBar"
import "@/pages/voice-materials/components/EmptyState"
import "@/pages/voice-materials/components/TtsModal"
// 工具函数
import "@/pages/voice-materials/utils/format"
+338
View File
@@ -0,0 +1,338 @@
"""URL 安全校验纯逻辑 — SSRF 防护.
纯函数模块,无网络/文件 IO,无环境变量依赖,所有配置通过参数传入。
供 packages/shared/url_security.py 作为薄包装调用,也可直接用于单测。
防护要点(纯逻辑部分):
1. Scheme 白名单校验
2. 内部主机名拦截(字符串匹配)
3. 端口白名单校验
4. IP 格式 SSRF 检查(回环/私有/链路本地/组播/未指定/保留)
5. 可信域名匹配(支持子域名)
6. 文件头魔数校验(接收 bytes)
"""
from __future__ import annotations
import ipaddress
from urllib.parse import urlparse
class UrlSecurityError(ValueError):
"""URL 安全校验失败."""
pass
# ── 常量 ────────────────────────────────────────────────────────────────────
ALLOWED_SCHEMES = frozenset({"http", "https"})
ALLOWED_PORTS = frozenset({80, 443})
MAX_URL_LENGTH = 2048
# 已知内部/敏感主机名集合
INTERNAL_HOSTNAMES = frozenset(
{
"localhost",
"localhost.localdomain",
"ip6-localhost",
"ip6-loopback",
"metadata",
"metadata.google.internal",
"169.254.169.254",
}
)
# 内网域名后缀
INTERNAL_DOMAIN_SUFFIXES = (".local", ".internal", ".localdomain")
# 文件魔数表 — key: MIME, value: list of 签名组,每组内所有 (offset, bytes) 都匹配才算命中
MAGIC_NUMBERS: dict[str, list[list[tuple[int, bytes]]]] = {
# 音频
"audio/mpeg": [
[(0, b"ID3")],
[(0, b"\xff\xfb")],
[(0, b"\xff\xf3")],
[(0, b"\xff\xf2")],
[(0, b"\xff\xfa")],
[(0, b"\xff\xf9")],
],
"audio/wav": [[(0, b"RIFF"), (8, b"WAVE")]],
"audio/x-wav": [[(0, b"RIFF"), (8, b"WAVE")]],
"audio/ogg": [[(0, b"OggS")]],
"application/ogg": [[(0, b"OggS")]],
"audio/flac": [[(0, b"fLaC")]],
"audio/aac": [[(0, b"\xff\xf1")], [(0, b"\xff\xf9")]],
"audio/aacp": [[(0, b"\xff\xf1")], [(0, b"\xff\xf9")]],
"audio/mp4": [[(4, b"ftyp")]],
"audio/x-m4a": [[(4, b"ftyp")]],
# 视频
"video/mp4": [[(4, b"ftyp")]],
"video/quicktime": [[(4, b"ftyp")]],
"video/x-matroska": [[(0, b"\x1a\x45\xdf\xa3")]],
"video/webm": [[(0, b"\x1a\x45\xdf\xa3")]],
"video/x-msvideo": [[(0, b"RIFF"), (8, b"AVI ")]],
# 图片
"image/jpeg": [[(0, b"\xff\xd8\xff")]],
"image/png": [[(0, b"\x89PNG\r\n\x1a\n")]],
"image/gif": [[(0, b"GIF87a")], [(0, b"GIF89a")]],
"image/webp": [[(0, b"RIFF"), (8, b"WEBP")]],
"image/bmp": [[(0, b"BM")]],
}
ALLOWED_AUDIO_MIME_TYPES = frozenset(
{
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-wav",
"audio/pcm",
"audio/ogg",
"audio/opus",
"audio/flac",
"audio/aac",
"audio/m4a",
"audio/x-m4a",
"audio/mp4",
"application/octet-stream",
}
)
ALLOWED_VIDEO_MIME_TYPES = frozenset(
{
"video/mp4",
"video/quicktime",
"video/x-matroska",
"video/webm",
"video/avi",
"video/x-msvideo",
"video/mpeg",
"application/octet-stream",
}
)
ALLOWED_IMAGE_MIME_TYPES = frozenset(
{
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
}
)
# ── 主机名 / 域名校验 ──────────────────────────────────────────────────────
def check_internal_hostname(hostname: str) -> None:
"""检查主机名是否为内部/敏感主机名,是则抛出 UrlSecurityError.
检查内容:
- 精确匹配 INTERNAL_HOSTNAMES 集合
- 后缀匹配 INTERNAL_DOMAIN_SUFFIXES.local/.internal/.localdomain
"""
hostname_lower = hostname.lower()
if hostname_lower in INTERNAL_HOSTNAMES:
raise UrlSecurityError(f"禁止访问内部主机名: {hostname}")
if hostname_lower.endswith(INTERNAL_DOMAIN_SUFFIXES):
raise UrlSecurityError(f"禁止访问内网域名: {hostname}")
def is_trusted_domain(hostname: str, trusted_domains: set[str]) -> bool:
"""检查域名是否在可信白名单中(支持子域名匹配).
匹配规则:
- 精确匹配
- 子域名匹配(hostname 以 .domain 结尾)
Args:
hostname: 待检查的主机名
trusted_domains: 可信域名集合,为空表示不限制
"""
if not trusted_domains:
return True
hostname_lower = hostname.lower()
if hostname_lower in {d.lower() for d in trusted_domains}:
return True
for domain in trusted_domains:
if hostname_lower.endswith("." + domain.lower()):
return True
return False
# ── IP SSRF 检查 ───────────────────────────────────────────────────────────
def check_ssrf_ip(ip_str: str) -> None:
"""检查 IP 地址是否存在 SSRF 风险,有风险则抛出 UrlSecurityError.
检查项:回环、私有、链路本地、组播、未指定、保留地址。
Args:
ip_str: IP 地址字符串(IPv4 或 IPv6
Raises:
UrlSecurityError: IP 属于 SSRF 风险范围
ValueError: ip_str 不是合法 IP 地址(调用方应自行捕获处理)
"""
ip_obj = ipaddress.ip_address(ip_str)
if ip_obj.is_loopback:
raise UrlSecurityError(f"禁止访问回环地址: {ip_obj}")
if ip_obj.is_link_local:
raise UrlSecurityError(f"禁止访问链路本地地址: {ip_obj}")
if ip_obj.is_unspecified:
raise UrlSecurityError(f"禁止访问未指定地址: {ip_obj}")
if ip_obj.is_multicast:
raise UrlSecurityError(f"禁止访问组播地址: {ip_obj}")
if ip_obj.is_reserved:
raise UrlSecurityError(f"禁止访问保留地址: {ip_obj}")
if ip_obj.is_private:
raise UrlSecurityError(f"禁止访问内网地址: {ip_obj}")
def is_ip_address(hostname: str) -> bool:
"""判断主机名是否为 IP 地址格式(IPv4 或 IPv6."""
try:
ipaddress.ip_address(hostname)
return True
except ValueError:
return False
# ── URL 基础校验 ───────────────────────────────────────────────────────────
def validate_url_basic(
url: str,
*,
trusted_domains: set[str] | None = None,
allow_direct_ip: bool = False,
) -> str:
"""URL 基础安全校验(纯逻辑,不含 DNS 解析).
校验项:
1. URL 非空 & 长度限制
2. Scheme 白名单
3. 主机名存在性
4. 内部主机名拦截
5. 端口白名单
6. 直接 IP 访问限制
7. IP 格式 SSRF 检查(如果 hostname 是 IP
8. 可信域名白名单(如果配置了)
注意:域名格式的 SSRF 检查需要 DNS 解析,不在本函数范围内。
Args:
url: 待校验 URL
trusted_domains: 可信域名白名单,None/空表示不限制
allow_direct_ip: 是否允许直接 IP 访问
Returns:
原始 URL(校验通过)
Raises:
UrlSecurityError: 校验失败
"""
if not url:
raise UrlSecurityError("URL 为空")
if len(url) > MAX_URL_LENGTH:
raise UrlSecurityError(f"URL 过长 ({len(url)} > {MAX_URL_LENGTH})")
try:
parsed = urlparse(url)
except Exception as e:
raise UrlSecurityError(f"URL 解析失败: {e}") from e
# Scheme
if not parsed.scheme or parsed.scheme.lower() not in ALLOWED_SCHEMES:
raise UrlSecurityError(f"不允许的 URL scheme: {parsed.scheme}")
# Hostname
hostname = parsed.hostname
if not hostname:
raise UrlSecurityError("URL 缺少主机名")
# 内部主机名前置拦截
check_internal_hostname(hostname)
# 端口
port = parsed.port
if port is not None and port not in ALLOWED_PORTS:
raise UrlSecurityError(f"不允许的端口: {port}")
# IP 格式检查 & SSRF
if is_ip_address(hostname):
if not allow_direct_ip:
raise UrlSecurityError(f"禁止直接 IP 访问: {hostname}")
check_ssrf_ip(hostname)
# 可信域名白名单
if trusted_domains and not is_trusted_domain(hostname, trusted_domains):
raise UrlSecurityError(f"域名不在可信白名单中: {hostname}")
return url
def is_url_basic_safe(
url: str,
*,
trusted_domains: set[str] | None = None,
allow_direct_ip: bool = False,
) -> bool:
"""便捷函数:基础安全检查,不抛异常,返回 bool."""
try:
validate_url_basic(url, trusted_domains=trusted_domains, allow_direct_ip=allow_direct_ip)
return True
except UrlSecurityError:
return False
# ── 魔数校验 ───────────────────────────────────────────────────────────────
def validate_magic_number(header_bytes: bytes, allowed_mime_types: set[str]) -> None:
"""校验文件头魔数是否与允许的 MIME 类型匹配(纯函数).
读取 header_bytes,与 allowed_mime_types 对应格式的魔数逐一比对,
任一类型匹配即通过;全部不匹配则抛出 UrlSecurityError。
Args:
header_bytes: 文件头字节(建议至少 256 字节)
allowed_mime_types: 允许的 MIME 类型集合
Raises:
UrlSecurityError: 文件魔数与所有允许类型均不匹配
"""
# 收集所有允许类型对应的魔数签名
signatures: list[list[tuple[int, bytes]]] = []
for mime in allowed_mime_types:
sigs = MAGIC_NUMBERS.get(mime)
if sigs:
signatures.extend(sigs)
# 没有已知魔数的 MIME,跳过不阻断
if not signatures:
return
if not header_bytes:
raise UrlSecurityError("文件为空,无法校验格式")
# 任一签名匹配即通过
for sig in signatures:
match = True
for offset, expected in sig:
if offset + len(expected) > len(header_bytes):
match = False
break
if header_bytes[offset : offset + len(expected)] != expected:
match = False
break
if match:
return
raise UrlSecurityError(
f"文件魔数与允许的 MIME 类型不匹配,"
f"允许类型: {sorted(allowed_mime_types)}"
f"文件头前16字节: {header_bytes[:16].hex()}"
)
+87 -362
View File
@@ -1,239 +1,73 @@
"""URL 安全校验工具 — SSRF 防护.
"""URL 安全校验工具 — SSRF 防护(薄包装层).
统一的外部 URL 安全校验方案,覆盖所有渲染管线和 TTS 中的外部下载场景
放在 packages/shared/ 作为单一来源,worker 和 application 层都可引用
本文件保留原有对外 API,纯逻辑部分委托给 packages/domain/url_security.py
新增了 DNS 解析、文件下载、环境变量配置等有副作用的逻辑
防护要点:
1. Scheme 白名单:仅允许 http/https
2. 主机 SSRF 防护:禁止内网 IP、回环地址、链路本地地址、元数据服务
3. 端口白名单:仅允许 80/443(标准 HTTP/HTTPS
3. 端口白名单:仅允许 80/443
4. 域名校验:禁止 IP 直接访问(除非在白名单中)
5. 重定向防护:手动跟随重定向,每次跳转前重新校验目标 URL
5. 重定向防护:手动跟随重定向,每次跳转前重新校验
6. 文件大小限制:流式下载,超过上限立即中断
7. MIME 类型白名单:可选的内容类型校验
7. MIME 类型白名单 + 魔数二次校验
"""
from __future__ import annotations
import ipaddress
import logging
import os
import socket
import urllib.error
import urllib.request
from urllib.parse import urljoin, urlparse
from urllib.parse import urljoin
from packages.domain.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
ALLOWED_IMAGE_MIME_TYPES,
ALLOWED_PORTS as _allowed_ports_base,
ALLOWED_SCHEMES as _allowed_schemes_base,
ALLOWED_VIDEO_MIME_TYPES,
MAX_URL_LENGTH,
MAGIC_NUMBERS,
UrlSecurityError as _UrlSecurityError_base,
check_internal_hostname as _check_internal_hostname_base,
check_ssrf_ip as _check_ssrf_ip_base,
is_ip_address as _is_ip_address_base,
is_trusted_domain as _is_trusted_domain_base,
validate_magic_number as _validate_magic_number_base,
validate_url_basic as _validate_url_basic_base,
)
logger = logging.getLogger(__name__)
# 允许的 URL scheme
ALLOWED_SCHEMES = {"http", "https"}
# ── 兼容导出(保持原有变量名供外部引用) ──────────────────────────────────
ALLOWED_SCHEMES = set(_allowed_schemes_base)
ALLOWED_PORTS = set(_allowed_ports_base)
UrlSecurityError = _UrlSecurityError_base
# 允许的端口(标准 HTTP/HTTPS
ALLOWED_PORTS = {80, 443}
# 可信域名白名单(可根据实际 OSS/CDN 域名配置)
# 从环境变量读取,格式:"oss-cn-hangzhou.aliyuncs.com,cdn.example.com"
# 默认空表示所有公网域名都允许,但仍会做 SSRF 检查
# 可信域名白名单(从环境变量读取
TRUSTED_DOMAINS: set[str] = set()
_env_trusted = os.environ.get("URL_SECURITY_TRUSTED_DOMAINS", "")
if _env_trusted:
TRUSTED_DOMAINS = {d.strip() for d in _env_trusted.split(",") if d.strip()}
# 是否允许 IP 直接访问(默认禁止,防止绕过 DNS 校验)
# 是否允许 IP 直接访问
ALLOW_DIRECT_IP = os.environ.get("URL_SECURITY_ALLOW_DIRECT_IP", "false").lower() == "true"
# 最大 URL 长度
MAX_URL_LENGTH = 2048
# 单次下载最大文件大小(默认 200MB)
DEFAULT_MAX_DOWNLOAD_SIZE = int(os.environ.get("URL_SECURITY_MAX_DOWNLOAD_MB", "200")) * 1024 * 1024
# 允许的音频 MIME 类型白名单
ALLOWED_AUDIO_MIME_TYPES = {
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-wav",
"audio/pcm",
"audio/ogg",
"audio/opus",
"audio/flac",
"audio/aac",
"audio/m4a",
"audio/x-m4a",
"audio/mp4",
"application/octet-stream", # 兼容一些 CDN 返回通用类型
}
# 允许的视频 MIME 类型白名单
ALLOWED_VIDEO_MIME_TYPES = {
"video/mp4",
"video/quicktime",
"video/x-matroska",
"video/webm",
"video/avi",
"video/x-msvideo",
"video/mpeg",
"application/octet-stream",
}
# 允许的图片 MIME 类型白名单
ALLOWED_IMAGE_MIME_TYPES = {
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
}
# 下载块大小
_DOWNLOAD_CHUNK_SIZE = 8192
# 最大重定向次数
_MAX_REDIRECTS = 5
# 文件魔数(文件头签名)表 — 用于 MIME 白名单校验后的二次真实性校验
# key: MIME 类型,value: 签名列表,任一签名匹配即通过
# 每条签名: list of (offset, bytes),所有条目都匹配才算该签名命中(支持多处联合匹配如 RIFF+WAVE)
_MAGIC_NUMBERS: dict[str, list[list[tuple[int, bytes]]]] = {
# ── 音频 ──
"audio/mpeg": [
[(0, b"ID3")], # ID3v2 标签
[(0, b"\xff\xfb")], # MPEG1 Layer3
[(0, b"\xff\xf3")], # MPEG2 Layer3
[(0, b"\xff\xf2")], # MPEG2.5 Layer3
[(0, b"\xff\xfa")], # MPEG1 Layer2
[(0, b"\xff\xf9")], # 其他 MPEG ADTS
],
"audio/wav": [
[(0, b"RIFF"), (8, b"WAVE")], # RIFF + WAVE
],
"audio/x-wav": [
[(0, b"RIFF"), (8, b"WAVE")],
],
"audio/ogg": [
[(0, b"OggS")],
],
"application/ogg": [
[(0, b"OggS")],
],
"audio/flac": [
[(0, b"fLaC")],
],
"audio/aac": [
[(0, b"\xff\xf1")], # ADTS MPEG-4
[(0, b"\xff\xf9")], # ADTS MPEG-2
],
"audio/aacp": [
[(0, b"\xff\xf1")],
[(0, b"\xff\xf9")],
],
"audio/mp4": [
[(4, b"ftyp")], # ISO Base Media (M4A)
],
"audio/x-m4a": [
[(4, b"ftyp")],
],
# ── 视频 ──
"video/mp4": [
[(4, b"ftyp")], # ISO Base Media (MP4)
],
"video/quicktime": [
[(4, b"ftyp")],
],
"video/x-matroska": [
[(0, b"\x1a\x45\xdf\xa3")], # EBML header
],
"video/webm": [
[(0, b"\x1a\x45\xdf\xa3")],
],
"video/x-msvideo": [
[(0, b"RIFF"), (8, b"AVI ")],
],
# ── 图片 ──
"image/jpeg": [
[(0, b"\xff\xd8\xff")],
],
"image/png": [
[(0, b"\x89PNG\r\n\x1a\n")],
],
"image/gif": [
[(0, b"GIF87a")],
[(0, b"GIF89a")],
],
"image/webp": [
[(0, b"RIFF"), (8, b"WEBP")],
],
"image/bmp": [
[(0, b"BM")],
],
}
# 魔数校验最大读取字节数(文件头)
# 魔数校验最大读取字节数
_MAGIC_CHECK_READ_SIZE = 256
def _validate_magic_number(file_path: str, allowed_mime_types: set[str]) -> None:
"""校验文件头魔数是否与允许的 MIME 类型匹配.
读取文件前 256 字节,与 allowed_mime_types 对应格式的魔数逐一比对,
任一类型匹配即通过;全部不匹配则抛出 UrlSecurityError。
仅当 allowed_mime_types 非空时执行;空文件视为不匹配。
Args:
file_path: 本地文件路径
allowed_mime_types: 允许的 MIME 类型集合
Raises:
UrlSecurityError: 文件魔数与所有允许类型均不匹配
"""
# 收集所有允许类型对应的魔数签名
signatures: list[list[tuple[int, bytes]]] = []
for mime in allowed_mime_types:
sigs = _MAGIC_NUMBERS.get(mime)
if sigs:
signatures.extend(sigs)
# 如果没有已知魔数(比如自定义 MIME),跳过校验不阻断
if not signatures:
return
try:
with open(file_path, "rb") as f:
header = f.read(_MAGIC_CHECK_READ_SIZE)
except OSError as e:
raise UrlSecurityError(f"读取文件头失败: {e}") from e
if not header:
raise UrlSecurityError("文件为空,无法校验格式")
# 任一签名匹配即通过
for sig in signatures:
match = True
for offset, expected in sig:
if offset + len(expected) > len(header):
match = False
break
if header[offset : offset + len(expected)] != expected:
match = False
break
if match:
return
raise UrlSecurityError(
f"文件魔数与允许的 MIME 类型不匹配,"
f"允许类型: {sorted(allowed_mime_types)}"
f"文件头前16字节: {header[:16].hex()}"
)
class UrlSecurityError(ValueError):
"""URL 安全校验失败."""
pass
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""禁止自动重定向的 handler,用于手动控制重定向以做安全校验."""
@@ -241,124 +75,7 @@ class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
return None
def validate_url_safety(url: str, *, purpose: str = "download") -> str:
"""校验 URL 安全性,返回标准化后的 URL(供下游使用).
Args:
url: 待校验的 URL
purpose: 用途描述(用于日志),如 "bgm_download""tts_download"
Returns:
标准化后的 URL
Raises:
UrlSecurityError: URL 不安全
"""
if not url:
raise UrlSecurityError("URL 为空")
if len(url) > MAX_URL_LENGTH:
raise UrlSecurityError(f"URL 过长 ({len(url)} > {MAX_URL_LENGTH})")
# 解析 URL
try:
parsed = urlparse(url)
except Exception as e:
raise UrlSecurityError(f"URL 解析失败: {e}") from e
# 1. Scheme 校验
if not parsed.scheme or parsed.scheme.lower() not in ALLOWED_SCHEMES:
raise UrlSecurityError(f"不允许的 URL scheme: {parsed.scheme}")
# 2. 主机名校验
hostname = parsed.hostname
if not hostname:
raise UrlSecurityError("URL 缺少主机名")
# 2.1 常见内网主机名前置拦截(防止 DNS rebinding 绕过)
_check_internal_hostnames(hostname)
# 3. 端口校验
port = parsed.port
if port is not None and port not in ALLOWED_PORTS:
raise UrlSecurityError(f"不允许的端口: {port}")
# 4. SSRF 防护 - 解析 IP 并检查
try:
# 先判断是否是 IP 地址
ip_obj = None
try:
ip_obj = ipaddress.ip_address(hostname)
except ValueError:
pass # 不是 IP,继续走域名解析
if ip_obj is not None:
# 是直接 IP 访问
if not ALLOW_DIRECT_IP and not _is_trusted_ip(ip_obj):
raise UrlSecurityError(f"禁止直接 IP 访问: {hostname}")
_check_ssrf_ip(ip_obj)
else:
# 域名 — 解析 DNS 检查 SSRF
_check_ssrf_domain(hostname)
except UrlSecurityError:
raise
except Exception as e:
logger.warning("URL 安全校验异常: url=%s purpose=%s error=%s", url[:80], purpose, e)
raise UrlSecurityError(f"URL 安全校验异常: {e}") from e
# 5. 可信域名校验(如果配置了白名单)
if TRUSTED_DOMAINS and not _is_trusted_domain(hostname):
raise UrlSecurityError(f"域名不在可信白名单中: {hostname}")
logger.debug("URL 安全校验通过: url=%s purpose=%s", url[:80], purpose)
return url
def _check_internal_hostnames(hostname: str) -> None:
"""前置检查常见内网/敏感主机名,防止 DNS 解析层绕过."""
hostname_lower = hostname.lower()
internal_hostnames = {
"localhost",
"localhost.localdomain",
"ip6-localhost",
"ip6-loopback",
"metadata",
"metadata.google.internal",
"169.254.169.254", # 云元数据服务
}
if hostname_lower in internal_hostnames:
raise UrlSecurityError(f"禁止访问内部主机名: {hostname}")
# 检查以 .local / .internal 结尾的主机名
if hostname_lower.endswith((".local", ".internal", ".localdomain")):
raise UrlSecurityError(f"禁止访问内网域名: {hostname}")
def _check_ssrf_ip(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> None:
"""检查 IP 是否属于 SSRF 风险范围."""
# 回环地址
if ip_obj.is_loopback:
raise UrlSecurityError(f"禁止访问回环地址: {ip_obj}")
# 私有地址(内网)
if ip_obj.is_private:
raise UrlSecurityError(f"禁止访问内网地址: {ip_obj}")
# 链路本地地址
if ip_obj.is_link_local:
raise UrlSecurityError(f"禁止访问链路本地地址: {ip_obj}")
# 组播地址
if ip_obj.is_multicast:
raise UrlSecurityError(f"禁止访问组播地址: {ip_obj}")
# 未指定地址(0.0.0.0 / ::
if ip_obj.is_unspecified:
raise UrlSecurityError(f"禁止访问未指定地址: {ip_obj}")
# 保留地址
if ip_obj.is_reserved:
raise UrlSecurityError(f"禁止访问保留地址: {ip_obj}")
# ── DNS 解析 SSRF 检查(有副作用) ─────────────────────────────────────────
def _check_ssrf_domain(hostname: str) -> None:
@@ -367,7 +84,6 @@ def _check_ssrf_domain(hostname: str) -> None:
注意:这不能完全防止 DNS rebinding,但能防御大部分 SSRF 场景。
"""
try:
# 解析所有地址
infos = socket.getaddrinfo(hostname, None)
if not infos:
raise UrlSecurityError(f"域名解析失败: {hostname}")
@@ -375,30 +91,63 @@ def _check_ssrf_domain(hostname: str) -> None:
for info in infos:
ip_str = info[4][0]
try:
ip_obj = ipaddress.ip_address(ip_str)
_check_ssrf_ip(ip_obj)
_check_ssrf_ip_base(ip_str)
except ValueError:
# 无法解析为 IP,跳过(不应该发生)
continue
except socket.gaierror as e:
raise UrlSecurityError(f"域名解析失败: {hostname} ({e})") from e
def _is_trusted_ip(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""检查 IP 是否在可信列表中(目前通过环境变量配置域名,IP 级信任暂不开放."""
return False
def _validate_magic_number(file_path: str, allowed_mime_types: set[str]) -> None:
"""校验文件头魔数(从文件读取后委托给 domain 纯逻辑."""
try:
with open(file_path, "rb") as f:
header = f.read(_MAGIC_CHECK_READ_SIZE)
except OSError as e:
raise UrlSecurityError(f"读取文件头失败: {e}") from e
_validate_magic_number_base(header, allowed_mime_types)
def _is_trusted_domain(hostname: str) -> bool:
"""检查域名是否在可信白名单中(支持子域名匹配)."""
hostname_lower = hostname.lower()
if hostname_lower in TRUSTED_DOMAINS:
return True
# 检查子域名
for domain in TRUSTED_DOMAINS:
if hostname_lower.endswith("." + domain.lower()):
return True
return False
# ── 对外 API ────────────────────────────────────────────────────────────────
def validate_url_safety(url: str, *, purpose: str = "download") -> str:
"""校验 URL 安全性,返回标准化后的 URL(含 DNS 解析 SSRF 检查).
Args:
url: 待校验的 URL
purpose: 用途描述(用于日志)
Returns:
标准化后的 URL
Raises:
UrlSecurityError: URL 不安全
"""
# 基础校验(纯逻辑,不含 DNS
_validate_url_basic_base(
url,
trusted_domains=TRUSTED_DOMAINS,
allow_direct_ip=ALLOW_DIRECT_IP,
)
# 如果 hostname 是域名(不是 IP),做 DNS 解析 SSRF 检查
from urllib.parse import urlparse
parsed = urlparse(url)
hostname = parsed.hostname
if hostname and not _is_ip_address_base(hostname):
try:
_check_ssrf_domain(hostname)
except UrlSecurityError:
raise
except Exception as e:
logger.warning("URL 安全校验异常: url=%s purpose=%s error=%s", url[:80], purpose, e)
raise UrlSecurityError(f"URL 安全校验异常: {e}") from e
logger.debug("URL 安全校验通过: url=%s purpose=%s", url[:80], purpose)
return url
def is_url_safe(url: str, *, purpose: str = "download") -> bool:
@@ -410,7 +159,7 @@ def is_url_safe(url: str, *, purpose: str = "download") -> bool:
return False
# ── 安全下载 ───────────────────────────────────────────────────────────────────
# ── 安全下载 ────────────────────────────────────────────────────────────────
def safe_download_file(
@@ -426,34 +175,20 @@ def safe_download_file(
包含防护:
- SSRF 校验(初始 URL + 每次重定向后都校验)
- 重定向次数限制 + 手动跟随(避免重定向绕过 SSRF
- 文件大小限制(流式读取,超过立即中断
- MIME 类型白名单(可选)
- 文件头魔数校验(配合 MIME 白名单做二次真实性校验)
Args:
url: 下载 URL
dest_path: 目标文件路径
purpose: 用途描述(日志用)
max_size: 最大下载字节数,超过则中断并抛出 UrlSecurityError
allowed_mime_types: 允许的 Content-Type 集合,None 表示不校验
timeout: 单次请求超时(秒)
- 重定向次数限制 + 手动跟随
- 文件大小限制(流式读取)
- MIME 类型白名单 + 魔数二次校验
Returns:
实际下载的字节数
Raises:
UrlSecurityError: 安全校验失败
"""
current_url = url
redirect_count = 0
total_bytes = 0
# 使用不自动跟随重定向的 opener
no_redirect_opener = urllib.request.build_opener(NoRedirectHandler())
while True:
# 每次请求前都做 SSRF 校验(重定向目标也会校验)
validate_url_safety(current_url, purpose=purpose)
req = urllib.request.Request(current_url, method="GET")
@@ -462,7 +197,6 @@ def safe_download_file(
try:
resp = no_redirect_opener.open(req, timeout=timeout) # nosec B310
except urllib.error.HTTPError as e:
# 3xx 重定向
if 300 <= e.code < 400 and e.headers.get("Location"):
if redirect_count >= _MAX_REDIRECTS:
raise UrlSecurityError(f"重定向次数超过限制 ({_MAX_REDIRECTS})") from e
@@ -474,20 +208,15 @@ def safe_download_file(
raise UrlSecurityError(f"URL 错误: {e.reason}") from e
try:
# Content-Type 校验
if allowed_mime_types is not None:
content_type = resp.headers.get("Content-Type", "").split(";")[0].strip().lower()
if content_type and content_type not in allowed_mime_types:
raise UrlSecurityError(
f"不允许的 Content-Type: {content_type}, " f"允许: {sorted(allowed_mime_types)}"
)
raise UrlSecurityError(f"不允许的 Content-Type: {content_type}, 允许: {sorted(allowed_mime_types)}")
# Content-Length 预检
content_length = resp.headers.get("Content-Length")
if content_length and int(content_length) > max_size:
raise UrlSecurityError(f"文件过大: {content_length} bytes > {max_size} bytes 上限")
# 流式下载,实时检查大小
with open(dest_path, "wb") as f:
while True:
chunk = resp.read(_DOWNLOAD_CHUNK_SIZE)
@@ -498,7 +227,6 @@ def safe_download_file(
raise UrlSecurityError(f"下载超过大小限制: {total_bytes} bytes > {max_size} bytes")
f.write(chunk)
# 文件头魔数校验(MIME 白名单基础上的二次真实性校验)
if allowed_mime_types is not None:
_validate_magic_number(dest_path, allowed_mime_types)
@@ -515,10 +243,7 @@ def safe_download_bytes(
allowed_mime_types: set[str] | None = None,
timeout: float = 60.0,
) -> bytes:
"""安全下载 URL 并返回字节内容
防护同 safe_download_file,但结果返回在内存中(适合小文件)。
"""
"""安全下载 URL 并返回字节内容(适合小文件)。"""
import tempfile
fd, tmp_path = tempfile.mkstemp()
+515
View File
@@ -0,0 +1,515 @@
"""资产评分纯逻辑单元测试 — wave129."""
from __future__ import annotations
from dataclasses import dataclass
import pytest
from packages.domain.asset_scoring import (
AssetScoreDetail,
SmartSelectResult,
_bucket_by_duration,
calculate_total_score,
diverse_selection,
filter_candidates,
score_asset_detail,
score_bitrate,
score_duration,
score_resolution,
WEIGHT_QUALITY,
WEIGHT_RESOLUTION,
WEIGHT_DURATION,
WEIGHT_BITRATE,
)
# ── 常量与权重 ──────────────────────────────────────────────────────────────
class TestWeights:
def test_weights_sum_to_one(self):
total = WEIGHT_QUALITY + WEIGHT_RESOLUTION + WEIGHT_DURATION + WEIGHT_BITRATE
assert abs(total - 1.0) < 0.001
def test_quality_is_highest_weight(self):
assert WEIGHT_QUALITY > WEIGHT_RESOLUTION
assert WEIGHT_QUALITY > WEIGHT_DURATION
assert WEIGHT_QUALITY > WEIGHT_BITRATE
# ── 分辨率评分 ──────────────────────────────────────────────────────────────
class TestScoreResolution:
def test_exact_target_full_score(self):
assert score_resolution(1920, 1080) == 1.0
def test_higher_than_target_full_score(self):
"""4K 等高于目标分辨率也给满分."""
assert score_resolution(3840, 2160) == 1.0
assert score_resolution(2560, 1440) == 1.0
def test_seventytwop_less_than_one(self):
score = score_resolution(1280, 720)
assert 0.5 < score < 1.0
def test_fourheightyp_even_lower(self):
score_480 = score_resolution(854, 480)
score_720 = score_resolution(1280, 720)
assert score_480 < score_720
def test_none_returns_medium(self):
assert score_resolution(None, None) == 0.5
assert score_resolution(None, 1080) == 0.5
assert score_resolution(1920, None) == 0.5
def test_zero_or_negative_returns_medium(self):
assert score_resolution(0, 1080) == 0.5
assert score_resolution(-100, 1080) == 0.5
assert score_resolution(1920, 0) == 0.5
def test_custom_target_resolution(self):
score = score_resolution(1280, 720, target_width=1280, target_height=720)
assert score == 1.0
def test_score_between_zero_one(self):
score = score_resolution(320, 240)
assert 0.0 < score < 1.0
def test_very_low_resolution_not_zero(self):
"""低分也不会低于 0.1."""
score = score_resolution(160, 120)
assert score >= 0.1
# ── 时长评分 ────────────────────────────────────────────────────────────────
class TestScoreDuration:
def test_optimal_range_full_score(self):
"""3-30秒最佳区间满分."""
assert score_duration(3.0) == 1.0
assert score_duration(10.0) == 1.0
assert score_duration(30.0) == 1.0
def test_very_short_lower_score(self):
score_1s = score_duration(1.0)
assert 0.3 <= score_1s < 1.0
def test_shorter_than_optimal_lower(self):
"""越短分越低."""
score_1 = score_duration(1.0)
score_2 = score_duration(2.0)
assert score_1 < score_2
def test_just_below_optimal(self):
score = score_duration(2.9)
assert score < 1.0
assert score > 0.8 # 接近满分
def test_too_long_penalty(self):
score_30 = score_duration(30.0)
score_60 = score_duration(60.0)
assert score_60 < score_30
def test_very_long_minimum_floor(self):
"""超长素材最低 0.2 分."""
score = score_duration(1000.0)
assert score >= 0.2
def test_none_returns_medium(self):
assert score_duration(None) == 0.5
def test_zero_returns_medium(self):
assert score_duration(0) == 0.5
assert score_duration(0.0) == 0.5
def test_negative_returns_medium(self):
assert score_duration(-5.0) == 0.5
# ── 码率评分 ────────────────────────────────────────────────────────────────
class TestScoreBitrate:
def test_optimal_bitrate_full_score(self):
"""2-8 Mbps 区间满分."""
# 5 Mbps, 10秒 = 50Mbit = 6.25MB = 6,250,000 字节
size_5mbps_10s = int(5_000_000 * 10 / 8)
assert score_bitrate(size_5mbps_10s, 10.0) == 1.0
# 3 Mbps, 5秒
size_3mbps_5s = int(3_000_000 * 5 / 8)
assert score_bitrate(size_3mbps_5s, 5.0) == 1.0
def test_low_bitrate_lower_score(self):
"""码率低得分低."""
# 500 Kbps
size_low = int(500_000 * 10 / 8)
score = score_bitrate(size_low, 10.0)
assert 0.3 <= score < 1.0
def test_high_bitrate_moderate_penalty(self):
"""码率过高适度扣分,最低0.5."""
# 50 Mbps,远超 8Mbps
size_high = int(50_000_000 * 10 / 8)
score = score_bitrate(size_high, 10.0)
assert 0.5 <= score < 1.0
def test_none_duration_returns_medium(self):
assert score_bitrate(1_000_000, None) == 0.5
def test_zero_file_size_returns_medium(self):
assert score_bitrate(0, 10.0) == 0.5
def test_zero_duration_returns_medium(self):
assert score_bitrate(1_000_000, 0) == 0.5
assert score_bitrate(1_000_000, -5.0) == 0.5
# ── 加权总分 ────────────────────────────────────────────────────────────────
class TestCalculateTotalScore:
def test_all_perfect(self):
assert calculate_total_score(1.0, 1.0, 1.0, 1.0) == 1.0
def test_all_zero(self):
assert calculate_total_score(0.0, 0.0, 0.0, 0.0) == 0.0
def test_weighted_calculation(self):
"""手动验证加权计算."""
q, r, d, b = 0.8, 0.6, 0.4, 0.2
expected = WEIGHT_QUALITY * q + WEIGHT_RESOLUTION * r + WEIGHT_DURATION * d + WEIGHT_BITRATE * b
assert calculate_total_score(q, r, d, b) == pytest.approx(expected, rel=1e-3)
def test_quality_dominates(self):
"""质量分权重最高,质量分变化影响最大."""
score_high_quality = calculate_total_score(1.0, 0.5, 0.5, 0.5)
score_low_quality = calculate_total_score(0.0, 0.5, 0.5, 0.5)
diff_quality = score_high_quality - score_low_quality
score_high_res = calculate_total_score(0.5, 1.0, 0.5, 0.5)
score_low_res = calculate_total_score(0.5, 0.0, 0.5, 0.5)
diff_res = score_high_res - score_low_res
assert diff_quality > diff_res
def test_rounded_to_4_decimals(self):
result = calculate_total_score(0.3333, 0.3333, 0.3333, 0.3333)
assert result == round(result, 4)
# ── 单个素材评分详情 ────────────────────────────────────────────────────────
class TestScoreAssetDetail:
def test_normal_asset(self):
detail = score_asset_detail(
asset_id="asset_001",
quality=80.0,
width=1920,
height=1080,
duration=10.0,
file_size=5_000_000,
)
assert detail.asset_id == "asset_001"
assert 0.0 <= detail.total_score <= 1.0
assert detail.quality_score == pytest.approx(0.8)
assert detail.resolution_score == 1.0
assert detail.duration_score == 1.0
assert detail.duration == 10.0
def test_unknown_quality_defaults(self):
detail = score_asset_detail(
asset_id="a1",
quality=None,
width=1920,
height=1080,
duration=10.0,
file_size=5_000_000,
)
assert detail.quality_score == 0.5
def test_quality_hundred_is_one(self):
detail = score_asset_detail(
asset_id="a1",
quality=100.0,
width=1920,
height=1080,
duration=10.0,
file_size=1_000_000,
)
assert detail.quality_score == 1.0
def test_quality_zero_is_zero(self):
detail = score_asset_detail(
asset_id="a1",
quality=0.0,
width=1920,
height=1080,
duration=10.0,
file_size=1_000_000,
)
assert detail.quality_score == 0.0
def test_custom_target_resolution(self):
detail = score_asset_detail(
asset_id="a1",
quality=50.0,
width=1280,
height=720,
duration=10.0,
file_size=1_000_000,
target_width=1280,
target_height=720,
)
assert detail.resolution_score == 1.0
# ── 时长分桶 ────────────────────────────────────────────────────────────────
def _make_detail(asset_id: str, duration: float | None, score: float = 0.8) -> AssetScoreDetail:
return AssetScoreDetail(
asset_id=asset_id,
total_score=score,
quality_score=score,
resolution_score=score,
duration_score=score,
bitrate_score=score,
duration=duration,
)
class TestBucketByDuration:
def test_short_bucket(self):
item = _make_detail("s1", duration=3.0)
assert _bucket_by_duration(item) == "short"
def test_short_boundary(self):
item = _make_detail("s1", duration=4.9)
assert _bucket_by_duration(item) == "short"
def test_medium_bucket(self):
item = _make_detail("m1", duration=10.0)
assert _bucket_by_duration(item) == "medium"
def test_medium_boundary(self):
item = _make_detail("m1", duration=14.9)
assert _bucket_by_duration(item) == "medium"
def test_long_bucket(self):
item = _make_detail("l1", duration=20.0)
assert _bucket_by_duration(item) == "long"
def test_long_at_boundary(self):
""">=15s 为长素材."""
item = _make_detail("l1", duration=15.0)
assert _bucket_by_duration(item) == "long"
def test_unknown_bucket(self):
item = _make_detail("u1", duration=None)
assert _bucket_by_duration(item) == "unknown"
# ── 多样性选择 ──────────────────────────────────────────────────────────────
class TestDiverseSelection:
def _make_scored_list(self) -> list[AssetScoreDetail]:
"""构造一个包含各时长桶的测试列表,按分数降序."""
items = [
_make_detail("high_short", duration=3.0, score=0.95),
_make_detail("high_medium", duration=8.0, score=0.9),
_make_detail("high_long", duration=30.0, score=0.85),
_make_detail("mid_short", duration=2.0, score=0.8),
_make_detail("mid_medium", duration=10.0, score=0.75),
_make_detail("mid_long", duration=20.0, score=0.7),
_make_detail("low_short", duration=4.0, score=0.6),
_make_detail("low_medium", duration=12.0, score=0.5),
_make_detail("low_long", duration=60.0, score=0.4),
]
items.sort(key=lambda x: x.total_score, reverse=True)
return items
def test_empty_list_returns_empty(self):
assert diverse_selection([], 5) == []
def test_zero_count_returns_empty(self):
items = self._make_scored_list()
assert diverse_selection(items, 0) == []
def test_negative_count_returns_empty(self):
items = self._make_scored_list()
assert diverse_selection(items, -1) == []
def test_selects_from_multiple_buckets(self):
items = self._make_scored_list()
result = diverse_selection(items, 6)
assert len(result) == 6
# 应该包含来自不同桶的素材
durations = [r.duration for r in result]
has_short = any(d and d < 5.0 for d in durations)
has_medium = any(d and 5.0 <= d < 15.0 for d in durations)
has_long = any(d and d >= 15.0 for d in durations)
assert has_short and has_medium and has_long
def test_no_duplicate_ids(self):
items = self._make_scored_list()
result = diverse_selection(items, 9)
ids = [r.asset_id for r in result]
assert len(ids) == len(set(ids))
def test_not_more_than_count(self):
items = self._make_scored_list()
result = diverse_selection(items, 3)
assert len(result) <= 3
def test_fewer_assets_than_count(self):
items = [_make_detail("a1", duration=3.0, score=0.9)]
result = diverse_selection(items, 10)
assert len(result) == 1
def test_only_short_bucket(self):
items = [
_make_detail("s1", duration=1.0, score=0.9),
_make_detail("s2", duration=2.0, score=0.8),
_make_detail("s3", duration=3.0, score=0.7),
]
result = diverse_selection(items, 3)
assert len(result) == 3
# 只有短素材,应该都返回
assert all(r.duration and r.duration < 5.0 for r in result)
def test_highest_scores_priority(self):
"""分数最高的素材应该优先被选中."""
items = self._make_scored_list()
result = diverse_selection(items, 3)
# 最高分的那个应该在结果里
assert result[0].asset_id == "high_short"
def test_contains_top_scoring_items(self):
"""结果中应该包含全局最高分的素材."""
items = self._make_scored_list()
result = diverse_selection(items, 6)
result_ids = {r.asset_id for r in result}
# 全局最高分的应该在结果中
assert "high_short" in result_ids
assert "high_medium" in result_ids
# ── 候选过滤 ────────────────────────────────────────────────────────────────
@dataclass
class MockAsset:
asset_id: str
status: str = "ready"
mime_type: str = "video/mp4"
quality_score: float | None = None
class TestFilterCandidates:
def test_ready_video_passes(self):
assets = [MockAsset("a1", status="ready", mime_type="video/mp4")]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 1
assert filtered == 0
def test_not_ready_filtered(self):
assets = [
MockAsset("a1", status="processing"),
MockAsset("a2", status="failed"),
MockAsset("a3", status="ready"),
]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 1
assert candidates[0].asset_id == "a3"
assert filtered == 0 # 状态不对的不算质量过滤
def test_non_video_filtered(self):
assets = [
MockAsset("a1", mime_type="image/jpeg"),
MockAsset("a2", mime_type="audio/mp3"),
MockAsset("a3", mime_type="video/mp4"),
]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 1
assert filtered == 0
def test_video_mime_prefix(self):
"""所有以 video 开头的 MIME 都通过."""
assets = [
MockAsset("a1", mime_type="video/mp4"),
MockAsset("a2", mime_type="video/webm"),
MockAsset("a3", mime_type="video/x-matroska"),
]
candidates, _ = filter_candidates(assets)
assert len(candidates) == 3
def test_low_quality_filtered_counted(self):
"""质量分低于门槛的计入 filtered_out."""
assets = [
MockAsset("good", quality_score=80.0),
MockAsset("bad", quality_score=20.0),
]
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
assert len(candidates) == 1
assert candidates[0].asset_id == "good"
assert filtered == 1
def test_none_quality_passes(self):
"""quality_score 为 None 的不做质量检查,通过."""
assets = [MockAsset("a1", quality_score=None)]
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
assert len(candidates) == 1
assert filtered == 0
def test_quality_at_threshold_passes(self):
"""刚好等于门槛值的通过."""
assets = [MockAsset("a1", quality_score=30.0)]
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
assert len(candidates) == 1
assert filtered == 0
def test_empty_list(self):
candidates, filtered = filter_candidates([])
assert candidates == []
assert filtered == 0
def test_none_mime_type_treated_as_empty(self):
assets = [MockAsset("a1", mime_type=None)] # type: ignore
candidates, _ = filter_candidates(assets)
assert len(candidates) == 0
# ── 数据类 ──────────────────────────────────────────────────────────────────
class TestDataClasses:
def test_score_detail_defaults(self):
detail = AssetScoreDetail(
asset_id="test",
total_score=0.5,
quality_score=0.5,
resolution_score=0.5,
duration_score=0.5,
bitrate_score=0.5,
duration=None,
)
assert detail.asset_id == "test"
assert detail.total_score == 0.5
assert detail.duration is None
def test_smart_select_result_defaults(self):
result = SmartSelectResult(
selected_ids=["a1", "a2"],
total_candidates=10,
filtered_out=3,
avg_score=0.75,
)
assert len(result.selected_ids) == 2
assert result.total_candidates == 10
assert result.filtered_out == 3
assert result.details == [] # 默认空列表
+510
View File
@@ -0,0 +1,510 @@
"""entities 领域实体单测 — Asset/Project/AssetLibrary/IngestJob/AssetStatus."""
from __future__ import annotations
import pytest
from packages.domain.classification import (
AssetLibraryKind,
ClassificationStatus,
IngestJobStatus,
)
from packages.domain.entities import (
Asset,
AssetLibrary,
AssetStatus,
IngestJob,
Project,
)
# ── AssetStatus 枚举兼容 ────────────────────────────────────────────────────
class TestAssetStatus:
def test_basic_statuses(self):
assert AssetStatus.UPLOADING == "uploading"
assert AssetStatus.READY == "ready"
assert AssetStatus.PROCESSING == "processing"
assert AssetStatus.ERROR == "error"
assert AssetStatus.DELETED == "deleted"
def test_missing_uploaded_maps_to_ready(self):
# 兼容历史:uploaded → READY
assert AssetStatus("uploaded") == AssetStatus.READY
def test_missing_success_maps_to_ready(self):
assert AssetStatus("success") == AssetStatus.READY
def test_missing_ok_maps_to_ready(self):
assert AssetStatus("ok") == AssetStatus.READY
def test_missing_done_maps_to_ready(self):
assert AssetStatus("done") == AssetStatus.READY
def test_missing_complete_maps_to_ready(self):
assert AssetStatus("complete") == AssetStatus.READY
def test_missing_upload_maps_to_uploading(self):
assert AssetStatus("upload") == AssetStatus.UPLOADING
def test_missing_uploading_start_maps_to_uploading(self):
assert AssetStatus("uploading_start") == AssetStatus.UPLOADING
def test_missing_upload_start_maps_to_uploading(self):
assert AssetStatus("upload_start") == AssetStatus.UPLOADING
def test_missing_failed_maps_to_error(self):
assert AssetStatus("failed") == AssetStatus.ERROR
def test_missing_fail_maps_to_error(self):
assert AssetStatus("fail") == AssetStatus.ERROR
def test_missing_err_maps_to_error(self):
assert AssetStatus("err") == AssetStatus.ERROR
def test_missing_process_maps_to_processing(self):
assert AssetStatus("process") == AssetStatus.PROCESSING
def test_missing_running_maps_to_processing(self):
assert AssetStatus("running") == AssetStatus.PROCESSING
def test_missing_run_maps_to_processing(self):
assert AssetStatus("run") == AssetStatus.PROCESSING
def test_unknown_value_defaults_to_ready(self):
# 兜底:未知值 → READY,不阻塞业务
assert AssetStatus("some_weird_value") == AssetStatus.READY
def test_case_insensitive(self):
assert AssetStatus("READY") == AssetStatus.READY
assert AssetStatus("Ready") == AssetStatus.READY
def test_whitespace_stripped(self):
assert AssetStatus(" ready ") == AssetStatus.READY
# ── Project ────────────────────────────────────────────────────────────────
class TestProject:
def test_create_basic(self):
p = Project.create(owner_user_id="user_1", name="我的项目")
assert p.id # 自动生成
assert p.owner_user_id == "user_1"
assert p.name == "我的项目"
assert p.description == ""
assert p.shared_users == []
def test_create_with_description(self):
p = Project.create(owner_user_id="u1", name="P1", description="测试描述")
assert p.description == "测试描述"
def test_create_name_stripped(self):
p = Project.create(owner_user_id="u1", name=" 我的项目 ")
assert p.name == "我的项目"
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="项目名称不能为空"):
Project.create(owner_user_id="u1", name="")
def test_create_whitespace_name_raises(self):
with pytest.raises(ValueError, match="项目名称不能为空"):
Project.create(owner_user_id="u1", name=" ")
def test_is_owner_true(self):
p = Project.create(owner_user_id="u1", name="P1")
assert p.is_owner("u1") is True
def test_is_owner_false(self):
p = Project.create(owner_user_id="u1", name="P1")
assert p.is_owner("u2") is False
def test_is_shared_with_true(self):
p = Project.create(owner_user_id="u1", name="P1")
p.shared_users = ["u2", "u3"]
assert p.is_shared_with("u2") is True
def test_is_shared_with_false(self):
p = Project.create(owner_user_id="u1", name="P1")
assert p.is_shared_with("u2") is False
def test_can_access_owner(self):
p = Project.create(owner_user_id="u1", name="P1")
assert p.can_access("u1") is True
def test_can_access_shared_user(self):
p = Project.create(owner_user_id="u1", name="P1")
p.shared_users = ["u2"]
assert p.can_access("u2") is True
def test_can_access_stranger(self):
p = Project.create(owner_user_id="u1", name="P1")
assert p.can_access("u3") is False
def test_description_stripped(self):
p = Project.create(owner_user_id="u1", name="P1", description=" desc ")
assert p.description == "desc"
# ── AssetLibrary ───────────────────────────────────────────────────────────
class TestAssetLibrary:
def test_create_basic(self):
lib = AssetLibrary.create(
project_id="proj_1",
name="视频素材",
kind=AssetLibraryKind.VIDEO,
)
assert lib.id
assert lib.project_id == "proj_1"
assert lib.name == "视频素材"
assert lib.kind == AssetLibraryKind.VIDEO
assert lib.asset_count == 0
assert lib.total_size == 0
def test_create_name_stripped(self):
lib = AssetLibrary.create(
project_id="p1", name=" 我的素材库 ", kind=AssetLibraryKind.VOICE
)
assert lib.name == "我的素材库"
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="素材库名称不能为空"):
AssetLibrary.create(project_id="p1", name="", kind=AssetLibraryKind.VIDEO)
def test_create_whitespace_name_raises(self):
with pytest.raises(ValueError, match="素材库名称不能为空"):
AssetLibrary.create(project_id="p1", name=" ", kind=AssetLibraryKind.VIDEO)
def test_create_with_string_kind(self):
lib = AssetLibrary.create(project_id="p1", name="L1", kind="video")
assert lib.kind == AssetLibraryKind.VIDEO
# ── Asset ──────────────────────────────────────────────────────────────────
class TestAssetCreate:
def test_create_basic(self):
asset = Asset.create(
project_id="p1",
library_id="lib1",
name="测试视频.mp4",
storage_key="videos/test.mp4",
mime_type="video/mp4",
)
assert asset.id
assert asset.project_id == "p1"
assert asset.library_id == "lib1"
assert asset.name == "测试视频.mp4"
assert asset.storage_key == "videos/test.mp4"
assert asset.mime_type == "video/mp4"
assert asset.status == AssetStatus.UPLOADING
assert asset.classification_status == ClassificationStatus.PENDING
assert asset.file_size == 0
assert asset.tag_ids == []
assert asset.metadata == {}
def test_create_name_stripped(self):
asset = Asset.create(
project_id="p1", library_id="l1",
name=" video.mp4 ",
storage_key="k1", mime_type="video/mp4",
)
assert asset.name == "video.mp4"
def test_create_storage_key_stripped(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key=" key1 ", mime_type="video/mp4",
)
assert asset.storage_key == "key1"
def test_create_mime_type_stripped(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type=" video/mp4 ",
)
assert asset.mime_type == "video/mp4"
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="素材名称不能为空"):
Asset.create(
project_id="p1", library_id="l1", name="",
storage_key="k1", mime_type="video/mp4",
)
def test_create_empty_storage_key_raises(self):
with pytest.raises(ValueError, match="storage_key 不能为空"):
Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key=" ", mime_type="video/mp4",
)
def test_create_empty_mime_type_raises(self):
with pytest.raises(ValueError, match="mime_type 不能为空"):
Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="",
)
def test_create_with_file_size(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4", file_size=1024000,
)
assert asset.file_size == 1024000
def test_create_with_duration(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4", duration=30.5,
)
assert asset.duration == 30.5
def test_create_with_dimensions(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
width=1080, height=1920, fps=30.0, codec="h264",
)
assert asset.width == 1080
assert asset.height == 1920
assert asset.fps == 30.0
assert asset.codec == "h264"
def test_create_with_metadata(self):
meta = {"bitrate": 5000, "codec": "h264"}
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4", metadata=meta,
)
assert asset.metadata == meta
def test_create_metadata_none_defaults_empty(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4", metadata=None,
)
assert asset.metadata == {}
def test_create_thumbnail_url_none(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4", thumbnail_url=None,
)
assert asset.thumbnail_url is None
def test_create_uploaded_by_stripped(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
uploaded_by_user_id=" user1 ",
)
assert asset.uploaded_by_user_id == "user1"
def test_create_file_hash_stripped(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
file_hash=" abc123 ",
)
assert asset.file_hash == "abc123"
class TestAssetFileType:
def test_video_mime(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
)
assert asset.file_type == "video"
def test_audio_mime(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="a.mp3",
storage_key="k1", mime_type="audio/mpeg",
)
assert asset.file_type == "audio"
def test_image_mime(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="i.jpg",
storage_key="k1", mime_type="image/jpeg",
)
assert asset.file_type == "image"
def test_invalid_mime_returns_full(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="f.xxx",
storage_key="k1", mime_type="application/octet-stream",
)
assert asset.file_type == "application"
class TestAssetTags:
def test_add_tag(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
)
asset.add_tag("tag_1")
assert "tag_1" in asset.tag_ids
assert len(asset.tag_ids) == 1
def test_add_tag_dedup(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
)
asset.add_tag("tag_1")
asset.add_tag("tag_1")
assert len(asset.tag_ids) == 1
def test_add_tag_empty_raises(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
)
with pytest.raises(ValueError, match="标签 ID 不能为空"):
asset.add_tag("")
def test_add_tag_whitespace_raises(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
)
with pytest.raises(ValueError, match="标签 ID 不能为空"):
asset.add_tag(" ")
def test_add_tag_strips_whitespace(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
)
asset.add_tag(" tag_1 ")
assert asset.tag_ids == ["tag_1"]
def test_remove_tag(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
)
asset.add_tag("tag_1")
asset.add_tag("tag_2")
asset.remove_tag("tag_1")
assert asset.tag_ids == ["tag_2"]
def test_remove_nonexistent_tag_no_error(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
)
asset.add_tag("tag_1")
asset.remove_tag("nonexistent") # 幂等,不报错
assert asset.tag_ids == ["tag_1"]
def test_remove_tag_strips_whitespace(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
)
asset.add_tag("tag_1")
asset.remove_tag(" tag_1 ")
assert len(asset.tag_ids) == 0
def test_add_tag_updates_updated_at(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
)
old_updated = asset.updated_at
import time; time.sleep(0.001)
asset.add_tag("tag_1")
assert asset.updated_at > old_updated
def test_remove_tag_updates_updated_at(self):
asset = Asset.create(
project_id="p1", library_id="l1", name="v.mp4",
storage_key="k1", mime_type="video/mp4",
)
asset.add_tag("tag_1")
old_updated = asset.updated_at
import time; time.sleep(0.001)
asset.remove_tag("tag_1")
assert asset.updated_at > old_updated
# ── IngestJob ──────────────────────────────────────────────────────────────
class TestIngestJob:
def test_create_basic(self):
job = IngestJob.create(
project_id="p1",
library_id="lib1",
storage_key="videos/test.mp4",
)
assert job.id
assert job.project_id == "p1"
assert job.library_id == "lib1"
assert job.storage_key == "videos/test.mp4"
assert job.status == IngestJobStatus.PENDING
assert job.error_message == ""
assert job.result_asset_id == ""
assert job.file_hash == ""
def test_create_with_file_hash(self):
job = IngestJob.create(
project_id="p1", library_id="l1",
storage_key="k1", file_hash="abc123",
)
assert job.file_hash == "abc123"
def test_create_project_id_stripped(self):
job = IngestJob.create(
project_id=" p1 ", library_id="l1", storage_key="k1",
)
assert job.project_id == "p1"
def test_create_library_id_stripped(self):
job = IngestJob.create(
project_id="p1", library_id=" l1 ", storage_key="k1",
)
assert job.library_id == "l1"
def test_create_storage_key_stripped(self):
job = IngestJob.create(
project_id="p1", library_id="l1", storage_key=" k1 ",
)
assert job.storage_key == "k1"
def test_create_empty_project_id_raises(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
IngestJob.create(project_id="", library_id="l1", storage_key="k1")
def test_create_empty_library_id_raises(self):
with pytest.raises(ValueError, match="library_id 不能为空"):
IngestJob.create(project_id="p1", library_id="", storage_key="k1")
def test_create_empty_storage_key_raises(self):
with pytest.raises(ValueError, match="storage_key 不能为空"):
IngestJob.create(project_id="p1", library_id="l1", storage_key="")
def test_create_whitespace_project_id_raises(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
IngestJob.create(project_id=" ", library_id="l1", storage_key="k1")
def test_create_file_hash_stripped(self):
job = IngestJob.create(
project_id="p1", library_id="l1",
storage_key="k1", file_hash=" hash123 ",
)
assert job.file_hash == "hash123"
def test_created_at_auto_set(self):
job = IngestJob.create(project_id="p1", library_id="l1", storage_key="k1")
assert job.created_at is not None
assert job.updated_at is not None
+459
View File
@@ -0,0 +1,459 @@
"""Job 领域模型单测 — 状态机 + 实体方法全覆盖."""
from __future__ import annotations
import pytest
from packages.domain.job import (
Job,
JobStatus,
JobType,
TERMINAL_STATUSES,
)
# ── 枚举常量 ────────────────────────────────────────────────────────────────
class TestJobType:
def test_all_types_exist(self):
assert JobType.VIDEO_COMPOSE == "video_compose"
assert JobType.RENDER_EDIT_PLAN == "render_edit_plan"
assert JobType.ASSET_INGEST == "asset_ingest"
assert JobType.CLASSIFICATION == "classification"
assert JobType.VOICE_EXTRACTION == "voice_extraction"
assert JobType.GENERATION == "generation"
def test_from_string(self):
assert JobType("video_compose") == JobType.VIDEO_COMPOSE
assert JobType("render_edit_plan") == JobType.RENDER_EDIT_PLAN
def test_invalid_type_raises(self):
with pytest.raises(ValueError):
JobType("invalid_type")
class TestJobStatus:
def test_all_statuses_exist(self):
assert JobStatus.PENDING == "pending"
assert JobStatus.RUNNING == "running"
assert JobStatus.SUCCESS == "success"
assert JobStatus.FAILED == "failed"
assert JobStatus.CANCELLED == "cancelled"
def test_from_string(self):
assert JobStatus("pending") == JobStatus.PENDING
assert JobStatus("running") == JobStatus.RUNNING
class TestTerminalStatuses:
def test_success_is_terminal(self):
assert JobStatus.SUCCESS in TERMINAL_STATUSES
def test_failed_is_terminal(self):
assert JobStatus.FAILED in TERMINAL_STATUSES
def test_cancelled_is_terminal(self):
assert JobStatus.CANCELLED in TERMINAL_STATUSES
def test_pending_not_terminal(self):
assert JobStatus.PENDING not in TERMINAL_STATUSES
def test_running_not_terminal(self):
assert JobStatus.RUNNING not in TERMINAL_STATUSES
# ── Job.create ─────────────────────────────────────────────────────────────
class TestJobCreate:
def test_basic_creation(self):
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE)
assert job.id # 自动生成
assert job.project_id == "proj_1"
assert job.job_type == JobType.VIDEO_COMPOSE
assert job.status == JobStatus.PENDING
assert job.progress == 0.0
assert job.retry_count == 0
assert job.max_retries == 3
assert job.payload == {}
assert job.result == {}
def test_job_type_string_conversion(self):
job = Job.create(project_id="proj_1", job_type="video_compose")
assert job.job_type == JobType.VIDEO_COMPOSE
def test_invalid_job_type_raises(self):
with pytest.raises(ValueError, match="不支持的任务类型"):
Job.create(project_id="proj_1", job_type="invalid")
def test_empty_project_id_raises(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE)
def test_project_id_stripped(self):
job = Job.create(project_id=" proj_1 ", job_type=JobType.VIDEO_COMPOSE)
assert job.project_id == "proj_1"
def test_with_payload(self):
payload = {"video_url": "http://example.com/v.mp4"}
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, payload=payload)
assert job.payload == payload
def test_payload_none_defaults_empty_dict(self):
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, payload=None)
assert job.payload == {}
def test_with_source_id(self):
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, source_id="plan_123")
assert job.source_id == "plan_123"
def test_source_id_stripped(self):
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, source_id=" src ")
assert job.source_id == "src"
def test_with_created_by(self):
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user_1")
assert job.created_by_user_id == "user_1"
def test_custom_max_retries(self):
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, max_retries=5)
assert job.max_retries == 5
def test_created_at_auto_set(self):
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE)
assert job.created_at is not None
assert job.updated_at is not None
def test_started_at_none_initially(self):
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE)
assert job.started_at is None
assert job.completed_at is None
# ── 属性判断 ────────────────────────────────────────────────────────────────
class TestJobProperties:
def test_is_terminal_pending_false(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert not job.is_terminal
def test_is_terminal_running_false(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert not job.is_terminal
def test_is_terminal_success_true(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
assert job.is_terminal
def test_is_terminal_failed_true(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.is_terminal
def test_is_terminal_cancelled_true(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.CANCELLED)
assert job.is_terminal
def test_is_retryable_failed_within_limit(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.is_retryable
def test_is_retryable_failed_at_limit(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
job.retry_count = 3
assert not job.is_retryable
def test_is_retryable_pending_false(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert not job.is_retryable
def test_is_retryable_success_false(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
assert not job.is_retryable
# ── 状态转换 ────────────────────────────────────────────────────────────────
class TestTransitionTo:
def test_pending_to_running(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert job.status == JobStatus.RUNNING
def test_pending_to_success(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.SUCCESS)
assert job.status == JobStatus.SUCCESS
def test_pending_to_cancelled(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.CANCELLED)
assert job.status == JobStatus.CANCELLED
def test_running_to_success(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
assert job.status == JobStatus.SUCCESS
def test_running_to_failed(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.status == JobStatus.FAILED
def test_running_to_cancelled(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.CANCELLED)
assert job.status == JobStatus.CANCELLED
def test_failed_to_pending_retry(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
job.transition_to(JobStatus.PENDING)
assert job.status == JobStatus.PENDING
def test_invalid_transition_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
# success → running 是非法的
job.transition_to(JobStatus.SUCCESS)
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(JobStatus.RUNNING)
def test_string_status_conversion(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to("running")
assert job.status == JobStatus.RUNNING
def test_invalid_string_status_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="无效状态"):
job.transition_to("invalid_status")
def test_transition_updates_updated_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
old_updated = job.updated_at
import time; time.sleep(0.001)
job.transition_to(JobStatus.RUNNING)
assert job.updated_at >= old_updated
def test_running_sets_started_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert job.started_at is None
job.transition_to(JobStatus.RUNNING)
assert job.started_at is not None
def test_success_sets_completed_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert job.completed_at is None
job.transition_to(JobStatus.SUCCESS)
assert job.completed_at is not None
def test_failed_sets_completed_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.completed_at is not None
# ── 便捷方法 ────────────────────────────────────────────────────────────────
class TestMarkMethods:
def test_mark_running(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running(stage="初始化")
assert job.status == JobStatus.RUNNING
assert job.current_stage == "初始化"
def test_mark_running_no_stage(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
assert job.status == JobStatus.RUNNING
assert job.current_stage == ""
def test_mark_success(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
result = {"output_url": "http://example.com/out.mp4"}
job.mark_success(result=result)
assert job.status == JobStatus.SUCCESS
assert job.progress == 100.0
assert job.current_stage == "完成"
assert job.result == result
def test_mark_success_no_result(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.mark_success()
assert job.status == JobStatus.SUCCESS
assert job.result == {} # 保持默认空字典
def test_mark_failed(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.mark_failed("网络超时")
assert job.status == JobStatus.FAILED
assert job.error_message == "网络超时"
assert job.current_stage == "失败"
def test_mark_cancelled(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_cancelled()
assert job.status == JobStatus.CANCELLED
assert job.current_stage == "已取消"
# ── 进度更新 ────────────────────────────────────────────────────────────────
class TestUpdateProgress:
def test_normal_progress(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.update_progress(50.0, stage="渲染中")
assert job.progress == 50.0
assert job.current_stage == "渲染中"
def test_progress_zero(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.update_progress(0.0)
assert job.progress == 0.0
def test_progress_100(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.update_progress(100.0)
assert job.progress == 100.0
def test_progress_negative_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
job.update_progress(-1.0)
def test_progress_over_100_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
job.update_progress(100.1)
def test_progress_without_stage(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.update_progress(30.0)
assert job.progress == 30.0
def test_progress_updates_updated_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
old_updated = job.updated_at
import time; time.sleep(0.001)
job.update_progress(50.0)
assert job.updated_at > old_updated
# ── 重试 ────────────────────────────────────────────────────────────────────
class TestPrepareRetry:
def test_normal_retry(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
job.transition_to(JobStatus.RUNNING)
job.mark_failed("超时")
job.prepare_retry()
assert job.status == JobStatus.PENDING
assert job.retry_count == 1
assert job.progress == 0.0
assert "第 1 次重试" in job.current_stage
assert job.error_message == ""
assert job.started_at is None
assert job.completed_at is None
assert job.celery_task_id == ""
def test_retry_increments_count(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
job.transition_to(JobStatus.RUNNING)
job.mark_failed("超时")
job.prepare_retry()
assert job.retry_count == 1
job.transition_to(JobStatus.RUNNING)
job.mark_failed("又超时了")
job.prepare_retry()
assert job.retry_count == 2
def test_retry_exceeds_limit_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=1)
job.transition_to(JobStatus.RUNNING)
job.mark_failed("超时")
job.prepare_retry() # 第1次重试,ok
job.transition_to(JobStatus.RUNNING)
job.mark_failed("又超时了")
# retry_count=1, max_retries=1 → 不能再重试
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_retry_from_success_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.mark_success()
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_retry_from_pending_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
# ── 序列化 ──────────────────────────────────────────────────────────────────
class TestToDict:
def test_basic_fields(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, source_id="src1")
d = job.to_dict()
assert d["id"] == job.id
assert d["project_id"] == "p1"
assert d["job_type"] == "video_compose"
assert d["status"] == "pending"
assert d["progress"] == 0.0
assert d["source_id"] == "src1"
assert d["retry_count"] == 0
assert d["max_retries"] == 3
def test_is_retryable_in_output(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
d = job.to_dict()
assert "is_retryable" in d
assert d["is_retryable"] is False
def test_datetime_fields_are_strings(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
d = job.to_dict()
assert isinstance(d["created_at"], str)
assert isinstance(d["updated_at"], str)
def test_started_at_none_when_not_started(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
d = job.to_dict()
assert d["started_at"] is None
assert d["completed_at"] is None
def test_started_at_present_after_running(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
d = job.to_dict()
assert d["started_at"] is not None
assert isinstance(d["started_at"], str)
+316 -190
View File
@@ -1,264 +1,390 @@
"""JWT 服务单元测试."""
"""JWT 服务单元测试 — wave130."""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
import jwt as pyjwt
import pytest
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from packages.application.auth.jwt_service import JWTConfig, JWTService, TokenType
from packages.application.auth.jwt_service import (
JWTConfig,
JWTService,
TokenType,
)
# ── 测试常量 ────────────────────────────────────────────────────────────────
@pytest.fixture
def jwt_config():
return JWTConfig(
secret_key="test-secret-key-strong-enough-123456",
algorithm="HS256",
access_token_expire_minutes=30,
refresh_token_expire_days=7,
)
TEST_SECRET = "test-secret-key-for-unit-testing-only-1234567890"
TEST_ALGORITHM = "HS256"
@pytest.fixture
def jwt_service(jwt_config):
return JWTService(jwt_config)
# ── JWTConfig 配置 ──────────────────────────────────────────────────────────
class TestJWTConfig:
"""JWTConfig 测试"""
def test_normal_config(self):
config = JWTConfig(secret_key=TEST_SECRET)
assert config.SECRET_KEY == TEST_SECRET
assert config.ALGORITHM == "HS256"
assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 15
assert config.REFRESH_TOKEN_EXPIRE_DAYS == 7
def test_custom_config(self):
config = JWTConfig(
secret_key=TEST_SECRET,
algorithm="HS384",
access_token_expire_minutes=60,
refresh_token_expire_days=30,
)
assert config.ALGORITHM == "HS384"
assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 60
assert config.REFRESH_TOKEN_EXPIRE_DAYS == 30
def test_empty_secret_raises(self):
"""secret_key 抛出 ValueError"""
with pytest.raises(ValueError, match="must be provided"):
with pytest.raises(ValueError, match="secret_key must be provided"):
JWTConfig(secret_key="")
def test_whitespace_secret_raises(self):
"""纯空白 secret_key 抛出 ValueError"""
with pytest.raises(ValueError, match="must be provided"):
with pytest.raises(ValueError):
JWTConfig(secret_key=" ")
def test_insecure_default_secret_raises(self):
"""不安全的默认 secret 抛出 ValueError"""
insecure_secrets = [
def test_none_secret_raises(self):
with pytest.raises(ValueError):
JWTConfig(secret_key=None) # type: ignore
@pytest.mark.parametrize(
"bad_secret",
[
"your-secret-key-change-in-production",
"your-secret-key",
"secret",
"changeme",
"password",
"SECRET",
]
for secret in insecure_secrets:
with pytest.raises(ValueError, match="insecure"):
JWTConfig(secret_key=secret)
def test_strong_secret_accepted(self):
"""强 secret 可以正常创建"""
config = JWTConfig(secret_key="my-strong-secret-key-1234567890")
assert config.SECRET_KEY == "my-strong-secret-key-1234567890"
def test_default_values(self):
"""默认配置值正确"""
config = JWTConfig(secret_key="test-secret-12345")
assert config.ALGORITHM == "HS256"
assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 15
assert config.REFRESH_TOKEN_EXPIRE_DAYS == 7
def test_custom_expiry_values(self):
"""自定义过期时间"""
config = JWTConfig(
secret_key="test-secret-12345",
access_token_expire_minutes=60,
refresh_token_expire_days=30,
)
assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 60
assert config.REFRESH_TOKEN_EXPIRE_DAYS == 30
"Your-Secret-Key",
],
)
def test_insecure_defaults_rejected(self, bad_secret):
with pytest.raises(ValueError, match="insecure"):
JWTConfig(secret_key=bad_secret)
class TestTokenType:
"""TokenType 测试"""
def test_access_token_type(self):
"""access token 类型值"""
assert TokenType.ACCESS == "access"
def test_refresh_token_type(self):
"""refresh token 类型值"""
assert TokenType.REFRESH == "refresh"
# ── JWTService 初始化 ──────────────────────────────────────────────────────
class TestJWTServiceInit:
"""JWTService 初始化测试"""
def test_with_config_works(self):
config = JWTConfig(secret_key=TEST_SECRET)
service = JWTService(config)
assert service.config is config
def test_none_config_raises(self):
"""不传 config 抛出 ValueError"""
with pytest.raises(ValueError, match="requires a JWTConfig"):
with pytest.raises(ValueError, match="JWTService requires"):
JWTService(None)
def test_with_config_creates_service(self, jwt_config):
"""传入 config 正常创建"""
service = JWTService(jwt_config)
assert service.config is jwt_config
# ── create_access_token ────────────────────────────────────────────────────
class TestCreateAccessToken:
"""create_access_token 测试"""
def setup_method(self):
self.service = JWTService(JWTConfig(secret_key=TEST_SECRET))
def test_returns_string(self, jwt_service):
"""返回非空字符串"""
token = jwt_service.create_access_token(user_id="user_001")
def test_creates_valid_jwt(self):
token = self.service.create_access_token(user_id="user123")
assert isinstance(token, str)
assert len(token) > 0
# JWT 格式:xxx.yyy.zzz
assert token.count(".") == 2
def test_contains_user_id(self, jwt_service):
"""payload 包含正确的 user_idsub字段)"""
token = jwt_service.create_access_token(user_id="user_123")
payload = jwt_service.verify_token(token)
assert payload["sub"] == "user_123"
def test_payload_contains_user_id(self):
token = self.service.create_access_token(user_id="user_001")
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
assert payload["sub"] == "user_001"
def test_contains_role(self, jwt_service):
"""payload 包含 role"""
token = jwt_service.create_access_token(user_id="user_001", role="admin")
payload = jwt_service.verify_token(token)
def test_payload_contains_role(self):
token = self.service.create_access_token(user_id="u1", role="admin")
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
assert payload["role"] == "admin"
def test_default_role_empty(self, jwt_service):
"""不传 role 默认为空字符串"""
token = jwt_service.create_access_token(user_id="user_001")
payload = jwt_service.verify_token(token)
def test_default_role_empty(self):
token = self.service.create_access_token(user_id="u1")
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
assert payload["role"] == ""
def test_token_type_is_access(self, jwt_service):
"""access token 的 type 为 access"""
token = jwt_service.create_access_token(user_id="user_001")
payload = jwt_service.verify_token(token)
def test_token_type_is_access(self):
token = self.service.create_access_token(user_id="u1")
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
assert payload["type"] == TokenType.ACCESS
def test_additional_claims(self, jwt_service):
"""额外声明被包含在 payload 中"""
token = jwt_service.create_access_token(
user_id="user_001",
additional_claims={"email": "test@example.com", "tenant": "t1"},
)
payload = jwt_service.verify_token(token)
assert payload["email"] == "test@example.com"
assert payload["tenant"] == "t1"
def test_has_iat_and_exp(self, jwt_service):
"""payload 包含 iat 和 exp"""
token = jwt_service.create_access_token(user_id="user_001")
payload = jwt_service.verify_token(token)
def test_has_iat_and_exp(self):
token = self.service.create_access_token(user_id="u1")
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
assert "iat" in payload
assert "exp" in payload
assert payload["exp"] > payload["iat"]
def test_expiry_correct_duration(self, jwt_service):
"""过期时间设置正确"""
token = jwt_service.create_access_token(user_id="user_001")
payload = jwt_service.verify_token(token)
# 30分钟 = 1800秒
duration = payload["exp"] - payload["iat"]
assert 1790 <= duration <= 1810 # 允许10秒误差
def test_expiration_correct(self):
"""过期时间大约等于当前时间 + 配置的分钟数."""
config = JWTConfig(secret_key=TEST_SECRET, access_token_expire_minutes=30)
service = JWTService(config)
before = datetime.now(timezone.utc)
token = service.create_access_token(user_id="u1")
after = datetime.now(timezone.utc)
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
exp = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
min_expected = before + timedelta(minutes=30) - timedelta(seconds=1)
max_expected = after + timedelta(minutes=30) + timedelta(seconds=1)
assert min_expected <= exp <= max_expected
def test_additional_claims_included(self):
extra = {"email": "test@example.com", "org_id": "org_001", "level": 5}
token = self.service.create_access_token(user_id="u1", additional_claims=extra)
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
assert payload["email"] == "test@example.com"
assert payload["org_id"] == "org_001"
assert payload["level"] == 5
def test_additional_claims_none(self):
token = self.service.create_access_token(user_id="u1", additional_claims=None)
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
assert "email" not in payload
def test_signed_with_correct_key(self):
token = self.service.create_access_token(user_id="u1")
# 用正确的密钥可以解码
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
assert payload["sub"] == "u1"
# 用错误的密钥无法解码
with pytest.raises(InvalidTokenError):
pyjwt.decode(token, "wrong-secret", algorithms=["HS256"])
# ── create_refresh_token ───────────────────────────────────────────────────
class TestCreateRefreshToken:
"""create_refresh_token 测试"""
def setup_method(self):
self.service = JWTService(JWTConfig(secret_key=TEST_SECRET))
def test_returns_string(self, jwt_service):
"""返回非空字符串"""
token = jwt_service.create_refresh_token(user_id="user_001", session_id="sess_001")
def test_creates_valid_token(self):
token = self.service.create_refresh_token(user_id="u1", session_id="sess_001")
assert isinstance(token, str)
assert len(token) > 0
assert token.count(".") == 2
def test_contains_user_and_session(self, jwt_service):
"""包含 user_id 和 session_id"""
token = jwt_service.create_refresh_token(user_id="user_123", session_id="sess_456")
payload = jwt_service.verify_token(token)
assert payload["sub"] == "user_123"
assert payload["session_id"] == "sess_456"
def test_payload_contains_session_id(self):
token = self.service.create_refresh_token(user_id="u1", session_id="sess_abc")
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
assert payload["session_id"] == "sess_abc"
assert payload["sub"] == "u1"
def test_token_type_is_refresh(self, jwt_service):
"""refresh token 的 type 为 refresh"""
token = jwt_service.create_refresh_token(user_id="user_001", session_id="s1")
payload = jwt_service.verify_token(token)
def test_token_type_is_refresh(self):
token = self.service.create_refresh_token(user_id="u1", session_id="s1")
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
assert payload["type"] == TokenType.REFRESH
def test_refresh_expiration_days(self):
config = JWTConfig(secret_key=TEST_SECRET, refresh_token_expire_days=7)
service = JWTService(config)
before = datetime.now(timezone.utc)
token = service.create_refresh_token(user_id="u1", session_id="s1")
after = datetime.now(timezone.utc)
payload = pyjwt.decode(token, TEST_SECRET, algorithms=["HS256"])
exp = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
min_exp = before + timedelta(days=7) - timedelta(seconds=1)
max_exp = after + timedelta(days=7, seconds=1)
assert min_exp <= exp <= max_exp
# ── verify_token ───────────────────────────────────────────────────────────
class TestVerifyToken:
"""verify_token 测试"""
def setup_method(self):
self.service = JWTService(JWTConfig(secret_key=TEST_SECRET))
def test_valid_token(self, jwt_service):
"""有效 token 验证通过"""
token = jwt_service.create_access_token(user_id="u1")
payload = jwt_service.verify_token(token)
def test_valid_token_returns_payload(self):
token = self.service.create_access_token(user_id="u1")
payload = self.service.verify_token(token)
assert payload["sub"] == "u1"
def test_invalid_token_raises(self, jwt_service):
"""无效 token 抛出 InvalidTokenError"""
with pytest.raises(InvalidTokenError):
jwt_service.verify_token("not.a.valid.token")
def test_empty_token_raises(self, jwt_service):
"""空字符串 token 抛出异常"""
with pytest.raises(InvalidTokenError):
jwt_service.verify_token("")
def test_wrong_secret_fails(self, jwt_config):
"""不同密钥的 token 无法验证"""
service1 = JWTService(JWTConfig(secret_key="secret-one-123456"))
service2 = JWTService(JWTConfig(secret_key="secret-two-1234567"))
token = service1.create_access_token(user_id="u1")
with pytest.raises(InvalidTokenError):
service2.verify_token(token)
class TestVerifyAccessToken:
"""verify_access_token 测试"""
def test_valid_access_token(self, jwt_service):
"""有效 access token 验证通过"""
token = jwt_service.create_access_token(user_id="u1")
payload = jwt_service.verify_access_token(token)
assert payload["sub"] == "u1"
def test_refresh_token_fails(self, jwt_service):
"""refresh token 不能当 access token 用"""
token = jwt_service.create_refresh_token(user_id="u1", session_id="s1")
with pytest.raises(ValueError, match="Token type must be 'access'"):
jwt_service.verify_access_token(token)
class TestVerifyRefreshToken:
"""verify_refresh_token 测试"""
def test_valid_refresh_token(self, jwt_service):
"""有效 refresh token 验证通过"""
token = jwt_service.create_refresh_token(user_id="u1", session_id="s1")
payload = jwt_service.verify_refresh_token(token)
assert payload["sub"] == "u1"
assert payload["session_id"] == "s1"
def test_access_token_fails(self, jwt_service):
"""access token 不能当 refresh token 用"""
token = jwt_service.create_access_token(user_id="u1")
with pytest.raises(ValueError, match="Token type must be 'refresh'"):
jwt_service.verify_refresh_token(token)
class TestExpiredToken:
"""过期 token 测试"""
def test_expired_access_token_raises(self):
"""过期 token 验证抛出 ExpiredSignatureError"""
config = JWTConfig(
secret_key="test-secret-12345",
access_token_expire_minutes=-1, # 立即过期
)
def test_expired_token_raises(self):
# 创建一个 1 秒过期的 token
config = JWTConfig(secret_key=TEST_SECRET, access_token_expire_minutes=1)
service = JWTService(config)
token = service.create_access_token(user_id="u1")
time.sleep(0.1)
# 等待过期(用 pyjwt 直接构造过期 token 更可靠)
expired_payload = {
"sub": "u1",
"type": "access",
"exp": datetime.now(timezone.utc) - timedelta(seconds=10),
}
expired_token = pyjwt.encode(expired_payload, TEST_SECRET, algorithm="HS256")
with pytest.raises(ExpiredSignatureError, match="expired"):
self.service.verify_token(expired_token)
def test_invalid_token_raises(self):
with pytest.raises(InvalidTokenError, match="Invalid token"):
self.service.verify_token("not-a-valid-jwt-token")
def test_wrong_signature_raises(self):
token = pyjwt.encode({"sub": "u1"}, "different-secret", algorithm="HS256")
with pytest.raises(InvalidTokenError):
self.service.verify_token(token)
def test_tampered_payload_raises(self):
token = self.service.create_access_token(user_id="u1")
# 尝试篡改:JWT 有签名保护,篡改会导致验证失败
parts = token.split(".")
assert len(parts) == 3
# 把 payload 部分替换(不会成功,因为签名不对)
import base64
fake_payload = base64.urlsafe_b64encode(b'{"sub":"admin","role":"admin"}').rstrip(b"=").decode()
tampered = f"{parts[0]}.{fake_payload}.{parts[2]}"
with pytest.raises(InvalidTokenError):
self.service.verify_token(tampered)
# ── verify_access_token ─────────────────────────────────────────────────────
class TestVerifyAccessToken:
def setup_method(self):
self.service = JWTService(JWTConfig(secret_key=TEST_SECRET))
def test_access_token_passes(self):
token = self.service.create_access_token(user_id="u1", role="user")
payload = self.service.verify_access_token(token)
assert payload["sub"] == "u1"
assert payload["type"] == "access"
def test_refresh_token_rejected(self):
token = self.service.create_refresh_token(user_id="u1", session_id="s1")
with pytest.raises(ValueError, match="Token type must be 'access'"):
self.service.verify_access_token(token)
def test_expired_token_raises(self):
expired_payload = {
"sub": "u1",
"type": "access",
"exp": datetime.now(timezone.utc) - timedelta(seconds=10),
}
token = pyjwt.encode(expired_payload, TEST_SECRET, algorithm="HS256")
with pytest.raises(ExpiredSignatureError):
service.verify_access_token(token)
self.service.verify_access_token(token)
# ── verify_refresh_token ────────────────────────────────────────────────────
class TestVerifyRefreshToken:
def setup_method(self):
self.service = JWTService(JWTConfig(secret_key=TEST_SECRET))
def test_refresh_token_passes(self):
token = self.service.create_refresh_token(user_id="u1", session_id="sess_001")
payload = self.service.verify_refresh_token(token)
assert payload["sub"] == "u1"
assert payload["session_id"] == "sess_001"
def test_access_token_rejected(self):
token = self.service.create_access_token(user_id="u1")
with pytest.raises(ValueError, match="Token type must be 'refresh'"):
self.service.verify_refresh_token(token)
def test_has_session_id(self):
token = self.service.create_refresh_token(user_id="u1", session_id="custom_sess")
payload = self.service.verify_refresh_token(token)
assert payload["session_id"] == "custom_sess"
# ── TokenType 常量 ──────────────────────────────────────────────────────────
class TestTokenType:
def test_access_value(self):
assert TokenType.ACCESS == "access"
def test_refresh_value(self):
assert TokenType.REFRESH == "refresh"
def test_different_types(self):
assert TokenType.ACCESS != TokenType.REFRESH
# ── 多算法支持 ──────────────────────────────────────────────────────────────
class TestDifferentAlgorithms:
def test_hs384_works(self):
config = JWTConfig(secret_key=TEST_SECRET * 2, algorithm="HS384")
service = JWTService(config)
token = service.create_access_token(user_id="u1")
payload = service.verify_token(token)
assert payload["sub"] == "u1"
def test_hs512_works(self):
config = JWTConfig(secret_key=TEST_SECRET * 3, algorithm="HS512")
service = JWTService(config)
token = service.create_access_token(user_id="u1")
payload = service.verify_token(token)
assert payload["sub"] == "u1"
def test_algorithm_mismatch_fails(self):
config_hs256 = JWTConfig(secret_key=TEST_SECRET, algorithm="HS256")
config_hs384 = JWTConfig(secret_key=TEST_SECRET, algorithm="HS384")
service_256 = JWTService(config_hs256)
service_384 = JWTService(config_hs384)
token = service_256.create_access_token(user_id="u1")
with pytest.raises(InvalidTokenError):
service_384.verify_token(token)
# ── 边界:空用户ID等 ────────────────────────────────────────────────────────
class TestEdgeCases:
def setup_method(self):
self.service = JWTService(JWTConfig(secret_key=TEST_SECRET))
def test_empty_user_id(self):
token = self.service.create_access_token(user_id="")
payload = self.service.verify_access_token(token)
assert payload["sub"] == ""
def test_long_user_id(self):
long_id = "x" * 1000
token = self.service.create_access_token(user_id=long_id)
payload = self.service.verify_access_token(token)
assert payload["sub"] == long_id
def test_special_chars_in_user_id(self):
uid = "user@#$%^&*()_+-=[]{}|;:',.<>?/`~"
token = self.service.create_access_token(user_id=uid)
payload = self.service.verify_access_token(token)
assert payload["sub"] == uid
def test_unicode_user_id(self):
uid = "用户_测试_123_🎉"
token = self.service.create_access_token(user_id=uid)
payload = self.service.verify_access_token(token)
assert payload["sub"] == uid
def test_many_additional_claims(self):
claims = {f"key_{i}": f"value_{i}" for i in range(50)}
token = self.service.create_access_token(user_id="u1", additional_claims=claims)
payload = self.service.verify_access_token(token)
for i in range(50):
assert payload[f"key_{i}"] == f"value_{i}"
+274 -182
View File
@@ -1,4 +1,4 @@
"""密码哈希与验证单元测试."""
"""密码哈希与验证单元测试 — wave131."""
from __future__ import annotations
@@ -11,240 +11,332 @@ from packages.application.auth.password_hasher import (
password_validator,
)
# ── PasswordHasher 初始化 ───────────────────────────────────────────────────
class TestPasswordHasher:
"""PasswordHasher 测试"""
def test_hash_password_returns_string(self):
"""哈希密码返回非空字符串"""
class TestPasswordHasherInit:
def test_default_rounds(self):
hasher = PasswordHasher()
assert hasher.rounds == 12
def test_custom_rounds(self):
hasher = PasswordHasher(rounds=8)
assert hasher.rounds == 8
def test_min_rounds(self):
hasher = PasswordHasher(rounds=4)
hashed = hasher.hash_password("TestPass1!")
assert hasher.rounds == 4
assert isinstance(hashed, str)
assert len(hashed) > 0
assert hashed.startswith("$2") # bcrypt hash 格式
def test_max_rounds(self):
hasher = PasswordHasher(rounds=31)
assert hasher.rounds == 31
def test_hash_password_different_salts(self):
"""相同密码每次哈希结果不同(加盐)"""
hasher = PasswordHasher(rounds=4)
def test_rounds_below_min_raises(self):
with pytest.raises(ValueError, match="rounds must be between"):
PasswordHasher(rounds=3)
h1 = hasher.hash_password("SamePass1!")
h2 = hasher.hash_password("SamePass1!")
def test_rounds_above_max_raises(self):
with pytest.raises(ValueError, match="rounds must be between"):
PasswordHasher(rounds=32)
assert h1 != h2
def test_rounds_zero_raises(self):
with pytest.raises(ValueError):
PasswordHasher(rounds=0)
def test_verify_correct_password(self):
"""正确密码验证通过"""
hasher = PasswordHasher(rounds=4)
hashed = hasher.hash_password("Correct1!")
def test_rounds_negative_raises(self):
with pytest.raises(ValueError):
PasswordHasher(rounds=-5)
assert hasher.verify_password("Correct1!", hashed) is True
def test_verify_wrong_password(self):
"""错误密码验证失败"""
hasher = PasswordHasher(rounds=4)
hashed = hasher.hash_password("Right123!")
# ── hash_password ──────────────────────────────────────────────────────────
assert hasher.verify_password("Wrong123!", hashed) is False
def test_hash_empty_password_raises(self):
"""空密码哈希抛出 ValueError"""
hasher = PasswordHasher(rounds=4)
# 用 rounds=4 加速测试
def _make_hasher():
return PasswordHasher(rounds=4)
class TestHashPassword:
def test_returns_string(self):
hasher = _make_hasher()
result = hasher.hash_password("testpassword")
assert isinstance(result, str)
assert len(result) > 0
def test_empty_password_raises(self):
hasher = _make_hasher()
with pytest.raises(ValueError, match="Password cannot be empty"):
hasher.hash_password("")
def test_verify_empty_password_returns_false(self):
"""空密码验证返回 False"""
hasher = PasswordHasher(rounds=4)
hashed = hasher.hash_password("TestPass1!")
def test_none_password_raises(self):
hasher = _make_hasher()
with pytest.raises(ValueError):
hasher.hash_password(None) # type: ignore
def test_bcrypt_format(self):
"""bcrypt hash 格式: $2b$rounds$..."""
hasher = _make_hasher()
result = hasher.hash_password("test")
assert result.startswith("$2b$04$")
def test_same_password_different_hash(self):
"""每次哈希应该生成不同的 salt."""
hasher = _make_hasher()
h1 = hasher.hash_password("samepassword")
h2 = hasher.hash_password("samepassword")
assert h1 != h2
def test_hash_contains_salt(self):
"""bcrypt hash 长度固定约 60 字符."""
hasher = _make_hasher()
result = hasher.hash_password("test")
assert len(result) >= 59 # bcrypt 标准长度
def test_long_password(self):
hasher = _make_hasher()
long_pw = "x" * 100
result = hasher.hash_password(long_pw)
assert isinstance(result, str)
assert len(result) > 0
def test_unicode_password(self):
hasher = _make_hasher()
result = hasher.hash_password("密码测试_🎉_123")
assert isinstance(result, str)
assert len(result) > 0
def test_special_chars_password(self):
hasher = _make_hasher()
result = hasher.hash_password("p@ssw0rd!#$%^&*()")
assert isinstance(result, str)
# ── verify_password ─────────────────────────────────────────────────────────
class TestVerifyPassword:
def test_correct_password_returns_true(self):
hasher = _make_hasher()
pw = "MySecureP@ss123"
hashed = hasher.hash_password(pw)
assert hasher.verify_password(pw, hashed) is True
def test_wrong_password_returns_false(self):
hasher = _make_hasher()
hashed = hasher.hash_password("correct_password")
assert hasher.verify_password("wrong_password", hashed) is False
def test_empty_password_returns_false(self):
hasher = _make_hasher()
hashed = hasher.hash_password("test")
assert hasher.verify_password("", hashed) is False
def test_verify_empty_hash_returns_false(self):
"""空哈希验证返回 False"""
hasher = PasswordHasher(rounds=4)
def test_empty_hash_returns_false(self):
hasher = _make_hasher()
assert hasher.verify_password("test", "") is False
assert hasher.verify_password("TestPass1!", "") is False
def test_none_password_returns_false(self):
hasher = _make_hasher()
hashed = hasher.hash_password("test")
assert hasher.verify_password(None, hashed) is False # type: ignore
def test_verify_invalid_hash_format(self):
"""无效格式的哈希验证返回 False(不抛异常)"""
hasher = PasswordHasher(rounds=4)
def test_invalid_hash_format_returns_false(self):
hasher = _make_hasher()
assert hasher.verify_password("test", "not-a-valid-hash") is False
assert hasher.verify_password("TestPass1!", "not_a_valid_hash") is False
def test_case_sensitive(self):
hasher = _make_hasher()
hashed = hasher.hash_password("Password")
assert hasher.verify_password("password", hashed) is False
assert hasher.verify_password("PASSWORD", hashed) is False
assert hasher.verify_password("Password", hashed) is True
def test_needs_rehash_same_rounds(self):
"""相同 rounds 不需要重新哈希"""
hasher = PasswordHasher(rounds=4)
hashed = hasher.hash_password("TestPass1!")
def test_whitespace_matters(self):
hasher = _make_hasher()
hashed = hasher.hash_password("password")
assert hasher.verify_password(" password", hashed) is False
assert hasher.verify_password("password ", hashed) is False
def test_unicode_roundtrip(self):
hasher = _make_hasher()
pw = "密码_测试_🎉"
hashed = hasher.hash_password(pw)
assert hasher.verify_password(pw, hashed) is True
# ── needs_rehash ────────────────────────────────────────────────────────────
class TestNeedsRehash:
def test_same_rounds_no_rehash(self):
hasher = _make_hasher() # rounds=4
hashed = hasher.hash_password("test")
assert hasher.needs_rehash(hashed) is False
def test_needs_rehash_different_rounds(self):
"""不同 rounds 需要重新哈希"""
hasher_low = PasswordHasher(rounds=4)
hasher_high = PasswordHasher(rounds=5)
def test_different_rounds_needs_rehash(self):
hasher4 = PasswordHasher(rounds=4)
hashed = hasher4.hash_password("test")
hasher6 = PasswordHasher(rounds=6)
assert hasher6.needs_rehash(hashed) is True
hashed = hasher_low.hash_password("TestPass1!")
assert hasher_high.needs_rehash(hashed) is True
def test_higher_rounds_needs_rehash(self):
hasher4 = PasswordHasher(rounds=4)
hashed = hasher4.hash_password("test")
hasher10 = PasswordHasher(rounds=10)
assert hasher10.needs_rehash(hashed) is True
def test_needs_rehash_invalid_hash(self):
"""无效哈希格式返回 False(不抛异常)"""
hasher = PasswordHasher(rounds=4)
def test_lower_rounds_also_needs_rehash(self):
hasher10 = PasswordHasher(rounds=10)
# 用 rounds=10 的 hasher 生成的 hash,用 rounds=4 的检查应该也要重算(因为 cost 不同)
hashed = hasher10.hash_password("test")
hasher4 = PasswordHasher(rounds=4)
assert hasher4.needs_rehash(hashed) is True
assert hasher.needs_rehash("invalid_hash") is False
def test_invalid_hash_returns_false(self):
hasher = _make_hasher()
assert hasher.needs_rehash("invalid-hash") is False
def test_rounds_too_low_raises(self):
"""rounds 小于 4 抛出 ValueError"""
with pytest.raises(ValueError, match="rounds must be between 4 and 31"):
PasswordHasher(rounds=3)
def test_rounds_too_high_raises(self):
"""rounds 大于 31 抛出 ValueError"""
with pytest.raises(ValueError, match="rounds must be between 4 and 31"):
PasswordHasher(rounds=32)
def test_rounds_boundary_values(self):
"""rounds 边界值 4 和 31 是合法的"""
hasher_low = PasswordHasher(rounds=4)
hasher_high = PasswordHasher(rounds=31)
assert hasher_low.rounds == 4
assert hasher_high.rounds == 31
def test_hash_and_verify_various_passwords(self):
"""多种密码的哈希-验证往返"""
hasher = PasswordHasher(rounds=4)
passwords = [
"Simple12",
"C0mpl3x!@#",
" spaces ",
"中文密码123",
"a" * 50, # 50字节,在72字节限制内
"12345678",
]
for pwd in passwords:
hashed = hasher.hash_password(pwd)
assert hasher.verify_password(pwd, hashed)
assert not hasher.verify_password(pwd + "x", hashed)
def test_empty_hash_returns_false(self):
hasher = _make_hasher()
assert hasher.needs_rehash("") is False
class TestPasswordValidator:
"""PasswordValidator 测试"""
# ── PasswordValidator 初始化 ────────────────────────────────────────────────
def test_strong_password_passes(self):
"""强密码通过验证"""
class TestPasswordValidatorInit:
def test_default_settings(self):
validator = PasswordValidator()
valid, error = validator.validate("Str0ngP@ss")
assert validator.min_length == 8
assert validator.require_uppercase is True
assert validator.require_lowercase is True
assert validator.require_digit is True
assert validator.require_special is False
assert valid is True
assert error is None
def test_empty_password_fails(self):
"""空密码验证失败"""
validator = PasswordValidator()
valid, error = validator.validate("")
assert valid is False
assert "empty" in error.lower()
def test_too_short_fails(self):
"""密码太短失败"""
validator = PasswordValidator(min_length=8)
valid, error = validator.validate("Sh0rt!")
assert valid is False
assert "at least 8" in error
def test_no_uppercase_fails(self):
"""没有大写字母失败"""
validator = PasswordValidator(require_uppercase=True)
valid, error = validator.validate("lowercase1!")
assert valid is False
assert "uppercase" in error.lower()
def test_no_lowercase_fails(self):
"""没有小写字母失败"""
validator = PasswordValidator(require_lowercase=True)
valid, error = validator.validate("UPPERCASE1!")
assert valid is False
assert "lowercase" in error.lower()
def test_no_digit_fails(self):
"""没有数字失败"""
validator = PasswordValidator(require_digit=True)
valid, error = validator.validate("NoDigitsHere!")
assert valid is False
assert "digit" in error.lower()
def test_no_special_not_required_passes(self):
"""不要求特殊字符时,不含特殊字符也通过"""
validator = PasswordValidator(require_special=False)
valid, error = validator.validate("NoSpecial1")
assert valid is True
def test_no_special_required_fails(self):
"""要求特殊字符时,不含特殊字符失败"""
validator = PasswordValidator(require_special=True)
valid, error = validator.validate("NoSpecial1")
assert valid is False
assert "special" in error.lower()
def test_custom_min_length(self):
"""自定义最小长度"""
def test_custom_settings(self):
validator = PasswordValidator(
min_length=12,
require_uppercase=False,
require_lowercase=False,
require_digit=False,
require_special=True,
)
valid, _ = validator.validate("123456789012") # 12字符
assert valid is True
assert validator.min_length == 12
assert validator.require_uppercase is False
assert validator.require_special is True
valid, _ = validator.validate("12345678901") # 11字符
assert valid is False
def test_all_requirements_disabled(self):
"""所有要求都禁用时,任意非空密码都通过"""
validator = PasswordValidator(
min_length=1,
require_uppercase=False,
require_lowercase=False,
require_digit=False,
require_special=False,
)
valid, error = validator.validate("x")
# ── PasswordValidator.validate ──────────────────────────────────────────────
class TestPasswordValidator:
def setup_method(self):
self.validator = PasswordValidator() # 默认 8 位 + 大小写 + 数字
def test_strong_password_passes(self):
valid, error = self.validator.validate("MyStr0ngP@ss")
assert valid is True
assert error is None
def test_special_characters_recognized(self):
"""各种特殊字符都被识别"""
validator = PasswordValidator(require_special=True, require_uppercase=False, require_lowercase=False)
specials = ["!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "-", "_", "=", "+"]
for ch in specials:
valid, _ = validator.validate(f"abcd1234{ch}")
assert valid is True, f"Special char '{ch}' not recognized"
def test_empty_password_fails(self):
valid, error = self.validator.validate("")
assert valid is False
assert "empty" in error.lower()
def test_too_short_fails(self):
valid, error = self.validator.validate("Abc123") # 6 chars
assert valid is False
assert "at least" in error.lower()
assert "8" in error
def test_exactly_min_length_passes(self):
valid, error = self.validator.validate("Abcd1234") # 8 chars
assert valid is True
def test_no_uppercase_fails(self):
valid, error = self.validator.validate("lowercase123")
assert valid is False
assert "uppercase" in error.lower()
def test_no_lowercase_fails(self):
valid, error = self.validator.validate("UPPERCASE123")
assert valid is False
assert "lowercase" in error.lower()
def test_no_digit_fails(self):
valid, error = self.validator.validate("NoDigitsHere")
assert valid is False
assert "digit" in error.lower()
def test_special_char_not_required_by_default(self):
# 默认不要求特殊字符,没有也通过
valid, error = self.validator.validate("GoodPass123")
assert valid is True
def test_special_char_required_and_missing(self):
validator = PasswordValidator(require_special=True)
valid, error = validator.validate("GoodPass123")
assert valid is False
assert "special character" in error.lower()
def test_special_char_required_and_present(self):
validator = PasswordValidator(require_special=True)
valid, error = validator.validate("GoodPass123!")
assert valid is True
def test_all_special_chars_accepted(self):
validator = PasswordValidator(require_special=True)
specials = "!@#$%^&*()_+-=[]{}|;:,.<>?~"
for c in specials:
pw = f"Pass123{c}"
valid, _ = validator.validate(pw)
assert valid is True, f"Special char '{c}' should be accepted"
def test_minimal_valid_password(self):
"""刚好满足所有要求的最短密码."""
valid, _ = self.validator.validate("Ab1cdefg") # 8 chars: 1 upper, 1 lower, 1 digit
assert valid is True
def test_only_digits_fails(self):
valid, _ = self.validator.validate("12345678")
assert valid is False
def test_only_letters_fails(self):
valid, _ = self.validator.validate("Abcdefgh")
assert valid is False # 没有数字
def test_long_password_passes(self):
valid, _ = self.validator.validate("VeryLongPassword123WithManyCharacters")
assert valid is True
def test_unicode_password_passes(self):
"""Unicode 字符计数正确."""
valid, error = self.validator.validate("密码Abc12345")
assert valid is True
def test_whitespace_password_passes_if_meets_req(self):
"""包含空格的密码(如果满足其他要求)应该通过."""
valid, _ = self.validator.validate("Pass word 123")
assert valid is True
# ── 全局实例 ────────────────────────────────────────────────────────────────
class TestGlobalInstances:
"""全局实例测试"""
def test_global_password_hasher_exists(self):
"""全局 password_hasher 实例存在"""
def test_global_hasher_exists(self):
assert password_hasher is not None
assert isinstance(password_hasher, PasswordHasher)
assert password_hasher.rounds == 12
def test_global_password_validator_exists(self):
"""全局 password_validator 实例存在"""
def test_global_validator_exists(self):
assert password_validator is not None
assert isinstance(password_validator, PasswordValidator)
assert password_validator.min_length == 8
assert password_validator.require_uppercase is True
assert password_validator.require_special is False
def test_global_hasher_works(self):
hashed = password_hasher.hash_password("test_global")
assert password_hasher.verify_password("test_global", hashed) is True
def test_global_validator_works(self):
valid, _ = password_validator.validate("TestPass123")
assert valid is True
+397
View File
@@ -0,0 +1,397 @@
"""Quota 配额系统单测 — 全维度覆盖."""
from __future__ import annotations
import math
import pytest
from packages.domain.quota import (
QuotaCheckResult,
QuotaChecker,
QuotaDimension,
QuotaRegistry,
QuotaTier,
QuotaWarningLevel,
QUOTA_TIERS,
get_warning_level,
quota_checker,
quota_registry,
)
# ── 枚举与常量 ──────────────────────────────────────────────────────────────
class TestQuotaDimension:
def test_storage_gb(self):
assert QuotaDimension.STORAGE_GB == "storage_gb"
def test_videos_per_month(self):
assert QuotaDimension.VIDEOS_PER_MONTH == "videos_per_month"
def test_max_concurrent(self):
assert QuotaDimension.MAX_CONCURRENT == "max_concurrent"
def test_max_templates(self):
assert QuotaDimension.MAX_TEMPLATES == "max_templates"
def test_ai_voice_enabled(self):
assert QuotaDimension.AI_VOICE_ENABLED == "ai_voice_enabled"
def test_all_dimensions_count(self):
# 至少包含内置的几个核心维度
dims = list(QuotaDimension)
assert len(dims) >= 7
def test_from_string(self):
assert QuotaDimension("storage_gb") == QuotaDimension.STORAGE_GB
class TestQuotaWarningLevel:
def test_normal(self):
assert QuotaWarningLevel.NORMAL == "normal"
def test_warning(self):
assert QuotaWarningLevel.WARNING == "warning"
def test_critical(self):
assert QuotaWarningLevel.CRITICAL == "critical"
def test_exceeded(self):
assert QuotaWarningLevel.EXCEEDED == "exceeded"
# ── QuotaTier ──────────────────────────────────────────────────────────────
class TestQuotaTier:
def test_get_limit_defined(self):
tier = QuotaTier(name="test", limits={"storage_gb": 100})
assert tier.get_limit("storage_gb") == 100
def test_get_limit_undefined_returns_zero(self):
tier = QuotaTier(name="test", limits={})
assert tier.get_limit("unknown_dim") == 0
def test_is_unlimited_true(self):
tier = QuotaTier(name="test", limits={"templates": float("inf")})
assert tier.is_unlimited("templates") is True
def test_is_unlimited_false(self):
tier = QuotaTier(name="test", limits={"storage_gb": 100})
assert tier.is_unlimited("storage_gb") is False
def test_is_unlimited_undefined_defaults_true(self):
# 未定义的维度,get 默认为 inf → is_unlimited 返回 True
tier = QuotaTier(name="test", limits={})
assert tier.is_unlimited("unknown") is True
class TestQuotaTiers:
def test_free_tier_exists(self):
assert "free" in QUOTA_TIERS
free = QUOTA_TIERS["free"]
assert free.name == "free"
assert free.get_limit("storage_gb") == 2
assert free.get_limit("videos_per_month") == 5
def test_basic_tier_exists(self):
assert "basic" in QUOTA_TIERS
basic = QUOTA_TIERS["basic"]
assert basic.get_limit("storage_gb") == 20
assert basic.get_limit("videos_per_month") == 30
assert basic.get_limit("ai_voice_enabled") == 1
def test_premium_tier_exists(self):
assert "premium" in QUOTA_TIERS
premium = QUOTA_TIERS["premium"]
assert premium.get_limit("storage_gb") == 100
assert premium.get_limit("videos_per_month") == 100
def test_premium_templates_unlimited(self):
premium = QUOTA_TIERS["premium"]
assert premium.is_unlimited("max_templates") is True
def test_free_ai_voice_disabled(self):
free = QUOTA_TIERS["free"]
assert free.get_limit("ai_voice_enabled") == 0
def test_basic_ai_voice_enabled(self):
basic = QUOTA_TIERS["basic"]
assert basic.get_limit("ai_voice_enabled") == 1
def test_storage_increases_with_tier(self):
free = QUOTA_TIERS["free"].get_limit("storage_gb")
basic = QUOTA_TIERS["basic"].get_limit("storage_gb")
premium = QUOTA_TIERS["premium"].get_limit("storage_gb")
assert free < basic < premium
# ── QuotaCheckResult ───────────────────────────────────────────────────────
class TestQuotaCheckResult:
def test_usage_percent_normal(self):
result = QuotaCheckResult(
allowed=True, dimension="storage_gb",
limit=100, used=50, remaining=50,
warning_level=QuotaWarningLevel.NORMAL,
)
assert result.usage_percent == 50.0
def test_usage_percent_zero_usage(self):
result = QuotaCheckResult(
allowed=True, dimension="storage_gb",
limit=100, used=0, remaining=100,
warning_level=QuotaWarningLevel.NORMAL,
)
assert result.usage_percent == 0.0
def test_usage_percent_exceeded_capped(self):
result = QuotaCheckResult(
allowed=False, dimension="storage_gb",
limit=100, used=150, remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
)
assert result.usage_percent == 100.0 # capped at 100
def test_usage_percent_zero_limit_with_usage(self):
result = QuotaCheckResult(
allowed=False, dimension="storage_gb",
limit=0, used=10, remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
)
assert result.usage_percent == 100.0
def test_usage_percent_zero_limit_no_usage(self):
result = QuotaCheckResult(
allowed=True, dimension="storage_gb",
limit=0, used=0, remaining=0,
warning_level=QuotaWarningLevel.NORMAL,
)
assert result.usage_percent == 0.0
def test_usage_percent_unlimited(self):
result = QuotaCheckResult(
allowed=True, dimension="max_templates",
limit=float("inf"), used=100, remaining=float("inf"),
warning_level=QuotaWarningLevel.NORMAL,
)
assert result.usage_percent == 0.0
# ── QuotaRegistry ──────────────────────────────────────────────────────────
class TestQuotaRegistry:
def test_init_has_builtin_dimensions(self):
reg = QuotaRegistry()
dims = reg.list_dimensions()
assert "storage_gb" in dims
assert "videos_per_month" in dims
assert "max_concurrent" in dims
def test_init_has_three_tiers(self):
reg = QuotaRegistry()
tiers = reg.list_tiers()
assert "free" in tiers
assert "basic" in tiers
assert "premium" in tiers
def test_get_limit_free_storage(self):
reg = QuotaRegistry()
assert reg.get_limit("free", "storage_gb") == 2
def test_get_limit_unknown_plan_returns_zero(self):
reg = QuotaRegistry()
assert reg.get_limit("unknown_plan", "storage_gb") == 0
def test_get_tier_free(self):
reg = QuotaRegistry()
tier = reg.get_tier("free")
assert tier is not None
assert tier.name == "free"
def test_get_tier_unknown_returns_none(self):
reg = QuotaRegistry()
assert reg.get_tier("nonexistent") is None
def test_register_new_dimension(self):
reg = QuotaRegistry()
reg.register_dimension("custom_dim", "自定义维度", default_limits={"free": 5, "basic": 20})
assert "custom_dim" in reg.list_dimensions()
assert reg.get_limit("free", "custom_dim") == 5
assert reg.get_limit("basic", "custom_dim") == 20
def test_register_dimension_idempotent(self):
reg = QuotaRegistry()
reg.register_dimension("custom_dim", "v1", default_limits={"free": 5})
reg.register_dimension("custom_dim", "v2", default_limits={"free": 99})
# 幂等:第二次注册不改变
assert reg.list_dimensions()["custom_dim"] == "v1"
assert reg.get_limit("free", "custom_dim") == 5
def test_register_dimension_no_defaults(self):
reg = QuotaRegistry()
reg.register_dimension("new_dim", "新维度")
# 默认所有套餐都是 0
assert reg.get_limit("free", "new_dim") == 0
assert reg.get_limit("basic", "new_dim") == 0
assert reg.get_limit("premium", "new_dim") == 0
def test_list_dimensions_returns_copy(self):
reg = QuotaRegistry()
dims = reg.list_dimensions()
dims["fake"] = "test"
# 修改返回值不影响内部
assert "fake" not in reg.list_dimensions()
# ── QuotaChecker ───────────────────────────────────────────────────────────
class TestQuotaChecker:
def test_check_within_limit(self):
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 1.0)
assert result.allowed is True
assert result.limit == 2
assert result.used == 1.0
assert result.remaining == 1.0
assert result.dimension == "storage_gb"
def test_check_exceeds_limit(self):
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 3.0)
assert result.allowed is False
assert result.remaining == 0
def test_check_exactly_at_limit(self):
# used == limit 时 allowed 为 False(必须严格小于)
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 2.0)
assert result.allowed is False
def test_check_unlimited(self):
checker = QuotaChecker()
result = checker.check("premium", "max_templates", 1000.0)
assert result.allowed is True
assert math.isinf(result.remaining)
assert result.warning_level == QuotaWarningLevel.NORMAL
def test_check_unknown_plan(self):
checker = QuotaChecker()
result = checker.check("unknown", "storage_gb", 1.0)
assert result.allowed is False
assert result.limit == 0
def test_check_multiple(self):
checker = QuotaChecker()
results = checker.check_multiple(
"free",
{"storage_gb": 1.0, "videos_per_month": 2},
)
assert len(results) == 2
assert results[0].dimension == "storage_gb"
assert results[1].dimension == "videos_per_month"
assert all(r.allowed for r in results)
def test_warning_level_normal(self):
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 1.0) # 50%
assert result.warning_level == QuotaWarningLevel.NORMAL
def test_warning_level_warning(self):
checker = QuotaChecker()
# 80% < 95% → warning
result = checker.check("free", "storage_gb", 1.7) # 85%
assert result.warning_level == QuotaWarningLevel.WARNING
def test_warning_level_critical(self):
checker = QuotaChecker()
# 95% <= < 100% → critical
result = checker.check("free", "storage_gb", 1.95) # 97.5%
assert result.warning_level == QuotaWarningLevel.CRITICAL
def test_warning_level_exceeded(self):
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 2.5) # 125%
assert result.warning_level == QuotaWarningLevel.EXCEEDED
def test_warning_level_zero_limit_with_usage(self):
checker = QuotaChecker()
result = checker.check("free", "ai_voice_enabled", 1) # limit=0, used=1
assert result.warning_level == QuotaWarningLevel.EXCEEDED
def test_warning_level_zero_limit_no_usage(self):
checker = QuotaChecker()
# limit=0, used=0 → 特殊处理为 normal
# 但 allowed 是 False0 < 0 不成立)
result = checker.check("free", "ai_voice_enabled", 0)
# 0 < 0 是 False → not allowed
assert result.allowed is False
def test_checker_uses_provided_registry(self):
reg = QuotaRegistry()
reg.register_dimension("custom", "自定义", default_limits={"free": 42})
checker = QuotaChecker(reg)
result = checker.check("free", "custom", 10)
assert result.limit == 42
assert result.allowed is True
# ── get_warning_level 便捷函数 ────────────────────────────────────────────
class TestGetWarningLevel:
def test_normal_low_usage(self):
assert get_warning_level(50, 100) == QuotaWarningLevel.NORMAL
def test_normal_zero_usage(self):
assert get_warning_level(0, 100) == QuotaWarningLevel.NORMAL
def test_warning_threshold(self):
assert get_warning_level(80, 100) == QuotaWarningLevel.WARNING
def test_warning_between_80_and_95(self):
assert get_warning_level(90, 100) == QuotaWarningLevel.WARNING
def test_critical_threshold(self):
assert get_warning_level(95, 100) == QuotaWarningLevel.CRITICAL
def test_critical_between_95_and_100(self):
assert get_warning_level(99, 100) == QuotaWarningLevel.CRITICAL
def test_exceeded_at_100(self):
assert get_warning_level(100, 100) == QuotaWarningLevel.EXCEEDED
def test_exceeded_over_100(self):
assert get_warning_level(150, 100) == QuotaWarningLevel.EXCEEDED
def test_unlimited_always_normal(self):
assert get_warning_level(9999, float("inf")) == QuotaWarningLevel.NORMAL
def test_zero_limit_with_usage_exceeded(self):
assert get_warning_level(1, 0) == QuotaWarningLevel.EXCEEDED
def test_zero_limit_no_usage_normal(self):
assert get_warning_level(0, 0) == QuotaWarningLevel.NORMAL
# ── 全局单例 ───────────────────────────────────────────────────────────────
class TestGlobalSingletons:
def test_quota_registry_exists(self):
assert quota_registry is not None
assert isinstance(quota_registry, QuotaRegistry)
def test_quota_checker_exists(self):
assert quota_checker is not None
assert isinstance(quota_checker, QuotaChecker)
def test_global_checker_works(self):
result = quota_checker.check("free", "storage_gb", 1.0)
assert result.allowed is True
assert result.limit == 2
+377
View File
@@ -0,0 +1,377 @@
"""小领域模块组合单测 — template_clip_config / tag / exceptions / editing_mode."""
from __future__ import annotations
import pytest
from packages.domain.editing_mode import EditingMode
from packages.domain.exceptions import (
DomainError,
NotFoundError,
QuotaExceededError,
ValidationError,
)
from packages.domain.tag import Tag
from packages.domain.template_clip_config import (
ClipType,
TemplateClipConfig,
TransitionEffect,
)
# ── EditingMode ────────────────────────────────────────────────────────────
class TestEditingMode:
def test_one_take(self):
assert EditingMode.ONE_TAKE == "one_take"
def test_pip(self):
assert EditingMode.PIP == "pip"
def test_voice_over(self):
assert EditingMode.VOICE_OVER == "voice_over"
def test_voice_pip(self):
assert EditingMode.VOICE_PIP == "voice_pip"
def test_from_string(self):
assert EditingMode("one_take") == EditingMode.ONE_TAKE
assert EditingMode("pip") == EditingMode.PIP
def test_invalid_raises(self):
with pytest.raises(ValueError):
EditingMode("invalid_mode")
def test_is_str(self):
# StrEnum 实例本身就是 str
assert isinstance(EditingMode.ONE_TAKE, str)
assert EditingMode.ONE_TAKE + "_suffix" == "one_take_suffix"
# ── 异常类 ─────────────────────────────────────────────────────────────────
class TestExceptions:
def test_domain_error_is_exception(self):
err = DomainError("test")
assert isinstance(err, Exception)
assert str(err) == "test"
def test_not_found_inherits_domain(self):
err = NotFoundError("not found")
assert isinstance(err, DomainError)
assert str(err) == "not found"
def test_validation_error_inherits_domain(self):
err = ValidationError("bad input")
assert isinstance(err, DomainError)
assert str(err) == "bad input"
def test_quota_exceeded_error_attributes(self):
err = QuotaExceededError(dimension="storage_gb", limit=100, used=150)
assert err.dimension == "storage_gb"
assert err.limit == 100
assert err.used == 150
assert "storage_gb" in str(err)
assert "150" in str(err)
assert "100" in str(err)
def test_quota_exceeded_is_domain_error(self):
err = QuotaExceededError("x", 10, 20)
assert isinstance(err, DomainError)
def test_catch_domain_error_catches_all(self):
"""所有领域异常都能被 DomainError catch."""
for cls in [NotFoundError, ValidationError, QuotaExceededError]:
try:
if cls == QuotaExceededError:
raise cls("dim", 10, 20)
raise cls("msg")
except DomainError:
pass
else:
pytest.fail(f"{cls.__name__} 未被 DomainError 捕获")
# ── Tag ────────────────────────────────────────────────────────────────────
class TestTag:
def test_create_basic(self):
tag = Tag.create(user_id="user1", name="旅行")
assert tag.id
assert tag.user_id == "user1"
assert tag.name == "旅行"
def test_create_name_stripped(self):
tag = Tag.create(user_id="u1", name=" 美食 ")
assert tag.name == "美食"
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="标签名称不能为空"):
Tag.create(user_id="u1", name="")
def test_create_whitespace_name_raises(self):
with pytest.raises(ValueError, match="标签名称不能为空"):
Tag.create(user_id="u1", name=" ")
def test_create_has_created_at(self):
tag = Tag.create(user_id="u1", name="t1")
assert tag.created_at is not None
def test_unique_ids(self):
t1 = Tag.create(user_id="u1", name="t1")
t2 = Tag.create(user_id="u1", name="t2")
assert t1.id != t2.id
# ── ClipType 枚举 ─────────────────────────────────────────────────────────
class TestClipType:
def test_all_types(self):
assert ClipType.INTRO == "intro"
assert ClipType.MAIN == "main"
assert ClipType.TRANSITION == "transition"
assert ClipType.OUTRO == "outro"
assert ClipType.TITLE == "title"
assert ClipType.SUBTITLE == "subtitle"
def test_from_string(self):
assert ClipType("main") == ClipType.MAIN
assert ClipType("intro") == ClipType.INTRO
def test_invalid_raises(self):
with pytest.raises(ValueError):
ClipType("invalid")
# ── TransitionEffect 枚举 ─────────────────────────────────────────────────
class TestTransitionEffect:
def test_all_effects(self):
assert TransitionEffect.CUT == "cut"
assert TransitionEffect.FADE == "fade"
assert TransitionEffect.SLIDE_LEFT == "slide_left"
assert TransitionEffect.SLIDE_RIGHT == "slide_right"
assert TransitionEffect.DISSOLVE == "dissolve"
assert TransitionEffect.WIPE == "wipe"
def test_from_string(self):
assert TransitionEffect("fade") == TransitionEffect.FADE
assert TransitionEffect("cut") == TransitionEffect.CUT
def test_invalid_raises(self):
with pytest.raises(ValueError):
TransitionEffect("invalid_effect")
# ── TemplateClipConfig.create ──────────────────────────────────────────────
class TestTemplateClipConfigCreate:
def test_basic_creation(self):
clip = TemplateClipConfig.create(
template_id="tmpl_1",
clip_type=ClipType.MAIN,
order=1,
)
assert clip.id
assert clip.template_id == "tmpl_1"
assert clip.clip_type == ClipType.MAIN
assert clip.order == 1
assert clip.min_duration == 0.0
assert clip.max_duration == 0.0
assert clip.text_template == ""
assert clip.material_requirements == {}
assert clip.transition_effect == TransitionEffect.CUT
assert clip.config == {}
def test_clip_type_string(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type="intro", order=0,
)
assert clip.clip_type == ClipType.INTRO
def test_template_id_stripped(self):
clip = TemplateClipConfig.create(
template_id=" tmpl_1 ", clip_type=ClipType.MAIN, order=1,
)
assert clip.template_id == "tmpl_1"
def test_empty_template_id_raises(self):
with pytest.raises(ValueError, match="template_id 不能为空"):
TemplateClipConfig.create(template_id="", clip_type=ClipType.MAIN, order=1)
def test_whitespace_template_id_raises(self):
with pytest.raises(ValueError, match="template_id 不能为空"):
TemplateClipConfig.create(template_id=" ", clip_type=ClipType.MAIN, order=1)
def test_negative_min_duration_raises(self):
with pytest.raises(ValueError, match="min_duration 不能为负数"):
TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=-1.0,
)
def test_negative_max_duration_raises(self):
with pytest.raises(ValueError, match="max_duration 不能为负数"):
TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
max_duration=-1.0,
)
def test_min_greater_than_max_raises(self):
with pytest.raises(ValueError, match="min_duration 不能大于 max_duration"):
TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=10.0, max_duration=5.0,
)
def test_zero_min_and_max_ok(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=0, max_duration=0,
)
assert clip.min_duration == 0
assert clip.max_duration == 0
def test_min_zero_max_positive_ok(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=0, max_duration=10.0,
)
assert clip.max_duration == 10.0
def test_min_equals_max_ok(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=5.0, max_duration=5.0,
)
assert clip.min_duration == 5.0
assert clip.max_duration == 5.0
def test_with_text_template(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
text_template=" 欢迎收看 {channel} ",
)
# text_template 会 strip
assert clip.text_template == "欢迎收看 {channel}"
def test_with_material_requirements(self):
reqs = {"material_type": "video", "min_duration": 3}
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
material_requirements=reqs,
)
assert clip.material_requirements == reqs
def test_material_requirements_none_defaults_empty(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
material_requirements=None,
)
assert clip.material_requirements == {}
def test_transition_effect_string(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
transition_effect="fade",
)
assert clip.transition_effect == TransitionEffect.FADE
def test_with_config(self):
config = {"speed": 1.5, "filter": "vibrance"}
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
config=config,
)
assert clip.config == config
def test_config_none_defaults_empty(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
config=None,
)
assert clip.config == {}
def test_created_at_and_updated_at(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
)
assert clip.created_at is not None
assert clip.updated_at is not None
def test_unique_ids(self):
c1 = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=1)
c2 = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=2)
assert c1.id != c2.id
# ── TemplateClipConfig 属性 ────────────────────────────────────────────────
class TestTemplateClipConfigProperties:
def test_has_duration_range_false_both_zero(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
)
assert clip.has_duration_range is False
def test_has_duration_range_true_min_only(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=2.0,
)
assert clip.has_duration_range is True
def test_has_duration_range_true_max_only(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
max_duration=10.0,
)
assert clip.has_duration_range is True
def test_has_duration_range_true_both_set(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=2.0, max_duration=10.0,
)
assert clip.has_duration_range is True
def test_default_duration_zero(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
)
assert clip.default_duration == 0.0
def test_default_duration_min_only(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=3.0,
)
assert clip.default_duration == 3.0
def test_default_duration_max_only(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
max_duration=10.0,
)
assert clip.default_duration == 10.0
def test_default_duration_both_midpoint(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=2.0, max_duration=8.0,
)
assert clip.default_duration == 5.0
def test_default_duration_min_equals_max(self):
clip = TemplateClipConfig.create(
template_id="t1", clip_type=ClipType.MAIN, order=1,
min_duration=5.0, max_duration=5.0,
)
assert clip.default_duration == 5.0
@@ -0,0 +1,273 @@
"""EditTemplateVersion + 数据类 domain 模块单测."""
from __future__ import annotations
import pytest
from packages.domain.recipe import Recipe, RecipeItem
from packages.domain.template import Template, TemplateCategory, TemplateSegment
from packages.domain.template_version import EditTemplateVersion
from packages.domain.title_library import TitleLibraryItem
from packages.domain.voice_library import VoiceLibraryItem
# ── EditTemplateVersion ────────────────────────────────────────────────────
class TestEditTemplateVersion:
def test_create_basic(self):
v = EditTemplateVersion.create(template_id="t1", version=1)
assert v.id
assert v.template_id == "t1"
assert v.version == 1
assert v.name == ""
assert v.editing_mode == "one_take"
assert v.config == {}
assert v.clip_configs == []
assert v.change_note == ""
assert v.published_by == ""
def test_create_with_name(self):
v = EditTemplateVersion.create(template_id="t1", version=1, name="v1.0 初版")
assert v.name == "v1.0 初版"
def test_create_with_editing_mode(self):
v = EditTemplateVersion.create(template_id="t1", version=1, editing_mode="pip")
assert v.editing_mode == "pip"
def test_create_with_config(self):
config = {"bgm": {"volume": 0.5}}
v = EditTemplateVersion.create(template_id="t1", version=1, config=config)
assert v.config == config
def test_create_config_none_defaults_empty(self):
v = EditTemplateVersion.create(template_id="t1", version=1, config=None)
assert v.config == {}
def test_create_with_clip_configs(self):
clips = [{"type": "intro"}, {"type": "main"}]
v = EditTemplateVersion.create(template_id="t1", version=1, clip_configs=clips)
assert v.clip_configs == clips
def test_create_clip_configs_none_defaults_empty(self):
v = EditTemplateVersion.create(template_id="t1", version=1, clip_configs=None)
assert v.clip_configs == []
def test_create_with_change_note(self):
v = EditTemplateVersion.create(
template_id="t1", version=2,
change_note="修复时长计算问题",
)
assert v.change_note == "修复时长计算问题"
def test_create_with_published_by(self):
v = EditTemplateVersion.create(template_id="t1", version=1, published_by="user1")
assert v.published_by == "user1"
def test_created_at_auto_set(self):
v = EditTemplateVersion.create(template_id="t1", version=1)
assert v.created_at is not None
def test_unique_ids(self):
v1 = EditTemplateVersion.create(template_id="t1", version=1)
v2 = EditTemplateVersion.create(template_id="t1", version=2)
assert v1.id != v2.id
def test_version_integer(self):
v = EditTemplateVersion.create(template_id="t1", version=5)
assert v.version == 5
# ── Template / TemplateSegment / TemplateCategory ─────────────────────────
class TestTemplate:
def test_template_defaults(self):
t = Template(id="t1", user_id="u1", name="我的模板", mode="pip")
assert t.id == "t1"
assert t.user_id == "u1"
assert t.name == "我的模板"
assert t.mode == "pip"
assert t.category == ""
assert t.tags == []
assert t.title_config == {}
assert t.subtitle_config == {}
assert t.bgm_config == {}
assert t.estimated_duration == 0.0
assert t.segments == []
assert t.is_active is True
assert t.created_at is not None
assert t.updated_at is not None
def test_template_with_segments(self):
seg = TemplateSegment(
id="s1", template_id="t1", segment_order=0,
duration_min=2.0, duration_max=5.0,
)
t = Template(id="t1", user_id="u1", name="T1", mode="pip", segments=[seg])
assert len(t.segments) == 1
assert t.segments[0].id == "s1"
def test_template_with_tags(self):
t = Template(id="t1", user_id="u1", name="T1", mode="pip", tags=["旅行", "美食"])
assert t.tags == ["旅行", "美食"]
def test_template_segment_defaults(self):
seg = TemplateSegment(
id="s1", template_id="t1", segment_order=0,
duration_min=1.0, duration_max=3.0,
)
assert seg.material_type is None
assert seg.created_at is not None
def test_template_segment_with_material_type(self):
seg = TemplateSegment(
id="s1", template_id="t1", segment_order=0,
duration_min=1.0, duration_max=3.0,
material_type="人物",
)
assert seg.material_type == "人物"
def test_template_category(self):
cat = TemplateCategory(id="c1", user_id="u1", name="旅行vlog")
assert cat.id == "c1"
assert cat.user_id == "u1"
assert cat.name == "旅行vlog"
assert cat.created_at is not None
# ── Recipe ─────────────────────────────────────────────────────────────────
class TestRecipe:
def test_recipe_defaults(self):
r = Recipe(id="r1", user_id="u1", name="一键旅行")
assert r.id == "r1"
assert r.user_id == "u1"
assert r.name == "一键旅行"
assert r.description == ""
assert r.template_id == ""
assert r.generation_params == {}
assert r.items == []
assert r.is_active is True
assert r.metadata_ == {}
assert r.created_at is not None
assert r.updated_at is not None
def test_recipe_with_items(self):
item = RecipeItem(
id="i1", recipe_id="r1",
item_type="asset", item_id="asset_1", position=0,
)
r = Recipe(id="r1", user_id="u1", name="R1", items=[item])
assert len(r.items) == 1
assert r.items[0].item_type == "asset"
def test_recipe_item_defaults(self):
item = RecipeItem(id="i1", recipe_id="r1", item_type="title", item_id="t1")
assert item.position == 0
assert item.metadata_ == {}
def test_recipe_item_with_metadata(self):
item = RecipeItem(
id="i1", recipe_id="r1", item_type="voice", item_id="v1",
position=2, metadata_={"speed": 1.2},
)
assert item.position == 2
assert item.metadata_ == {"speed": 1.2}
# ── TitleLibraryItem ───────────────────────────────────────────────────────
class TestTitleLibraryItem:
def test_defaults(self):
item = TitleLibraryItem(
id="t1", user_id="u1", name="标题1", text="欢迎收看",
)
assert item.id == "t1"
assert item.user_id == "u1"
assert item.name == "标题1"
assert item.text == "欢迎收看"
assert item.category == "default"
assert item.description == ""
assert item.tags == []
assert item.usage_count == 0
assert item.is_active is True
assert item.metadata_ == {}
assert item.created_at is not None
assert item.updated_at is not None
def test_with_category(self):
item = TitleLibraryItem(
id="t1", user_id="u1", name="t1", text="txt",
category="opening",
)
assert item.category == "opening"
def test_with_tags(self):
item = TitleLibraryItem(
id="t1", user_id="u1", name="t1", text="txt",
tags=["搞笑", "热门"],
)
assert item.tags == ["搞笑", "热门"]
def test_with_usage_count(self):
item = TitleLibraryItem(
id="t1", user_id="u1", name="t1", text="txt",
usage_count=42,
)
assert item.usage_count == 42
# ── VoiceLibraryItem ───────────────────────────────────────────────────────
class TestVoiceLibraryItem:
def test_defaults(self):
item = VoiceLibraryItem(
id="v1", user_id="u1", name="温柔女声",
)
assert item.id == "v1"
assert item.user_id == "u1"
assert item.name == "温柔女声"
assert item.text == ""
assert item.voice_provider == ""
assert item.voice_id == ""
assert item.voice_name == ""
assert item.audio_url == ""
assert item.duration == 0
assert item.file_size == 0
assert item.status == "completed"
assert item.project_id is None
assert item.tags == []
assert item.metadata_ == {}
assert item.created_at is not None
assert item.updated_at is not None
def test_with_voice_info(self):
item = VoiceLibraryItem(
id="v1", user_id="u1", name="v1",
voice_provider="cosyvoice", voice_id="voice_001", voice_name="小溪",
audio_url="https://example.com/audio.mp3",
duration=15.5, file_size=320000,
)
assert item.voice_provider == "cosyvoice"
assert item.voice_id == "voice_001"
assert item.voice_name == "小溪"
assert item.duration == 15.5
assert item.file_size == 320000
def test_with_project_id(self):
item = VoiceLibraryItem(
id="v1", user_id="u1", name="v1",
project_id="proj_123",
)
assert item.project_id == "proj_123"
def test_with_tags(self):
item = VoiceLibraryItem(
id="v1", user_id="u1", name="v1",
tags=["温柔", "女声"],
)
assert item.tags == ["温柔", "女声"]
+689
View File
@@ -0,0 +1,689 @@
"""TTS 相关领域模块单测 — text_splitter + tts_config + tts_job."""
from __future__ import annotations
import pytest
from packages.application.tts_job.text_splitter import split_text
from packages.domain.tts_config import TtsConfig
from packages.domain.tts_job import TTSJob, TTSJobStatus, TERMINAL_STATUSES
# ═══════════════════════════════════════════════════════════════════════════════
# text_splitter 文本分段
# ═══════════════════════════════════════════════════════════════════════════════
class TestSplitTextBasic:
"""基础分段功能."""
def test_empty_text_returns_empty_list(self):
assert split_text("", max_chars=500) == []
def test_whitespace_only_returns_empty_list(self):
assert split_text(" \n\n ", max_chars=500) == []
def test_short_text_returns_single_segment(self):
text = "你好世界"
result = split_text(text, max_chars=500)
assert len(result) == 1
assert result[0] == text
def test_text_equal_to_max_chars_single_segment(self):
text = "a" * 500
result = split_text(text, max_chars=500)
assert len(result) == 1
assert len(result[0]) == 500
def test_none_max_chars_uses_default(self):
"""默认 max_chars=500."""
text = "你好"
result = split_text(text) # 使用默认值
assert len(result) == 1
class TestSplitTextSentenceBoundary:
"""按句子边界分段."""
def test_splits_at_period(self):
text = "第一句。第二句。第三句。"
result = split_text(text, max_chars=10)
# 每句都比较短,会在句子边界处合并
assert len(result) >= 2
assert "".join(result) == text.strip()
def test_splits_at_question_mark(self):
text = "你是谁?我是AI。你好吗?很好。"
result = split_text(text, max_chars=15)
assert len(result) >= 2
assert "".join(result) == text.strip()
def test_splits_at_exclamation_mark(self):
text = "太棒了!真厉害!好厉害!"
result = split_text(text, max_chars=10)
assert len(result) >= 2
assert "".join(result) == text.strip()
def test_splits_at_newline(self):
text = "第一段\n第二段\n第三段"
result = split_text(text, max_chars=10)
assert len(result) >= 2
def test_splits_at_semicolon(self):
text = "第一部分;第二部分;第三部分。"
result = split_text(text, max_chars=15)
assert len(result) >= 1
assert "".join(result) == text.strip()
class TestSplitTextLongSentence:
"""长句子(超过 max_chars)强制切段."""
def test_very_long_sentence_hard_cut(self):
"""单个超长句子会被强制切段."""
text = "" * 600 # 没有标点
result = split_text(text, max_chars=500)
assert len(result) >= 2
total = sum(len(seg) for seg in result)
assert total == len(text)
def test_each_segment_leq_max_chars(self):
"""每个分段都不超过 max_chars."""
text = "测试句子。" * 100
result = split_text(text, max_chars=100)
for seg in result:
assert len(seg) <= 100
def test_no_empty_segments(self):
"""不产生空分段."""
text = "测试。" * 50
result = split_text(text, max_chars=50)
for seg in result:
assert len(seg) > 0
class TestSplitTextMergeShortSegments:
"""合并过短的分段."""
def test_short_segments_get_merged(self):
"""< 50 字符的段会被合并(如果不超限)."""
# 多个短句子,应该会被合并
text = "你好。我是。他是。她是。它是。"
result = split_text(text, max_chars=50)
# 每段6字符左右,应该被合并成一段
assert len(result) < 5
def test_last_short_segment_merged_to_previous(self):
"""最后一段如果很短,会合并到前一段."""
text = "a" * 48 + "" + "b" * 48 + "" + "cc"
result = split_text(text, max_chars=100)
# 最后的 "cc" 很短,应该被合并
assert result[-1] != "cc"
class TestSplitTextEdgeCases:
"""边界情况."""
def test_single_character(self):
result = split_text("", max_chars=500)
assert result == [""]
def test_only_punctuation(self):
text = "。。。"
result = split_text(text, max_chars=500)
assert len(result) == 1
def test_mixed_chinese_english(self):
text = "Hello世界。Hello世界。" * 20
result = split_text(text, max_chars=50)
assert len(result) >= 2
assert "".join(result) == text.strip()
def test_max_chars_one(self):
"""极端情况:max_chars=1."""
text = "abc"
result = split_text(text, max_chars=1)
assert len(result) == 3
assert result == ["a", "b", "c"]
# ═══════════════════════════════════════════════════════════════════════════════
# TtsConfig 配置解析 + 边界钳制
# ═══════════════════════════════════════════════════════════════════════════════
class TestTtsConfigDefaults:
"""默认值."""
def test_default_config_disabled(self):
config = TtsConfig()
assert config.enabled is False
assert config.voice_id == ""
assert config.speed == 1.0
assert config.pitch == 0.0
assert config.volume == 0.8
assert config.text == ""
assert config.align_mode == "full"
assert config.overlap_mode == "replace"
def test_parse_none_returns_default(self):
config = TtsConfig.parse(None)
assert config.enabled is False
def test_parse_empty_dict_returns_default(self):
config = TtsConfig.parse({})
assert config.enabled is False
def test_parse_non_dict_returns_default(self):
config = TtsConfig.parse("not a dict")
assert config.enabled is False
class TestTtsConfigParseEnabled:
"""enabled 字段解析."""
def test_parse_enabled_true(self):
config = TtsConfig.parse({"enabled": True})
assert config.enabled is True
def test_parse_enabled_false(self):
config = TtsConfig.parse({"enabled": False})
assert config.enabled is False
def test_parse_enabled_invalid_type(self):
"""enabled 不是 bool 时回退到 False."""
config = TtsConfig.parse({"enabled": "true"})
assert config.enabled is False
def test_disabled_ignores_other_fields(self):
"""enabled=False 时其他字段都用默认值."""
config = TtsConfig.parse(
{
"enabled": False,
"voice_id": "test",
"speed": 2.0,
}
)
assert config.enabled is False
assert config.voice_id == ""
assert config.speed == 1.0
class TestTtsConfigParseFields:
"""各字段解析."""
def test_parse_voice_id(self):
config = TtsConfig.parse({"enabled": True, "voice_id": "voice_001"})
assert config.voice_id == "voice_001"
def test_parse_voice_id_invalid_type(self):
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
assert config.voice_id == ""
def test_parse_speed(self):
config = TtsConfig.parse({"enabled": True, "speed": 1.5})
assert config.speed == 1.5
def test_parse_speed_int(self):
config = TtsConfig.parse({"enabled": True, "speed": 2})
assert config.speed == 2.0
def test_parse_speed_invalid_type(self):
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
assert config.speed == 1.0
def test_parse_pitch(self):
config = TtsConfig.parse({"enabled": True, "pitch": 5})
assert config.pitch == 5.0
def test_parse_pitch_invalid_type(self):
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
assert config.pitch == 0.0
def test_parse_volume(self):
config = TtsConfig.parse({"enabled": True, "volume": 0.5})
assert config.volume == 0.5
def test_parse_volume_invalid_type(self):
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
assert config.volume == 0.8
def test_parse_text(self):
config = TtsConfig.parse({"enabled": True, "text": "你好世界"})
assert config.text == "你好世界"
def test_parse_text_invalid_type(self):
config = TtsConfig.parse({"enabled": True, "text": 12345})
assert config.text == ""
def test_parse_align_mode_valid(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"})
assert config.align_mode == "subtitle"
def test_parse_align_mode_invalid(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "invalid"})
assert config.align_mode == "full"
def test_parse_overlap_mode_mix(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"})
assert config.overlap_mode == "mix"
def test_parse_overlap_mode_invalid(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "invalid"})
assert config.overlap_mode == "replace"
class TestTtsConfigClamp:
"""边界钳制."""
def test_speed_below_minimum_clamped(self):
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
assert config.speed == 0.5
def test_speed_above_maximum_clamped(self):
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
assert config.speed == 2.0
def test_speed_at_minimum_ok(self):
config = TtsConfig.parse({"enabled": True, "speed": 0.5})
assert config.speed == 0.5
def test_speed_at_maximum_ok(self):
config = TtsConfig.parse({"enabled": True, "speed": 2.0})
assert config.speed == 2.0
def test_pitch_below_minimum_clamped(self):
config = TtsConfig.parse({"enabled": True, "pitch": -20})
assert config.pitch == -12
def test_pitch_above_maximum_clamped(self):
config = TtsConfig.parse({"enabled": True, "pitch": 20})
assert config.pitch == 12
def test_pitch_at_minimum_ok(self):
config = TtsConfig.parse({"enabled": True, "pitch": -12})
assert config.pitch == -12
def test_pitch_at_maximum_ok(self):
config = TtsConfig.parse({"enabled": True, "pitch": 12})
assert config.pitch == 12
def test_volume_below_minimum_clamped(self):
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
assert config.volume == 0.0
def test_volume_above_maximum_clamped(self):
config = TtsConfig.parse({"enabled": True, "volume": 2.0})
assert config.volume == 1.0
def test_volume_at_minimum_ok(self):
config = TtsConfig.parse({"enabled": True, "volume": 0.0})
assert config.volume == 0.0
def test_volume_at_maximum_ok(self):
config = TtsConfig.parse({"enabled": True, "volume": 1.0})
assert config.volume == 1.0
# ═══════════════════════════════════════════════════════════════════════════════
# TTSJob 领域模型 — 状态机
# ═══════════════════════════════════════════════════════════════════════════════
class TestTTSJobCreate:
"""创建任务."""
def test_create_basic(self):
job = TTSJob.create(user_id="user1", input_text="你好")
assert job.id is not None
assert len(job.id) > 0
assert job.user_id == "user1"
assert job.input_text == "你好"
assert job.status == TTSJobStatus.PENDING
assert job.retry_count == 0
assert job.max_retries == 3
assert job.started_at is None
assert job.completed_at is None
def test_create_with_voice_id(self):
job = TTSJob.create(user_id="user1", input_text="你好", voice_id="voice_001")
assert job.voice_id == "voice_001"
def test_create_with_project_id(self):
job = TTSJob.create(user_id="user1", input_text="你好", project_id="proj_001")
assert job.project_id == "proj_001"
def test_create_with_voice_clone_profile_id(self):
job = TTSJob.create(
user_id="user1",
input_text="你好",
voice_clone_profile_id="clone_001",
)
assert job.voice_clone_profile_id == "clone_001"
def test_create_with_custom_max_retries(self):
job = TTSJob.create(user_id="user1", input_text="你好", max_retries=5)
assert job.max_retries == 5
def test_create_with_format(self):
job = TTSJob.create(user_id="user1", input_text="你好", format="wav")
assert job.format == "wav"
def test_create_with_sample_rate(self):
job = TTSJob.create(user_id="user1", input_text="你好", sample_rate=44100)
assert job.sample_rate == 44100
class TestTTSJobStatusProperties:
"""状态查询属性."""
def test_pending_not_terminal(self):
job = TTSJob.create(user_id="u1", input_text="hi")
assert job.is_terminal is False
def test_processing_not_terminal(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
assert job.is_terminal is False
def test_completed_is_terminal(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_completed(output_audio_url="url")
assert job.is_terminal is True
assert job.is_completed is True
def test_failed_is_terminal(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_failed("error")
assert job.is_terminal is True
assert job.status == TTSJobStatus.FAILED
def test_cancelled_is_terminal(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_cancelled()
assert job.is_terminal is True
def test_terminal_statuses_contains_all_three(self):
assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES
assert TTSJobStatus.FAILED in TERMINAL_STATUSES
assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES
class TestTTSJobTransitions:
"""状态转换."""
def test_pending_to_processing(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
assert job.status == TTSJobStatus.PROCESSING
assert job.started_at is not None
assert job.error_message == ""
def test_pending_to_failed(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_failed("网络错误")
assert job.status == TTSJobStatus.FAILED
assert job.error_message == "网络错误"
def test_pending_to_cancelled(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
def test_processing_to_completed(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/audio.mp3")
assert job.status == TTSJobStatus.COMPLETED
assert job.output_audio_url == "https://example.com/audio.mp3"
assert job.completed_at is not None
def test_processing_to_failed(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_failed("API超时")
assert job.status == TTSJobStatus.FAILED
assert job.error_message == "API超时"
def test_processing_to_cancelled(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
def test_failed_to_pending_retry(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_failed("error")
job.prepare_retry()
assert job.status == TTSJobStatus.PENDING
assert job.retry_count == 1
assert job.error_message == ""
assert job.started_at is None
def test_completed_cannot_transition_back(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_completed(output_audio_url="url")
with pytest.raises(ValueError, match="非法状态转换"):
job.mark_failed("test")
def test_cancelled_cannot_retry(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_cancelled()
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_mark_failed_sets_error_message(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_failed("服务端错误")
assert job.error_message == "服务端错误"
def test_failed_status_after_mark_failed(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_failed("API超时")
assert job.status == TTSJobStatus.FAILED
assert job.error_message == "API超时"
class TestTTSJobRetry:
"""重试逻辑."""
def test_retry_increments_retry_count(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_failed("error1")
job.prepare_retry()
assert job.retry_count == 1
job.mark_processing()
job.mark_failed("error2")
job.prepare_retry()
assert job.retry_count == 2
def test_retry_clears_error_message(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_failed("error")
job.prepare_retry()
assert job.error_message == ""
def test_retry_resets_timestamps(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_failed("error")
job.prepare_retry()
assert job.started_at is None
def test_can_retry_while_below_max_retries(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=2)
# 第一次失败+重试
job.mark_failed("e1")
job.prepare_retry()
assert job.retry_count == 1
# 第二次失败+重试
job.mark_processing()
job.mark_failed("e2")
job.prepare_retry()
assert job.retry_count == 2
def test_cannot_retry_when_exceeded_max_retries(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=1)
job.mark_failed("e1")
job.prepare_retry()
assert job.retry_count == 1
# 再次失败就不能重试了(已经用完1次重试)
job.mark_processing()
job.mark_failed("e2")
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_is_retryable_true_when_failed_and_under_limit(self):
job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3)
job.mark_failed("error")
assert job.is_retryable is True
def test_is_retryable_false_when_not_failed(self):
job = TTSJob.create(user_id="u1", input_text="hi")
assert job.is_retryable is False
def test_prepare_retry_fails_when_not_failed(self):
job = TTSJob.create(user_id="u1", input_text="hi")
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
class TestTTSJobCompleted:
"""完成时的字段."""
def test_mark_completed_sets_output_url(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_completed(
output_audio_url="https://cdn.example.com/audio.mp3",
output_audio_key="audio/xxx.mp3",
duration=10.5,
file_size=102400,
)
assert job.output_audio_url == "https://cdn.example.com/audio.mp3"
assert job.output_audio_key == "audio/xxx.mp3"
assert job.duration == 10.5
assert job.file_size == 102400
assert job.completed_at is not None
def test_mark_completed_default_values(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_completed(output_audio_url="url")
assert job.duration == 0.0
assert job.file_size == 0
class TestTTSJobMetadata:
"""元数据."""
def test_create_with_metadata(self):
meta = {"source": "api", "priority": "high"}
job = TTSJob.create(user_id="u1", input_text="hi", metadata=meta)
assert job.metadata["source"] == "api"
assert job.metadata["priority"] == "high"
def test_default_metadata_empty_dict(self):
job = TTSJob.create(user_id="u1", input_text="hi")
assert job.metadata == {}
class TestTTSJobCreateValidation:
"""创建时的参数校验."""
def test_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id"):
TTSJob.create(user_id="", input_text="hi")
def test_whitespace_user_id_raises(self):
with pytest.raises(ValueError, match="user_id"):
TTSJob.create(user_id=" ", input_text="hi")
def test_empty_input_text_raises(self):
with pytest.raises(ValueError, match="input_text"):
TTSJob.create(user_id="u1", input_text="")
def test_input_text_too_long_raises(self):
long_text = "a" * 10001
with pytest.raises(ValueError, match="10000"):
TTSJob.create(user_id="u1", input_text=long_text)
def test_invalid_format_raises(self):
with pytest.raises(ValueError, match="不支持的输出格式"):
TTSJob.create(user_id="u1", input_text="hi", format="flac")
def test_valid_format_wav(self):
job = TTSJob.create(user_id="u1", input_text="hi", format="wav")
assert job.format == "wav"
def test_valid_format_pcm(self):
job = TTSJob.create(user_id="u1", input_text="hi", format="pcm")
assert job.format == "pcm"
def test_input_text_stripped(self):
job = TTSJob.create(user_id="u1", input_text=" 你好 ")
assert job.input_text == "你好"
class TestTTSJobToDict:
"""序列化."""
def test_to_dict_contains_key_fields(self):
job = TTSJob.create(user_id="u1", input_text="hi", voice_id="v1")
d = job.to_dict()
assert d["id"] == job.id
assert d["user_id"] == "u1"
assert d["input_text"] == "hi"
assert d["voice_id"] == "v1"
assert d["status"] == "pending"
assert d["retry_count"] == 0
assert d["is_retryable"] is False
def test_to_dict_completed_status(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/a.mp3", duration=10.5, file_size=1024)
d = job.to_dict()
assert d["status"] == "completed"
assert d["output_audio_url"] == "https://example.com/a.mp3"
assert d["duration"] == 10.5
assert d["file_size"] == 1024
assert d["is_completed"] is True
assert d["started_at"] is not None
assert d["completed_at"] is not None
def test_to_dict_failed_status(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_failed("error msg")
d = job.to_dict()
assert d["status"] == "failed"
assert d["error_message"] == "error msg"
assert d["is_retryable"] is True
class TestTTSJobCompletedValidation:
"""完成时的校验."""
def test_mark_completed_empty_url_raises(self):
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
with pytest.raises(ValueError, match="output_audio_url"):
job.mark_completed(output_audio_url="")
def test_is_completed_requires_url(self):
"""is_completed 属性需要 output_audio_url."""
job = TTSJob.create(user_id="u1", input_text="hi")
job.mark_processing()
# 直接设置状态为 completed 但不给 URL(模拟异常情况)
# 正常流程 mark_completed 会校验 URL,所以这里不会出现
# 但确认属性逻辑:没有 URL 时 is_completed 为 False
job.output_audio_url = ""
# 直接绕过状态机
from packages.domain.tts_job import _VALID_TRANSITIONS # noqa
job.status = TTSJobStatus.COMPLETED
assert job.is_completed is False
+423
View File
@@ -0,0 +1,423 @@
"""URL 安全校验纯逻辑单元测试 — wave128."""
import pytest
from packages.domain.url_security import (
ALLOWED_PORTS,
ALLOWED_SCHEMES,
MAGIC_NUMBERS,
MAX_URL_LENGTH,
UrlSecurityError,
check_internal_hostname,
check_ssrf_ip,
is_ip_address,
is_trusted_domain,
is_url_basic_safe,
validate_magic_number,
validate_url_basic,
)
# ── 常量校验 ────────────────────────────────────────────────────────────────
class TestConstants:
def test_allowed_schemes(self):
assert "http" in ALLOWED_SCHEMES
assert "https" in ALLOWED_SCHEMES
def test_allowed_ports(self):
assert 80 in ALLOWED_PORTS
assert 443 in ALLOWED_PORTS
def test_max_url_length(self):
assert MAX_URL_LENGTH == 2048
def test_magic_numbers_has_common_formats(self):
assert "audio/mpeg" in MAGIC_NUMBERS
assert "image/png" in MAGIC_NUMBERS
assert "video/mp4" in MAGIC_NUMBERS
# ── 内部主机名检查 ──────────────────────────────────────────────────────────
class TestCheckInternalHostname:
@pytest.mark.parametrize(
"hostname",
[
"localhost",
"LOCALHOST",
"LocalHost",
"localhost.localdomain",
"ip6-localhost",
"ip6-loopback",
"metadata",
"metadata.google.internal",
"169.254.169.254",
],
)
def test_internal_hostnames_rejected(self, hostname):
with pytest.raises(UrlSecurityError, match="禁止访问内部主机名"):
check_internal_hostname(hostname)
@pytest.mark.parametrize(
"hostname",
[
"foo.local",
"bar.internal",
"baz.localdomain",
"sub.foo.local",
],
)
def test_internal_domain_suffixes_rejected(self, hostname):
with pytest.raises(UrlSecurityError, match="禁止访问内网域名"):
check_internal_hostname(hostname)
@pytest.mark.parametrize(
"hostname",
[
"example.com",
"www.google.com",
"oss-cn-hangzhou.aliyuncs.com",
"123.45.67.89",
],
)
def test_normal_hostnames_allowed(self, hostname):
check_internal_hostname("example.com") # 不抛异常即通过
# ── 可信域名匹配 ────────────────────────────────────────────────────────────
class TestIsTrustedDomain:
def test_empty_trusted_always_true(self):
assert is_trusted_domain("anything.com", set()) is True
def test_exact_match(self):
trusted = {"example.com", "foo.bar"}
assert is_trusted_domain("example.com", trusted) is True
assert is_trusted_domain("foo.bar", trusted) is True
def test_exact_no_match(self):
trusted = {"example.com"}
assert is_trusted_domain("other.com", trusted) is False
def test_subdomain_match(self):
trusted = {"example.com"}
assert is_trusted_domain("sub.example.com", trusted) is True
assert is_trusted_domain("a.b.example.com", trusted) is True
def test_subdomain_partial_no_match(self):
trusted = {"example.com"}
# fakeexample.com 不是 example.com 的子域名
assert is_trusted_domain("fakeexample.com", trusted) is False
def test_case_insensitive(self):
trusted = {"Example.COM"}
assert is_trusted_domain("example.com", trusted) is True
assert is_trusted_domain("SUB.Example.COM", trusted) is True
# ── IP SSRF 检查 ────────────────────────────────────────────────────────────
class TestCheckSrfIp:
@pytest.mark.parametrize("ip", ["127.0.0.1", "127.1.2.3", "::1"])
def test_loopback_rejected(self, ip):
with pytest.raises(UrlSecurityError, match="回环"):
check_ssrf_ip(ip)
@pytest.mark.parametrize(
"ip",
[
"10.0.0.1",
"10.255.255.255",
"172.16.0.1",
"172.31.255.255",
"192.168.1.1",
"192.168.0.1",
"fd00::1", # IPv6 unique local
],
)
def test_private_rejected(self, ip):
with pytest.raises(UrlSecurityError, match="内网"):
check_ssrf_ip(ip)
@pytest.mark.parametrize("ip", ["169.254.1.1", "169.254.169.254", "fe80::1"])
def test_link_local_rejected(self, ip):
with pytest.raises(UrlSecurityError, match="链路本地"):
check_ssrf_ip(ip)
@pytest.mark.parametrize("ip", ["224.0.0.1", "239.255.255.255", "ff00::1"])
def test_multicast_rejected(self, ip):
with pytest.raises(UrlSecurityError, match="组播"):
check_ssrf_ip(ip)
@pytest.mark.parametrize("ip", ["0.0.0.0", "::"])
def test_unspecified_rejected(self, ip):
with pytest.raises(UrlSecurityError, match="未指定"):
check_ssrf_ip(ip)
def test_reserved_rejected(self):
with pytest.raises(UrlSecurityError):
check_ssrf_ip("240.0.0.1") # 保留地址段
@pytest.mark.parametrize(
"ip",
[
"8.8.8.8",
"1.1.1.1",
"223.5.5.5",
"2001:4860:4860::8888",
],
)
def test_public_ip_allowed(self, ip):
check_ssrf_ip(ip) # 不抛异常即通过
def test_invalid_ip_raises_value_error(self):
with pytest.raises(ValueError):
check_ssrf_ip("not-an-ip")
# ── IP 地址判断 ─────────────────────────────────────────────────────────────
class TestIsIpAddress:
@pytest.mark.parametrize(
"host",
[
"127.0.0.1",
"8.8.8.8",
"192.168.1.1",
"::1",
"2001:db8::1",
"fe80::1",
],
)
def test_ip_addresses(self, host):
assert is_ip_address(host) is True
@pytest.mark.parametrize(
"host",
[
"example.com",
"www.google.com",
"localhost",
"not-an-ip",
"",
],
)
def test_not_ip_addresses(self, host):
assert is_ip_address(host) is False
# ── URL 基础校验 ────────────────────────────────────────────────────────────
class TestValidateUrlBasic:
def test_normal_http_url_passes(self):
result = validate_url_basic("http://example.com/file.txt")
assert result == "http://example.com/file.txt"
def test_normal_https_url_passes(self):
result = validate_url_basic("https://www.example.com/path?q=1")
assert result == "https://www.example.com/path?q=1"
def test_standard_port_80_passes(self):
validate_url_basic("http://example.com:80/file")
def test_standard_port_443_passes(self):
validate_url_basic("https://example.com:443/file")
def test_empty_url_rejected(self):
with pytest.raises(UrlSecurityError, match="URL 为空"):
validate_url_basic("")
def test_none_url_rejected(self):
with pytest.raises(UrlSecurityError, match="URL 为空"):
validate_url_basic(None) # type: ignore
def test_too_long_url_rejected(self):
long_url = "https://example.com/" + "a" * 2100
with pytest.raises(UrlSecurityError, match="URL 过长"):
validate_url_basic(long_url)
@pytest.mark.parametrize(
"url",
[
"ftp://example.com/file",
"file:///etc/passwd",
"javascript:alert(1)",
"data:text/html,<h1>hi</h1>",
],
)
def test_bad_scheme_rejected(self, url):
with pytest.raises(UrlSecurityError, match="不允许的 URL scheme"):
validate_url_basic(url)
def test_missing_hostname_rejected(self):
with pytest.raises(UrlSecurityError, match="URL 缺少主机名"):
validate_url_basic("http:///path")
@pytest.mark.parametrize(
"url",
[
"http://localhost/test",
"http://metadata/test",
"http://foo.local/test",
],
)
def test_internal_hostname_rejected(self, url):
with pytest.raises(UrlSecurityError):
validate_url_basic(url)
@pytest.mark.parametrize(
"url",
[
"http://example.com:8080/file",
"http://example.com:22/file",
"http://example.com:3306/file",
],
)
def test_non_standard_port_rejected(self, url):
with pytest.raises(UrlSecurityError, match="不允许的端口"):
validate_url_basic(url)
def test_direct_ip_rejected_by_default(self):
with pytest.raises(UrlSecurityError, match="禁止直接 IP 访问"):
validate_url_basic("http://8.8.8.8/file")
def test_direct_ip_allowed_with_flag_public(self):
result = validate_url_basic("http://8.8.8.8/file", allow_direct_ip=True)
assert result == "http://8.8.8.8/file"
def test_direct_ip_allowed_flag_but_private_still_rejected(self):
with pytest.raises(UrlSecurityError, match="内网地址"):
validate_url_basic("http://10.0.0.1/file", allow_direct_ip=True)
def test_direct_ip_loopback_rejected(self):
with pytest.raises(UrlSecurityError):
validate_url_basic("http://127.0.0.1/test", allow_direct_ip=True)
def test_trusted_domains_whitelist_pass(self):
trusted = {"example.com"}
result = validate_url_basic("https://example.com/file", trusted_domains=trusted)
assert result == "https://example.com/file"
def test_trusted_domains_subdomain_pass(self):
trusted = {"example.com"}
result = validate_url_basic("https://cdn.example.com/file", trusted_domains=trusted)
assert result == "https://cdn.example.com/file"
def test_trusted_domains_not_in_list_rejected(self):
trusted = {"example.com"}
with pytest.raises(UrlSecurityError, match="不在可信白名单"):
validate_url_basic("https://other.com/file", trusted_domains=trusted)
def test_case_insensitive_scheme(self):
# 大写 HTTP 也应该通过(我们用 .lower() 检查)
result = validate_url_basic("HTTP://example.com/file")
assert "HTTP://example.com/file" == result
# ── is_url_basic_safe 便捷函数 ──────────────────────────────────────────────
class TestIsUrlBasicSafe:
def test_safe_url_returns_true(self):
assert is_url_basic_safe("https://example.com/file") is True
def test_unsafe_url_returns_false(self):
assert is_url_basic_safe("http://localhost/test") is False
def test_empty_returns_false(self):
assert is_url_basic_safe("") is False
def test_with_trusted_domains(self):
trusted = {"allowed.com"}
assert is_url_basic_safe("https://allowed.com/x", trusted_domains=trusted) is True
assert is_url_basic_safe("https://other.com/x", trusted_domains=trusted) is False
# ── 魔数校验 ────────────────────────────────────────────────────────────────
class TestValidateMagicNumber:
def test_mp3_id3_header(self):
data = b"ID3" + b"\x00" * 100
validate_magic_number(data, {"audio/mpeg"}) # 不抛异常
def test_mp3_frame_sync(self):
data = b"\xff\xfb" + b"\x00" * 100
validate_magic_number(data, {"audio/mpeg"})
def test_wav_header(self):
data = b"RIFF" + b"\x00" * 4 + b"WAVE" + b"\x00" * 100
validate_magic_number(data, {"audio/wav"})
def test_png_header(self):
data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
validate_magic_number(data, {"image/png"})
def test_jpeg_header(self):
data = b"\xff\xd8\xff" + b"\x00" * 100
validate_magic_number(data, {"image/jpeg"})
def test_gif87a_header(self):
data = b"GIF87a" + b"\x00" * 100
validate_magic_number(data, {"image/gif"})
def test_gif89a_header(self):
data = b"GIF89a" + b"\x00" * 100
validate_magic_number(data, {"image/gif"})
def test_mp4_ftyp_header(self):
data = b"\x00\x00\x00\x20ftypisom" + b"\x00" * 100
validate_magic_number(data, {"video/mp4"})
def test_ogg_header(self):
data = b"OggS" + b"\x00" * 100
validate_magic_number(data, {"audio/ogg"})
def test_flac_header(self):
data = b"fLaC" + b"\x00" * 100
validate_magic_number(data, {"audio/flac"})
def test_webp_header(self):
data = b"RIFF" + b"\x00" * 4 + b"WEBP" + b"\x00" * 100
validate_magic_number(data, {"image/webp"})
def test_bmp_header(self):
data = b"BM" + b"\x00" * 100
validate_magic_number(data, {"image/bmp"})
def test_empty_file_rejected(self):
with pytest.raises(UrlSecurityError, match="文件为空"):
validate_magic_number(b"", {"image/png"})
def test_mismatched_magic_rejected(self):
data = b"NOTAPNG" + b"\x00" * 100
with pytest.raises(UrlSecurityError, match="魔数与允许的 MIME 类型不匹配"):
validate_magic_number(data, {"image/png"})
def test_multiple_allowed_types_one_matches(self):
data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
# 多个允许类型,只要有一个匹配就通过
validate_magic_number(data, {"image/png", "image/jpeg", "image/gif"})
def test_no_known_magic_skips_validation(self):
# 自定义 MIME 类型没有已知魔数,跳过校验不阻断
validate_magic_number(b"random data", {"application/x-custom"})
def test_too_short_header_no_match(self):
# 文件头太短,无法匹配需要 8 字节偏移的格式
data = b"RIFF" # 只有 4 字节,不够 offset 8 的 WAVE 匹配
with pytest.raises(UrlSecurityError, match="魔数"):
validate_magic_number(data, {"audio/wav"})
def test_error_message_contains_mime_and_header(self):
with pytest.raises(UrlSecurityError) as exc_info:
validate_magic_number(b"XXXXYYY", {"image/png"})
msg = str(exc_info.value)
assert "image/png" in msg
assert "文件头前16字节" in msg