Compare commits

...

6 Commits

Author SHA1 Message Date
xiaoxia 797e9df48f style: prettier format MyVoices.tsx
CI/CD Pipeline / Check if frontend-only change (pull_request) Failing after 0s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Failing after 0s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 0s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Failing after 0s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 0s
AI Code Review / AI Code Review (pull_request) Failing after 0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 0s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 0s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 0s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 0s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 0s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m36s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m21s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 8m12s
CI/CD Pipeline / CI Gate (pull_request) Failing after 0s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Waiting to run
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 12s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 19:03:59 +08:00
xiaoxia c45a845df4 fix: remove unused editingVoice variable
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 1s
2026-07-27 18:31:27 +08:00
xiaoxia a9c549ef0e Merge branch 'develop' into refactor/my-voices
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m5s
2026-07-27 18:07:57 +08:00
xiaoxia 6481aa3cf2 Merge branch 'develop' into refactor/my-voices
CI/CD Pipeline / CI Gate (pull_request) CI运行中 [frontend-only]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 11s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 25s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 18s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m8s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 3m7s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m33s
AI Code Review / AI Code Review (pull_request) Successful in 1m16s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 40s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
2026-07-27 15:30:50 +08:00
xiaoxia 7b14b7b065 Merge branch 'develop' into refactor/my-voices
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 47s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 55s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m39s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m3s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m6s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m26s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 49s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 42s
AI Code Review / AI Code Review (pull_request) Successful in 1m2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 55s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m37s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
2026-07-27 12:34:46 +08:00
xiaoxia 5d3c4a8e21 refactor(my-voices): 三阶段重构 MyVoices(381→148行, -61%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 28s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 45s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m12s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m19s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 55s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m16s
AI Code Review / AI Code Review (pull_request) Successful in 1m47s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m54s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m6s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 28s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- Phase 1: 抽 types.ts(STATUS_CONFIG/ToastItem)+ utils.ts(formatDate)
- Phase 2: 抽 3 个 UI 组件(VoiceCard/States(5个状态组件))
- Phase 3: 抽 useMyVoices 业务 Hook(播放/编辑/删除/Toast)
- 主文件 381→148 行,逻辑与视图分离
2026-07-26 17:52:24 +08:00
6 changed files with 405 additions and 294 deletions
+41 -294
View File
@@ -1,259 +1,47 @@
/**
* 我的音色页面 — V21 设计系统(任务 3.12 升级 / 3.15 进度轮询)
* 我的音色页面 — V21 设计系统
*
* 功能:克隆音色卡片列表、试听播放、状态标签、删除、编辑名称、空状态引导
* 使用 useCloneProgress hook 实现 processing 状态自动轮询
*/
import React, { useState, useCallback, useRef } from "react"
import { useNavigate } from "react-router-dom"
import {
PlayCircleOutlined,
PauseCircleOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
SoundOutlined,
ClockCircleOutlined,
} from "@ant-design/icons"
import { Button, Modal, Input, Tooltip } from "@/components/ui"
import React from "react"
import { PlusOutlined } from "@ant-design/icons"
import { Button, Modal, Input } from "@/components/ui"
import type { ButtonProps } from "antd"
import PageHead from "@/components/layout/PageHead"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import { deleteVoiceClone, updateVoiceClone, formatDuration } from "@/api/voice-clone"
import type { VoiceClone, VoiceCloneStatus } from "@/api/voice-clone"
import { VoiceCard } from "./components/VoiceCard"
import {
StatsBar,
EmptyState,
LoadingState,
PollingHint,
ToastContainer,
} from "./components/States"
import { useMyVoices } from "./hooks/useMyVoices"
import "./my-voices.css"
/* ============================================================
* 工具函数
* ============================================================ */
function formatDate(isoStr: string): string {
const d = new Date(isoStr)
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
}
/* ============================================================
* 状态配置
* ============================================================ */
const STATUS_CONFIG: Record<VoiceCloneStatus, { label: string; dotClass: string }> = {
ready: { label: "就绪", dotClass: "xx-mv-status-dot--ready" },
processing: { label: "克隆中", dotClass: "xx-mv-status-dot--processing" },
failed: { label: "失败", dotClass: "xx-mv-status-dot--failed" },
}
/* ============================================================
* Toast 组件
* ============================================================ */
interface ToastItem {
id: number
message: string
type: "success" | "error"
}
let _toastId = 0
/* ============================================================
* 音色卡片组件
* ============================================================ */
interface VoiceCardProps {
voice: VoiceClone
isPlaying: boolean
onTogglePlay: (voice: VoiceClone) => void
onEdit: (voice: VoiceClone) => void
onDelete: (voice: VoiceClone) => void
}
const VoiceCard: React.FC<VoiceCardProps> = ({
voice,
isPlaying,
onTogglePlay,
onEdit,
onDelete,
}) => {
const statusCfg = STATUS_CONFIG[voice.status]
const isReady = voice.status === "ready"
return (
<div className={`xx-mv-card xx-mv-card--${voice.status}`}>
{/* 头部:头像 + 名称 + 状态 */}
<div className="xx-mv-card-header">
<div className={`xx-mv-card-avatar xx-mv-card-avatar--${voice.status}`}>
<SoundOutlined />
</div>
<div className="xx-mv-card-info">
<h4 className="xx-mv-card-name">{voice.name}</h4>
<span className="xx-mv-status">
<span className={`xx-mv-status-dot ${statusCfg.dotClass}`} />
{statusCfg.label}
</span>
</div>
</div>
{/* 元信息 */}
<div className="xx-mv-card-meta">
<span className="xx-mv-card-meta-item">
<ClockCircleOutlined /> {formatDate(voice.created_at)}
</span>
{voice.duration_seconds > 0 && (
<span className="xx-mv-card-meta-item">{formatDuration(voice.duration_seconds)}</span>
)}
</div>
{/* 进度条(克隆中 — indeterminate 条纹流动动画) */}
{voice.status === "processing" && (
<div className="xx-mv-progress xx-mv-progress--indeterminate">
<div className="xx-mv-progress-bar" />
<span className="xx-mv-progress-text"></span>
</div>
)}
{/* 操作区 */}
<div className="xx-mv-card-actions">
{isReady ? (
<Button
buttonType={isPlaying ? "secondary" : "ghost"}
buttonSize="sm"
onClick={() => onTogglePlay(voice)}
>
{isPlaying ? (
<>
<PauseCircleOutlined />
</>
) : (
<>
<PlayCircleOutlined />
</>
)}
</Button>
) : voice.status === "failed" ? (
<Button buttonType="ghost" buttonSize="sm" disabled>
</Button>
) : (
<Button buttonType="ghost" buttonSize="sm" disabled>
...
</Button>
)}
<div className="xx-mv-card-icon-actions">
<Tooltip title="编辑名称">
<button
type="button"
className="xx-mv-icon-btn"
onClick={() => onEdit(voice)}
disabled={!isReady}
>
<EditOutlined />
</button>
</Tooltip>
<Tooltip title="删除">
<button
type="button"
className="xx-mv-icon-btn xx-mv-icon-btn--danger"
onClick={() => onDelete(voice)}
>
<DeleteOutlined />
</button>
</Tooltip>
</div>
</div>
</div>
)
}
/* ============================================================
* 主页面组件
* ============================================================ */
const MyVoices: React.FC = () => {
const navigate = useNavigate()
const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress()
const [playingId, setPlayingId] = useState<string | null>(null)
const [toasts, setToasts] = useState<ToastItem[]>([])
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(null)
const [editName, setEditName] = useState("")
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
// Toast
const showToast = useCallback((message: string, type: ToastItem["type"]) => {
const id = ++_toastId
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000)
}, [])
// 试听播放
const handleTogglePlay = useCallback(
(voice: VoiceClone) => {
if (playingId === voice.id) {
audioRef.current?.pause()
setPlayingId(null)
return
}
if (audioRef.current) {
audioRef.current.pause()
}
if (!voice.sample_url) {
showToast("暂无试听音频", "error")
return
}
const audio = new Audio(voice.sample_url)
audioRef.current = audio
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"))
audio.onended = () => setPlayingId(null)
setPlayingId(voice.id)
},
[playingId, showToast],
)
// 编辑
const handleEdit = (voice: VoiceClone) => {
setEditingVoice(voice)
setEditName(voice.name)
setEditModalOpen(true)
}
const handleEditConfirm = async () => {
if (!editingVoice || !editName.trim()) return
try {
const updated = await updateVoiceClone(editingVoice.id, {
name: editName.trim(),
})
updateClone(updated)
setEditModalOpen(false)
setEditingVoice(null)
showToast("名称已更新", "success")
} catch {
showToast("更新失败,请重试", "error")
}
}
// 删除
const handleDelete = (voice: VoiceClone) => {
setDeleteConfirmId(voice.id)
}
const handleDeleteConfirm = async () => {
if (!deleteConfirmId) return
try {
await deleteVoiceClone(deleteConfirmId)
removeClone(deleteConfirmId)
setDeleteConfirmId(null)
showToast("音色已删除", "success")
} catch {
showToast("删除失败,请重试", "error")
}
}
// 克隆新音色
const handleCloneNew = () => {
navigate("/app/voices")
}
// 统计
const readyCount = clones.filter((v) => v.status === "ready").length
const processingCount = clones.filter((v) => v.status === "processing").length
const {
clones,
loading,
hasProcessing,
playingId,
toasts,
editModalOpen,
editName,
setEditName,
deleteConfirmId,
readyCount,
processingCount,
handleTogglePlay,
handleEdit,
handleEditCancel,
handleEditConfirm,
handleDelete,
handleDeleteCancel,
handleDeleteConfirm,
handleCloneNew,
} = useMyVoices()
return (
<div className="xx-mv-page">
@@ -269,39 +57,15 @@ const MyVoices: React.FC = () => {
/>
{/* 轮询提示 */}
{hasProcessing && (
<div className="xx-mv-polling-hint">
<span className="xx-mv-polling-dot" />
...
</div>
)}
{hasProcessing && <PollingHint />}
{/* 统计栏 */}
{!loading && clones.length > 0 && (
<div className="xx-mv-stats">
<span className="xx-mv-stat">
<strong>{clones.length}</strong>
</span>
<span className="xx-mv-stat xx-mv-stat--ready">
<span className="xx-mv-stat-dot xx-mv-stat-dot--ready" />
{readyCount}
</span>
{processingCount > 0 && (
<span className="xx-mv-stat xx-mv-stat--processing">
<span className="xx-mv-stat-dot xx-mv-stat-dot--processing" />
{processingCount}
</span>
)}
</div>
<StatsBar total={clones.length} readyCount={readyCount} processingCount={processingCount} />
)}
{/* 加载状态 */}
{loading && (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon"></div>
<p className="xx-mv-empty-desc">...</p>
</div>
)}
{loading && <LoadingState />}
{/* 卡片网格 */}
{!loading && clones.length > 0 && (
@@ -320,22 +84,13 @@ const MyVoices: React.FC = () => {
)}
{/* 空状态 */}
{!loading && clones.length === 0 && (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon">🎤</div>
<h3 className="xx-mv-empty-title"></h3>
<p className="xx-mv-empty-desc">AI </p>
<Button buttonType="primary" onClick={handleCloneNew}>
<PlusOutlined />
</Button>
</div>
)}
{!loading && clones.length === 0 && <EmptyState onClone={handleCloneNew} />}
{/* 编辑弹窗 */}
<Modal
open={editModalOpen}
title="编辑音色名称"
onCancel={() => setEditModalOpen(false)}
onCancel={handleEditCancel}
onOk={handleEditConfirm}
okText="保存"
cancelText="取消"
@@ -355,7 +110,7 @@ const MyVoices: React.FC = () => {
<Modal
open={!!deleteConfirmId}
title="确认删除"
onCancel={() => setDeleteConfirmId(null)}
onCancel={handleDeleteCancel}
onOk={handleDeleteConfirm}
okText="删除"
cancelText="取消"
@@ -365,15 +120,7 @@ const MyVoices: React.FC = () => {
</Modal>
{/* Toast */}
{toasts.length > 0 && (
<div className="xx-mv-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`xx-mv-toast xx-mv-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)}
<ToastContainer toasts={toasts} />
</div>
)
}
@@ -0,0 +1,79 @@
import React from "react"
import { PlusOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import type { ToastItem } from "../types"
interface StatsBarProps {
total: number
readyCount: number
processingCount: number
}
/** 统计栏 */
export const StatsBar: React.FC<StatsBarProps> = ({ total, readyCount, processingCount }) => (
<div className="xx-mv-stats">
<span className="xx-mv-stat">
<strong>{total}</strong>
</span>
<span className="xx-mv-stat xx-mv-stat--ready">
<span className="xx-mv-stat-dot xx-mv-stat-dot--ready" />
{readyCount}
</span>
{processingCount > 0 && (
<span className="xx-mv-stat xx-mv-stat--processing">
<span className="xx-mv-stat-dot xx-mv-stat-dot--processing" />
{processingCount}
</span>
)}
</div>
)
interface EmptyStateProps {
onClone: () => void
}
/** 空状态 */
export const EmptyState: React.FC<EmptyStateProps> = ({ onClone }) => (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon">🎤</div>
<h3 className="xx-mv-empty-title"></h3>
<p className="xx-mv-empty-desc">AI </p>
<Button buttonType="primary" onClick={onClone}>
<PlusOutlined />
</Button>
</div>
)
/** 加载状态 */
export const LoadingState: React.FC = () => (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon"></div>
<p className="xx-mv-empty-desc">...</p>
</div>
)
/** 轮询提示 */
export const PollingHint: React.FC = () => (
<div className="xx-mv-polling-hint">
<span className="xx-mv-polling-dot" />
...
</div>
)
interface ToastContainerProps {
toasts: ToastItem[]
}
/** Toast 容器 */
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts }) => {
if (toasts.length === 0) return null
return (
<div className="xx-mv-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`xx-mv-toast xx-mv-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)
}
@@ -0,0 +1,121 @@
import React from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
DeleteOutlined,
EditOutlined,
SoundOutlined,
ClockCircleOutlined,
} from "@ant-design/icons"
import { Button, Tooltip } from "@/components/ui"
import { formatDuration, type VoiceClone } from "@/api/voice-clone"
import { STATUS_CONFIG } from "../types"
import { formatDate } from "../utils"
interface VoiceCardProps {
voice: VoiceClone
isPlaying: boolean
onTogglePlay: (voice: VoiceClone) => void
onEdit: (voice: VoiceClone) => void
onDelete: (voice: VoiceClone) => void
}
/**
* 音色卡片组件
*/
export const VoiceCard: React.FC<VoiceCardProps> = ({
voice,
isPlaying,
onTogglePlay,
onEdit,
onDelete,
}) => {
const statusCfg = STATUS_CONFIG[voice.status]
const isReady = voice.status === "ready"
return (
<div className={`xx-mv-card xx-mv-card--${voice.status}`}>
{/* 头部:头像 + 名称 + 状态 */}
<div className="xx-mv-card-header">
<div className={`xx-mv-card-avatar xx-mv-card-avatar--${voice.status}`}>
<SoundOutlined />
</div>
<div className="xx-mv-card-info">
<h4 className="xx-mv-card-name">{voice.name}</h4>
<span className="xx-mv-status">
<span className={`xx-mv-status-dot ${statusCfg.dotClass}`} />
{statusCfg.label}
</span>
</div>
</div>
{/* 元信息 */}
<div className="xx-mv-card-meta">
<span className="xx-mv-card-meta-item">
<ClockCircleOutlined /> {formatDate(voice.created_at)}
</span>
{voice.duration_seconds > 0 && (
<span className="xx-mv-card-meta-item">{formatDuration(voice.duration_seconds)}</span>
)}
</div>
{/* 进度条(克隆中 — indeterminate 条纹流动动画) */}
{voice.status === "processing" && (
<div className="xx-mv-progress xx-mv-progress--indeterminate">
<div className="xx-mv-progress-bar" />
<span className="xx-mv-progress-text"></span>
</div>
)}
{/* 操作区 */}
<div className="xx-mv-card-actions">
{isReady ? (
<Button
buttonType={isPlaying ? "secondary" : "ghost"}
buttonSize="sm"
onClick={() => onTogglePlay(voice)}
>
{isPlaying ? (
<>
<PauseCircleOutlined />
</>
) : (
<>
<PlayCircleOutlined />
</>
)}
</Button>
) : voice.status === "failed" ? (
<Button buttonType="ghost" buttonSize="sm" disabled>
</Button>
) : (
<Button buttonType="ghost" buttonSize="sm" disabled>
...
</Button>
)}
<div className="xx-mv-card-icon-actions">
<Tooltip title="编辑名称">
<button
type="button"
className="xx-mv-icon-btn"
onClick={() => onEdit(voice)}
disabled={!isReady}
>
<EditOutlined />
</button>
</Tooltip>
<Tooltip title="删除">
<button
type="button"
className="xx-mv-icon-btn xx-mv-icon-btn--danger"
onClick={() => onDelete(voice)}
>
<DeleteOutlined />
</button>
</Tooltip>
</div>
</div>
</div>
)
}
@@ -0,0 +1,140 @@
import { useState, useCallback, useRef } from "react"
import { useNavigate } from "react-router-dom"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import { deleteVoiceClone, updateVoiceClone } from "@/api/voice-clone"
import type { VoiceClone } from "@/api/voice-clone"
import type { ToastItem } from "../types"
let _toastId = 0
/**
* 我的音色业务 Hook
* 封装播放、编辑、删除、Toast 等业务逻辑
*/
export const useMyVoices = () => {
const navigate = useNavigate()
const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress()
const [playingId, setPlayingId] = useState<string | null>(null)
const [toasts, setToasts] = useState<ToastItem[]>([])
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(null)
const [editName, setEditName] = useState("")
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
// Toast
const showToast = useCallback((message: string, type: ToastItem["type"]) => {
const id = ++_toastId
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000)
}, [])
// 试听播放
const handleTogglePlay = useCallback(
(voice: VoiceClone) => {
if (playingId === voice.id) {
audioRef.current?.pause()
setPlayingId(null)
return
}
if (audioRef.current) {
audioRef.current.pause()
}
if (!voice.sample_url) {
showToast("暂无试听音频", "error")
return
}
const audio = new Audio(voice.sample_url)
audioRef.current = audio
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"))
audio.onended = () => setPlayingId(null)
setPlayingId(voice.id)
},
[playingId, showToast],
)
// 编辑
const handleEdit = useCallback((voice: VoiceClone) => {
setEditingVoice(voice)
setEditName(voice.name)
setEditModalOpen(true)
}, [])
const handleEditCancel = useCallback(() => {
setEditModalOpen(false)
setEditingVoice(null)
}, [])
const handleEditConfirm = useCallback(async () => {
if (!editingVoice || !editName.trim()) return
try {
const updated = await updateVoiceClone(editingVoice.id, {
name: editName.trim(),
})
updateClone(updated)
setEditModalOpen(false)
setEditingVoice(null)
showToast("名称已更新", "success")
} catch {
showToast("更新失败,请重试", "error")
}
}, [editingVoice, editName, updateClone, showToast])
// 删除
const handleDelete = useCallback((voice: VoiceClone) => {
setDeleteConfirmId(voice.id)
}, [])
const handleDeleteCancel = useCallback(() => {
setDeleteConfirmId(null)
}, [])
const handleDeleteConfirm = useCallback(async () => {
if (!deleteConfirmId) return
try {
await deleteVoiceClone(deleteConfirmId)
removeClone(deleteConfirmId)
setDeleteConfirmId(null)
showToast("音色已删除", "success")
} catch {
showToast("删除失败,请重试", "error")
}
}, [deleteConfirmId, removeClone, showToast])
// 克隆新音色
const handleCloneNew = useCallback(() => {
navigate("/app/voices")
}, [navigate])
// 统计
const readyCount = clones.filter((v) => v.status === "ready").length
const processingCount = clones.filter((v) => v.status === "processing").length
return {
// 数据
clones,
loading,
hasProcessing,
// 状态
playingId,
toasts,
editModalOpen,
editingVoice,
editName,
setEditName,
deleteConfirmId,
// 统计
readyCount,
processingCount,
// 操作
showToast,
handleTogglePlay,
handleEdit,
handleEditCancel,
handleEditConfirm,
handleDelete,
handleDeleteCancel,
handleDeleteConfirm,
handleCloneNew,
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { VoiceCloneStatus } from "@/api/voice-clone"
/** 状态配置 */
export const STATUS_CONFIG: Record<VoiceCloneStatus, { label: string; dotClass: string }> = {
ready: { label: "就绪", dotClass: "xx-mv-status-dot--ready" },
processing: { label: "克隆中", dotClass: "xx-mv-status-dot--processing" },
failed: { label: "失败", dotClass: "xx-mv-status-dot--failed" },
}
/** Toast 类型 */
export interface ToastItem {
id: number
message: string
type: "success" | "error"
}
+9
View File
@@ -0,0 +1,9 @@
/** 格式化日期 */
export function formatDate(isoStr: string): string {
const d = new Date(isoStr)
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
}