From 9c2f7b1ae589632b98dddf7ec1f87a0d408410b5 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 10:05:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor(voice-materials):=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=20VoiceMaterialCard=20=E4=B8=BA=E5=AD=90=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三阶段重构: - Phase 1: 提取类型到 voice-material-card/types.ts - Phase 2: 拆分为 6 个 UI 子组件 - CardHeader: 卡片头部 - CardTags: 标签展示区 - CardMeta: 元信息(时长/大小/日期) - CardPlayer: 播放控制区(含拖拽seek) - CardActions: 操作按钮(编辑/删除) - BatchCheckbox: 批量选择 - Phase 3: 主文件 245→72 行(-71%) 保持导入路径向后兼容 --- .../components/VoiceMaterialCard.tsx | 243 +++--------------- .../voice-material-card/BatchCheckbox.tsx | 26 ++ .../voice-material-card/CardActions.tsx | 39 +++ .../voice-material-card/CardHeader.tsx | 30 +++ .../voice-material-card/CardMeta.tsx | 21 ++ .../voice-material-card/CardPlayer.tsx | 105 ++++++++ .../voice-material-card/CardTags.tsx | 52 ++++ .../components/voice-material-card/index.ts | 8 + .../components/voice-material-card/types.ts | 21 ++ .../test/pages/voice-materials/smoke.test.tsx | 7 + 10 files changed, 344 insertions(+), 208 deletions(-) mode change 100644 => 100755 apps/web/src/pages/voice-materials/components/VoiceMaterialCard.tsx create mode 100755 apps/web/src/pages/voice-materials/components/voice-material-card/BatchCheckbox.tsx create mode 100755 apps/web/src/pages/voice-materials/components/voice-material-card/CardActions.tsx create mode 100755 apps/web/src/pages/voice-materials/components/voice-material-card/CardHeader.tsx create mode 100755 apps/web/src/pages/voice-materials/components/voice-material-card/CardMeta.tsx create mode 100755 apps/web/src/pages/voice-materials/components/voice-material-card/CardPlayer.tsx create mode 100755 apps/web/src/pages/voice-materials/components/voice-material-card/CardTags.tsx create mode 100755 apps/web/src/pages/voice-materials/components/voice-material-card/index.ts create mode 100755 apps/web/src/pages/voice-materials/components/voice-material-card/types.ts mode change 100644 => 100755 apps/web/src/test/pages/voice-materials/smoke.test.tsx diff --git a/apps/web/src/pages/voice-materials/components/VoiceMaterialCard.tsx b/apps/web/src/pages/voice-materials/components/VoiceMaterialCard.tsx old mode 100644 new mode 100755 index 2048865d2..1e3d97bb5 --- a/apps/web/src/pages/voice-materials/components/VoiceMaterialCard.tsx +++ b/apps/web/src/pages/voice-materials/components/VoiceMaterialCard.tsx @@ -1,45 +1,12 @@ -import React, { useRef } from "react" -import { - AudioOutlined, - PlayCircleOutlined, - PauseCircleOutlined, - EditOutlined, - DeleteOutlined, - CheckOutlined, - SoundOutlined, - MutedOutlined, -} from "@ant-design/icons" -import { Tooltip } from "antd" -import { Tag } from "@/components/ui" -import { type TagItem } from "@/api/tags" -import { type VoiceMaterial } from "../types" -import { MAX_CARD_TAGS, TAG_VARIANTS } from "../constants" -import { - genderClass, - genderIcon, - genderLabel, - formatDuration, - formatFileSize, - formatDate, -} from "../utils/format" - -export interface VoiceCardProps { - material: VoiceMaterial - isPlaying: boolean - currentTime: number - isSelected: boolean - batchMode: boolean - volume: number - tagMap: Map - onPlay: () => void - onPause: () => void - onSeek: (time: number) => void - onEdit: () => void - onDelete: () => void - onToggleSelect: (id: string) => void - onVolumeChange: (e: React.ChangeEvent) => void - onToggleMute: () => void -} +import React from "react" +import { type VoiceCardProps } from "./voice-material-card/types" +import BatchCheckbox from "./voice-material-card/BatchCheckbox" +import CardActions from "./voice-material-card/CardActions" +import CardHeader from "./voice-material-card/CardHeader" +import CardTags from "./voice-material-card/CardTags" +import CardMeta from "./voice-material-card/CardMeta" +import CardPlayer from "./voice-material-card/CardPlayer" +import { genderClass } from "../utils/format" const VoiceMaterialCard: React.FC = ({ material, @@ -58,29 +25,6 @@ const VoiceMaterialCard: React.FC = ({ onVolumeChange, onToggleMute, }) => { - const progressRef = useRef(null) - - const handleProgressMouseDown = (e: React.MouseEvent) => { - if (!progressRef.current) return - e.preventDefault() - const doSeek = (ev: MouseEvent) => { - if (!progressRef.current) return - const rect = progressRef.current.getBoundingClientRect() - const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width)) - onSeek(percent * material.duration) - } - doSeek(e.nativeEvent) - const handleMove = (ev: MouseEvent) => doSeek(ev) - const handleUp = () => { - document.removeEventListener("mousemove", handleMove) - document.removeEventListener("mouseup", handleUp) - } - document.addEventListener("mousemove", handleMove) - document.addEventListener("mouseup", handleUp) - } - - const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0 - const handleCardClick = () => { if (batchMode) { onToggleSelect(material.id) @@ -92,154 +36,37 @@ const VoiceMaterialCard: React.FC = ({ className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`} onClick={handleCardClick} > - {/* 批量选择 checkbox */} - {(batchMode || isSelected) && ( -
{ - e.stopPropagation() - onToggleSelect(material.id) - }} - > - {isSelected && } -
- )} + onToggleSelect(material.id)} + /> + + - {/* 操作按钮 */} -
- - -
- - {/* 头部:图标 + 名称 + 性别 */} -
-
- -
-
-

- {material.name} -

- - {genderIcon(material.gender)} - {genderLabel(material.gender)} - -
-
- - {/* 描述 */} {material.description &&

{material.description}

} - {/* 标签 */} -
- {material.tagIds.length === 0 ? ( - { - e.stopPropagation() - onEdit() - }} - > - 添加标签 - - ) : ( - <> - {material.tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => ( - - {tagMap.get(tagId)?.name ?? tagId} - - ))} - {material.tagIds.length > MAX_CARD_TAGS && ( - tagMap.get(id)?.name ?? id) - .join("、")} - > - +{material.tagIds.length - MAX_CARD_TAGS} - - )} - - )} -
- - {/* 元信息 */} -
- {formatDuration(material.duration)} - {formatFileSize(material.fileSize)} - {formatDate(material.createdAt)} -
- - {/* 播放控制 */} -
- -
-
- {isPlaying &&
} -
- - {isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)} - - {/* 音量控制 */} -
- - { - e.stopPropagation() - onVolumeChange(e) - }} - onClick={(e) => e.stopPropagation()} - /> -
-
+ + +
) } export default VoiceMaterialCard +export type { VoiceCardProps } diff --git a/apps/web/src/pages/voice-materials/components/voice-material-card/BatchCheckbox.tsx b/apps/web/src/pages/voice-materials/components/voice-material-card/BatchCheckbox.tsx new file mode 100755 index 000000000..fcb0047cf --- /dev/null +++ b/apps/web/src/pages/voice-materials/components/voice-material-card/BatchCheckbox.tsx @@ -0,0 +1,26 @@ +import React from "react" +import { CheckOutlined } from "@ant-design/icons" + +interface BatchCheckboxProps { + isSelected: boolean + visible: boolean + onToggle: () => void +} + +/** 批量选择 checkbox */ +const BatchCheckbox: React.FC = ({ isSelected, visible, onToggle }) => { + if (!visible) return null + return ( +
{ + e.stopPropagation() + onToggle() + }} + > + {isSelected && } +
+ ) +} + +export default BatchCheckbox diff --git a/apps/web/src/pages/voice-materials/components/voice-material-card/CardActions.tsx b/apps/web/src/pages/voice-materials/components/voice-material-card/CardActions.tsx new file mode 100755 index 000000000..2c7bb219c --- /dev/null +++ b/apps/web/src/pages/voice-materials/components/voice-material-card/CardActions.tsx @@ -0,0 +1,39 @@ +import React from "react" +import { EditOutlined, DeleteOutlined } from "@ant-design/icons" + +interface CardActionsProps { + onEdit: () => void + onDelete: () => void +} + +/** 卡片操作按钮:编辑 / 删除 */ +const CardActions: React.FC = ({ onEdit, onDelete }) => { + return ( +
+ + +
+ ) +} + +export default CardActions diff --git a/apps/web/src/pages/voice-materials/components/voice-material-card/CardHeader.tsx b/apps/web/src/pages/voice-materials/components/voice-material-card/CardHeader.tsx new file mode 100755 index 000000000..0c57877de --- /dev/null +++ b/apps/web/src/pages/voice-materials/components/voice-material-card/CardHeader.tsx @@ -0,0 +1,30 @@ +import React from "react" +import { AudioOutlined } from "@ant-design/icons" +import { type VoiceMaterial } from "../../types" +import { genderClass, genderIcon, genderLabel } from "../../utils/format" + +interface CardHeaderProps { + material: VoiceMaterial +} + +/** 卡片头部:头像 + 名称 + 性别标签 */ +const CardHeader: React.FC = ({ material }) => { + return ( +
+
+ +
+
+

+ {material.name} +

+ + {genderIcon(material.gender)} + {genderLabel(material.gender)} + +
+
+ ) +} + +export default CardHeader diff --git a/apps/web/src/pages/voice-materials/components/voice-material-card/CardMeta.tsx b/apps/web/src/pages/voice-materials/components/voice-material-card/CardMeta.tsx new file mode 100755 index 000000000..9ef2d7c4b --- /dev/null +++ b/apps/web/src/pages/voice-materials/components/voice-material-card/CardMeta.tsx @@ -0,0 +1,21 @@ +import React from "react" +import { formatDuration, formatFileSize, formatDate } from "../../utils/format" + +interface CardMetaProps { + duration: number + fileSize: number + createdAt: string +} + +/** 元信息:时长 / 文件大小 / 创建日期 */ +const CardMeta: React.FC = ({ duration, fileSize, createdAt }) => { + return ( +
+ {formatDuration(duration)} + {formatFileSize(fileSize)} + {formatDate(createdAt)} +
+ ) +} + +export default CardMeta diff --git a/apps/web/src/pages/voice-materials/components/voice-material-card/CardPlayer.tsx b/apps/web/src/pages/voice-materials/components/voice-material-card/CardPlayer.tsx new file mode 100755 index 000000000..3c86e9868 --- /dev/null +++ b/apps/web/src/pages/voice-materials/components/voice-material-card/CardPlayer.tsx @@ -0,0 +1,105 @@ +import React, { useRef } from "react" +import { PlayCircleOutlined, PauseCircleOutlined, SoundOutlined, MutedOutlined } from "@ant-design/icons" +import { formatDuration } from "../../utils/format" + +interface CardPlayerProps { + isPlaying: boolean + currentTime: number + duration: number + volume: number + fileUrl?: string + onPlay: () => void + onPause: () => void + onSeek: (time: number) => void + onVolumeChange: (e: React.ChangeEvent) => void + onToggleMute: () => void +} + +/** 播放控制区:播放按钮 + 进度条 + 时间 + 音量 */ +const CardPlayer: React.FC = ({ + isPlaying, + currentTime, + duration, + volume, + fileUrl, + onPlay, + onPause, + onSeek, + onVolumeChange, + onToggleMute, +}) => { + const progressRef = useRef(null) + + const handleProgressMouseDown = (e: React.MouseEvent) => { + if (!progressRef.current) return + e.preventDefault() + const doSeek = (ev: MouseEvent) => { + if (!progressRef.current) return + const rect = progressRef.current.getBoundingClientRect() + const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width)) + onSeek(percent * duration) + } + doSeek(e.nativeEvent) + const handleMove = (ev: MouseEvent) => doSeek(ev) + const handleUp = () => { + document.removeEventListener("mousemove", handleMove) + document.removeEventListener("mouseup", handleUp) + } + document.addEventListener("mousemove", handleMove) + document.addEventListener("mouseup", handleUp) + } + + const progress = duration > 0 ? (currentTime / duration) * 100 : 0 + + return ( +
+ +
+
+ {isPlaying &&
} +
+ + {isPlaying ? formatDuration(currentTime) : formatDuration(duration)} + + {/* 音量控制 */} +
+ + { + e.stopPropagation() + onVolumeChange(e) + }} + onClick={(e) => e.stopPropagation()} + /> +
+
+ ) +} + +export default CardPlayer diff --git a/apps/web/src/pages/voice-materials/components/voice-material-card/CardTags.tsx b/apps/web/src/pages/voice-materials/components/voice-material-card/CardTags.tsx new file mode 100755 index 000000000..98f5e21cd --- /dev/null +++ b/apps/web/src/pages/voice-materials/components/voice-material-card/CardTags.tsx @@ -0,0 +1,52 @@ +import React from "react" +import { Tooltip } from "antd" +import { Tag } from "@/components/ui" +import { type TagItem } from "@/api/tags" +import { MAX_CARD_TAGS, TAG_VARIANTS } from "../../constants" + +interface CardTagsProps { + tagIds: string[] + tagMap: Map + onEdit: () => void +} + +/** 标签展示区 */ +const CardTags: React.FC = ({ tagIds, tagMap, onEdit }) => { + if (tagIds.length === 0) { + return ( +
+ { + e.stopPropagation() + onEdit() + }} + > + 添加标签 + +
+ ) + } + + return ( +
+ {tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => ( + + {tagMap.get(tagId)?.name ?? tagId} + + ))} + {tagIds.length > MAX_CARD_TAGS && ( + tagMap.get(id)?.name ?? id) + .join("、")} + > + +{tagIds.length - MAX_CARD_TAGS} + + )} +
+ ) +} + +export default CardTags diff --git a/apps/web/src/pages/voice-materials/components/voice-material-card/index.ts b/apps/web/src/pages/voice-materials/components/voice-material-card/index.ts new file mode 100755 index 000000000..871b06083 --- /dev/null +++ b/apps/web/src/pages/voice-materials/components/voice-material-card/index.ts @@ -0,0 +1,8 @@ +export { default } from "../VoiceMaterialCard" +export * from "./types" +export { default as CardHeader } from "./CardHeader" +export { default as CardTags } from "./CardTags" +export { default as CardMeta } from "./CardMeta" +export { default as CardPlayer } from "./CardPlayer" +export { default as CardActions } from "./CardActions" +export { default as BatchCheckbox } from "./BatchCheckbox" diff --git a/apps/web/src/pages/voice-materials/components/voice-material-card/types.ts b/apps/web/src/pages/voice-materials/components/voice-material-card/types.ts new file mode 100755 index 000000000..00993a0cf --- /dev/null +++ b/apps/web/src/pages/voice-materials/components/voice-material-card/types.ts @@ -0,0 +1,21 @@ +import React from "react" +import { type VoiceMaterial } from "../../types" +import { type TagItem } from "@/api/tags" + +export interface VoiceCardProps { + material: VoiceMaterial + isPlaying: boolean + currentTime: number + isSelected: boolean + batchMode: boolean + volume: number + tagMap: Map + onPlay: () => void + onPause: () => void + onSeek: (time: number) => void + onEdit: () => void + onDelete: () => void + onToggleSelect: (id: string) => void + onVolumeChange: (e: React.ChangeEvent) => void + onToggleMute: () => void +} diff --git a/apps/web/src/test/pages/voice-materials/smoke.test.tsx b/apps/web/src/test/pages/voice-materials/smoke.test.tsx old mode 100644 new mode 100755 index cafe25e2b..5292af805 --- a/apps/web/src/test/pages/voice-materials/smoke.test.tsx +++ b/apps/web/src/test/pages/voice-materials/smoke.test.tsx @@ -12,6 +12,13 @@ import "@/pages/voice-materials/VoiceMaterialLibrary" import "@/pages/voice-materials/components/TagSelector" import "@/pages/voice-materials/components/MaterialForm" import "@/pages/voice-materials/components/VoiceMaterialCard" +import "@/pages/voice-materials/components/voice-material-card/CardHeader" +import "@/pages/voice-materials/components/voice-material-card/CardTags" +import "@/pages/voice-materials/components/voice-material-card/CardMeta" +import "@/pages/voice-materials/components/voice-material-card/CardPlayer" +import "@/pages/voice-materials/components/voice-material-card/CardActions" +import "@/pages/voice-materials/components/voice-material-card/BatchCheckbox" +import "@/pages/voice-materials/components/voice-material-card/types" import "@/pages/voice-materials/components/VoiceMaterialRow" import "@/pages/voice-materials/components/Toolbar" import "@/pages/voice-materials/components/TagFilterBar" -- 2.54.0 From c72677dd93d798a6d25b9dd94d17951c39c04dc0 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 10:18:11 +0800 Subject: [PATCH 2/2] fix(voice-material-card): fix prettier formatting --- .../components/voice-material-card/CardPlayer.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/voice-materials/components/voice-material-card/CardPlayer.tsx b/apps/web/src/pages/voice-materials/components/voice-material-card/CardPlayer.tsx index 3c86e9868..5dfb24fe8 100755 --- a/apps/web/src/pages/voice-materials/components/voice-material-card/CardPlayer.tsx +++ b/apps/web/src/pages/voice-materials/components/voice-material-card/CardPlayer.tsx @@ -1,5 +1,10 @@ import React, { useRef } from "react" -import { PlayCircleOutlined, PauseCircleOutlined, SoundOutlined, MutedOutlined } from "@ant-design/icons" +import { + PlayCircleOutlined, + PauseCircleOutlined, + SoundOutlined, + MutedOutlined, +} from "@ant-design/icons" import { formatDuration } from "../../utils/format" interface CardPlayerProps { -- 2.54.0