feat(voice-materials): 配音模块体验打磨 — 批量操作/上传进度/播放器优化
CI/CD Pipeline / Frontend Lint (push) Failing after 49h22m24s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h22m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h57m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h57m30s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h57m32s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h57m32s
CI/CD Pipeline / Deploy Production (push) Failing after 1708h28m58s

- 配音素材库批量操作:多选删除、批量打标签(Popover 选标签)
- 上传配音进度条:XMLHttpRequest 替换 fetch,实时百分比进度
- 试听播放器优化:暂停恢复、进度条拖拽 seek、音量控制
- 整体交互走查:错误处理、空状态提示、动画过渡

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
灵应
2026-07-07 11:32:03 +08:00
parent f8bc252ded
commit d698d78b35
3 changed files with 640 additions and 61 deletions
+20 -7
View File
@@ -233,10 +233,11 @@ export const completeDirectUpload = async (data: {
return response.data;
};
/** 直传上传(大文件推荐) */
/** 直传上传(大文件推荐),支持可选进度回调 */
export const uploadAssetDirect = async (data: {
file: File;
library_id: string;
onProgress?: (percent: number) => void;
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
// 后端要求 project_id,前端自动获取默认项目
const project = await getOrCreateDefaultProject();
@@ -255,13 +256,25 @@ export const uploadAssetDirect = async (data: {
);
directForm.append("file", data.file);
const uploadResponse = await fetch(prepared.upload_url, {
method: prepared.method,
body: directForm,
// 使用 XMLHttpRequest 以获取上传进度(fetch 不支持)
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(prepared.method, prepared.upload_url);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && data.onProgress) {
data.onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`OSS direct upload failed: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("OSS direct upload failed"));
xhr.send(directForm);
});
if (!uploadResponse.ok) {
throw new Error(`OSS direct upload failed: ${uploadResponse.status}`);
}
return completeDirectUpload({
project_id: project.id,
@@ -32,8 +32,12 @@ import {
UserOutlined,
ManOutlined,
WomanOutlined,
CheckOutlined,
TagsOutlined,
MutedOutlined,
} from "@ant-design/icons";
import { Button, Input, Select, Modal, Tag } from "@/components/ui";
import { message, Popover, Popconfirm } from "antd";
import PageHead from "@/components/layout/PageHead";
import {
getAssetsByKind,
@@ -195,6 +199,7 @@ interface MaterialFormProps {
) => void;
onCancel: () => void;
loading?: boolean;
uploadProgress?: number | null;
}
const MaterialForm: React.FC<MaterialFormProps> = ({
@@ -202,6 +207,7 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
onSubmit,
onCancel,
loading,
uploadProgress,
}) => {
const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
@@ -288,6 +294,13 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
</div>
)}
</div>
{/* 上传进度条 */}
{uploadProgress !== null && uploadProgress !== undefined && (
<div className="vmat-upload-progress">
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
</div>
)}
</div>
)}
@@ -376,39 +389,83 @@ interface VoiceCardProps {
material: VoiceMaterial;
isPlaying: boolean;
currentTime: number;
isSelected: boolean;
batchMode: boolean;
volume: number;
onPlay: () => void;
onPause: () => void;
onSeek: (time: number) => void;
onEdit: () => void;
onDelete: () => void;
onToggleSelect: (id: string) => void;
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onToggleMute: () => void;
}
const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
material,
isPlaying,
currentTime,
isSelected,
batchMode,
volume,
onPlay,
onPause,
onSeek,
onEdit,
onDelete,
onToggleSelect,
onVolumeChange,
onToggleMute,
}) => {
const progressRef = useRef<HTMLDivElement>(null);
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return;
const rect = progressRef.current.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
onSeek(Math.max(0, Math.min(1, percent)) * material.duration);
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);
}
};
return (
<div
className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}`}
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>
)}
{/* 操作按钮 */}
<div className="vmat-card-actions">
<button
@@ -488,18 +545,51 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
<div
ref={progressRef}
className="vmat-progress"
onClick={handleProgressClick}
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>
</div>
);
@@ -513,30 +603,48 @@ interface VoiceRowProps {
material: VoiceMaterial;
isPlaying: boolean;
currentTime: number;
isSelected: boolean;
batchMode: boolean;
onPlay: () => void;
onPause: () => void;
onSeek: (time: number) => void;
onEdit: () => void;
onDelete: () => void;
onToggleSelect: (id: string) => void;
}
const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
material,
isPlaying,
currentTime,
isSelected,
batchMode,
onPlay,
onPause,
onSeek,
onEdit,
onDelete,
onToggleSelect,
}) => {
const progressRef = useRef<HTMLDivElement>(null);
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return;
const rect = progressRef.current.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
onSeek(Math.max(0, Math.min(1, percent)) * material.duration);
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 =
@@ -544,8 +652,21 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
return (
<div
className={`vmat-row ${genderClass(material.gender)}${isPlaying ? " playing" : ""}`}
className={`vmat-row ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
>
{/* 批量选择 checkbox */}
{(batchMode || isSelected) && (
<div
className={`vmat-row-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation();
onToggleSelect(material.id);
}}
>
{isSelected && <CheckOutlined />}
</div>
)}
{/* 播放按钮 */}
<button
type="button"
@@ -582,16 +703,22 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
))}
</div>
{/* 进度条 */}
{/* 进度条(可拖拽) */}
<div
ref={progressRef}
className="vmat-row-progress"
onClick={handleProgressClick}
onMouseDown={handleProgressMouseDown}
>
<div
className="vmat-row-progress-bar"
style={{ width: `${progress}%` }}
/>
{isPlaying && (
<div
className="vmat-progress-thumb"
style={{ left: `${progress}%` }}
/>
)}
</div>
{/* 时长 */}
@@ -711,46 +838,55 @@ const VoiceMaterialLibrary: React.FC = () => {
description: string;
tags: string[];
}) => {
// 1. 获取或等待 voice library
let lib = voiceLibrary;
if (!lib) {
if (createLibMutation.isPending) {
await createLibMutation.mutateAsync();
setUploadProgress(0);
try {
// 1. 获取或等待 voice library
let lib = voiceLibrary;
if (!lib) {
if (createLibMutation.isPending) {
await createLibMutation.mutateAsync();
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
});
lib = libs.find((l) => l.kind === "voice");
if (!lib) throw new Error("无法创建配音素材库");
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
// 2. 上传文件(带进度)
const { storage_key } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
});
lib = libs.find((l) => l.kind === "voice");
if (!lib) throw new Error("无法创建配音素材库");
// 3. 获取音频时长
const duration = await getAudioDuration(data.file);
// 4. 创建素材记录
await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildMetadata({
gender: data.gender,
description: data.description,
tags: data.tags,
duration,
}),
});
} finally {
setUploadProgress(null);
}
// 2. 上传文件
const { storage_key } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
});
// 3. 获取音频时长
const duration = await getAudioDuration(data.file);
// 4. 创建素材记录
await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildMetadata({
gender: data.gender,
description: data.description,
tags: data.tags,
duration,
}),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
},
onError: (err: Error) => {
message.error(err.message || "上传失败,请重试");
},
});
// ── 编辑 mutation ─────────────────────────────────────────
@@ -794,6 +930,12 @@ const VoiceMaterialLibrary: React.FC = () => {
null,
);
// ── 批量操作 / 上传进度 / 音量 ────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [volume, setVolume] = useState(0.7);
const [pausedMaterial, setPausedMaterial] = useState<VoiceMaterial | null>(null);
// ── 播放控制 ──────────────────────────────────────────────
const stopPlayback = useCallback(() => {
if (audioRef.current) {
@@ -802,6 +944,7 @@ const VoiceMaterialLibrary: React.FC = () => {
}
setPlayingId(null);
setCurrentTime(0);
setPausedMaterial(null);
}, []);
const startPlayback = useCallback(
@@ -810,6 +953,7 @@ const VoiceMaterialLibrary: React.FC = () => {
stopPlayback();
const audio = new Audio(material.fileUrl);
audio.volume = volume;
audioRef.current = audio;
audio.addEventListener("timeupdate", () => {
@@ -820,6 +964,7 @@ const VoiceMaterialLibrary: React.FC = () => {
setPlayingId(null);
setCurrentTime(0);
audioRef.current = null;
setPausedMaterial(null);
});
audio.play().catch(() => {
@@ -829,21 +974,36 @@ const VoiceMaterialLibrary: React.FC = () => {
setPlayingId(material.id);
setCurrentTime(0);
setPausedMaterial(null);
},
[stopPlayback],
[stopPlayback, volume],
);
const handlePlay = useCallback(
(material: VoiceMaterial) => {
if (playingId === material.id) return;
// 恢复暂停
if (pausedMaterial?.id === material.id && audioRef.current && audioRef.current.paused) {
audioRef.current.play().catch(() => {});
setPlayingId(material.id);
setPausedMaterial(null);
return;
}
startPlayback(material);
},
[playingId, startPlayback],
[playingId, pausedMaterial, startPlayback],
);
const handlePause = useCallback(() => {
stopPlayback();
}, [stopPlayback]);
const handlePause = useCallback(
(material?: VoiceMaterial) => {
if (audioRef.current) {
audioRef.current.pause();
}
setPlayingId(null);
if (material) setPausedMaterial(material);
},
[],
);
const handleSeek = useCallback(
(material: VoiceMaterial, time: number) => {
@@ -862,6 +1022,22 @@ const VoiceMaterialLibrary: React.FC = () => {
[startPlayback],
);
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value);
setVolume(v);
if (audioRef.current) audioRef.current.volume = v;
}, []);
const toggleMute = useCallback(() => {
if (volume > 0) {
setVolume(0);
if (audioRef.current) audioRef.current.volume = 0;
} else {
setVolume(0.7);
if (audioRef.current) audioRef.current.volume = 0.7;
}
}, [volume]);
// 组件卸载时清理 audio
useEffect(() => {
return () => {
@@ -954,6 +1130,74 @@ const VoiceMaterialLibrary: React.FC = () => {
return Array.from(set).sort();
}, [materials]);
/* ── 批量操作 ─────────────────────────────────────────── */
const batchMode = selectedIds.size > 0;
const allSelected =
filtered.length > 0 && filtered.every((m) => selectedIds.has(m.id));
const handleToggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleSelectAll = useCallback(() => {
if (allSelected) setSelectedIds(new Set());
else setSelectedIds(new Set(filtered.map((m) => m.id)));
}, [allSelected, filtered]);
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds);
let successCount = 0;
for (const id of ids) {
try {
await deleteAsset(id);
successCount++;
} catch {
/* ignore individual failures */
}
if (playingId === id) stopPlayback();
}
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
setSelectedIds(new Set());
message.success(`已批量删除 ${successCount}/${ids.length} 个素材`);
}, [selectedIds, playingId, stopPlayback, queryClient]);
const handleBatchTag = useCallback(
async (tag: string) => {
const ids = Array.from(selectedIds);
let successCount = 0;
for (const id of ids) {
try {
const m = materials.find((mat) => mat.id === id);
const existingTags = m?.tags || [];
const newTags = existingTags.includes(tag)
? existingTags
: [...existingTags, tag];
await updateAsset(id, {
metadata: {
style: newTags,
gender: m?.gender || "neutral",
description: m?.description || "",
duration: m?.duration || 0,
},
});
successCount++;
} catch {
/* ignore individual failures */
}
}
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
setSelectedIds(new Set());
message.success(`已为 ${successCount}/${ids.length} 个素材添加标签「${tag}」`);
},
[selectedIds, materials, queryClient],
);
/* ── 渲染 ─────────────────────────────────────────────── */
const isUploading = uploadMutation.isPending;
@@ -1046,6 +1290,68 @@ const VoiceMaterialLibrary: React.FC = () => {
</div>
)}
{/* 批量操作栏 */}
{batchMode && (
<div className="vmat-batch-bar">
<div className="vmat-batch-bar-left">
<div
className={`vmat-checkbox${allSelected ? " checked" : ""}`}
onClick={handleSelectAll}
>
{allSelected && <CheckOutlined />}
</div>
<span className="vmat-select-all" onClick={handleSelectAll}>
{allSelected ? "取消全选" : "全选"}
</span>
<span className="vmat-batch-count">
已选择 {selectedIds.size} 项
</span>
</div>
<div className="vmat-batch-bar-right">
<Popover
content={
<div className="vmat-tag-popover">
{STYLE_TAGS.map((tag) => (
<button
key={tag}
type="button"
className="vmat-tag-pop-btn"
onClick={() => handleBatchTag(tag)}
>
{tag}
</button>
))}
</div>
}
title="批量打标签"
trigger="click"
>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<TagsOutlined />}
>
批量打标签
</Button>
</Popover>
<Popconfirm
title={`确定删除选中的 ${selectedIds.size} 个素材?`}
onConfirm={handleBatchDelete}
okText="删除"
cancelText="取消"
>
<Button
buttonType="danger"
buttonSize="sm"
icon={<DeleteOutlined />}
>
批量删除
</Button>
</Popconfirm>
</div>
</div>
)}
{/* 内容区 */}
{!isLoading && filtered.length > 0 && viewMode === "card" && (
<div className="vmat-grid">
@@ -1055,20 +1361,27 @@ const VoiceMaterialLibrary: React.FC = () => {
material={m}
isPlaying={playingId === m.id}
currentTime={playingId === m.id ? currentTime : 0}
isSelected={selectedIds.has(m.id)}
batchMode={batchMode}
volume={volume}
onPlay={() => handlePlay(m)}
onPause={handlePause}
onPause={() => handlePause(m)}
onSeek={(t) => handleSeek(m, t)}
onEdit={() => setEditingMaterial(m)}
onDelete={() => handleDelete(m.id)}
onToggleSelect={handleToggleSelect}
onVolumeChange={handleVolumeChange}
onToggleMute={toggleMute}
/>
))}
</div>
)}
{!isLoading && filtered.length > 0 && viewMode === "list" && (
<div className="vmat-list">
<div className={`vmat-list${batchMode ? " batch-mode" : ""}`}>
{/* 列表头 */}
<div className="vmat-list-header">
{batchMode && <span className="vmat-lh-checkbox" />}
<span className="vmat-lh-play" />
<span className="vmat-lh-info">名称</span>
<span className="vmat-lh-gender">性别</span>
@@ -1084,11 +1397,14 @@ const VoiceMaterialLibrary: React.FC = () => {
material={m}
isPlaying={playingId === m.id}
currentTime={playingId === m.id ? currentTime : 0}
isSelected={selectedIds.has(m.id)}
batchMode={batchMode}
onPlay={() => handlePlay(m)}
onPause={handlePause}
onPause={() => handlePause(m)}
onSeek={(t) => handleSeek(m, t)}
onEdit={() => setEditingMaterial(m)}
onDelete={() => handleDelete(m.id)}
onToggleSelect={handleToggleSelect}
/>
))}
</div>
@@ -1131,6 +1447,7 @@ const VoiceMaterialLibrary: React.FC = () => {
onSubmit={handleUpload}
onCancel={() => setUploadOpen(false)}
loading={isUploading}
uploadProgress={uploadProgress}
/>
</Modal>
@@ -812,3 +812,252 @@
justify-content: space-between;
}
}
/* ─── 批量操作栏 ─────────────────────────────────────────── */
.vmat-batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
margin-bottom: 16px;
background: var(--primary-soft, #eef2ff);
border: 1px solid var(--primary-color, #6366f1);
border-radius: 8px;
animation: vmat-batch-bar-in 0.2s ease;
}
@keyframes vmat-batch-bar-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.vmat-batch-bar-left {
display: flex;
align-items: center;
gap: 12px;
}
.vmat-batch-bar-right {
display: flex;
align-items: center;
gap: 8px;
}
.vmat-select-all {
font-size: 13px;
color: var(--text-secondary, #64748b);
cursor: pointer;
user-select: none;
}
.vmat-select-all:hover {
color: var(--primary-color, #6366f1);
}
.vmat-batch-count {
font-size: 13px;
font-weight: 500;
color: var(--primary-color, #6366f1);
}
/* ─── 自定义 checkbox ────────────────────────────────────── */
.vmat-checkbox {
width: 18px;
height: 18px;
border: 2px solid var(--neutral-300, #cbd5e1);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
background: var(--bg-primary, #fff);
flex-shrink: 0;
}
.vmat-checkbox:hover {
border-color: var(--primary-color, #6366f1);
}
.vmat-checkbox.checked {
background: var(--primary-color, #6366f1);
border-color: var(--primary-color, #6366f1);
color: #fff;
font-size: 10px;
}
/* ─── 卡片 checkbox 覆盖 ─────────────────────────────────── */
.vmat-card-checkbox {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
}
.vmat-row-checkbox {
flex-shrink: 0;
}
/* ─── 选中态 + 批量模式 ──────────────────────────────────── */
.vmat-card.selected {
border-color: var(--primary-color, #6366f1);
box-shadow: 0 0 0 1px var(--primary-color, #6366f1);
}
.vmat-row.selected {
background: var(--primary-soft, #eef2ff);
}
.vmat-card.batch-mode {
cursor: pointer;
}
.vmat-row.batch-mode {
cursor: pointer;
}
/* ─── 进度条拖拽 thumb ───────────────────────────────────── */
.vmat-progress-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--primary-color, #6366f1);
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
pointer-events: none;
z-index: 1;
}
/* ─── 音量控制 ────────────────────────────────────────────── */
.vmat-volume {
display: flex;
align-items: center;
gap: 4px;
margin-top: 6px;
}
.vmat-volume-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text-secondary, #64748b);
font-size: 14px;
display: flex;
align-items: center;
transition: color 0.15s;
}
.vmat-volume-btn:hover {
color: var(--primary-color, #6366f1);
}
.vmat-volume-slider {
-webkit-appearance: none;
appearance: none;
width: 48px;
height: 4px;
border-radius: 2px;
background: var(--neutral-200, #e2e8f0);
outline: none;
cursor: pointer;
}
.vmat-volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--primary-color, #6366f1);
cursor: pointer;
}
.vmat-volume-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--primary-color, #6366f1);
cursor: pointer;
border: none;
}
/* ─── 上传进度条 ──────────────────────────────────────────── */
.vmat-upload-progress {
position: relative;
height: 20px;
background: var(--neutral-100, #f1f5f9);
border-radius: 10px;
overflow: hidden;
margin-top: 12px;
}
.vmat-upload-progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--primary-color, #6366f1), #818cf8);
border-radius: 10px;
transition: width 0.3s ease;
}
.vmat-upload-progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 11px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
/* ─── 批量打标签 Popover ──────────────────────────────────── */
.vmat-tag-popover {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 240px;
}
.vmat-tag-pop-btn {
padding: 4px 12px;
border: 1px solid var(--neutral-200, #e2e8f0);
border-radius: 14px;
background: var(--bg-primary, #fff);
font-size: 12px;
color: var(--text-secondary, #64748b);
cursor: pointer;
transition: all 0.15s;
}
.vmat-tag-pop-btn:hover {
border-color: var(--primary-color, #6366f1);
color: var(--primary-color, #6366f1);
background: var(--primary-soft, #eef2ff);
}
/* ─── 列表 checkbox 列 ────────────────────────────────────── */
.vmat-lh-checkbox {
width: 30px;
}
.vmat-list.batch-mode .vmat-list-header,
.vmat-list.batch-mode .vmat-row {
grid-template-columns: 30px 48px 1fr 80px 160px 120px 60px 70px 80px;
}