Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d261652c7f | |||
| 5371b4a4d1 |
@@ -12,6 +12,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
Regular → Executable
+37
-294
@@ -4,232 +4,36 @@
|
||||
* 展示克隆音色列表,卡片网格布局
|
||||
* 支持试听、使用、编辑名称、删除操作
|
||||
*/
|
||||
import React, { useState, useCallback } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Button, Tooltip } from "@/components/ui"
|
||||
import { Popconfirm } from "antd"
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
AudioOutlined,
|
||||
SoundOutlined,
|
||||
CalendarOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import {
|
||||
getVoiceClones,
|
||||
deleteVoiceClone,
|
||||
updateVoiceClone,
|
||||
formatDuration,
|
||||
type VoiceClone as VoiceCloneType,
|
||||
} from "@/api/voice-clone"
|
||||
import { VoiceCloneCard } from "./components/VoiceCloneCard"
|
||||
import { VoiceCloneEmpty, VoiceCloneSkeleton, ToastContainer } from "./components/States"
|
||||
import { EditNameDialog } from "./components/EditNameDialog"
|
||||
import { useVoiceCloneList } from "./hooks/useVoiceCloneList"
|
||||
import "./voice-clone.css"
|
||||
|
||||
/* ── 状态配置 ─────────────────────────────────────────── */
|
||||
|
||||
const STATUS_CONFIG: Record<VoiceCloneType["status"], { label: string; className: string }> = {
|
||||
ready: { label: "就绪", className: "vc-status-pill--ready" },
|
||||
processing: { label: "克隆中", className: "vc-status-pill--processing" },
|
||||
failed: { label: "失败", className: "vc-status-pill--failed" },
|
||||
}
|
||||
|
||||
/* ── Toast 系统 ─────────────────────────────────────────── */
|
||||
|
||||
interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: "success" | "error"
|
||||
}
|
||||
|
||||
let toastId = 0
|
||||
|
||||
/* ── 音色卡片组件 ───────────────────────────────────────── */
|
||||
|
||||
interface VoiceCloneCardProps {
|
||||
voice: VoiceCloneType
|
||||
onPlay: (voice: VoiceCloneType) => void
|
||||
onUse: (voice: VoiceCloneType) => void
|
||||
onEdit: (voice: VoiceCloneType) => void
|
||||
onDelete: (voice: VoiceCloneType) => void
|
||||
}
|
||||
|
||||
const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
|
||||
voice,
|
||||
onPlay,
|
||||
onUse,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const statusCfg = STATUS_CONFIG[voice.status]
|
||||
const isProcessing = voice.status === "processing"
|
||||
const createdDate = new Date(voice.created_at).toLocaleDateString("zh-CN")
|
||||
|
||||
return (
|
||||
<div className="vc-card">
|
||||
{/* 右上角操作 */}
|
||||
<div className="vc-card-actions">
|
||||
<Tooltip title="编辑名称">
|
||||
<button type="button" className="vc-card-action-btn" onClick={() => onEdit(voice)}>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Popconfirm
|
||||
title={`确定删除音色「${voice.name}」吗?`}
|
||||
onConfirm={() => onDelete(voice)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button type="button" className="vc-card-action-btn vc-card-action-btn--danger">
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 头部:头像 + 名称 + 状态 */}
|
||||
<div className="vc-card-header">
|
||||
<div className={`vc-card-avatar${isProcessing ? " vc-card-avatar--processing" : ""}`}>
|
||||
<AudioOutlined style={{ fontSize: 22 }} />
|
||||
</div>
|
||||
<div className="vc-card-info">
|
||||
<h4 className="vc-card-name">{voice.name}</h4>
|
||||
<span className={`vc-status-pill ${statusCfg.className}`}>
|
||||
<span className="vc-status-dot" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="vc-card-meta">
|
||||
<div className="vc-card-meta-row">
|
||||
<span className="vc-card-meta-icon">
|
||||
<SoundOutlined />
|
||||
</span>
|
||||
<span>时长:{formatDuration(voice.duration_seconds)}</span>
|
||||
</div>
|
||||
<div className="vc-card-meta-row">
|
||||
<span className="vc-card-meta-icon">
|
||||
<CalendarOutlined />
|
||||
</span>
|
||||
<span>创建于:{createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作区 */}
|
||||
<div className="vc-card-footer">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
disabled={isProcessing}
|
||||
onClick={() => onPlay(voice)}
|
||||
>
|
||||
▶ 试听
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
disabled={isProcessing}
|
||||
onClick={() => onUse(voice)}
|
||||
>
|
||||
使用此音色
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 主页面 ─────────────────────────────────────────────── */
|
||||
|
||||
const VoiceClone: React.FC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const [editingVoice, setEditingVoice] = useState<VoiceCloneType | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback((message: string, type: Toast["type"]) => {
|
||||
const id = ++toastId
|
||||
setToasts((prev) => [...prev, { id, message, type }])
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 3000)
|
||||
}, [])
|
||||
|
||||
/** 查询克隆音色列表 */
|
||||
const { data: voices = [], isLoading } = useQuery({
|
||||
queryKey: ["voiceClones"],
|
||||
queryFn: () => getVoiceClones(),
|
||||
})
|
||||
|
||||
/** 删除 mutation */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteVoiceClone,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
|
||||
showToast("音色已删除", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("删除失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 编辑 mutation */
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => updateVoiceClone(id, { name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
|
||||
setEditingVoice(null)
|
||||
showToast("名称已更新", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("更新失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 克隆新音色 — 打开弹窗 */
|
||||
const handleCloneNew = () => {
|
||||
setCloneModalOpen(true)
|
||||
}
|
||||
|
||||
/** 试听 */
|
||||
const handlePlay = (voice: VoiceCloneType) => {
|
||||
if (voice.sample_url) {
|
||||
const audio = new Audio(voice.sample_url)
|
||||
audio.play().catch(() => {
|
||||
showToast("播放失败", "error")
|
||||
})
|
||||
} else {
|
||||
showToast("暂无试听音频", "error")
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用音色 — 跳转到生成页面 */
|
||||
const handleUse = (_voice: VoiceCloneType) => {
|
||||
showToast("已选择音色,跳转到生成页面", "success")
|
||||
}
|
||||
|
||||
/** 打开编辑弹窗 */
|
||||
const handleEdit = (voice: VoiceCloneType) => {
|
||||
setEditingVoice(voice)
|
||||
setEditName(voice.name)
|
||||
}
|
||||
|
||||
/** 确认编辑 */
|
||||
const handleEditConfirm = () => {
|
||||
if (!editingVoice || !editName.trim()) return
|
||||
updateMutation.mutate({ id: editingVoice.id, name: editName.trim() })
|
||||
}
|
||||
|
||||
/** 删除确认 — 使用 Popconfirm */
|
||||
const handleDelete = (voice: VoiceCloneType) => {
|
||||
deleteMutation.mutate(voice.id)
|
||||
}
|
||||
const {
|
||||
voices,
|
||||
isLoading,
|
||||
toasts,
|
||||
editingVoice,
|
||||
editName,
|
||||
setEditName,
|
||||
cloneModalOpen,
|
||||
updateLoading,
|
||||
handleCloneNew,
|
||||
handleCloseCloneModal,
|
||||
handleCloneSuccess,
|
||||
handlePlay,
|
||||
handleUse,
|
||||
handleEdit,
|
||||
handleCloseEdit,
|
||||
handleEditConfirm,
|
||||
handleDelete,
|
||||
} = useVoiceCloneList()
|
||||
|
||||
return (
|
||||
<div className="vc-page">
|
||||
@@ -244,24 +48,7 @@ const VoiceClone: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 加载状态 — 骨架屏 */}
|
||||
{isLoading && (
|
||||
<div className="vc-grid">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="vc-card">
|
||||
<div className="vc-card-header">
|
||||
<div className="vc-skeleton-avatar" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="vc-skeleton-line vc-skeleton-line--title" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--short" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="vc-skeleton-line" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--short" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--footer" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <VoiceCloneSkeleton />}
|
||||
|
||||
{/* 卡片网格 */}
|
||||
{!isLoading && voices.length > 0 && (
|
||||
@@ -280,71 +67,27 @@ const VoiceClone: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!isLoading && voices.length === 0 && (
|
||||
<div className="vc-empty">
|
||||
<div className="vc-empty-icon">
|
||||
<AudioOutlined style={{ fontSize: 48 }} />
|
||||
</div>
|
||||
<h3 className="vc-empty-title">还没有克隆音色</h3>
|
||||
<p className="vc-empty-desc">上传你的声音,AI将克隆你的专属音色</p>
|
||||
<Button buttonType="primary" onClick={handleCloneNew}>
|
||||
立即克隆
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && voices.length === 0 && <VoiceCloneEmpty onClone={handleCloneNew} />}
|
||||
|
||||
{/* 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" ? <CheckCircleOutlined /> : <CloseCircleOutlined />} {t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ToastContainer toasts={toasts} />
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{editingVoice && (
|
||||
<div className="vc-edit-overlay" onClick={() => setEditingVoice(null)}>
|
||||
<div className="vc-edit-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="vc-edit-title">编辑音色名称</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="vc-edit-input"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleEditConfirm()
|
||||
if (e.key === "Escape") setEditingVoice(null)
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="输入音色名称"
|
||||
/>
|
||||
<div className="vc-edit-buttons">
|
||||
<Button buttonType="ghost" onClick={() => setEditingVoice(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={handleEditConfirm}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EditNameDialog
|
||||
name={editName}
|
||||
onNameChange={setEditName}
|
||||
onConfirm={handleEditConfirm}
|
||||
onCancel={handleCloseEdit}
|
||||
loading={updateLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 克隆音色弹窗 */}
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
onClose={() => setCloneModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
|
||||
showToast("音色克隆已提交", "success")
|
||||
}}
|
||||
onClose={handleCloseCloneModal}
|
||||
onSuccess={handleCloneSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
interface EditNameDialogProps {
|
||||
name: string
|
||||
onNameChange: (name: string) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑音色名称弹窗
|
||||
*/
|
||||
export const EditNameDialog: React.FC<EditNameDialogProps> = ({
|
||||
name,
|
||||
onNameChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading,
|
||||
}) => {
|
||||
return (
|
||||
<div className="vc-edit-overlay" onClick={onCancel}>
|
||||
<div className="vc-edit-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="vc-edit-title">编辑音色名称</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="vc-edit-input"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onConfirm()
|
||||
if (e.key === "Escape") onCancel()
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="输入音色名称"
|
||||
/>
|
||||
<div className="vc-edit-buttons">
|
||||
<Button buttonType="ghost" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" onClick={onConfirm} disabled={loading}>
|
||||
{loading ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from "react"
|
||||
import { AudioOutlined, CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { Toast } from "../types"
|
||||
|
||||
interface VoiceCloneEmptyProps {
|
||||
onClone: () => void
|
||||
}
|
||||
|
||||
/** 空状态 */
|
||||
export const VoiceCloneEmpty: React.FC<VoiceCloneEmptyProps> = ({ onClone }) => (
|
||||
<div className="vc-empty">
|
||||
<div className="vc-empty-icon">
|
||||
<AudioOutlined style={{ fontSize: 48 }} />
|
||||
</div>
|
||||
<h3 className="vc-empty-title">还没有克隆音色</h3>
|
||||
<p className="vc-empty-desc">上传你的声音,AI将克隆你的专属音色</p>
|
||||
<Button buttonType="primary" onClick={onClone}>
|
||||
立即克隆
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
/** 骨架屏加载 */
|
||||
export const VoiceCloneSkeleton: React.FC = () => (
|
||||
<div className="vc-grid">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="vc-card">
|
||||
<div className="vc-card-header">
|
||||
<div className="vc-skeleton-avatar" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="vc-skeleton-line vc-skeleton-line--title" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--short" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="vc-skeleton-line" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--short" />
|
||||
<div className="vc-skeleton-line vc-skeleton-line--footer" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: Toast[]
|
||||
}
|
||||
|
||||
/** Toast 提示容器 */
|
||||
export const ToastContainer: React.FC<ToastContainerProps> = ({ 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" ? <CheckCircleOutlined /> : <CloseCircleOutlined />} {t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from "react"
|
||||
import { Tooltip } from "@/components/ui"
|
||||
import { Popconfirm } from "antd"
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
AudioOutlined,
|
||||
SoundOutlined,
|
||||
CalendarOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import { formatDuration, type VoiceClone } from "@/api/voice-clone"
|
||||
import { STATUS_CONFIG } from "../types"
|
||||
|
||||
interface VoiceCloneCardProps {
|
||||
voice: VoiceClone
|
||||
onPlay: (voice: VoiceClone) => void
|
||||
onUse: (voice: VoiceClone) => void
|
||||
onEdit: (voice: VoiceClone) => void
|
||||
onDelete: (voice: VoiceClone) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 音色克隆卡片
|
||||
*/
|
||||
export const VoiceCloneCard: React.FC<VoiceCloneCardProps> = ({
|
||||
voice,
|
||||
onPlay,
|
||||
onUse,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const statusCfg = STATUS_CONFIG[voice.status]
|
||||
const isProcessing = voice.status === "processing"
|
||||
const createdDate = new Date(voice.created_at).toLocaleDateString("zh-CN")
|
||||
|
||||
return (
|
||||
<div className="vc-card">
|
||||
{/* 右上角操作 */}
|
||||
<div className="vc-card-actions">
|
||||
<Tooltip title="编辑名称">
|
||||
<button type="button" className="vc-card-action-btn" onClick={() => onEdit(voice)}>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Popconfirm
|
||||
title={`确定删除音色「${voice.name}」吗?`}
|
||||
onConfirm={() => onDelete(voice)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button type="button" className="vc-card-action-btn vc-card-action-btn--danger">
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 头部:头像 + 名称 + 状态 */}
|
||||
<div className="vc-card-header">
|
||||
<div className={`vc-card-avatar${isProcessing ? " vc-card-avatar--processing" : ""}`}>
|
||||
<AudioOutlined style={{ fontSize: 22 }} />
|
||||
</div>
|
||||
<div className="vc-card-info">
|
||||
<h4 className="vc-card-name">{voice.name}</h4>
|
||||
<span className={`vc-status-pill ${statusCfg.className}`}>
|
||||
<span className="vc-status-dot" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="vc-card-meta">
|
||||
<div className="vc-card-meta-row">
|
||||
<span className="vc-card-meta-icon">
|
||||
<SoundOutlined />
|
||||
</span>
|
||||
<span>时长:{formatDuration(voice.duration_seconds)}</span>
|
||||
</div>
|
||||
<div className="vc-card-meta-row">
|
||||
<span className="vc-card-meta-icon">
|
||||
<CalendarOutlined />
|
||||
</span>
|
||||
<span>创建于:{createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作区 */}
|
||||
<div className="vc-card-footer">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
disabled={isProcessing}
|
||||
onClick={() => onPlay(voice)}
|
||||
>
|
||||
▶ 试听
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
disabled={isProcessing}
|
||||
onClick={() => onUse(voice)}
|
||||
>
|
||||
使用此音色
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
getVoiceClones,
|
||||
deleteVoiceClone,
|
||||
updateVoiceClone,
|
||||
type VoiceClone,
|
||||
} from "@/api/voice-clone"
|
||||
import type { Toast } from "../types"
|
||||
|
||||
let toastId = 0
|
||||
|
||||
/**
|
||||
* 音色克隆列表业务 Hook
|
||||
* 封装列表查询、删除、编辑、试听、Toast 等所有业务逻辑
|
||||
*/
|
||||
export const useVoiceCloneList = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback((message: string, type: Toast["type"]) => {
|
||||
const id = ++toastId
|
||||
setToasts((prev) => [...prev, { id, message, type }])
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 3000)
|
||||
}, [])
|
||||
|
||||
/** 查询克隆音色列表 */
|
||||
const { data: voices = [], isLoading } = useQuery({
|
||||
queryKey: ["voiceClones"],
|
||||
queryFn: () => getVoiceClones(),
|
||||
})
|
||||
|
||||
/** 删除 mutation */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteVoiceClone,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
|
||||
showToast("音色已删除", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("删除失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 编辑 mutation */
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => updateVoiceClone(id, { name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
|
||||
setEditingVoice(null)
|
||||
showToast("名称已更新", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("更新失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 克隆新音色 — 打开弹窗 */
|
||||
const handleCloneNew = useCallback(() => {
|
||||
setCloneModalOpen(true)
|
||||
}, [])
|
||||
|
||||
/** 关闭克隆弹窗 */
|
||||
const handleCloseCloneModal = useCallback(() => {
|
||||
setCloneModalOpen(false)
|
||||
}, [])
|
||||
|
||||
/** 克隆成功回调 */
|
||||
const handleCloneSuccess = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voiceClones"] })
|
||||
showToast("音色克隆已提交", "success")
|
||||
}, [queryClient, showToast])
|
||||
|
||||
/** 试听 */
|
||||
const handlePlay = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
if (voice.sample_url) {
|
||||
// 停止之前的播放
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
const audio = new Audio(voice.sample_url)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {
|
||||
showToast("播放失败", "error")
|
||||
})
|
||||
} else {
|
||||
showToast("暂无试听音频", "error")
|
||||
}
|
||||
},
|
||||
[showToast],
|
||||
)
|
||||
|
||||
/** 使用音色 */
|
||||
const handleUse = useCallback(() => {
|
||||
showToast("已选择音色,跳转到生成页面", "success")
|
||||
}, [showToast])
|
||||
|
||||
/** 打开编辑弹窗 */
|
||||
const handleEdit = useCallback((voice: VoiceClone) => {
|
||||
setEditingVoice(voice)
|
||||
setEditName(voice.name)
|
||||
}, [])
|
||||
|
||||
/** 关闭编辑弹窗 */
|
||||
const handleCloseEdit = useCallback(() => {
|
||||
setEditingVoice(null)
|
||||
}, [])
|
||||
|
||||
/** 确认编辑 */
|
||||
const handleEditConfirm = useCallback(() => {
|
||||
if (!editingVoice || !editName.trim()) return
|
||||
updateMutation.mutate({ id: editingVoice.id, name: editName.trim() })
|
||||
}, [editingVoice, editName, updateMutation])
|
||||
|
||||
/** 删除确认 */
|
||||
const handleDelete = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
deleteMutation.mutate(voice.id)
|
||||
},
|
||||
[deleteMutation],
|
||||
)
|
||||
|
||||
return {
|
||||
// 数据
|
||||
voices,
|
||||
isLoading,
|
||||
// 状态
|
||||
toasts,
|
||||
editingVoice,
|
||||
editName,
|
||||
setEditName,
|
||||
cloneModalOpen,
|
||||
// 加载状态
|
||||
deleteLoading: deleteMutation.isPending,
|
||||
updateLoading: updateMutation.isPending,
|
||||
// 操作
|
||||
showToast,
|
||||
handleCloneNew,
|
||||
handleCloseCloneModal,
|
||||
handleCloneSuccess,
|
||||
handlePlay,
|
||||
handleUse,
|
||||
handleEdit,
|
||||
handleCloseEdit,
|
||||
handleEditConfirm,
|
||||
handleDelete,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
/** 状态配置 */
|
||||
export const STATUS_CONFIG: Record<VoiceClone["status"], { label: string; className: string }> = {
|
||||
ready: { label: "就绪", className: "vc-status-pill--ready" },
|
||||
processing: { label: "克隆中", className: "vc-status-pill--processing" },
|
||||
failed: { label: "失败", className: "vc-status-pill--failed" },
|
||||
}
|
||||
|
||||
/** Toast 类型 */
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: "success" | "error"
|
||||
}
|
||||
@@ -9,11 +9,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from packages.domain.chroma_key_config import CHROMA_KEY_PRESETS # noqa: F401
|
||||
from packages.domain.chroma_key_config import apply_chroma_key_if_needed # noqa: F401
|
||||
from packages.domain.chroma_key_config import (
|
||||
CHROMA_KEY_PRESETS,
|
||||
ChromaKeyConfig,
|
||||
apply_chroma_key_if_needed,
|
||||
)
|
||||
from packages.domain.chroma_key_config import ( # noqa: F401 — 向后兼容
|
||||
build_chromakey_filter as _build_chromakey_filter_base,
|
||||
|
||||
@@ -195,3 +195,17 @@ class ColorGradeEngine:
|
||||
|
||||
|
||||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_preset_names() -> list[tuple[str, str]]:
|
||||
"""获取所有预设名称列表.
|
||||
|
||||
Returns:
|
||||
[(preset_key, display_name), ...]
|
||||
"""
|
||||
return [(key, PRESET_DISPLAY_NAMES.get(key, key)) for key in PRESET_PARAMS.keys()]
|
||||
|
||||
|
||||
def get_preset_params(preset: str) -> dict[str, float] | None:
|
||||
"""获取指定预设的参数."""
|
||||
return PRESET_PARAMS.get(preset)
|
||||
|
||||
@@ -22,9 +22,11 @@ from shared.ffmpeg_utils import ( # noqa: F401
|
||||
|
||||
# xfade 转场纯逻辑已抽离到 domain 层,这里 re-export 保持向后兼容
|
||||
from packages.domain.xfade_builder import DEFAULT_TRANSITION_DURATION as _default_transition_duration_base # noqa: F401
|
||||
from packages.domain.xfade_builder import SUPPORTED_TRANSITIONS # noqa: F401
|
||||
from packages.domain.xfade_builder import XFADE_TRANSITION_MAP # noqa: F401
|
||||
from packages.domain.xfade_builder import XFade_TRANSITION_NAMES # noqa: F401
|
||||
from packages.domain.xfade_builder import (
|
||||
SUPPORTED_TRANSITIONS,
|
||||
XFADE_TRANSITION_MAP,
|
||||
XFade_TRANSITION_NAMES,
|
||||
)
|
||||
from packages.domain.xfade_builder import build_xfade_filter_chain as _build_xfade_filter_chain_base
|
||||
from packages.domain.xfade_builder import chain_filters as _chain_filters_base
|
||||
from packages.domain.xfade_builder import resolve_xfade_transition as _resolve_xfade_transition_base
|
||||
|
||||
@@ -9,21 +9,16 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
# isort: off
|
||||
from packages.domain.noise_reduction_config import (
|
||||
NoiseReductionConfig,
|
||||
NoiseReductionLevel, # noqa: F401
|
||||
NoiseReductionLevel,
|
||||
)
|
||||
from packages.domain.noise_reduction_config import (
|
||||
apply_noise_reduction_if_needed as _apply_noise_reduction_if_needed_base,
|
||||
)
|
||||
from packages.domain.noise_reduction_config import (
|
||||
build_afftdn_filter as _build_afftdn_filter_base,
|
||||
) # noqa: F401 — 向后兼容
|
||||
from packages.domain.noise_reduction_config import build_afftdn_filter as _build_afftdn_filter_base # noqa: F401 — 向后兼容
|
||||
from packages.domain.noise_reduction_config import build_arnndn_filter as _build_arnndn_filter_base
|
||||
|
||||
# isort: on
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -18,13 +18,22 @@ from pathlib import Path
|
||||
|
||||
# 向后兼容:POSITION_BOTTOM_CENTER 也从 pip_config 再导出
|
||||
from packages.domain.pip_config import POSITION_BOTTOM_CENTER # noqa: E402, F401
|
||||
from packages.domain.pip_config import PiPConfig # noqa: F401
|
||||
from packages.domain.pip_config import (
|
||||
ANIMATION_FADE,
|
||||
ANIMATION_SCALE,
|
||||
ANIMATION_SLIDE_BOTTOM,
|
||||
ANIMATION_SLIDE_LEFT,
|
||||
ANIMATION_SLIDE_RIGHT,
|
||||
ANIMATION_SLIDE_TOP,
|
||||
POSITION_BOTTOM_LEFT,
|
||||
POSITION_BOTTOM_RIGHT,
|
||||
POSITION_CENTER,
|
||||
POSITION_CENTER_LEFT,
|
||||
POSITION_CENTER_RIGHT,
|
||||
POSITION_TOP_CENTER,
|
||||
POSITION_TOP_LEFT,
|
||||
POSITION_TOP_RIGHT,
|
||||
PiPConfig,
|
||||
PiPLayerConfig,
|
||||
)
|
||||
from packages.domain.pip_config import ( # noqa: F401 — 向后兼容:保留模块级导出
|
||||
|
||||
@@ -13,6 +13,9 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.ass_subtitle_builder import (
|
||||
TITLE_MARGIN_BOTTOM,
|
||||
TITLE_MARGIN_SIDE,
|
||||
TITLE_MARGIN_TOP,
|
||||
build_ass_content,
|
||||
)
|
||||
from packages.domain.ass_subtitle_builder import build_ass_style as _build_ass_style_base # noqa: F401 — 向后兼容
|
||||
|
||||
@@ -14,20 +14,16 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# isort: off
|
||||
from packages.domain.sticker_config import (
|
||||
POSITION_PRESETS, # noqa: F401
|
||||
STICKER_CATEGORIES, # noqa: F401
|
||||
POSITION_PRESETS,
|
||||
STICKER_CATEGORIES,
|
||||
ImageStickerConfig,
|
||||
StickerOverlayResult,
|
||||
TextStickerConfig,
|
||||
)
|
||||
from packages.domain.sticker_config import (
|
||||
get_sticker_categories as _get_sticker_categories_base,
|
||||
) # noqa: F401 向后兼容导出
|
||||
from packages.domain.sticker_config import get_sticker_categories as _get_sticker_categories_base # noqa: F401 向后兼容导出
|
||||
from packages.domain.sticker_config import parse_stickers_from_config as _parse_stickers_base
|
||||
from packages.domain.sticker_config import (
|
||||
# isort: on
|
||||
resolve_sticker_position,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,22 +25,28 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||
|
||||
from packages.domain.subtitle_style import (
|
||||
ALLOWED_SUBTITLE_EXTENSIONS,
|
||||
DEFAULT_COLOR,
|
||||
DEFAULT_FONT,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_MAX_CHARS_PER_LINE,
|
||||
DEFAULT_POSITION,
|
||||
DEFAULT_STROKE_COLOR,
|
||||
DEFAULT_STROKE_WIDTH,
|
||||
POSITION_ALIASES,
|
||||
POSITION_ALIGNMENT,
|
||||
SubtitleSegment,
|
||||
SubtitleStyle,
|
||||
)
|
||||
from packages.domain.subtitle_style import escape_ass_text as _escape_ass_text # noqa: F401 向后兼容导出
|
||||
from packages.domain.subtitle_style import format_ass_time as _format_ass_time
|
||||
from packages.domain.subtitle_style import hex_to_ass_bgr as _hex_to_ass_bgr # noqa: F401
|
||||
from packages.domain.subtitle_style import hex_to_ass_color as _hex_to_ass_color # noqa: F401
|
||||
from packages.domain.subtitle_style import opacity_to_ass_alpha as _opacity_to_ass_alpha # noqa: F401
|
||||
from packages.domain.subtitle_style import hex_to_ass_bgr as _hex_to_ass_bgr
|
||||
from packages.domain.subtitle_style import hex_to_ass_color as _hex_to_ass_color
|
||||
from packages.domain.subtitle_style import opacity_to_ass_alpha as _opacity_to_ass_alpha
|
||||
from packages.domain.subtitle_style import wrap_text as _wrap_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,14 +15,16 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.trim_config import MIN_TRIM_DURATION # noqa: F401
|
||||
from packages.domain.trim_config import extract_trim_from_clip_config # noqa: F401
|
||||
from packages.domain.trim_config import (
|
||||
MIN_TRIM_DURATION,
|
||||
TrimConfig,
|
||||
TrimSegment,
|
||||
)
|
||||
from packages.domain.trim_config import build_audio_trim_filter as _build_audio_trim_filter # noqa: F401 — 向后兼容
|
||||
from packages.domain.trim_config import build_video_trim_filter as _build_video_trim_filter
|
||||
from packages.domain.trim_config import (
|
||||
extract_trim_from_clip_config,
|
||||
)
|
||||
from packages.domain.trim_config import parse_segments_from_config as _parse_segments_from_config
|
||||
from packages.domain.trim_config import resolve_segments as _resolve_segments
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ from video_processing.tts_engine import TtsEngine
|
||||
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
|
||||
|
||||
from packages.domain.render_layer_utils import LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX
|
||||
from packages.domain.render_layer_utils import can_pass_through as _can_pass_through_pure
|
||||
from packages.domain.render_layer_utils import clip_adjusted_duration as _clip_adjusted_duration_pure
|
||||
from packages.domain.render_layer_utils import clip_effective_duration as _clip_effective_duration_pure
|
||||
from packages.domain.render_layer_utils import clip_playback_speed as _clip_playback_speed_pure
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.watermark_config import WATERMARK_POSITIONS # noqa: F401
|
||||
from packages.domain.watermark_config import (
|
||||
WATERMARK_POSITIONS,
|
||||
WatermarkConfig,
|
||||
)
|
||||
from packages.domain.watermark_config import ( # noqa: F401 — 向后兼容
|
||||
|
||||
@@ -186,7 +186,7 @@ class PiPConfig:
|
||||
"""最大 z_index."""
|
||||
if not self.layers:
|
||||
return 0
|
||||
return max(layer.z_index for layer in self.layers)
|
||||
return max(l.z_index for l in self.layers)
|
||||
|
||||
|
||||
# ── 纯逻辑工具函数 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +39,7 @@ class TestConstants:
|
||||
|
||||
def test_preset_params_complete(self):
|
||||
assert set(PRESET_PARAMS.keys()) == VALID_PRESETS
|
||||
for _preset, params in PRESET_PARAMS.items():
|
||||
for preset, params in PRESET_PARAMS.items():
|
||||
assert set(params.keys()) == set(ALL_PARAM_KEYS)
|
||||
|
||||
def test_default_params_keys(self):
|
||||
@@ -54,7 +54,7 @@ class TestConstants:
|
||||
assert min_val <= DEFAULT_PARAMS[key] <= max_val
|
||||
|
||||
def test_all_presets_within_ranges(self):
|
||||
for _preset, params in PRESET_PARAMS.items():
|
||||
for preset, params in PRESET_PARAMS.items():
|
||||
for key in ALL_PARAM_KEYS:
|
||||
min_val, max_val = PARAM_RANGES[key]
|
||||
assert min_val <= params[key] <= max_val, f"{preset}.{key}={params[key]} out of range"
|
||||
|
||||
@@ -208,7 +208,7 @@ class TestPiPConfigFromDict:
|
||||
}
|
||||
)
|
||||
assert cfg.layer_count == 3
|
||||
assert [layer.source for layer in cfg.layers] == ["bottom", "mid", "top"]
|
||||
assert [l.source for l in cfg.layers] == ["bottom", "mid", "top"]
|
||||
|
||||
def test_invalid_layer_skipped(self):
|
||||
cfg = PiPConfig.from_dict(
|
||||
|
||||
@@ -23,7 +23,7 @@ class TestConstants:
|
||||
assert len(POSITION_PRESETS) == 9
|
||||
|
||||
def test_position_presets_normalized(self):
|
||||
for _name, (x, y) in POSITION_PRESETS.items():
|
||||
for name, (x, y) in POSITION_PRESETS.items():
|
||||
assert 0.0 <= x <= 1.0
|
||||
assert 0.0 <= y <= 1.0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user