Files
ViGent2/frontend/src/features/home/ui/RefAudioPanel.tsx
Kevin Wong 1a291a03b8 更新
2026-02-08 10:46:08 +08:00

279 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import type { MouseEvent } from "react";
import { Upload, RefreshCw, Play, Pause, Pencil, Trash2, Check, X, Mic, Square } from "lucide-react";
interface RefAudio {
id: string;
name: string;
path: string;
ref_text: string;
duration_sec: number;
created_at: number;
}
interface RefAudioPanelProps {
refAudios: RefAudio[];
selectedRefAudio: RefAudio | null;
onSelectRefAudio: (audio: RefAudio) => void;
isUploadingRef: boolean;
uploadRefError: string | null;
onClearUploadRefError: () => void;
onUploadRefAudio: (file: File) => void;
onFetchRefAudios: () => void;
playingAudioId: string | null;
onTogglePlayPreview: (audio: RefAudio, event: MouseEvent) => void;
editingAudioId: string | null;
editName: string;
onEditNameChange: (value: string) => void;
onStartEditing: (audio: RefAudio, event: MouseEvent) => void;
onSaveEditing: (id: string, event: MouseEvent) => void;
onCancelEditing: (event: MouseEvent) => void;
onDeleteRefAudio: (id: string) => void;
recordedBlob: Blob | null;
isRecording: boolean;
recordingTime: number;
onStartRecording: () => void;
onStopRecording: () => void;
onUseRecording: () => void;
formatRecordingTime: (seconds: number) => string;
fixedRefText: string;
}
export function RefAudioPanel({
refAudios,
selectedRefAudio,
onSelectRefAudio,
isUploadingRef,
uploadRefError,
onClearUploadRefError,
onUploadRefAudio,
onFetchRefAudios,
playingAudioId,
onTogglePlayPreview,
editingAudioId,
editName,
onEditNameChange,
onStartEditing,
onSaveEditing,
onCancelEditing,
onDeleteRefAudio,
recordedBlob,
isRecording,
recordingTime,
onStartRecording,
onStopRecording,
onUseRecording,
formatRecordingTime,
fixedRefText,
}: RefAudioPanelProps) {
const [recordedUrl, setRecordedUrl] = useState<string | null>(null);
useEffect(() => {
if (!recordedBlob) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setRecordedUrl(null);
return;
}
const url = URL.createObjectURL(recordedBlob);
setRecordedUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [recordedBlob]);
return (
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-300">📁 </span>
<div className="flex gap-2">
<input
type="file"
id="ref-audio-upload"
accept=".wav,.mp3,.m4a,.webm,.ogg,.flac,.aac"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onUploadRefAudio(file);
}
e.target.value = '';
}}
className="hidden"
/>
<label
htmlFor="ref-audio-upload"
className={`px-2 py-1 text-xs rounded cursor-pointer transition-all flex items-center gap-1 ${isUploadingRef
? "bg-gray-600 cursor-not-allowed text-gray-400"
: "bg-purple-600 hover:bg-purple-700 text-white"
}`}
>
<Upload className="h-3.5 w-3.5" />
</label>
<button
onClick={onFetchRefAudios}
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
</div>
{isUploadingRef && (
<div className="mb-2 p-2 bg-purple-500/10 rounded text-sm text-purple-300">
...
</div>
)}
{uploadRefError && (
<div className="mb-2 p-2 bg-red-500/20 text-red-200 rounded text-xs flex justify-between">
<span> {uploadRefError}</span>
<button onClick={onClearUploadRefError} className="text-red-300 hover:text-white">
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{refAudios.length === 0 ? (
<div className="text-center py-4 text-gray-500 text-sm">
</div>
) : (
<div className="grid grid-cols-2 gap-2" style={{ contentVisibility: 'auto' }}>
{refAudios.map((audio) => (
<div
key={audio.id}
className={`p-2 rounded-lg border transition-all relative group cursor-pointer ${selectedRefAudio?.id === audio.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
onClick={() => {
if (editingAudioId !== audio.id) {
onSelectRefAudio(audio);
}
}}
>
{editingAudioId === audio.id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={editName}
onChange={(e) => onEditNameChange(e.target.value)}
className="w-full bg-black/50 text-white text-xs px-1 py-0.5 rounded border border-purple-500 focus:outline-none"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') onSaveEditing(audio.id, e as unknown as MouseEvent);
if (e.key === 'Escape') onCancelEditing(e as unknown as MouseEvent);
}}
/>
<button onClick={(e) => onSaveEditing(audio.id, e)} className="text-green-400 hover:text-green-300 text-xs">
<Check className="h-3 w-3" />
</button>
<button onClick={(e) => onCancelEditing(e)} className="text-gray-400 hover:text-gray-300 text-xs">
<X className="h-3 w-3" />
</button>
</div>
) : (
<>
<div className="flex justify-between items-start mb-1">
<div className="text-white text-xs truncate pr-1 flex-1" title={audio.name}>
{audio.name}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => onTogglePlayPreview(audio, e)}
className="text-gray-400 hover:text-purple-400 text-xs"
title="试听"
>
{playingAudioId === audio.id ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</button>
<button
onClick={(e) => onStartEditing(audio, e)}
className="text-gray-400 hover:text-blue-400 text-xs"
title="重命名"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteRefAudio(audio.id);
}}
className="text-gray-400 hover:text-red-400 text-xs"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="text-gray-400 text-xs">{audio.duration_sec.toFixed(1)}s</div>
</>
)}
</div>
))}
</div>
)}
</div>
<div className="border-t border-white/10 pt-4">
<span className="text-sm text-gray-300 mb-2 block">🎤 线</span>
<div className="flex gap-2 items-center">
{!isRecording ? (
<button
onClick={onStartRecording}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<Mic className="h-4 w-4" />
</button>
) : (
<button
onClick={onStopRecording}
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<Square className="h-4 w-4" />
</button>
)}
{isRecording && (
<span className="text-red-400 text-sm animate-pulse">
🔴 {formatRecordingTime(recordingTime)}
</span>
)}
</div>
{recordedBlob && !isRecording && (
<div className="mt-3 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-green-300 text-sm"> ({formatRecordingTime(recordingTime)})</span>
<audio src={recordedUrl || ''} controls className="h-8" />
</div>
<button
onClick={onUseRecording}
disabled={isUploadingRef}
className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm disabled:bg-gray-600"
>
使
</button>
</div>
)}
</div>
<div className="border-t border-white/10 pt-4">
<label className="text-sm text-gray-300 mb-2 block">📝 /</label>
<div className="w-full bg-black/30 border border-white/10 rounded-lg p-3 text-white text-sm">
{fixedRefText}
</div>
<p className="text-xs text-gray-500 mt-1">
</p>
</div>
</div>
);
}