refactor(voice-materials): 拆分 VoiceMaterialCard 为子组件(245→72行, -71%) #1054

Closed
xiaoxia wants to merge 2 commits from refactor/voice-material-card into develop
10 changed files with 349 additions and 208 deletions
+35 -208
View File
@@ -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<string, TagItem>
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onEdit: () => void
onDelete: () => void
onToggleSelect: (id: string) => void
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => 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<VoiceCardProps> = ({
material,
@@ -58,29 +25,6 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
onVolumeChange,
onToggleMute,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
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<VoiceCardProps> = ({
className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
onClick={handleCardClick}
>
{/* 批量选择 checkbox */}
{(batchMode || isSelected) && (
<div
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(material.id)
}}
>
{isSelected && <CheckOutlined />}
</div>
)}
<BatchCheckbox
isSelected={isSelected}
visible={batchMode || isSelected}
onToggle={() => onToggleSelect(material.id)}
/>
<CardActions onEdit={onEdit} onDelete={onDelete} />
<CardHeader material={material} />
{/* 操作按钮 */}
<div className="vmat-card-actions">
<button
type="button"
className="vmat-card-action-btn"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="编辑"
>
<EditOutlined />
</button>
<button
type="button"
className="vmat-card-action-btn vmat-card-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="删除"
>
<DeleteOutlined />
</button>
</div>
{/* 头部:图标 + 名称 + 性别 */}
<div className="vmat-card-header">
<div className="vmat-card-avatar">
<AudioOutlined />
</div>
<div className="vmat-card-title-area">
<h4 className="vmat-card-name" title={material.name}>
{material.name}
</h4>
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)}
{genderLabel(material.gender)}
</span>
</div>
</div>
{/* 描述 */}
{material.description && <p className="vmat-card-desc">{material.description}</p>}
{/* 标签 */}
<div className="vmat-card-tags">
{material.tagIds.length === 0 ? (
<span
className="vmat-tag-empty"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
>
</span>
) : (
<>
{material.tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{material.tagIds.length > MAX_CARD_TAGS && (
<Tooltip
title={material.tagIds
.slice(MAX_CARD_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_CARD_TAGS}</Tag>
</Tooltip>
)}
</>
)}
</div>
{/* 元信息 */}
<div className="vmat-card-meta">
<span>{formatDuration(material.duration)}</span>
<span>{formatFileSize(material.fileSize)}</span>
<span>{formatDate(material.createdAt)}</span>
</div>
{/* 播放控制 */}
<div className="vmat-card-player">
<button
type="button"
className="vmat-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
disabled={!material.fileUrl}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div>
<span className="vmat-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
</span>
{/* 音量控制 */}
<div className="vmat-volume">
<button
type="button"
className="vmat-volume-btn"
onClick={(e) => {
e.stopPropagation()
onToggleMute()
}}
title={volume === 0 ? "取消静音" : "静音"}
>
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
</button>
<input
type="range"
className="vmat-volume-slider"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => {
e.stopPropagation()
onVolumeChange(e)
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
<CardTags tagIds={material.tagIds} tagMap={tagMap} onEdit={onEdit} />
<CardMeta
duration={material.duration}
fileSize={material.fileSize}
createdAt={material.createdAt}
/>
<CardPlayer
isPlaying={isPlaying}
currentTime={currentTime}
duration={material.duration}
volume={volume}
fileUrl={material.fileUrl}
onPlay={onPlay}
onPause={onPause}
onSeek={onSeek}
onVolumeChange={onVolumeChange}
onToggleMute={onToggleMute}
/>
</div>
)
}
export default VoiceMaterialCard
export type { VoiceCardProps }
@@ -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<BatchCheckboxProps> = ({ isSelected, visible, onToggle }) => {
if (!visible) return null
return (
<div
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
>
{isSelected && <CheckOutlined />}
</div>
)
}
export default BatchCheckbox
@@ -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<CardActionsProps> = ({ onEdit, onDelete }) => {
return (
<div className="vmat-card-actions">
<button
type="button"
className="vmat-card-action-btn"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="编辑"
>
<EditOutlined />
</button>
<button
type="button"
className="vmat-card-action-btn vmat-card-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="删除"
>
<DeleteOutlined />
</button>
</div>
)
}
export default CardActions
@@ -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<CardHeaderProps> = ({ material }) => {
return (
<div className="vmat-card-header">
<div className="vmat-card-avatar">
<AudioOutlined />
</div>
<div className="vmat-card-title-area">
<h4 className="vmat-card-name" title={material.name}>
{material.name}
</h4>
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)}
{genderLabel(material.gender)}
</span>
</div>
</div>
)
}
export default CardHeader
@@ -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<CardMetaProps> = ({ duration, fileSize, createdAt }) => {
return (
<div className="vmat-card-meta">
<span>{formatDuration(duration)}</span>
<span>{formatFileSize(fileSize)}</span>
<span>{formatDate(createdAt)}</span>
</div>
)
}
export default CardMeta
@@ -0,0 +1,110 @@
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<HTMLInputElement>) => void
onToggleMute: () => void
}
/** 播放控制区:播放按钮 + 进度条 + 时间 + 音量 */
const CardPlayer: React.FC<CardPlayerProps> = ({
isPlaying,
currentTime,
duration,
volume,
fileUrl,
onPlay,
onPause,
onSeek,
onVolumeChange,
onToggleMute,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
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 (
<div className="vmat-card-player">
<button
type="button"
className="vmat-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
disabled={!fileUrl}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div>
<span className="vmat-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(duration)}
</span>
{/* 音量控制 */}
<div className="vmat-volume">
<button
type="button"
className="vmat-volume-btn"
onClick={(e) => {
e.stopPropagation()
onToggleMute()
}}
title={volume === 0 ? "取消静音" : "静音"}
>
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
</button>
<input
type="range"
className="vmat-volume-slider"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => {
e.stopPropagation()
onVolumeChange(e)
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
)
}
export default CardPlayer
@@ -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<string, TagItem>
onEdit: () => void
}
/** 标签展示区 */
const CardTags: React.FC<CardTagsProps> = ({ tagIds, tagMap, onEdit }) => {
if (tagIds.length === 0) {
return (
<div className="vmat-card-tags">
<span
className="vmat-tag-empty"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
>
</span>
</div>
)
}
return (
<div className="vmat-card-tags">
{tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{tagIds.length > MAX_CARD_TAGS && (
<Tooltip
title={tagIds
.slice(MAX_CARD_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{tagIds.length - MAX_CARD_TAGS}</Tag>
</Tooltip>
)}
</div>
)
}
export default CardTags
@@ -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"
@@ -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<string, TagItem>
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onEdit: () => void
onDelete: () => void
onToggleSelect: (id: string) => void
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onToggleMute: () => void
}
+7
View File
@@ -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"