feat(voices): 配音素材卡片 UI 对齐预置音色 + 删除常驻 + 批量删除 #1545

Merged
auto-approve-bot merged 1 commits from feat/material-card-ui-batch-delete into develop 2026-08-29 23:21:00 +08:00
5 changed files with 364 additions and 155 deletions
+29 -7
View File
@@ -33,6 +33,7 @@ import { useCloneOperations } from "./hooks/useCloneOperations"
import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
import { useVoiceUpload } from "./hooks/useVoiceUpload"
import { useMaterialDelete } from "./hooks/useMaterialDelete"
import { useMaterialBatchDelete } from "./hooks/useMaterialBatchDelete"
import "./voices.css"
let toastIdSeq = 0
@@ -71,12 +72,6 @@ const VoiceLibrary: React.FC = () => {
materialCount,
} = useVoicesData()
// ── 配音素材删除 ──────────────────────────────────────
const { handleMaterialDelete } = useMaterialDelete({
materials: materials as AssetItem[],
showToast,
})
// ── 播放控制(三 tab 共用:真实 Audio 播放 + TTS 试听) ──
const {
playingId,
@@ -90,6 +85,26 @@ const VoiceLibrary: React.FC = () => {
stopPlayback,
} = useAudioPlayer()
// ── 配音素材删除 / 批量删除(删除正在播放的素材时停止播放) ──
const { handleMaterialDelete } = useMaterialDelete({
materials: materials as AssetItem[],
stopPlayback,
showToast,
})
const {
selectedIds: materialSelectedIds,
selectedCount: materialSelectedCount,
allSelected: materialAllSelected,
batchDeleting: materialBatchDeleting,
toggleSelect: toggleMaterialSelect,
toggleSelectAll: toggleMaterialSelectAll,
handleBatchDelete: handleMaterialBatchDelete,
} = useMaterialBatchDelete({
materials: materials as AssetItem[],
stopPlayback,
showToast,
})
// ── 克隆音色操作 ──────────────────────────────────────
const {
detailVoice,
@@ -249,10 +264,17 @@ const VoiceLibrary: React.FC = () => {
materials={materials as AssetItem[]}
onOpenUpload={() => setUploadOpen(true)}
onDelete={handleMaterialDelete}
selectedIds={materialSelectedIds}
selectedCount={materialSelectedCount}
allSelected={materialAllSelected}
batchDeleting={materialBatchDeleting}
onToggleSelect={toggleMaterialSelect}
onToggleSelectAll={toggleMaterialSelectAll}
onBatchDelete={handleMaterialBatchDelete}
playingId={playingId}
currentTime={currentTime}
playDuration={playDuration}
onToggle={handleToggleMaterial}
onTogglePlay={handleToggleMaterial}
onSeek={handleSeek}
/>
)}
@@ -1,8 +1,10 @@
/**
* VoiceLibrary 配音素材 Tab 内容
*
* 播放状态由页面级统一 hookVoiceLibrary/useAudioPlayer)下发,
* 与预置/克隆音色互斥:同一时刻全库只有一个音频在响
* 卡片视觉与预置音色卡片(xx-voice-card)一致:白底圆角横向布局、
* 左侧圆形图标、名称/副标题、波形条、底部播放控件
* 删除按钮常驻右上角;支持全选/多选 + 批量删除。
* 播放状态由页面级统一 hookVoiceLibrary/useAudioPlayer)下发。
*/
import React, { useRef } from "react"
import {
@@ -12,23 +14,31 @@ import {
DeleteOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
CheckOutlined,
} from "@ant-design/icons"
import { Button } from "@/components/ui"
import type { AssetItem } from "@/api/assets"
import { mapAssetToMaterial } from "@/pages/voice-materials/types"
import type { VoiceMaterial } from "@/pages/voice-materials/types"
import { formatTime } from "../utils/format"
import { mapAssetToMaterial, type VoiceMaterial } from "@/pages/voice-materials/types"
import { formatTime, formatFileSize } from "../utils/format"
export interface MaterialVoiceTabProps {
loading: boolean
materials: AssetItem[]
onOpenUpload: () => void
onDelete: (asset: AssetItem) => void
// 批量删除
selectedIds: Set<string>
selectedCount: number
allSelected: boolean
batchDeleting: boolean
onToggleSelect: (id: string) => void
onToggleSelectAll: () => void
onBatchDelete: () => void
// 播放控制(页面级统一 hook)
playingId: string | null
currentTime: number
playDuration: number
onToggle: (material: VoiceMaterial) => void
onTogglePlay: (material: VoiceMaterial) => void
onSeek: (time: number) => void
}
@@ -37,22 +47,29 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
materials,
onOpenUpload,
onDelete,
selectedIds,
selectedCount,
allSelected,
batchDeleting,
onToggleSelect,
onToggleSelectAll,
onBatchDelete,
playingId,
currentTime,
playDuration,
onToggle,
onTogglePlay,
onSeek,
}) => {
const progressRefs = useRef<Record<string, HTMLDivElement | null>>({})
/** 播放中点击/拖动进度条 seek;非播放态点击则开始播放 */
/** 播放中点击进度条 seek;非播放态点击则开始播放 */
const handleProgressClick =
(asset: AssetItem, material: VoiceMaterial, dur: number) =>
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
if (!asset.file_url) return
if (playingId !== asset.id) {
onToggle(material)
onTogglePlay(material)
return
}
const el = progressRefs.current[asset.id]
@@ -64,13 +81,46 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
return (
<div className="xx-voices-tab-content">
{/* 批量操作栏 */}
{!loading && materials.length > 0 && (
<div className="vmat-batch-bar">
<div className="vmat-batch-bar-left">
<button
type="button"
className={`vmat-checkbox${allSelected ? " checked" : ""}`}
onClick={onToggleSelectAll}
aria-label={allSelected ? "取消全选" : "全选"}
>
{allSelected && <CheckOutlined />}
</button>
<button type="button" className="vmat-select-all" onClick={onToggleSelectAll}>
{allSelected ? "取消全选" : "全选"}
</button>
{selectedCount > 0 && (
<span className="vmat-batch-count"> {selectedCount} </span>
)}
</div>
<div className="vmat-batch-bar-right">
<Button
buttonType="danger"
buttonSize="sm"
icon={<DeleteOutlined />}
disabled={selectedCount === 0 || batchDeleting}
onClick={onBatchDelete}
>
{batchDeleting ? "删除中..." : "批量删除"}
</Button>
</div>
</div>
)}
{/* 骨架屏加载 */}
{loading && (
<div className="xx-voice-grid">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="vmat-card vmat-card--skeleton">
<div className="vmat-thumb" />
<div className="vmat-info">
<div key={i} className="xx-voice-card vmat-card--skeleton">
<div className="vmat-skeleton-avatar" />
<div className="xx-voice-info">
<div className="vmat-skeleton-line vmat-skeleton-title" />
<div className="vmat-skeleton-line" />
</div>
@@ -87,38 +137,56 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
// duration 优先取顶层(后端从 metadata 提取),兜底 metadata
const cardDuration = asset.duration || material.duration || 0
const isPlaying = playingId === asset.id
const isSelected = selectedIds.has(asset.id)
// 播放中以 audio 真实时长为准,未播放显示卡片时长
const effectiveDuration = isPlaying ? playDuration || cardDuration : cardDuration
const progress = effectiveDuration > 0 ? (currentTime / effectiveDuration) * 100 : 0
return (
<div key={asset.id} className={`vmat-card${isPlaying ? " playing" : ""}`}>
{/* hover 操作区:删除 */}
<div className="vmat-card-actions">
<button
type="button"
className="vmat-card-action-btn vmat-card-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete(asset)
}}
title="删除"
aria-label="删除配音素材"
>
<DeleteOutlined />
</button>
<div
key={asset.id}
className={`xx-voice-card vmat-card${isSelected ? " selected" : ""}${
isPlaying ? " playing" : ""
}`}
>
{/* 左上角批量选择 checkbox */}
<button
type="button"
className={`vmat-card-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(asset.id)
}}
aria-label={isSelected ? "取消选择" : "选择素材"}
>
{isSelected && <CheckOutlined />}
</button>
{/* 右上角删除按钮(常驻可见) */}
<button
type="button"
className="vmat-card-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(asset)
}}
title="删除"
aria-label="删除配音素材"
>
<DeleteOutlined />
</button>
<div className="xx-voice-avatar vmat-avatar">
<AudioOutlined />
</div>
<div className="vmat-thumb">
<AudioOutlined className="vmat-thumb-icon" />
<span className="vmat-duration">{formatTime(cardDuration)}</span>
</div>
<div className="vmat-info">
<div className="vmat-name" title={asset.name}>
<div className="xx-voice-info vmat-info">
<div className="xx-voice-name" title={asset.name}>
{asset.name}
</div>
<div className="vmat-meta">
<span>
{asset.file_size ? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB` : "--"}
</span>
<div className="xx-voice-subtitle">
{asset.file_size ? `${formatFileSize(asset.file_size)}` : "--"}
{" · "}
{cardDuration > 0 ? formatTime(cardDuration) : "--:--"}
</div>
</div>
@@ -133,7 +201,7 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
disabled={!asset.file_url}
onClick={(e) => {
e.stopPropagation()
onToggle(material)
onTogglePlay(material)
}}
title={asset.file_url ? (isPlaying ? "暂停" : "试听") : "暂无可播放音频"}
aria-label={isPlaying ? "暂停播放" : "播放音频"}
@@ -0,0 +1,97 @@
/**
* 配音库「配音素材」Tab 批量删除逻辑
*
* 选择状态本地维护;批量删除循环调 deleteAsset(无批量接口),
* 单个失败不中断;删除项含正在播放的素材时停止播放。
*/
import { useState, useCallback, useMemo } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { Modal } from "@/components/ui"
import { deleteAsset, type AssetItem } from "@/api/assets"
import type { Toast } from "../components/VoiceToasts"
interface UseMaterialBatchDeleteOptions {
materials: AssetItem[]
/** 停止播放回调(删除正在播放的素材时调用) */
stopPlayback: () => void
showToast: (message: string, type: Toast["type"]) => void
}
export function useMaterialBatchDelete({
materials,
stopPlayback,
showToast,
}: UseMaterialBatchDeleteOptions) {
const queryClient = useQueryClient()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [batchDeleting, setBatchDeleting] = useState(false)
/** 只统计当前列表中仍然存在的选中项(删除后自动收敛) */
const validSelected = useMemo(
() => materials.filter((m) => selectedIds.has(m.id)),
[materials, selectedIds],
)
const selectedCount = validSelected.length
const allSelected = materials.length > 0 && selectedCount === materials.length
const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const toggleSelectAll = useCallback(() => {
setSelectedIds(allSelected ? new Set() : new Set(materials.map((m) => m.id)))
}, [allSelected, materials])
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
const handleBatchDelete = useCallback(() => {
const targets = validSelected
if (targets.length === 0) return
Modal.confirm({
title: "确认批量删除",
content: `确定删除选中的 ${targets.length} 个素材?删除后不可恢复。`,
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: async () => {
setBatchDeleting(true)
let successCount = 0
for (const asset of targets) {
try {
await deleteAsset(asset.id)
successCount++
} catch {
/* 单个失败不中断 */
}
}
// 删除项含正在播放的素材(播放 id 与素材 id 一致)→ 停止播放
stopPlayback()
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
setSelectedIds(new Set())
setBatchDeleting(false)
if (successCount === targets.length) {
showToast(`已批量删除 ${successCount} 个素材`, "success")
} else {
showToast(`已批量删除 ${successCount}/${targets.length} 个素材,部分失败`, "error")
}
},
})
}, [validSelected, stopPlayback, queryClient, showToast])
return {
selectedIds,
selectedCount,
allSelected,
batchDeleting,
toggleSelect,
toggleSelectAll,
clearSelection,
handleBatchDelete,
}
}
@@ -1,5 +1,5 @@
/**
* 配音库「配音素材」Tab 删除逻辑
* 配音库「配音素材」Tab 单个删除逻辑
*/
import { useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
@@ -9,10 +9,16 @@ import type { Toast } from "../components/VoiceToasts"
interface UseMaterialDeleteOptions {
materials: AssetItem[]
/** 停止播放回调(删除正在播放的素材时调用) */
stopPlayback: () => void
showToast: (message: string, type: Toast["type"]) => void
}
export function useMaterialDelete({ materials, showToast }: UseMaterialDeleteOptions) {
export function useMaterialDelete({
materials,
stopPlayback,
showToast,
}: UseMaterialDeleteOptions) {
const queryClient = useQueryClient()
const deleteMutation = useMutation({
@@ -20,6 +26,8 @@ export function useMaterialDelete({ materials, showToast }: UseMaterialDeleteOpt
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
// 删除成功后停止播放(若删的是正在播放的素材,播放 id 与素材 id 一致)
stopPlayback()
showToast("素材已删除", "success")
},
onError: (err: unknown) => {
+122 -108
View File
@@ -972,142 +972,88 @@
}
/* ================================================================
配音素材卡片(与配音库Tab集成
配音素材卡片(复用预置音色卡片 .xx-voice-card 布局与播放控件
================================================================ */
.vmat-card {
position: relative;
background: var(--bg-primary);
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--border-color);
transition: all 0.2s;
cursor: pointer;
/* 圆形图标:紫色系(素材统一配色,不用预置音色的性别色) */
.vmat-card .vmat-avatar {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
/* 卡片 hover 操作按钮 */
.vmat-card-actions {
/* 左上角批量选择 checkbox */
.vmat-card-checkbox {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
display: flex;
gap: 4px;
top: 10px;
left: 10px;
z-index: 3;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
border: 1.5px solid var(--border-color);
background: var(--bg-primary);
color: #fff;
display: grid;
place-items: center;
font-size: 11px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast);
transition: var(--transition-fast);
}
.vmat-card:hover .vmat-card-actions {
.vmat-card:hover .vmat-card-checkbox,
.vmat-card-checkbox.checked {
opacity: 1;
}
/* 触摸屏设备无 hover:操作按钮常驻显示 */
@media (hover: none) {
.vmat-card-actions {
opacity: 1;
}
.vmat-card-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
.vmat-card-action-btn {
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
/* 触摸屏无 hovercheckbox 常驻显示 */
@media (hover: none) {
.vmat-card-checkbox {
opacity: 1;
}
}
.vmat-card-action-btn {
display: flex;
align-items: center;
justify-content: center;
/* 右上角删除按钮:常驻可见,hover 加深为 danger 色 */
.vmat-card-delete {
position: absolute;
top: 8px;
right: 8px;
z-index: 3;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: var(--bg-hover);
color: var(--text-secondary);
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
font-size: 13px;
transition: all var(--transition-fast);
font-size: 14px;
display: grid;
place-items: center;
transition: var(--transition-fast);
}
.vmat-card-action-btn:hover {
background: var(--primary-50);
color: var(--primary-600);
}
.vmat-card-action-btn--danger:hover {
.vmat-card-delete:hover {
background: var(--error-50);
color: var(--error-600);
}
.vmat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
border-color: var(--primary-300);
/* 选中态高亮(覆盖预置卡片的选中边框) */
.vmat-card.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color);
}
.vmat-thumb {
position: relative;
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
display: flex;
align-items: center;
justify-content: center;
/* 信息区给 checkbox / 删除按钮留位 */
.vmat-card .vmat-info {
padding-right: 24px;
}
.vmat-thumb-icon {
font-size: 32px;
color: rgba(255, 255, 255, 0.9);
}
.vmat-duration {
position: absolute;
bottom: 8px;
right: 8px;
padding: 2px 8px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 11px;
border-radius: 4px;
font-family: monospace;
}
.vmat-info {
padding: 12px 14px;
}
.vmat-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
}
.vmat-meta {
font-size: 12px;
color: var(--text-tertiary);
}
/* 播放区:复用预置音色卡片 .xx-voice-wave / .xx-voice-controls 视觉 */
.vmat-card .xx-voice-wave {
margin: 2px 14px 0;
grid-column: auto;
}
.vmat-card .xx-voice-controls {
grid-column: auto;
padding: 8px 14px 12px;
}
.vmat-card:hover .xx-voice-wave {
opacity: 0.3;
}
.vmat-card.playing .xx-voice-wave {
opacity: 0.5;
animation: xx-wave-pulse 0.8s ease-in-out infinite;
}
/* 播放波形/控件继承 .xx-voice-card 网格,无需额外样式 */
/* 无可播放音频时播放按钮置灰 */
.vmat-card .xx-voice-play-btn:disabled {
@@ -1118,13 +1064,81 @@
box-shadow: none;
}
/* 骨架屏 */
/* ── 批量操作栏 ─────────────────────────────────── */
.vmat-batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: var(--space-md);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
.vmat-batch-bar-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.vmat-batch-bar-right {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.vmat-checkbox {
width: 18px;
height: 18px;
border-radius: var(--radius-sm);
border: 1.5px solid var(--border-color);
background: var(--bg-primary);
color: #fff;
display: grid;
place-items: center;
font-size: 10px;
line-height: 1;
cursor: pointer;
padding: 0;
transition: var(--transition-fast);
}
.vmat-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
.vmat-select-all {
background: none;
border: none;
padding: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
cursor: pointer;
}
.vmat-select-all:hover {
color: var(--primary-color);
}
.vmat-batch-count {
font-size: var(--font-size-sm);
color: var(--primary-color);
font-weight: var(--font-weight-medium);
}
/* ── 骨架屏 ─────────────────────────────────────── */
.vmat-card--skeleton {
pointer-events: none;
}
.vmat-card--skeleton .vmat-thumb {
.vmat-skeleton-avatar {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
background: var(--bg-tertiary);
grid-row: 1 / 3;
}
.vmat-skeleton-line {