Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 201c4ae5a6 | |||
| 1598da861a | |||
| a258d7f9d1 | |||
| 30713e698c | |||
| 2b120f5c65 | |||
| bf55cfc3ca | |||
| f23b0ceae4 | |||
| 66b6ed9f12 | |||
| 1e49618817 | |||
| 06716a0678 | |||
| 31dc5b382a | |||
| 57c7543c7f | |||
| 796cac249f | |||
| abe275acc4 | |||
| 1f11981a95 | |||
| 1b7a7bcb55 | |||
| da3ca4bc46 | |||
| 8849f5b358 | |||
| f246f7d8aa | |||
| 602bb388d1 | |||
| fba0ade0ca | |||
| 8882e24fd4 |
@@ -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">支持 MP3、WAV、M4A 格式,最大 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">支持 MP3、WAV、M4A 格式,最大 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,
|
||||
// 操作结果
|
||||
|
||||
Regular → Executable
+23
-239
@@ -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>
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Regular → Executable
+41
-294
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -4,731 +4,132 @@
|
||||
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
|
||||
* 对接后端真实 API(GET/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable → Regular
+59
-329
@@ -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,
|
||||
}
|
||||
}
|
||||
+21
-156
@@ -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,
|
||||
}
|
||||
}
|
||||
+24
@@ -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,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+88
-281
@@ -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
@@ -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
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable
+338
@@ -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
@@ -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()
|
||||
|
||||
Executable
+515
@@ -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 == [] # 默认空列表
|
||||
@@ -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
|
||||
@@ -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
@@ -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_id(sub字段)"""
|
||||
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
@@ -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
|
||||
|
||||
@@ -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 是 False(0 < 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
|
||||
@@ -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 == ["温柔", "女声"]
|
||||
Executable
+689
@@ -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
|
||||
Executable
+423
@@ -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
|
||||
Reference in New Issue
Block a user