From 18a40f037705d16902e8aa8b1d0df4c77b0a2a64 Mon Sep 17 00:00:00 2001 From: SaaS Frontend Date: Sat, 25 Jul 2026 12:48:46 +0800 Subject: [PATCH 1/7] refactor(voices): Phase 2 - extract 7 UI sub-components Extract 7 sub-components from VoiceLibrary.tsx into separate files: - VoiceCard: preset voice card with play controls - CloneVoiceCard: cloned voice card with actions - CloneDetailModal: clone voice detail modal - CloneCardSkeleton: skeleton loading for clone cards - UploadVoiceModal: audio upload modal with drag & drop - TtsModal: AI TTS synthesis modal - VoiceFilterBar: search + gender + language filter bar Main file reduced from 1645 to 798 lines (-51.5%). All components follow the same controlled pattern with props interfaces. Functionally equivalent, no behavior changes. Signed-off-by: xiaoxia --- apps/web/src/pages/voices/VoiceLibrary.tsx | 963 ++---------------- .../voices/components/CloneCardSkeleton.tsx | 17 + .../voices/components/CloneDetailModal.tsx | 100 ++ .../voices/components/CloneVoiceCard.tsx | 180 ++++ .../voices/components/MaterialVoiceCard.tsx | 41 + .../src/pages/voices/components/TtsModal.tsx | 264 +++++ .../voices/components/UploadVoiceModal.tsx | 329 ++++++ .../src/pages/voices/components/VoiceCard.tsx | 133 +++ .../voices/components/VoiceFilterBar.tsx | 61 ++ 9 files changed, 1183 insertions(+), 905 deletions(-) create mode 100755 apps/web/src/pages/voices/components/CloneCardSkeleton.tsx create mode 100755 apps/web/src/pages/voices/components/CloneDetailModal.tsx create mode 100755 apps/web/src/pages/voices/components/CloneVoiceCard.tsx create mode 100755 apps/web/src/pages/voices/components/MaterialVoiceCard.tsx create mode 100755 apps/web/src/pages/voices/components/TtsModal.tsx create mode 100755 apps/web/src/pages/voices/components/UploadVoiceModal.tsx create mode 100755 apps/web/src/pages/voices/components/VoiceCard.tsx create mode 100755 apps/web/src/pages/voices/components/VoiceFilterBar.tsx diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index 6185d6717..a9340ae43 100644 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -61,137 +61,15 @@ import { } from "@/pages/voices/utils/format" import { getAudioDuration } from "@/pages/voices/utils/audio" import CloneModal from "@/components/voice/CloneModal" +import VoiceCard from "@/pages/voices/components/VoiceCard" +import CloneVoiceCard from "@/pages/voices/components/CloneVoiceCard" +import CloneDetailModal from "@/pages/voices/components/CloneDetailModal" +import CloneCardSkeleton from "@/pages/voices/components/CloneCardSkeleton" +import UploadVoiceModal from "@/pages/voices/components/UploadVoiceModal" +import TtsModal, { type TtsStatus } from "@/pages/voices/components/TtsModal" +import VoiceFilterBar from "@/pages/voices/components/VoiceFilterBar" import "./voices.css" -/* ============================================================ - * VoiceCard 组件(预置音色 + 克隆音色统一) - * ============================================================ */ -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 = ({ - id: _id, - name, - subtitle, - tags, - duration, - gender, - isPlaying, - isSelected, - currentTime, - starred, - status = "ready", - onPlay, - onPause, - onSeek, - onSelect, - onToggleStar, -}) => { - const progressRef = useRef(null) - - const handleProgressClick = (e: React.MouseEvent) => { - 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 ( -
-
- -
- -
-
-

- {name} -

- {starred !== undefined && ( - - )} -
-
{subtitle}
-
- {tags.slice(0, 3).map((tag) => ( - - {tag} - - ))} -
-
- - {status === "processing" && ( -
- - 处理中... -
- )} - {status === "failed" && ( -
克隆失败
- )} - - {status === "ready" &&
} - - {status === "ready" && ( -
- -
-
-
- - {isPlaying ? formatTime(currentTime) : formatTime(duration)} - -
- )} -
- ) -} - -/* ============================================================ - * 克隆音色卡片组件(任务 3.12) - * ============================================================ */ - -/** Toast 类型 */ interface Toast { id: number message: string @@ -200,268 +78,7 @@ interface Toast { let toastIdSeq = 0 -/** 克隆音色卡片 */ -interface CloneVoiceCardProps { - voice: ClonedVoiceDisplay - isPlaying: boolean - currentTime: number - onPlay: () => void - onPause: () => void - onUse: () => void - onDelete: () => void - onRetry: () => void - onShowDetail: () => void -} -const CloneVoiceCard: React.FC = ({ - 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 ( -
- {/* 右上角操作按钮 */} -
- - - - {isFailed && ( - - - - )} -
- - {/* 头部:头像 + 名称 + 状态 */} -
-
- -
-
-

- {voice.name} -

- - - {statusCfg.label} - -
-
- - {/* 描述 */} - {voice.description &&

{voice.description}

} - - {/* 元信息 */} -
- {(voice.gender || voice.language) && ( - - - {genderText} - {voice.language ? ` · ${voice.language}` : ""} - - )} - {voice.createdAt} -
- - {/* 错误信息 */} - {isFailed && voice.errorMessage && ( -
- - {voice.errorMessage} -
- )} - - {/* 底部操作区 */} -
- {voice.status === "ready" && ( - <> - -
-
-
- - - )} - {isProcessing && ( -
- - 克隆处理中,请稍候... -
- )} - {isFailed && ( - - )} -
-
- ) -} - -/** 克隆音色详情弹窗 */ -const CloneDetailModal: React.FC<{ - voice: ClonedVoiceDisplay - onClose: () => void - onUse: () => void - onDelete: () => void - onRetry: () => void -}> = ({ 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 ( -
-
e.stopPropagation()}> - - -
-
- -
-
-

{voice.name}

- - - {statusCfg.label} - -
-
- - {voice.description &&

{voice.description}

} - -
-
- 性别 - {genderText} -
-
- 语言 - {langText} -
-
- 来源 - {voice.sourceName} -
-
- 创建时间 - {voice.createdAt} -
- {voice.errorMessage && ( -
- 错误 - {voice.errorMessage} -
- )} -
- -
- {voice.status === "failed" && ( - - )} - - {voice.status === "ready" && ( - - )} -
-
-
- ) -} - -/** 骨架屏 */ -const CloneCardSkeleton: React.FC = () => ( -
-
-
-
-
-
-
-
-
-
-) - -/* ============================================================ - * 主组件 - * ============================================================ */ const VoiceLibrary: React.FC = () => { const [activeTab, setActiveTab] = useState("preset") const [searchText, setSearchText] = useState("") @@ -491,7 +108,7 @@ const VoiceLibrary: React.FC = () => { const [ttsVoiceId, setTtsVoiceId] = useState("") const [ttsSpeed, setTtsSpeed] = useState(1.0) const [ttsJobId, setTtsJobId] = useState(null) - const [ttsStatus, setTtsStatus] = useState<"idle" | "synthesizing" | "done" | "error">("idle") + const [ttsStatus, setTtsStatus] = useState("idle") const [ttsAudioUrl, setTtsAudioUrl] = useState(null) const [ttsError, setTtsError] = useState(null) const ttsTimerRef = useRef | null>(null) @@ -874,15 +491,14 @@ const VoiceLibrary: React.FC = () => { {activeTab === "preset" && (
-
- } - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear - style={{ width: 260 }} - /> + setUploadName(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", - }} - /> -
- - {/* 性别选择 */} -
-
- 音色性别 -
-
- {(["female", "male", "child"] as VoiceGender[]).map((g) => ( - - ))} -
-
- - {/* 描述 */} -
-
- 音色描述(可选) -
-