From ec689d27bacff9eb78521861260a32a9bc8a316e Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 29 Aug 2026 22:54:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(voices):=20=E9=85=8D=E9=9F=B3=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=8D=A1=E7=89=87=20UI=20=E5=AF=B9=E9=BD=90=E9=A2=84?= =?UTF-8?q?=E7=BD=AE=E9=9F=B3=E8=89=B2=20+=20=E5=88=A0=E9=99=A4=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E5=B8=B8=E9=A9=BB=20+=20=E6=89=B9=E9=87=8F=E5=88=A0?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 卡片重做为预置音色卡片样式(xx-voice-card 体系):白底圆角横向布局, 左侧紫色圆形 AudioOutlined 图标,右侧文件名+副标题(大小·时长), 波形条+播放控件视觉与预置音色卡片一致;移除紫色大缩略图/时长角标 - 删除按钮改为常驻右上角(hover 加深为 danger 色),不再 hover 才出现 - 新增批量删除:tab 顶部批量栏(全选 checkbox+已选 N 项+danger 批量删除按钮), 卡片左上角 checkbox 选中态紫色边框高亮;Modal.confirm 二次确认后循环 deleteAsset,单个失败不中断,完成后 toast X/N 并 invalidate 列表 - 删除正在播放的素材时停止播放(单个/批量均接入 stopPlayback) - 清理旧 vmat-thumb/vmat-duration/vmat-card-actions 等死代码 CSS; 播放/TTS 试听逻辑不动;voice-materials 独立页面不动 --- apps/web/src/pages/voices/VoiceLibrary.tsx | 36 ++- .../voices/components/MaterialVoiceTab.tsx | 144 ++++++++--- .../voices/hooks/useMaterialBatchDelete.ts | 97 ++++++++ .../pages/voices/hooks/useMaterialDelete.ts | 12 +- apps/web/src/pages/voices/voices.css | 230 ++++++++++-------- 5 files changed, 364 insertions(+), 155 deletions(-) create mode 100644 apps/web/src/pages/voices/hooks/useMaterialBatchDelete.ts diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index a062c6df1..6d678d1b4 100755 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -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} /> )} diff --git a/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx b/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx index 52d66d8eb..bb97b0322 100644 --- a/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx +++ b/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx @@ -1,8 +1,10 @@ /** * VoiceLibrary 配音素材 Tab 内容 * - * 播放状态由页面级统一 hook(VoiceLibrary/useAudioPlayer)下发, - * 与预置/克隆音色互斥:同一时刻全库只有一个音频在响。 + * 卡片视觉与预置音色卡片(xx-voice-card)一致:白底圆角横向布局、 + * 左侧圆形图标、名称/副标题、波形条、底部播放控件。 + * 删除按钮常驻右上角;支持全选/多选 + 批量删除。 + * 播放状态由页面级统一 hook(VoiceLibrary/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 + 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 = ({ materials, onOpenUpload, onDelete, + selectedIds, + selectedCount, + allSelected, + batchDeleting, + onToggleSelect, + onToggleSelectAll, + onBatchDelete, playingId, currentTime, playDuration, - onToggle, + onTogglePlay, onSeek, }) => { const progressRefs = useRef>({}) - /** 播放中点击/拖动进度条 seek;非播放态点击则开始播放 */ + /** 播放中点击进度条 seek;非播放态点击则开始播放 */ const handleProgressClick = (asset: AssetItem, material: VoiceMaterial, dur: number) => (e: React.MouseEvent) => { 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 = ({ return (
+ {/* 批量操作栏 */} + {!loading && materials.length > 0 && ( +
+
+ + + {selectedCount > 0 && ( + 已选择 {selectedCount} 项 + )} +
+
+ +
+
+ )} + {/* 骨架屏加载 */} {loading && (
{Array.from({ length: 6 }).map((_, i) => ( -
-
-
+
+
+
@@ -87,38 +137,56 @@ export const MaterialVoiceTab: React.FC = ({ // 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 ( -
- {/* hover 操作区:删除 */} -
- +
+ {/* 左上角批量选择 checkbox */} + + + {/* 右上角删除按钮(常驻可见) */} + + +
+
-
- - {formatTime(cardDuration)} -
-
-
+ +
+
{asset.name}
-
- - {asset.file_size ? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB` : "--"} - +
+ {asset.file_size ? `${formatFileSize(asset.file_size)}` : "--"} + {" · "} + {cardDuration > 0 ? formatTime(cardDuration) : "--:--"}
@@ -133,7 +201,7 @@ export const MaterialVoiceTab: React.FC = ({ disabled={!asset.file_url} onClick={(e) => { e.stopPropagation() - onToggle(material) + onTogglePlay(material) }} title={asset.file_url ? (isPlaying ? "暂停" : "试听") : "暂无可播放音频"} aria-label={isPlaying ? "暂停播放" : "播放音频"} diff --git a/apps/web/src/pages/voices/hooks/useMaterialBatchDelete.ts b/apps/web/src/pages/voices/hooks/useMaterialBatchDelete.ts new file mode 100644 index 000000000..59241d22a --- /dev/null +++ b/apps/web/src/pages/voices/hooks/useMaterialBatchDelete.ts @@ -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>(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, + } +} diff --git a/apps/web/src/pages/voices/hooks/useMaterialDelete.ts b/apps/web/src/pages/voices/hooks/useMaterialDelete.ts index 8a8f88bb6..e36a9b648 100644 --- a/apps/web/src/pages/voices/hooks/useMaterialDelete.ts +++ b/apps/web/src/pages/voices/hooks/useMaterialDelete.ts @@ -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) => { diff --git a/apps/web/src/pages/voices/voices.css b/apps/web/src/pages/voices/voices.css index f2de715bd..24d8f1adf 100644 --- a/apps/web/src/pages/voices/voices.css +++ b/apps/web/src/pages/voices/voices.css @@ -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); +/* 触摸屏无 hover:checkbox 常驻显示 */ +@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 { -- 2.54.0