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
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user