refactor(voices): Phase 2 - extract 7 UI components #875

Merged
auto-approve-bot merged 9 commits from refactor/voice-library-phase2 into develop 2026-07-25 13:57:19 +08:00
10 changed files with 1186 additions and 948 deletions
+61 -948
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
import React from "react"
/** 克隆音色卡片骨架屏 */
const CloneCardSkeleton: React.FC = () => (
<div className="xx-voice-card xx-skeleton-clone">
<div className="xx-skeleton-clone-avatar" />
<div className="xx-skeleton-clone-info">
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--name" />
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--status" />
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--desc" />
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--meta" />
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--footer" />
</div>
</div>
)
export default CloneCardSkeleton
+100
View File
@@ -0,0 +1,100 @@
import React from "react"
import {
SoundOutlined,
CloseCircleOutlined,
ReloadOutlined,
DeleteOutlined,
} from "@ant-design/icons"
import { Button } from "@/components/ui"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
export interface CloneDetailModalProps {
voice: ClonedVoiceDisplay
onClose: () => void
onUse: () => void
onDelete: () => void
onRetry: () => void
}
/** 克隆音色详情弹窗 */
const CloneDetailModal: React.FC<CloneDetailModalProps> = ({
voice,
onClose,
onUse,
onDelete,
onRetry,
}) => {
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
const genderText =
voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender || "未知"
const langText = voice.language || "未知"
return (
<div className="xx-clone-detail-overlay" onClick={onClose}>
<div className="xx-clone-detail" onClick={(e) => e.stopPropagation()}>
<button type="button" className="xx-clone-detail-close" onClick={onClose}>
<CloseCircleOutlined />
</button>
<div className="xx-clone-detail-header">
<div className="xx-clone-avatar">
<SoundOutlined />
</div>
<div>
<h3 className="xx-clone-detail-name">{voice.name}</h3>
<span className={`xx-clone-status ${statusCfg.className}`}>
<span className="xx-clone-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{voice.description && <p className="xx-clone-detail-desc">{voice.description}</p>}
<div className="xx-clone-detail-info">
<div className="xx-clone-detail-row">
<span className="xx-clone-detail-label"></span>
<span>{genderText}</span>
</div>
<div className="xx-clone-detail-row">
<span className="xx-clone-detail-label"></span>
<span>{langText}</span>
</div>
<div className="xx-clone-detail-row">
<span className="xx-clone-detail-label"></span>
<span>{voice.sourceName}</span>
</div>
<div className="xx-clone-detail-row">
<span className="xx-clone-detail-label"></span>
<span>{voice.createdAt}</span>
</div>
{voice.errorMessage && (
<div className="xx-clone-detail-row" style={{ color: "var(--error-color, #ef4444)" }}>
<span className="xx-clone-detail-label"></span>
<span>{voice.errorMessage}</span>
</div>
)}
</div>
<div className="xx-clone-detail-actions">
{voice.status === "failed" && (
<Button buttonType="ghost" buttonSize="sm" icon={<ReloadOutlined />} onClick={onRetry}>
</Button>
)}
<Button buttonType="ghost" buttonSize="sm" icon={<DeleteOutlined />} onClick={onDelete}>
</Button>
{voice.status === "ready" && (
<Button buttonType="primary" buttonSize="sm" onClick={onUse}>
使
</Button>
)}
</div>
</div>
</div>
)
}
export default CloneDetailModal
+180
View File
@@ -0,0 +1,180 @@
import React from "react"
import {
SoundOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DeleteOutlined,
ReloadOutlined,
CloseCircleOutlined,
UserOutlined,
} from "@ant-design/icons"
import { Tooltip } from "antd"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
export interface CloneVoiceCardProps {
voice: ClonedVoiceDisplay
isPlaying: boolean
currentTime: number
onPlay: () => void
onPause: () => void
onUse: () => void
onDelete: () => void
onRetry: () => void
onShowDetail: () => void
}
/** 克隆音色卡片 */
const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
voice,
isPlaying,
currentTime,
onPlay,
onPause,
onUse,
onDelete,
onRetry,
onShowDetail,
}) => {
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
const isFailed = voice.status === "failed"
const isProcessing = voice.status === "processing"
const genderText =
voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender
return (
<div
className={`xx-clone-card${isPlaying ? " playing" : ""}${isFailed ? " failed" : ""}`}
onClick={isFailed ? undefined : onShowDetail}
>
{/* 右上角操作按钮 */}
<div className="xx-clone-card-actions">
<Tooltip title="删除">
<button
type="button"
className="xx-clone-action-btn xx-clone-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<DeleteOutlined />
</button>
</Tooltip>
{isFailed && (
<Tooltip title="重试">
<button
type="button"
className="xx-clone-action-btn"
onClick={(e) => {
e.stopPropagation()
onRetry()
}}
>
<ReloadOutlined />
</button>
</Tooltip>
)}
</div>
{/* 头部:头像 + 名称 + 状态 */}
<div className="xx-clone-card-header">
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
<SoundOutlined />
</div>
<div className="xx-clone-header-info">
<h4 className="xx-clone-name" title={voice.name}>
{voice.name}
</h4>
<span className={`xx-clone-status ${statusCfg.className}`}>
<span className="xx-clone-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{/* 描述 */}
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
{/* 元信息 */}
<div className="xx-clone-meta">
{(voice.gender || voice.language) && (
<span className="xx-clone-meta-item">
<UserOutlined />
{genderText}
{voice.language ? ` · ${voice.language}` : ""}
</span>
)}
<span className="xx-clone-meta-item">{voice.createdAt}</span>
</div>
{/* 错误信息 */}
{isFailed && voice.errorMessage && (
<div className="xx-clone-error">
<CloseCircleOutlined />
<span>{voice.errorMessage}</span>
</div>
)}
{/* 底部操作区 */}
<div className="xx-clone-footer">
{voice.status === "ready" && (
<>
<button
type="button"
className="xx-clone-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div className="xx-clone-progress">
<div
className="xx-clone-progress-bar"
style={{
width: isPlaying
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
: "0%",
}}
/>
</div>
<button
type="button"
className="xx-clone-use-btn"
onClick={(e) => {
e.stopPropagation()
onUse()
}}
>
使
</button>
</>
)}
{isProcessing && (
<div className="xx-clone-processing-hint">
<ReloadOutlined spin />
...
</div>
)}
{isFailed && (
<button
type="button"
className="xx-clone-retry-btn"
onClick={(e) => {
e.stopPropagation()
onRetry()
}}
>
<ReloadOutlined />
</button>
)}
</div>
</div>
)
}
export default CloneVoiceCard
+37
View File
@@ -0,0 +1,37 @@
import React from "react"
import { AudioOutlined } from "@ant-design/icons"
import { type AssetItem } from "@/api/assets"
import { formatFileSize } from "@/pages/voices/utils/format"
export interface MaterialVoiceCardProps {
asset: AssetItem
onClick?: () => void
}
/** 配音素材卡片 */
const MaterialVoiceCard: React.FC<MaterialVoiceCardProps> = ({ asset, onClick }) => {
const duration = (asset.metadata?.duration as number) || 0
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
return (
<div className="vmat-card" onClick={onClick}>
<div className="vmat-thumb">
<AudioOutlined className="vmat-thumb-icon" />
<span className="vmat-duration">
{minutes}:{seconds.toString().padStart(2, "0")}
</span>
</div>
<div className="vmat-info">
<div className="vmat-name" title={asset.name}>
{asset.name}
</div>
<div className="vmat-meta">
<span>{asset.file_size ? formatFileSize(asset.file_size) : "--"}</span>
</div>
</div>
</div>
)
}
export default MaterialVoiceCard
+258
View File
@@ -0,0 +1,258 @@
import React from "react"
import { RobotOutlined } from "@ant-design/icons"
import { Modal, message } from "antd"
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import { genderLabel } from "@/pages/voices/utils/format"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
export interface TtsModalProps {
open: boolean
ttsText: string
ttsVoiceId: string
ttsSpeed: number
ttsStatus: TtsStatus
ttsAudioUrl: string | null
ttsError: string | null
presetVoices: PresetVoiceDisplay[]
onClose: () => void
onTextChange: (text: string) => void
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onSynthesize: () => void
onSave: () => void
}
/** AI 配音弹窗 */
const TtsModal: React.FC<TtsModalProps> = ({
open,
ttsText,
ttsVoiceId,
ttsSpeed,
ttsStatus,
ttsAudioUrl,
ttsError,
presetVoices,
onClose,
onTextChange,
onVoiceChange,
onSpeedChange,
onSynthesize,
onSave,
}) => {
return (
<Modal title="AI 配音" open={open} onCancel={onClose} footer={null} width={560}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
{/* 文本输入 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={ttsText}
onChange={(e) => onTextChange(e.target.value)}
placeholder="输入要配音的文本内容..."
maxLength={2000}
rows={4}
style={{
width: "100%",
padding: "10px 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",
lineHeight: 1.6,
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
textAlign: "right",
marginTop: 4,
}}
>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<select
value={ttsVoiceId}
onChange={(e) => onVoiceChange(e.target.value)}
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",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {genderLabel(v.gender)}
</option>
))}
</select>
</div>
{/* 语速 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
{ttsSpeed.toFixed(1)}x
</div>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--primary-color)" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--text-tertiary)",
}}
>
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 合成按钮 */}
<button
type="button"
onClick={() => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本")
return
}
onSynthesize()
}}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
style={{
width: "100%",
padding: "10px 0",
borderRadius: 8,
border: "none",
background:
ttsStatus === "synthesizing" || !ttsText.trim()
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: ttsStatus === "synthesizing" || !ttsText.trim() ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<RobotOutlined />
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
</button>
{/* 错误提示 */}
{ttsError && (
<div
style={{
padding: "10px 12px",
background: "var(--error-soft, #fff2f0)",
borderRadius: 8,
color: "var(--error-color, #ff4d4f)",
fontSize: 13,
}}
>
{ttsError}
</div>
)}
{/* 合成结果 */}
{ttsStatus === "done" && ttsAudioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--success-color, #52c41a)",
}}
>
</div>
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
<button
type="button"
onClick={onSave}
style={{
padding: "8px 0",
borderRadius: 8,
border: "1px solid var(--primary-color)",
background: "var(--primary-soft)",
color: "var(--primary-color)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
</button>
</div>
)}
</div>
</Modal>
)
}
export default TtsModal
+329
View File
@@ -0,0 +1,329 @@
import React from "react"
import { UploadOutlined, SoundOutlined } from "@ant-design/icons"
import { Modal, Upload, message } from "antd"
import { type VoiceGender } from "@/pages/voices/types"
import { formatFileSize } from "@/pages/voices/utils/format"
export interface UploadVoiceModalProps {
open: boolean
uploadFile: File | null
uploadName: string
uploadGender: VoiceGender
uploadDesc: string
uploadProgress: number | null
onClose: () => void
onFileSelect: (file: File) => void
onFileRemove: () => void
onNameChange: (name: string) => void
onGenderChange: (gender: VoiceGender) => void
onDescChange: (desc: string) => void
onUpload: () => void
}
/** 上传音频弹窗 */
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
open,
uploadFile,
uploadName,
uploadGender,
uploadDesc,
uploadProgress,
onClose,
onFileSelect,
onFileRemove,
onNameChange,
onGenderChange,
onDescChange,
onUpload,
}) => {
return (
<Modal
title="上传音频"
open={open}
onCancel={() => {
if (uploadProgress !== null) return // 上传中不可关闭
onClose()
}}
footer={null}
width={520}
maskClosable={uploadProgress === null}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
{/* 拖拽上传区 */}
<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 && (
<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>
)}
{/* 上传进度 */}
{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>
)}
{/* 名称 */}
<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>
{/* 操作按钮 */}
<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>
)
}
export default UploadVoiceModal
+133
View File
@@ -0,0 +1,133 @@
import React, { useRef } from "react"
import {
SoundOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
HeartOutlined,
} from "@ant-design/icons"
import { type VoiceGender } from "@/pages/voices/types"
import { genderClass, formatTime } from "@/pages/voices/utils/format"
export interface VoiceCardProps {
id: string
name: string
subtitle: string
tags: string[]
duration: number
gender: VoiceGender
isPlaying: boolean
isSelected: boolean
currentTime: number
starred?: boolean
status?: "ready" | "processing" | "failed"
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onSelect?: () => void
onToggleStar?: () => void
}
/** 音色卡片组件(预置音色 + 克隆音色统一) */
const VoiceCard: React.FC<VoiceCardProps> = ({
id: _id,
name,
subtitle,
tags,
duration,
gender,
isPlaying,
isSelected,
currentTime,
starred,
status = "ready",
onPlay,
onPause,
onSeek,
onSelect,
onToggleStar,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current || status !== "ready") return
const rect = progressRef.current.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
onSeek(percent * duration)
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div
className={`xx-voice-card ${genderClass(gender)}${isSelected ? " selected" : ""}${isPlaying ? " playing" : ""}`}
onClick={onSelect}
>
<div className="xx-voice-avatar">
<SoundOutlined />
</div>
<div className="xx-voice-info">
<div className="xx-voice-name-row">
<h4 className="xx-voice-name" title={name}>
{name}
</h4>
{starred !== undefined && (
<button
className={`xx-voice-star${starred ? " active" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleStar?.()
}}
title={starred ? "取消收藏" : "收藏"}
>
<HeartOutlined />
</button>
)}
</div>
<div className="xx-voice-subtitle">{subtitle}</div>
<div className="xx-voice-tags">
{tags.slice(0, 3).map((tag) => (
<span key={tag} className="xx-voice-tag">
{tag}
</span>
))}
</div>
</div>
{status === "processing" && (
<div className="xx-voice-status xx-voice-status--processing">
<span className="xx-voice-status-dot" />
...
</div>
)}
{status === "failed" && (
<div className="xx-voice-status xx-voice-status--failed"></div>
)}
{status === "ready" && <div className="xx-voice-wave" />}
{status === "ready" && (
<div className="xx-voice-controls">
<button
className="xx-voice-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div ref={progressRef} className="xx-voice-progress" onClick={handleProgressClick}>
<div className="xx-voice-progress-bar" style={{ width: `${progress}%` }} />
</div>
<span className="xx-voice-time">
{isPlaying ? formatTime(currentTime) : formatTime(duration)}
</span>
</div>
)}
</div>
)
}
export default VoiceCard
+61
View File
@@ -0,0 +1,61 @@
import React from "react"
import { SearchOutlined } from "@ant-design/icons"
import { Input, Select } from "@/components/ui"
export interface VoiceFilterBarProps {
searchText: string
filterGender: string
filterLang: string
onSearchChange: (value: string) => void
onGenderChange: (value: string) => void
onLangChange: (value: string) => void
}
/** 音色筛选栏(搜索 + 性别 + 语言) */
const VoiceFilterBar: React.FC<VoiceFilterBarProps> = ({
searchText,
filterGender,
filterLang,
onSearchChange,
onGenderChange,
onLangChange,
}) => {
return (
<div className="xx-voices-filters">
<Input
placeholder="搜索音色名称、标签..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
style={{ width: 260 }}
/>
<Select
value={filterGender}
onChange={onGenderChange}
style={{ width: 130 }}
options={[
{ value: "all", label: "全部音色" },
{ value: "male", label: "男声" },
{ value: "female", label: "女声" },
{ value: "child", label: "童声" },
{ value: "elderly", label: "老年" },
]}
/>
<Select
value={filterLang}
onChange={onLangChange}
style={{ width: 130 }}
options={[
{ value: "all", label: "全部语言" },
{ value: "zh", label: "中文" },
{ value: "en", label: "英文" },
{ value: "ja", label: "日文" },
{ value: "ko", label: "韩文" },
]}
/>
</div>
)
}
export default VoiceFilterBar
+10
View File
@@ -8,6 +8,16 @@ import { describe, it, expect } from "vitest"
// 主组件
import "@/pages/voices/VoiceLibrary"
// 子组件
import "@/pages/voices/components/VoiceCard"
import "@/pages/voices/components/CloneVoiceCard"
import "@/pages/voices/components/CloneDetailModal"
import "@/pages/voices/components/CloneCardSkeleton"
import "@/pages/voices/components/UploadVoiceModal"
import "@/pages/voices/components/TtsModal"
import "@/pages/voices/components/VoiceFilterBar"
import "@/pages/voices/components/MaterialVoiceCard"
// 类型与常量
import "@/pages/voices/types"
import "@/pages/voices/constants"