diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index 964f82bd7..96d80754e 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -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((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, diff --git a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx index e232889e5..3743c8410 100644 --- a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx +++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx @@ -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 = ({ @@ -202,6 +207,7 @@ const MaterialForm: React.FC = ({ onSubmit, onCancel, loading, + uploadProgress, }) => { const [name, setName] = useState(initial?.name ?? ""); const [description, setDescription] = useState(initial?.description ?? ""); @@ -288,6 +294,13 @@ const MaterialForm: React.FC = ({ )} + {/* 上传进度条 */} + {uploadProgress !== null && uploadProgress !== undefined && ( +
+
+ {uploadProgress}% +
+ )}
)} @@ -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) => void; + onToggleMute: () => void; } const VoiceMaterialCard: React.FC = ({ material, isPlaying, currentTime, + isSelected, + batchMode, + volume, onPlay, onPause, onSeek, onEdit, onDelete, + onToggleSelect, + onVolumeChange, + onToggleMute, }) => { const progressRef = useRef(null); - const handleProgressClick = (e: React.MouseEvent) => { + const handleProgressMouseDown = (e: React.MouseEvent) => { 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 (
+ {/* 批量选择 checkbox */} + {(batchMode || isSelected) && ( +
{ + e.stopPropagation(); + onToggleSelect(material.id); + }} + > + {isSelected && } +
+ )} + {/* 操作按钮 */}
+ { + e.stopPropagation(); + onVolumeChange(e); + }} + onClick={(e) => e.stopPropagation()} + /> +
); @@ -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 = ({ material, isPlaying, currentTime, + isSelected, + batchMode, onPlay, onPause, onSeek, onEdit, onDelete, + onToggleSelect, }) => { const progressRef = useRef(null); - const handleProgressClick = (e: React.MouseEvent) => { + const handleProgressMouseDown = (e: React.MouseEvent) => { 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 = ({ return (
+ {/* 批量选择 checkbox */} + {(batchMode || isSelected) && ( +
{ + e.stopPropagation(); + onToggleSelect(material.id); + }} + > + {isSelected && } +
+ )} + {/* 播放按钮 */}
- {/* 进度条 */} + {/* 进度条(可拖拽) */}
+ {isPlaying && ( +
+ )}
{/* 时长 */} @@ -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>(new Set()); + const [uploadProgress, setUploadProgress] = useState(null); + const [volume, setVolume] = useState(0.7); + const [pausedMaterial, setPausedMaterial] = useState(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) => { + 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 = () => {
)} + {/* 批量操作栏 */} + {batchMode && ( +
+
+
+ {allSelected && } +
+ + {allSelected ? "取消全选" : "全选"} + + + 已选择 {selectedIds.size} 项 + +
+
+ + {STYLE_TAGS.map((tag) => ( + + ))} +
+ } + title="批量打标签" + trigger="click" + > + + + + + +
+
+ )} + {/* 内容区 */} {!isLoading && filtered.length > 0 && viewMode === "card" && (
@@ -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} /> ))}
)} {!isLoading && filtered.length > 0 && viewMode === "list" && ( -
+
{/* 列表头 */}
+ {batchMode && } 名称 性别 @@ -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} /> ))}
@@ -1131,6 +1447,7 @@ const VoiceMaterialLibrary: React.FC = () => { onSubmit={handleUpload} onCancel={() => setUploadOpen(false)} loading={isUploading} + uploadProgress={uploadProgress} /> diff --git a/apps/web/src/pages/voice-materials/voice-materials.css b/apps/web/src/pages/voice-materials/voice-materials.css index bcdb88ba5..e798f3165 100644 --- a/apps/web/src/pages/voice-materials/voice-materials.css +++ b/apps/web/src/pages/voice-materials/voice-materials.css @@ -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; +}