Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d261652c7f | |||
| 5371b4a4d1 |
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API
|
||||
* 后端实际接口:/videos
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected"
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
id: string
|
||||
title: string
|
||||
video_url?: string
|
||||
thumbnail_url?: string
|
||||
duration_seconds?: number
|
||||
file_size?: number
|
||||
resolution?: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus
|
||||
/** 所属项目 ID */
|
||||
project_id?: string
|
||||
/** 所属项目名称 */
|
||||
project_name?: string
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
project_id?: string
|
||||
review_status?: ReviewStatus | "all"
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string
|
||||
/** 进度百分比 */
|
||||
progress?: number
|
||||
}
|
||||
|
||||
/** 后端 /videos 接口返回的原始视频条目 */
|
||||
interface VideoItem {
|
||||
id: string
|
||||
project_id: string
|
||||
generation_task_id: string
|
||||
name: string
|
||||
file_url: string
|
||||
file_size: number
|
||||
duration: number
|
||||
thumbnail_url: string | null
|
||||
width: number
|
||||
height: number
|
||||
fps: number
|
||||
status: string
|
||||
review_status: ReviewStatus
|
||||
generation_params: Record<string, unknown>
|
||||
download_url: string
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端 VideoItem 映射为 ProductItem 格式
|
||||
*/
|
||||
function mapVideoToProductItem(video: VideoItem): ProductItem {
|
||||
return {
|
||||
id: video.id,
|
||||
title: video.name || "未命名视频",
|
||||
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
|
||||
video_url: video.download_url || video.file_url,
|
||||
thumbnail_url: video.thumbnail_url || undefined,
|
||||
duration_seconds: video.duration,
|
||||
file_size: video.file_size,
|
||||
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
|
||||
status:
|
||||
video.status === "completed"
|
||||
? "completed"
|
||||
: video.status === "failed"
|
||||
? "failed"
|
||||
: "processing",
|
||||
review_status: video.review_status,
|
||||
project_id: video.project_id,
|
||||
// 后端 /videos 接口暂无 project_name 字段
|
||||
project_name: undefined,
|
||||
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
|
||||
created_at: video.generated_at,
|
||||
updated_at: video.generated_at,
|
||||
// 后端 /videos 接口暂无 duplicate_rate 字段
|
||||
duplicate_rate: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取成品列表(支持分页和筛选) */
|
||||
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/videos", { params })
|
||||
const data = response.data
|
||||
const videos: VideoItem[] = Array.isArray(data?.items)
|
||||
? data.items
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: []
|
||||
return videos.map(mapVideoToProductItem)
|
||||
}
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/videos/${productId}`)
|
||||
return mapVideoToProductItem(response.data as VideoItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成品
|
||||
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
|
||||
* 待后端实现后自动生效
|
||||
*/
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/videos/${productId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成品下载链接
|
||||
* 直接使用列表返回的 download_url(带OSS签名)
|
||||
*/
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
// 优先从列表缓存取;如果没有则调详情接口
|
||||
const product = await getProduct(productId)
|
||||
if (!product.video_url) throw new Error("下载链接不可用")
|
||||
return { url: product.video_url, expires_at: "" }
|
||||
}
|
||||
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
productId: string,
|
||||
status: ReviewStatus,
|
||||
): Promise<ProductItem> => {
|
||||
// 后端暂无 /videos/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId)
|
||||
return { ...product, review_status: status }
|
||||
}
|
||||
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /videos/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
|
||||
return { job_id: `mock-${Date.now()}` }
|
||||
}
|
||||
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /videos/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
||||
return { job_id: jobId, status: "processing", progress: 0 }
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API — 目录化入口
|
||||
* 保持与原 products.ts 相同导出,向后兼容
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
ReviewStatus,
|
||||
ProductItem,
|
||||
ProductListParams,
|
||||
ProductListResponse,
|
||||
BatchDownloadStatus,
|
||||
VideoItem,
|
||||
} from "./types"
|
||||
|
||||
// 工具函数
|
||||
export { mapVideoToProductItem } from "./utils"
|
||||
|
||||
// API 函数
|
||||
export {
|
||||
getProducts,
|
||||
getProduct,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
updateReviewStatus,
|
||||
batchDownload,
|
||||
getBatchDownloadStatus,
|
||||
} from "./products"
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API 函数
|
||||
* 后端实际接口:/videos
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
BatchDownloadStatus,
|
||||
ProductItem,
|
||||
ProductListParams,
|
||||
VideoItem,
|
||||
ReviewStatus,
|
||||
} from "./types"
|
||||
import { mapVideoToProductItem } from "./utils"
|
||||
|
||||
/** 获取成品列表(支持分页和筛选) */
|
||||
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/videos", { params })
|
||||
const data = response.data
|
||||
const videos: VideoItem[] = Array.isArray(data?.items)
|
||||
? data.items
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: []
|
||||
return videos.map(mapVideoToProductItem)
|
||||
}
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/videos/${productId}`)
|
||||
return mapVideoToProductItem(response.data as VideoItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成品
|
||||
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
|
||||
* 待后端实现后自动生效
|
||||
*/
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/videos/${productId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成品下载链接
|
||||
* 直接使用列表返回的 download_url(带OSS签名)
|
||||
*/
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
// 优先从列表缓存取;如果没有则调详情接口
|
||||
const product = await getProduct(productId)
|
||||
if (!product.video_url) throw new Error("下载链接不可用")
|
||||
return { url: product.video_url, expires_at: "" }
|
||||
}
|
||||
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
productId: string,
|
||||
status: ReviewStatus,
|
||||
): Promise<ProductItem> => {
|
||||
// 后端暂无 /videos/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId)
|
||||
return { ...product, review_status: status }
|
||||
}
|
||||
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /videos/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
|
||||
return { job_id: `mock-${Date.now()}` }
|
||||
}
|
||||
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /videos/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
||||
return { job_id: jobId, status: "processing", progress: 0 }
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* 成品 / 视频相关类型定义
|
||||
*/
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected"
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
id: string
|
||||
title: string
|
||||
video_url?: string
|
||||
thumbnail_url?: string
|
||||
duration_seconds?: number
|
||||
file_size?: number
|
||||
resolution?: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus
|
||||
/** 所属项目 ID */
|
||||
project_id?: string
|
||||
/** 所属项目名称 */
|
||||
project_name?: string
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
project_id?: string
|
||||
review_status?: ReviewStatus | "all"
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string
|
||||
/** 进度百分比 */
|
||||
progress?: number
|
||||
}
|
||||
|
||||
/** 后端 /videos 接口返回的原始视频条目 */
|
||||
export interface VideoItem {
|
||||
id: string
|
||||
project_id: string
|
||||
generation_task_id: string
|
||||
name: string
|
||||
file_url: string
|
||||
file_size: number
|
||||
duration: number
|
||||
thumbnail_url: string | null
|
||||
width: number
|
||||
height: number
|
||||
fps: number
|
||||
status: string
|
||||
review_status: ReviewStatus
|
||||
generation_params: Record<string, unknown>
|
||||
download_url: string
|
||||
generated_at: string
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 成品数据转换工具函数
|
||||
*/
|
||||
import type { ProductItem, VideoItem } from "./types"
|
||||
|
||||
/**
|
||||
* 将后端 VideoItem 映射为 ProductItem 格式
|
||||
*/
|
||||
export function mapVideoToProductItem(video: VideoItem): ProductItem {
|
||||
return {
|
||||
id: video.id,
|
||||
title: video.name || "未命名视频",
|
||||
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
|
||||
video_url: video.download_url || video.file_url,
|
||||
thumbnail_url: video.thumbnail_url || undefined,
|
||||
duration_seconds: video.duration,
|
||||
file_size: video.file_size,
|
||||
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
|
||||
status:
|
||||
video.status === "completed"
|
||||
? "completed"
|
||||
: video.status === "failed"
|
||||
? "failed"
|
||||
: "processing",
|
||||
review_status: video.review_status,
|
||||
project_id: video.project_id,
|
||||
// 后端 /videos 接口暂无 project_name 字段
|
||||
project_name: undefined,
|
||||
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
|
||||
created_at: video.generated_at,
|
||||
updated_at: video.generated_at,
|
||||
// 后端 /videos 接口暂无 duplicate_rate 字段
|
||||
duplicate_rate: undefined,
|
||||
}
|
||||
}
|
||||
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