Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 3562b33ad0 Merge branch 'develop' into refactor/voice-clone-api
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m12s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m45s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m21s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m42s
AI Code Review / AI Code Review (pull_request) Successful in 1m39s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m19s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request) [frontend-only]
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 22s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 10s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1220h39m50s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1221h14m12s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1221h14m13s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1221h14m13s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1221h14m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1222h59m27s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1222h59m28s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1222h59m30s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1222h59m31s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1222h59m32s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1222h59m33s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1224h56m35s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1224h56m39s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1225h29m22s
2026-07-27 12:35:00 +08:00
xiaoxia 01ca228c07 refactor(voice-clone): API层按模块拆分(208行→入口28行, -87%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 27s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 59s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 32s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m7s
AI Code Review / AI Code Review (pull_request) Successful in 1m38s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m12s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 42s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 3m14s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 26s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1245h47m52s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1245h56m2s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1245h56m4s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1246h5m57s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1246h13m27s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1246h16m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1246h13m29s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1246h16m49s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1246h16m51s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1246h22m28s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1246h22m30s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1246h22m32s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1246h49m28s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1246h28m44s
将 voice-clone.ts 单文件拆分为目录结构:
- types.ts — 类型定义
- utils.ts — toVoiceClone 映射 + formatDuration
- clones.ts — 克隆 CRUD + 状态 + 重试
- index.ts — 统一入口 re-export

导入路径 @/api/voice-clone 保持向后兼容。
2026-07-26 16:56:23 +08:00
11 changed files with 507 additions and 603 deletions
-208
View File
@@ -1,208 +0,0 @@
/**
* 音色克隆 API
* 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05)
* 任务 3.15:新增 progress 字段用于进度展示
*/
import apiClient from "./client"
/* ── 前端兼容类型 ─────────────────────────────────────── */
/** 克隆音色状态(前端展示用) */
export type VoiceCloneStatus = "ready" | "processing" | "failed"
/** 克隆音色条目(前端展示用) */
export interface VoiceClone {
id: string
name: string
description: string
duration_seconds: number
status: VoiceCloneStatus
/** 克隆进度 0-100,仅 processing 状态时有值 */
progress: number
sample_url?: string
language: string
gender: string
error_message: string | null
created_at: string
updated_at: string
}
/** 创建克隆请求(前端简化版) */
export interface CreateVoiceCloneRequest {
name: string
audio_url: string
description?: string
}
/* ── 后端 API 类型 ────────────────────────────────────── */
/** 音色克隆元数据(克隆时附带的扩展信息) */
export interface VoiceCloneMetadata {
/** 语音时长(秒) */
duration?: number
/** 采样率(Hz */
sample_rate?: number
/** 音色 ID(克隆完成后分配) */
voice_id?: string
/** 其他扩展字段 */
[key: string]: unknown
}
/** 后端克隆档案响应 */
export interface VoiceCloneProfile {
id: string
user_id: string
name: string
description: string
source_audio_url: string
voice_id: string | null
voice_model: string
language: string
gender: string
status: "pending" | "processing" | "ready" | "failed"
error_message: string | null
retry_count: number
max_retries: number
metadata_: VoiceCloneMetadata | null
created_at: string
updated_at: string
}
/** 后端克隆列表响应 */
export interface ListVoiceCloneResponse {
items: VoiceCloneProfile[]
total: number
}
/** 后端克隆状态响应 */
export interface VoiceCloneStatusResponse {
id: string
status: "pending" | "processing" | "ready" | "failed"
error_message: string | null
voice_id: string | null
retry_count: number
}
/** 后端创建克隆请求(完整版) */
export interface CreateVoiceCloneRequestFull {
name: string
description?: string
source_audio_url: string
voice_model?: string
language?: string
gender?: string
max_retries?: number
metadata_?: VoiceCloneMetadata
}
/* ── 辅助函数 ─────────────────────────────────────────── */
/**
* 将后端 VoiceCloneProfile 转换为前端 VoiceClone
* 后端 status "pending" 映射为前端 "processing"
*/
export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
id: profile.id,
name: profile.name,
description: profile.description || "",
duration_seconds: 0,
status: profile.status === "pending" ? "processing" : profile.status,
progress: 0,
sample_url: profile.source_audio_url || undefined,
language: profile.language || "",
gender: profile.gender || "",
error_message: profile.error_message || null,
created_at: profile.created_at,
updated_at: profile.updated_at,
})
/** 格式化时长 */
export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${String(s).padStart(2, "0")}`
}
/* ── 查询参数 ─────────────────────────────────────────── */
export interface VoiceCloneListParams {
status?: string
skip?: number
limit?: number
}
/* ── API 函数 ─────────────────────────────────────────── */
/** 获取克隆音色列表(返回前端兼容数组) */
export const getVoiceClones = async (params?: VoiceCloneListParams): Promise<VoiceClone[]> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
return response.data.items.map(toVoiceClone)
}
/** 获取克隆音色列表(返回完整响应含 total) */
export const getVoiceClonesWithTotal = async (
params?: VoiceCloneListParams,
): Promise<ListVoiceCloneResponse> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
return response.data
}
/** 获取单个克隆音色详情 */
export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
return response.data
}
/** 创建克隆音色 */
export const createVoiceClone = async (
data: CreateVoiceCloneRequest,
): Promise<VoiceCloneProfile> => {
const payload: CreateVoiceCloneRequestFull = {
name: data.name,
description: data.description,
source_audio_url: data.audio_url,
}
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
return response.data
}
/** 删除克隆音色 */
export const deleteVoiceClone = async (id: string): Promise<void> => {
await apiClient.delete(`/voice-clones/${id}`)
}
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
export const updateVoiceClone = async (
id: string,
data: Partial<Pick<VoiceClone, "name">>,
): Promise<VoiceClone> => {
// 后端暂未提供更新端点,暂用详情接口模拟
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
return toVoiceClone({
...response.data,
...data,
updated_at: new Date().toISOString(),
})
}
/** 获取克隆状态 */
export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
return response.data
}
/** 重试克隆 */
export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
return response.data
}
+87
View File
@@ -0,0 +1,87 @@
/**
* 音色克隆 API 函数
*/
import apiClient from "../client"
import { toVoiceClone } from "./utils"
import type {
VoiceClone,
VoiceCloneProfile,
CreateVoiceCloneRequest,
CreateVoiceCloneRequestFull,
VoiceCloneListParams,
ListVoiceCloneResponse,
VoiceCloneStatusResponse,
} from "./types"
/** 获取克隆音色列表(返回前端兼容数组) */
export const getVoiceClones = async (params?: VoiceCloneListParams): Promise<VoiceClone[]> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
return response.data.items.map(toVoiceClone)
}
/** 获取克隆音色列表(返回完整响应含 total) */
export const getVoiceClonesWithTotal = async (
params?: VoiceCloneListParams,
): Promise<ListVoiceCloneResponse> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
return response.data
}
/** 获取单个克隆音色详情 */
export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
return response.data
}
/** 创建克隆音色 */
export const createVoiceClone = async (
data: CreateVoiceCloneRequest,
): Promise<VoiceCloneProfile> => {
const payload: CreateVoiceCloneRequestFull = {
name: data.name,
description: data.description,
source_audio_url: data.audio_url,
}
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
return response.data
}
/** 删除克隆音色 */
export const deleteVoiceClone = async (id: string): Promise<void> => {
await apiClient.delete(`/voice-clones/${id}`)
}
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
export const updateVoiceClone = async (
id: string,
data: Partial<Pick<VoiceClone, "name">>,
): Promise<VoiceClone> => {
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
return toVoiceClone({
...response.data,
...data,
updated_at: new Date().toISOString(),
})
}
/** 获取克隆状态 */
export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
return response.data
}
/** 重试克隆 */
export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
return response.data
}
+32
View File
@@ -0,0 +1,32 @@
/**
* 音色克隆 API — 目录化入口
* 保持与原 voice-clone.ts 相同导出,向后兼容
*/
// 类型
export type {
VoiceCloneStatus,
VoiceClone,
CreateVoiceCloneRequest,
VoiceCloneMetadata,
VoiceCloneProfile,
ListVoiceCloneResponse,
VoiceCloneStatusResponse,
CreateVoiceCloneRequestFull,
VoiceCloneListParams,
} from "./types"
// 工具函数
export { toVoiceClone, formatDuration } from "./utils"
// API 函数
export {
getVoiceClones,
getVoiceClonesWithTotal,
getVoiceCloneDetail,
createVoiceClone,
deleteVoiceClone,
updateVoiceClone,
getVoiceCloneStatus,
retryVoiceClone,
} from "./clones"
+92
View File
@@ -0,0 +1,92 @@
/**
* 音色克隆类型定义
*/
/** 克隆音色状态(前端展示用) */
export type VoiceCloneStatus = "ready" | "processing" | "failed"
/** 克隆音色条目(前端展示用) */
export interface VoiceClone {
id: string
name: string
description: string
duration_seconds: number
status: VoiceCloneStatus
/** 克隆进度 0-100,仅 processing 状态时有值 */
progress: number
sample_url?: string
language: string
gender: string
error_message: string | null
created_at: string
updated_at: string
}
/** 创建克隆请求(前端简化版) */
export interface CreateVoiceCloneRequest {
name: string
audio_url: string
description?: string
}
/** 音色克隆元数据 */
export interface VoiceCloneMetadata {
duration?: number
sample_rate?: number
voice_id?: string
[key: string]: unknown
}
/** 后端克隆档案响应 */
export interface VoiceCloneProfile {
id: string
user_id: string
name: string
description: string
source_audio_url: string
voice_id: string | null
voice_model: string
language: string
gender: string
status: "pending" | "processing" | "ready" | "failed"
error_message: string | null
retry_count: number
max_retries: number
metadata_: VoiceCloneMetadata | null
created_at: string
updated_at: string
}
/** 后端克隆列表响应 */
export interface ListVoiceCloneResponse {
items: VoiceCloneProfile[]
total: number
}
/** 后端克隆状态响应 */
export interface VoiceCloneStatusResponse {
id: string
status: "pending" | "processing" | "ready" | "failed"
error_message: string | null
voice_id: string | null
retry_count: number
}
/** 后端创建克隆请求(完整版) */
export interface CreateVoiceCloneRequestFull {
name: string
description?: string
source_audio_url: string
voice_model?: string
language?: string
gender?: string
max_retries?: number
metadata_?: VoiceCloneMetadata
}
/** 查询参数 */
export interface VoiceCloneListParams {
status?: string
skip?: number
limit?: number
}
+30
View File
@@ -0,0 +1,30 @@
/**
* 音色克隆工具函数
*/
import type { VoiceCloneProfile, VoiceClone } from "./types"
/**
* 将后端 VoiceCloneProfile 转换为前端 VoiceClone
* 后端 status "pending" 映射为前端 "processing"
*/
export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
id: profile.id,
name: profile.name,
description: profile.description || "",
duration_seconds: 0,
status: profile.status === "pending" ? "processing" : profile.status,
progress: 0,
sample_url: profile.source_audio_url || undefined,
language: profile.language || "",
gender: profile.gender || "",
error_message: profile.error_message || null,
created_at: profile.created_at,
updated_at: profile.updated_at,
})
/** 格式化时长 */
export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${String(s).padStart(2, "0")}`
}
+266 -49
View File
@@ -1,21 +1,8 @@
/**
* 上传音频弹窗
*
* 子组件目录: upload-voice/
* - UploadDragger: 拖拽上传区
* - SelectedFileInfo: 已选文件信息
* - UploadProgress: 上传进度条
* - UploadFormFields: 表单区域(名称/性别/描述)
* - UploadActions: 底部操作按钮
*/
import React from "react"
import { Modal } from "antd"
import { UploadOutlined, SoundOutlined } from "@ant-design/icons"
import { Modal, Upload, message } from "antd"
import { type VoiceGender } from "@/pages/voices/types"
import UploadDragger from "./upload-voice/UploadDragger"
import SelectedFileInfo from "./upload-voice/SelectedFileInfo"
import UploadProgress from "./upload-voice/UploadProgress"
import UploadFormFields from "./upload-voice/UploadFormFields"
import UploadActions from "./upload-voice/UploadActions"
import { formatFileSize } from "@/pages/voices/utils/format"
export interface UploadVoiceModalProps {
open: boolean
@@ -33,6 +20,7 @@ export interface UploadVoiceModalProps {
onUpload: () => void
}
/** 上传音频弹窗 */
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
open,
uploadFile,
@@ -48,21 +36,17 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
onDescChange,
onUpload,
}) => {
const isUploading = uploadProgress !== null
const handleClose = () => {
if (isUploading) return // 上传中不可关闭
onClose()
}
return (
<Modal
title="上传音频"
open={open}
onCancel={handleClose}
onCancel={() => {
if (uploadProgress !== null) return // 上传中不可关闭
onClose()
}}
footer={null}
width={520}
maskClosable={!isUploading}
maskClosable={uploadProgress === null}
>
<div
style={{
@@ -73,37 +57,270 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
}}
>
{/* 拖拽上传区 */}
<UploadDragger
disabled={isUploading}
onFileSelect={onFileSelect}
onFileRemove={onFileRemove}
/>
<Upload.Dragger
accept="audio/*"
maxCount={1}
beforeUpload={(file) => {
onFileSelect(file)
return false
}}
onRemove={() => {
onFileRemove()
}}
showUploadList={false}
disabled={uploadProgress !== null}
>
<p
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
}}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
</p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
MP3WAVAACFLAC 200MB
</p>
</Upload.Dragger>
{/* 已选文件信息 */}
{uploadFile && <SelectedFileInfo file={uploadFile} />}
{uploadFile && (
<div
style={{
padding: "10px 12px",
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{uploadFile.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{formatFileSize(uploadFile.size)}
</div>
</div>
</div>
)}
{/* 上传进度 */}
{isUploading && <UploadProgress progress={uploadProgress!} />}
{uploadProgress !== null && (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--primary-color)",
}}
>
{uploadProgress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{uploadProgress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${uploadProgress}%`,
background: "var(--primary-color)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)}
{/* 表单字段 */}
<UploadFormFields
name={uploadName}
gender={uploadGender}
description={uploadDesc}
disabled={isUploading}
onNameChange={onNameChange}
onGenderChange={onGenderChange}
onDescriptionChange={onDescChange}
/>
{/* 名称 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<input
value={uploadName}
onChange={(e) => onNameChange(e.target.value)}
placeholder="输入素材名称"
maxLength={100}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
/>
</div>
{/* 性别选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<div style={{ display: "flex", gap: 8 }}>
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
<button
key={g}
type="button"
onClick={() => onGenderChange(g)}
disabled={uploadProgress !== null}
style={{
flex: 1,
padding: "6px 0",
borderRadius: 8,
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
background: uploadGender === g ? "var(--primary-soft)" : "transparent",
color: uploadGender === g ? "var(--primary-color)" : "var(--text-secondary)",
fontSize: 13,
fontWeight: uploadGender === g ? 600 : 400,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
transition: "all 0.2s",
}}
>
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
</button>
))}
</div>
</div>
{/* 描述 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={uploadDesc}
onChange={(e) => onDescChange(e.target.value)}
placeholder="描述这个音色的特点..."
maxLength={500}
rows={2}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
}}
/>
</div>
{/* 操作按钮 */}
<UploadActions
hasFile={!!uploadFile}
hasName={!!uploadName.trim()}
uploading={isUploading}
onCancel={onClose}
onUpload={onUpload}
/>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
paddingTop: 4,
}}
>
<button
type="button"
onClick={onClose}
disabled={uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "1px solid var(--border-color)",
background: "transparent",
fontSize: 13,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
color: "var(--text-secondary)",
}}
>
</button>
<button
type="button"
onClick={() => {
if (!uploadFile) {
message.warning("请先选择音频文件")
return
}
if (!uploadName.trim()) {
message.warning("请输入素材名称")
return
}
onUpload()
}}
disabled={!uploadFile || !uploadName.trim() || uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "none",
background:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "not-allowed"
: "pointer",
}}
>
{uploadProgress !== null ? "上传中..." : "开始上传"}
</button>
</div>
</div>
</Modal>
)
@@ -1,45 +0,0 @@
/**
* 上传音频 - 已选文件信息
*/
import React from "react"
import { SoundOutlined } from "@ant-design/icons"
import { formatFileSize } from "@/pages/voices/utils/format"
export interface SelectedFileInfoProps {
file: File
}
export const SelectedFileInfo: React.FC<SelectedFileInfoProps> = ({ file }) => {
return (
<div
style={{
padding: "10px 12px",
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{file.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{formatFileSize(file.size)}
</div>
</div>
</div>
)
}
export default SelectedFileInfo
@@ -1,82 +0,0 @@
/**
* 上传音频 - 底部操作按钮
*/
import React from "react"
import { message } from "antd"
export interface UploadActionsProps {
hasFile: boolean
hasName: boolean
uploading: boolean
onCancel: () => void
onUpload: () => void
}
export const UploadActions: React.FC<UploadActionsProps> = ({
hasFile,
hasName,
uploading,
onCancel,
onUpload,
}) => {
const canUpload = hasFile && hasName && !uploading
const handleUploadClick = () => {
if (!hasFile) {
message.warning("请先选择音频文件")
return
}
if (!hasName) {
message.warning("请输入素材名称")
return
}
onUpload()
}
return (
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
paddingTop: 4,
}}
>
<button
type="button"
onClick={onCancel}
disabled={uploading}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "1px solid var(--border-color)",
background: "transparent",
fontSize: 13,
cursor: uploading ? "not-allowed" : "pointer",
color: "var(--text-secondary)",
}}
>
</button>
<button
type="button"
onClick={handleUploadClick}
disabled={!canUpload}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "none",
background: canUpload ? "var(--primary-color)" : "var(--text-tertiary)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor: canUpload ? "pointer" : "not-allowed",
}}
>
{uploading ? "上传中..." : "开始上传"}
</button>
</div>
)
}
export default UploadActions
@@ -1,56 +0,0 @@
/**
* 上传音频 - 拖拽上传区
*/
import React from "react"
import { UploadOutlined } from "@ant-design/icons"
import { Upload } from "antd"
export interface UploadDraggerProps {
disabled: boolean
onFileSelect: (file: File) => void
onFileRemove: () => void
}
export const UploadDragger: React.FC<UploadDraggerProps> = ({
disabled,
onFileSelect,
onFileRemove,
}) => {
return (
<Upload.Dragger
accept="audio/*"
maxCount={1}
beforeUpload={(file) => {
onFileSelect(file)
return false
}}
onRemove={() => {
onFileRemove()
}}
showUploadList={false}
disabled={disabled}
>
<p
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
}}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}></p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
MP3WAVAACFLAC 200MB
</p>
</Upload.Dragger>
)
}
export default UploadDragger
@@ -1,115 +0,0 @@
/**
* 上传音频 - 表单区域(名称/性别/描述)
*/
import React from "react"
import { type VoiceGender } from "@/pages/voices/types"
export interface UploadFormFieldsProps {
name: string
gender: VoiceGender
description: string
disabled: boolean
onNameChange: (name: string) => void
onGenderChange: (gender: VoiceGender) => void
onDescriptionChange: (desc: string) => void
}
const GENDER_LABELS: Record<VoiceGender, string> = {
female: "女声",
male: "男声",
child: "童声",
elderly: "老年音",
}
export const UploadFormFields: React.FC<UploadFormFieldsProps> = ({
name,
gender,
description,
disabled,
onNameChange,
onGenderChange,
onDescriptionChange,
}) => {
return (
<>
{/* 名称 */}
<div>
<div style={labelStyle}></div>
<input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="输入素材名称"
maxLength={100}
disabled={disabled}
style={inputStyle}
/>
</div>
{/* 性别选择 */}
<div>
<div style={labelStyle}></div>
<div style={{ display: "flex", gap: 8 }}>
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
<button
key={g}
type="button"
onClick={() => onGenderChange(g)}
disabled={disabled}
style={{
flex: 1,
padding: "6px 0",
borderRadius: 8,
border: `1px solid ${gender === g ? "var(--primary-color)" : "var(--border-color)"}`,
background: gender === g ? "var(--primary-soft)" : "transparent",
color: gender === g ? "var(--primary-color)" : "var(--text-secondary)",
fontSize: 13,
fontWeight: gender === g ? 600 : 400,
cursor: disabled ? "not-allowed" : "pointer",
transition: "all 0.2s",
}}
>
{GENDER_LABELS[g]}
</button>
))}
</div>
</div>
{/* 描述 */}
<div>
<div style={labelStyle}></div>
<textarea
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="描述这个音色的特点..."
maxLength={500}
rows={2}
disabled={disabled}
style={{
...inputStyle,
resize: "vertical",
fontFamily: "inherit",
}}
/>
</div>
</>
)
}
const labelStyle: React.CSSProperties = {
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}
export default UploadFormFields
@@ -1,48 +0,0 @@
/**
* 上传音频 - 上传进度条
*/
import React from "react"
export interface UploadProgressProps {
progress: number
}
export const UploadProgress: React.FC<UploadProgressProps> = ({ progress }) => {
return (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--primary-color)",
}}
>
{progress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{progress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${progress}%`,
background: "var(--primary-color)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)
}
export default UploadProgress