fix(voices): 配音库「配音素材」tab 补删除按钮 #1538

Merged
auto-approve-bot merged 2 commits from fix/voice-material-tab-delete-btn into develop 2026-08-29 15:11:01 +08:00
4 changed files with 128 additions and 1 deletions
@@ -32,6 +32,7 @@ import { useAudioPlayer } from "./hooks/useAudioPlayer"
import { useCloneOperations } from "./hooks/useCloneOperations"
import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
import { useVoiceUpload } from "./hooks/useVoiceUpload"
import { useMaterialDelete } from "./hooks/useMaterialDelete"
import "./voices.css"
let toastIdSeq = 0
@@ -70,6 +71,12 @@ const VoiceLibrary: React.FC = () => {
materialCount,
} = useVoicesData()
// ── 配音素材删除 ──────────────────────────────────────
const { handleMaterialDelete } = useMaterialDelete({
materials: materials as AssetItem[],
showToast,
})
// ── 播放控制 ──────────────────────────────────────────
const {
playingId,
@@ -234,6 +241,7 @@ const VoiceLibrary: React.FC = () => {
loading={materialLoading}
materials={materials as AssetItem[]}
onOpenUpload={() => setUploadOpen(true)}
onDelete={handleMaterialDelete}
/>
)}
@@ -2,7 +2,7 @@
* VoiceLibrary 配音素材 Tab 内容
*/
import React from "react"
import { SoundOutlined, UploadOutlined, AudioOutlined } from "@ant-design/icons"
import { SoundOutlined, UploadOutlined, AudioOutlined, DeleteOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import type { AssetItem } from "@/api/assets"
@@ -10,12 +10,14 @@ export interface MaterialVoiceTabProps {
loading: boolean
materials: AssetItem[]
onOpenUpload: () => void
onDelete: (asset: AssetItem) => void
}
export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
loading,
materials,
onOpenUpload,
onDelete,
}) => {
return (
<div className="xx-voices-tab-content">
@@ -43,6 +45,21 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
const seconds = Math.floor(duration % 60)
return (
<div key={asset.id} className="vmat-card">
{/* 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>
<div className="vmat-thumb">
<AudioOutlined className="vmat-thumb-icon" />
<span className="vmat-duration">
@@ -0,0 +1,48 @@
/**
* 配音库「配音素材」Tab 删除逻辑
*/
import { useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Modal } from "@/components/ui"
import { deleteAsset, type AssetItem } from "@/api/assets"
import type { Toast } from "../components/VoiceToasts"
interface UseMaterialDeleteOptions {
materials: AssetItem[]
showToast: (message: string, type: Toast["type"]) => void
}
export function useMaterialDelete({ materials, showToast }: UseMaterialDeleteOptions) {
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: (assetId: string) => deleteAsset(assetId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
showToast("素材已删除", "success")
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : "未知错误"
showToast(`删除失败:${msg}`, "error")
},
})
const handleMaterialDelete = useCallback(
(asset: AssetItem) => {
const material = materials.find((m) => m.id === asset.id)
if (!material) return
Modal.confirm({
title: "确认删除",
content: `确定删除配音素材「${material.name}」吗?删除后不可恢复。`,
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: () => deleteMutation.mutate(material.id),
})
},
[materials, deleteMutation],
)
return { handleMaterialDelete, isDeleting: deleteMutation.isPending }
}
+54
View File
@@ -976,6 +976,7 @@
================================================================ */
.vmat-card {
position: relative;
background: var(--bg-primary);
border-radius: var(--radius-lg);
overflow: hidden;
@@ -984,6 +985,59 @@
cursor: pointer;
}
/* 卡片 hover 操作按钮 */
.vmat-card-actions {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity var(--transition-fast);
}
.vmat-card:hover .vmat-card-actions {
opacity: 1;
}
/* 触摸屏设备无 hover:操作按钮常驻显示 */
@media (hover: none) {
.vmat-card-actions {
opacity: 1;
}
.vmat-card-action-btn {
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
}
.vmat-card-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: var(--bg-hover);
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
transition: all var(--transition-fast);
}
.vmat-card-action-btn:hover {
background: var(--primary-50);
color: var(--primary-600);
}
.vmat-card-action-btn--danger: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);