feat(voices): 配音素材卡片 UI 对齐预置音色 + 删除常驻 + 批量删除 #1545
@@ -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 内容
|
||||
*
|
||||
* 播放状态由页面级统一 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<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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user