Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 797e9df48f | |||
| c45a845df4 | |||
| a9c549ef0e | |||
| 6481aa3cf2 | |||
| 7b14b7b065 | |||
| 5d3c4a8e21 |
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",
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user