Compare commits

..

4 Commits

Author SHA1 Message Date
xiaoxia 4f3d7d3c1a fix(upload-voice): Prettier 格式化 UploadDragger.tsx
CI/CD Pipeline / CI Gate (pull_request) CI运行中 [frontend-only]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 17s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 56s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 42s
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 - Type Check (mypy) (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m8s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m12s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m9s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 44s
AI Code Review / AI Code Review (pull_request) Successful in 1m44s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m18s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 8m1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 27s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 20s
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 15:35:22 +08:00
xiaoxia 7603aeca5c fix(upload-voice): GENDER_LABELS 补充 elderly 选项,修复 TS2741
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m1s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m2s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
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 Web Image (pull_request) Successful in 1m45s
AI Code Review / AI Code Review (pull_request) Successful in 1m16s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 57s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m46s
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:44:47 +08:00
xiaoxia 033bbf1ec3 Merge branch 'develop' into refactor/upload-voice-modal-split
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
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 1m4s
2026-07-27 12:34:30 +08:00
xiaoxia 35dd478f1d refactor(voice): 拆分 UploadVoiceModal,抽离 5 个子组件
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 29s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 52s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 58s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m26s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 31s
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 Worker Image (pull_request) Successful in 25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 44s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 35s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m21s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m9s
AI Code Review / AI Code Review (pull_request) Successful in 2m43s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 24s
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 / 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
- UploadDragger: 拖拽上传区
- SelectedFileInfo: 已选文件信息
- UploadProgress: 上传进度条
- UploadFormFields: 表单区域(名称/性别/描述)
- UploadActions: 底部操作按钮(取消/上传)
- 主文件 329→112 行 (-66%),仅保留 Modal 壳与状态组装
2026-07-27 00:53:00 +08:00
10 changed files with 583 additions and 481 deletions
+188
View File
@@ -0,0 +1,188 @@
/**
* TTS 语音合成 API
* 对接后端 /api/v1/tts/* 端点
*
* 任务 3.14 新增
*/
import apiClient from "./client"
/* ── 类型定义 ──────────────────────────────────── */
/** TTS 元数据(合成时附带的扩展信息) */
export interface TTSMetadata {
/** 语音时长(秒) */
duration?: number
/** 采样率(Hz */
sample_rate?: number
/** 语言 */
language?: string
/** 其他扩展字段 */
[key: string]: unknown
}
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string
voice_id?: string
output_name?: string
language?: string
speed?: number
voice_model?: string
voice_clone_profile_id?: string
format?: string
metadata?: TTSMetadata
}
/** TTS 合成创建响应 */
export interface TTSSynthesizeResponse {
job_id: string
status: string
message: string
}
/** TTS 任务详情 */
export interface TTSJob {
id: string
user_id: string
project_id: string | null
text: string
voice_id: string | null
voice_model: string | null
voice_clone_profile_id: string | null
language: string
speed: number
output_name: string | null
output_audio_url: string | null
output_format: string
duration_seconds: number | null
file_size_bytes: number | null
sample_rate: number | null
status: string
error_message: string | null
retry_count: number
max_retries: number
metadata_: TTSMetadata | null
created_at: string
updated_at: string
}
/** TTS 任务状态(轻量轮询用) */
export interface TTSJobStatus {
id: string
status: string
output_audio_url: string | null
error_message: string | null
duration_seconds: number | null
retry_count: number
}
/** TTS 任务列表响应 */
export interface TTSJobListResponse {
items: TTSJob[]
total: number
skip: number
limit: number
}
/** TTS 任务列表查询参数 */
export interface TTSJobListParams {
status?: string
skip?: number
limit?: number
}
/* ── API 函数 ──────────────────────────────────── */
/** 创建 TTS 合成任务 */
export const synthesizeSpeech = async (
data: TTSSynthesizeRequest,
): Promise<TTSSynthesizeResponse> => {
const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
return response.data
}
/** 获取 TTS 任务详情 */
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
return response.data
}
/** 获取 TTS 任务状态(轻量轮询) */
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
return response.data
}
/** 获取 TTS 任务列表 */
export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
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<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
return response.data
}
/** 存为素材请求参数 */
export interface SaveTtsToLibraryRequest {
name?: string
tag_ids?: string[]
}
/** 将 TTS 合成结果保存到配音库 */
export const saveTtsToLibrary = async (
jobId: string,
data?: SaveTtsToLibraryRequest,
): Promise<void> => {
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
}
/** 删除 TTS 任务 */
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`)
}
/* ── 音色列表 ──────────────────────────────────── */
/** TTS 音色 */
export interface TTSVoice {
id: string
name: string
/** 音色分类标签:male/female/young/service/news/emotion */
category?: string
/** 语言 */
language?: string
/** 试听 URL */
preview_url?: string
/** 描述 */
description?: string
}
/** 获取 TTS 音色列表 */
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
const response = await apiClient.get<TTSVoice[]>("/tts/voices")
return response.data
}
/* ── TTS 试听 ──────────────────────────────────── */
/** TTS 试听请求参数 */
export interface TTSPreviewRequest {
text: string
voice_id: string
speed?: number
pitch?: number
}
/** TTS 试听响应 */
export interface TTSPreviewResponse {
audio_url: string
duration?: number
}
/** TTS 试听 */
export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewResponse> => {
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
return response.data
}
-31
View File
@@ -1,31 +0,0 @@
/**
* TTS 语音合成 API — 目录化入口
* 保持与原 tts.ts 相同导出,向后兼容
*/
// 类型
export type {
TTSMetadata,
TTSSynthesizeRequest,
TTSSynthesizeResponse,
TTSJob,
TTSJobStatus,
TTSJobListResponse,
TTSJobListParams,
SaveTtsToLibraryRequest,
TTSVoice,
TTSPreviewRequest,
TTSPreviewResponse,
} from "./types"
// API 函数
export {
synthesizeSpeech,
getTTSJob,
getTTSJobStatus,
getTTSJobs,
saveTtsToLibrary,
deleteTTSJob,
getTtsVoices,
previewTts,
} from "./jobs"
-72
View File
@@ -1,72 +0,0 @@
/**
* TTS 语音合成 API 函数
*/
import apiClient from "../client"
import type {
TTSSynthesizeRequest,
TTSSynthesizeResponse,
TTSJob,
TTSJobStatus,
TTSJobListResponse,
TTSJobListParams,
SaveTtsToLibraryRequest,
TTSVoice,
TTSPreviewRequest,
TTSPreviewResponse,
} from "./types"
/** 创建 TTS 合成任务 */
export const synthesizeSpeech = async (
data: TTSSynthesizeRequest,
): Promise<TTSSynthesizeResponse> => {
const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
return response.data
}
/** 获取 TTS 任务详情 */
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
return response.data
}
/** 获取 TTS 任务状态(轻量轮询) */
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
return response.data
}
/** 获取 TTS 任务列表 */
export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
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<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
return response.data
}
/** 将 TTS 合成结果保存到配音库 */
export const saveTtsToLibrary = async (
jobId: string,
data?: SaveTtsToLibraryRequest,
): Promise<void> => {
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
}
/** 删除 TTS 任务 */
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`)
}
/** 获取 TTS 音色列表 */
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
const response = await apiClient.get<TTSVoice[]>("/tts/voices")
return response.data
}
/** TTS 试听 */
export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewResponse> => {
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
return response.data
}
-112
View File
@@ -1,112 +0,0 @@
/**
* TTS 语音合成类型定义
*/
/** TTS 元数据 */
export interface TTSMetadata {
duration?: number
sample_rate?: number
language?: string
[key: string]: unknown
}
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string
voice_id?: string
output_name?: string
language?: string
speed?: number
voice_model?: string
voice_clone_profile_id?: string
format?: string
metadata?: TTSMetadata
}
/** TTS 合成创建响应 */
export interface TTSSynthesizeResponse {
job_id: string
status: string
message: string
}
/** TTS 任务详情 */
export interface TTSJob {
id: string
user_id: string
project_id: string | null
text: string
voice_id: string | null
voice_model: string | null
voice_clone_profile_id: string | null
language: string
speed: number
output_name: string | null
output_audio_url: string | null
output_format: string
duration_seconds: number | null
file_size_bytes: number | null
sample_rate: number | null
status: string
error_message: string | null
retry_count: number
max_retries: number
metadata_: TTSMetadata | null
created_at: string
updated_at: string
}
/** TTS 任务状态(轻量轮询用) */
export interface TTSJobStatus {
id: string
status: string
output_audio_url: string | null
error_message: string | null
duration_seconds: number | null
retry_count: number
}
/** TTS 任务列表响应 */
export interface TTSJobListResponse {
items: TTSJob[]
total: number
skip: number
limit: number
}
/** TTS 任务列表查询参数 */
export interface TTSJobListParams {
status?: string
skip?: number
limit?: number
}
/** 存为素材请求参数 */
export interface SaveTtsToLibraryRequest {
name?: string
tag_ids?: string[]
}
/** TTS 音色 */
export interface TTSVoice {
id: string
name: string
category?: string
language?: string
preview_url?: string
description?: string
}
/** TTS 试听请求参数 */
export interface TTSPreviewRequest {
text: string
voice_id: string
speed?: number
pitch?: number
}
/** TTS 试听响应 */
export interface TTSPreviewResponse {
audio_url: string
duration?: number
}
+49 -266
View File
@@ -1,8 +1,21 @@
/**
* 上传音频弹窗
*
* 子组件目录: upload-voice/
* - UploadDragger: 拖拽上传区
* - SelectedFileInfo: 已选文件信息
* - UploadProgress: 上传进度条
* - UploadFormFields: 表单区域(名称/性别/描述)
* - UploadActions: 底部操作按钮
*/
import React from "react"
import { UploadOutlined, SoundOutlined } from "@ant-design/icons"
import { Modal, Upload, message } from "antd"
import { Modal } from "antd"
import { type VoiceGender } from "@/pages/voices/types"
import { formatFileSize } from "@/pages/voices/utils/format"
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"
export interface UploadVoiceModalProps {
open: boolean
@@ -20,7 +33,6 @@ export interface UploadVoiceModalProps {
onUpload: () => void
}
/** 上传音频弹窗 */
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
open,
uploadFile,
@@ -36,17 +48,21 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
onDescChange,
onUpload,
}) => {
const isUploading = uploadProgress !== null
const handleClose = () => {
if (isUploading) return // 上传中不可关闭
onClose()
}
return (
<Modal
title="上传音频"
open={open}
onCancel={() => {
if (uploadProgress !== null) return // 上传中不可关闭
onClose()
}}
onCancel={handleClose}
footer={null}
width={520}
maskClosable={uploadProgress === null}
maskClosable={!isUploading}
>
<div
style={{
@@ -57,270 +73,37 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
}}
>
{/* 拖拽上传区 */}
<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>
<UploadDragger
disabled={isUploading}
onFileSelect={onFileSelect}
onFileRemove={onFileRemove}
/>
{/* 已选文件信息 */}
{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>
)}
{uploadFile && <SelectedFileInfo file={uploadFile} />}
{/* 上传进度 */}
{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>
)}
{isUploading && <UploadProgress progress={uploadProgress!} />}
{/* 名称 */}
<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>
{/* 表单字段 */}
<UploadFormFields
name={uploadName}
gender={uploadGender}
description={uploadDesc}
disabled={isUploading}
onNameChange={onNameChange}
onGenderChange={onGenderChange}
onDescriptionChange={onDescChange}
/>
{/* 操作按钮 */}
<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>
<UploadActions
hasFile={!!uploadFile}
hasName={!!uploadName.trim()}
uploading={isUploading}
onCancel={onClose}
onUpload={onUpload}
/>
</div>
</Modal>
)
@@ -0,0 +1,45 @@
/**
* 上传音频 - 已选文件信息
*/
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
@@ -0,0 +1,82 @@
/**
* 上传音频 - 底部操作按钮
*/
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
@@ -0,0 +1,56 @@
/**
* 上传音频 - 拖拽上传区
*/
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
@@ -0,0 +1,115 @@
/**
* 上传音频 - 表单区域(名称/性别/描述)
*/
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
@@ -0,0 +1,48 @@
/**
* 上传音频 - 上传进度条
*/
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