Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d261652c7f | |||
| 5371b4a4d1 |
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"
|
||||
}
|
||||
Reference in New Issue
Block a user