Initial commit

This commit is contained in:
theshy
2025-07-31 17:05:07 +08:00
parent 8fab3b19cc
commit 24f21144ab
91 changed files with 16311 additions and 159 deletions

431
components/AudioPlayer.tsx Normal file
View File

@ -0,0 +1,431 @@
"use client";
import { useState, useRef, useEffect } from "react";
interface AudioPlayerProps {
src: string;
title?: string;
duration?: number; // 从数据库获取的时长
recordingId?: string; // 录音ID用于检查访问权限
}
export default function AudioPlayer({
src,
title,
duration: dbDuration,
recordingId,
}: AudioPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [duration, setDuration] = useState(dbDuration || 0);
const [currentTime, setCurrentTime] = useState(0);
const [volume, setVolume] = useState(1);
const [showVolumeControl, setShowVolumeControl] = useState(false);
const [showDownloadMenu, setShowDownloadMenu] = useState(false);
const [error, setError] = useState<string | null>(null);
const [audioUrl, setAudioUrl] = useState(src);
const audioRef = useRef<HTMLAudioElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
// 检查 S3 文件访问权限
useEffect(() => {
if (recordingId && src.includes("amazonaws.com")) {
const checkAccess = async () => {
try {
const response = await fetch(
`/api/recordings/${recordingId}/check-access`
);
const data = await response.json();
if (data.accessible) {
console.log("S3 文件可访问:", data.url);
setError(null);
// 使用代理 URL 而不是直接访问 S3
const proxyUrl = `/api/recordings/${recordingId}/stream`;
setAudioUrl(proxyUrl);
} else {
console.error("S3 文件无法访问:", data.error);
setError("音频文件无法访问,请检查权限设置");
}
} catch (error) {
console.error("检查文件访问失败:", error);
setError("检查文件访问失败");
}
};
checkAccess();
}
}, [recordingId, src]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handleLoadedMetadata = () => {
console.log("音频元数据加载完成:", {
duration: audio.duration,
src: audio.src,
readyState: audio.readyState,
networkState: audio.networkState,
currentSrc: audio.currentSrc,
error: audio.error,
});
// 如果数据库中没有时长,则从音频文件获取
if (!dbDuration) {
setDuration(audio.duration);
}
setIsLoading(false);
setError(null);
};
const handleTimeUpdate = () => {
setCurrentTime(audio.currentTime);
};
const handlePlay = () => {
console.log("音频开始播放");
setIsPlaying(true);
};
const handlePause = () => {
console.log("音频暂停");
setIsPlaying(false);
};
const handleEnded = () => {
console.log("音频播放结束");
setIsPlaying(false);
setCurrentTime(0);
};
const handleError = (e: Event) => {
const target = e.target as HTMLAudioElement;
console.error("音频加载失败:", {
error: target.error,
errorCode: target.error?.code,
errorMessage: target.error?.message,
src: target.src,
networkState: target.networkState,
readyState: target.readyState,
currentSrc: target.currentSrc,
});
setIsLoading(false);
setError("音频加载失败,请检查文件是否存在");
};
const handleLoadStart = () => {
console.log("开始加载音频:", src);
setIsLoading(true);
setError(null);
};
const handleCanPlay = () => {
console.log("音频可以播放:", src);
setIsLoading(false);
setError(null);
};
const handleCanPlayThrough = () => {
console.log("音频可以流畅播放:", src);
setIsLoading(false);
setError(null);
};
const handleLoad = () => {
console.log("音频加载完成:", src);
setIsLoading(false);
setError(null);
};
const handleAbort = () => {
console.log("音频加载被中止:", src);
};
const handleSuspend = () => {
console.log("音频加载被暂停:", src);
};
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
audio.addEventListener("timeupdate", handleTimeUpdate);
audio.addEventListener("play", handlePlay);
audio.addEventListener("pause", handlePause);
audio.addEventListener("ended", handleEnded);
audio.addEventListener("error", handleError);
audio.addEventListener("loadstart", handleLoadStart);
audio.addEventListener("canplay", handleCanPlay);
audio.addEventListener("canplaythrough", handleCanPlayThrough);
audio.addEventListener("load", handleLoad);
audio.addEventListener("abort", handleAbort);
audio.addEventListener("suspend", handleSuspend);
return () => {
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
audio.removeEventListener("timeupdate", handleTimeUpdate);
audio.removeEventListener("play", handlePlay);
audio.removeEventListener("pause", handlePause);
audio.removeEventListener("ended", handleEnded);
audio.removeEventListener("error", handleError);
audio.removeEventListener("loadstart", handleLoadStart);
audio.removeEventListener("canplay", handleCanPlay);
audio.removeEventListener("canplaythrough", handleCanPlayThrough);
audio.removeEventListener("load", handleLoad);
audio.removeEventListener("abort", handleAbort);
audio.removeEventListener("suspend", handleSuspend);
};
}, [dbDuration, src]);
// 点击外部区域关闭菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest(".audio-player-controls")) {
setShowVolumeControl(false);
setShowDownloadMenu(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const togglePlay = () => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
} else {
audio.play().catch((error) => {
console.error("播放失败:", error);
setError("播放失败,请重试");
});
}
};
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const audio = audioRef.current;
const progress = progressRef.current;
if (!audio || !progress || duration === 0) return;
const rect = progress.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const progressWidth = rect.width;
const clickPercent = clickX / progressWidth;
audio.currentTime = clickPercent * duration;
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const audio = audioRef.current;
if (!audio) return;
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);
audio.volume = newVolume;
};
const handleDownload = () => {
const link = document.createElement("a");
link.href = src;
link.download = `${title || "recording"}.webm`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const formatTime = (time: number) => {
if (isNaN(time) || time === Infinity) return "0:00";
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
return (
<div className="w-full">
<audio ref={audioRef} preload="metadata">
<source src={audioUrl} type="audio/webm" />
</audio>
<div className="space-y-3">
{/* 播放控制 */}
<div className="flex items-center gap-4">
<button
onClick={togglePlay}
disabled={isLoading}
className="flex items-center justify-center w-10 h-10 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 disabled:from-gray-400 disabled:to-gray-400 text-white rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none group"
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
) : isPlaying ? (
<svg
className="w-5 h-5 group-hover:scale-110 transition-transform"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
) : (
<svg
className="w-5 h-5 ml-0.5 group-hover:scale-110 transition-transform"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
{title || "录音"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-2">
<span>{formatTime(currentTime)}</span>
<span>/</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* 控制按钮组 */}
<div className="flex items-center gap-1">
{/* 音量控制 */}
<div className="relative audio-player-controls">
<button
onClick={() => setShowVolumeControl(!showVolumeControl)}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all duration-200"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M6.343 6.343a1 1 0 011.414 0l8.486 8.486a1 1 0 01-1.414 1.414L6.343 7.757a1 1 0 010-1.414z"
/>
</svg>
</button>
{showVolumeControl && (
<div className="absolute bottom-full right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl p-3 shadow-xl z-10 backdrop-blur-sm">
<div className="flex items-center gap-3">
<svg
className="w-4 h-4 text-gray-500 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M6.343 6.343a1 1 0 011.414 0l8.486 8.486a1 1 0 01-1.414 1.414L6.343 7.757a1 1 0 010-1.414z"
/>
</svg>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={handleVolumeChange}
className="w-24 h-2 bg-gray-200 dark:bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
/>
</div>
</div>
)}
</div>
{/* 下载按钮 */}
<div className="relative audio-player-controls">
<button
onClick={() => setShowDownloadMenu(!showDownloadMenu)}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all duration-200"
title="下载录音"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</button>
{showDownloadMenu && (
<div className="absolute bottom-full right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl p-2 shadow-xl z-10 backdrop-blur-sm">
<button
onClick={handleDownload}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors whitespace-nowrap"
>
<svg
className="w-4 h-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<span></span>
</button>
</div>
)}
</div>
</div>
</div>
{/* 进度条 */}
<div
ref={progressRef}
onClick={handleProgressClick}
className="w-full h-2 bg-gray-200 dark:bg-gray-600 rounded-full cursor-pointer relative group"
>
<div
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all duration-100 relative overflow-hidden"
style={{
width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%`,
}}
>
{/* 进度条动画效果 */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-pulse"></div>
</div>
{/* 进度条悬停效果 */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="absolute top-0 left-0 w-full h-full bg-blue-200 dark:bg-blue-400/30 rounded-full opacity-30"></div>
</div>
{/* 进度条滑块 */}
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white dark:bg-gray-200 rounded-full shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200"
style={{
left: `${duration > 0 ? (currentTime / duration) * 100 : 0}%`,
transform: "translate(-50%, -50%)",
}}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,534 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import LoadingSpinner from "./LoadingSpinner";
import {
getBestRecordingFormat,
SUPPORTED_AUDIO_FORMATS,
} from "@/lib/config/audio-config";
interface AudioRecorderProps {
onRecordingComplete?: () => void;
}
export default function AudioRecorder({
onRecordingComplete,
}: AudioRecorderProps) {
const [isRecording, setIsRecording] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [audioLevel, setAudioLevel] = useState(0);
const [recordingTitle, setRecordingTitle] = useState(""); // 录音标题
const [isRenaming, setIsRenaming] = useState(false); // 是否正在重命名
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const animationFrameRef = useRef<number | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
// 音频分析器设置
const setupAudioAnalyzer = (stream: MediaStream) => {
try {
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(stream);
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.8;
source.connect(analyser);
audioContextRef.current = audioContext;
analyserRef.current = analyser;
return true; // 表示设置成功
} catch (error) {
console.error("音频分析器设置失败:", error);
return false; // 表示设置失败
}
};
// 更新音频电平
const updateAudioLevel = () => {
if (!analyserRef.current || !isRecording || isPaused) {
setAudioLevel(0);
return;
}
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
analyserRef.current.getByteFrequencyData(dataArray);
// 计算平均音量
const average =
dataArray.reduce((sum, value) => sum + value, 0) / dataArray.length;
const normalizedLevel = Math.min(average / 128, 1);
setAudioLevel(normalizedLevel);
if (isRecording && !isPaused) {
animationFrameRef.current = requestAnimationFrame(updateAudioLevel);
}
};
// 清理音频分析器
const cleanupAudioAnalyzer = () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
analyserRef.current = null;
setAudioLevel(0);
};
const startRecording = async () => {
try {
// 录音参数直接写死
const constraints = {
audio: {
sampleRate: 48000,
channelCount: 2,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = stream;
// 录音格式直接写死为 webm
const mimeType = "audio/webm;codecs=opus";
// 检查浏览器是否支持该格式
if (!MediaRecorder.isTypeSupported(mimeType)) {
console.warn(`不支持的格式: ${mimeType},使用默认格式`);
// 使用默认格式
const defaultFormat = getBestRecordingFormat();
const mediaRecorder = new MediaRecorder(stream, {
mimeType: defaultFormat.mimeType,
});
mediaRecorderRef.current = mediaRecorder;
} else {
const mediaRecorder = new MediaRecorder(stream, {
mimeType: mimeType,
});
mediaRecorderRef.current = mediaRecorder;
}
const audioChunks: Blob[] = [];
mediaRecorderRef.current.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorderRef.current.onstop = () => {
// 使用实际使用的 MIME 类型
const actualMimeType =
mediaRecorderRef.current?.mimeType || "audio/webm";
const audioBlob = new Blob(audioChunks, {
type: actualMimeType,
});
setAudioBlob(audioBlob);
stream.getTracks().forEach((track) => track.stop());
cleanupAudioAnalyzer();
};
// 设置音频分析器
let analyzerSetup = false;
try {
analyzerSetup = setupAudioAnalyzer(stream);
if (analyzerSetup) {
// 音频分析器设置成功
}
} catch (error) {
// 音频分析器设置失败,但不影响录音功能
return false; // 表示设置失败
}
// 开始录音
mediaRecorderRef.current.start();
setIsRecording(true);
setIsPaused(false);
setRecordingTime(0);
// 开始计时
timerRef.current = setInterval(() => {
setRecordingTime((prev) => prev + 1);
}, 1000);
// 开始音频分析
if (analyzerSetup) {
updateAudioLevel();
}
} catch (error) {
// 无法访问麦克风
alert("无法访问麦克风,请检查权限设置。");
}
};
const pauseRecording = () => {
if (mediaRecorderRef.current && isRecording) {
if (isPaused) {
// 继续录音
mediaRecorderRef.current.resume();
setIsPaused(false);
// 重新启动计时器
timerRef.current = setInterval(() => {
setRecordingTime((prev) => prev + 1);
}, 1000);
// 重新开始音频分析
updateAudioLevel();
} else {
// 暂停录音
mediaRecorderRef.current.pause();
setIsPaused(true);
// 停止计时器
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// 停止音频分析
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
setAudioLevel(0);
}
}
};
const stopRecording = () => {
if (mediaRecorderRef.current) {
mediaRecorderRef.current.stop();
setIsRecording(false);
setIsPaused(false);
// setIsAnalyzing(false); // This line is removed
// 停止计时器
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// 清理音频分析器
cleanupAudioAnalyzer();
// 停止音频流
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
// 保持录音时长,不清零
}
};
const cancelRecording = () => {
if (mediaRecorderRef.current) {
mediaRecorderRef.current.stop();
setIsRecording(false);
setIsPaused(false);
// 停止计时器
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// 清理音频分析器
cleanupAudioAnalyzer();
// 停止音频流
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
setAudioBlob(null);
}
};
const uploadRecording = async () => {
if (!audioBlob) return;
setIsUploading(true);
try {
// 直接上传文件到后端,让后端处理 S3 上传
const formData = new FormData();
formData.append("audio", audioBlob, `recording-${Date.now()}.webm`);
formData.append("duration", recordingTime.toString());
// 添加录音标题
const title =
recordingTitle.trim() || `录音 ${new Date().toLocaleString("zh-CN")}`;
formData.append("title", title);
const response = await fetch("/api/recordings/upload", {
method: "POST",
body: formData,
});
if (response.ok) {
alert("录音上传成功!");
setAudioBlob(null);
setRecordingTitle(""); // 清空录音标题
// 保持录音时长用于显示不重置为0
// 触发父组件刷新
console.log("录音上传成功,触发刷新事件");
window.dispatchEvent(new CustomEvent("recording-uploaded"));
} else {
const errorData = await response.json();
throw new Error(errorData.error || "保存录音记录失败");
}
} catch (error) {
console.error("上传失败:", error);
alert(`上传失败:${error instanceof Error ? error.message : "请重试"}`);
} finally {
setIsUploading(false);
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
};
// 生成波形条
const generateWaveformBars = () => {
const bars = [];
const barCount = 20;
const baseHeight = 4;
for (let i = 0; i < barCount; i++) {
const height =
isRecording && !isPaused
? baseHeight + audioLevel * 20 * Math.random()
: baseHeight;
bars.push(
<div
key={i}
className="bg-blue-500 rounded-full transition-all duration-100"
style={{
height: `${height}px`,
width: "3px",
opacity: isRecording && !isPaused ? 0.8 : 0.3,
}}
/>
);
}
return bars;
};
// 组件卸载时清理
useEffect(() => {
return () => {
cleanupAudioAnalyzer();
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
}
};
}, []);
return (
<>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 border border-gray-100 dark:border-gray-700">
<div className="text-center">
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
</h3>
{/* 录音状态指示器 */}
<div className="mb-4">
<div className="flex items-center justify-center space-x-2">
<div
className={`w-3 h-3 rounded-full transition-colors ${
isRecording && !isPaused
? "bg-red-500 animate-pulse"
: isPaused
? "bg-yellow-500"
: "bg-gray-300"
}`}
></div>
<span className="text-sm text-gray-600 dark:text-gray-400">
{!isRecording ? "准备就绪" : isPaused ? "已暂停" : "录音中..."}
</span>
</div>
</div>
{/* 波形可视化 */}
<div className="mb-6">
<div className="flex justify-center items-end gap-1 h-8">
{generateWaveformBars()}
</div>
{isRecording && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-2">
{isPaused ? "已暂停" : "录音中..."}
</div>
)}
</div>
{isRecording && (
<div className="mb-4">
<div className="text-2xl font-mono text-red-500">
{formatTime(recordingTime)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mb-3">
{isPaused ? "已暂停" : "录音中..."}
</div>
{/* 录音进度条 */}
<div className="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2 mb-2">
<div
className="bg-red-500 h-2 rounded-full transition-all duration-300"
style={{
width: `${Math.min((recordingTime / 300) * 100, 100)}%`, // 5分钟为100%
}}
></div>
</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
: {Math.round((recordingTime / 300) * 100)}% (5)
</div>
</div>
)}
<div className="flex justify-center gap-4">
{!isRecording ? (
<button
onClick={startRecording}
className="bg-red-500 hover:bg-red-600 text-white px-6 py-3 rounded-full font-medium transition-colors"
>
</button>
) : (
<>
<button
onClick={pauseRecording}
className="bg-yellow-500 hover:bg-yellow-600 text-white px-4 py-2 rounded-full font-medium transition-colors"
>
{isPaused ? "继续" : "暂停"}
</button>
<button
onClick={stopRecording}
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-full font-medium transition-colors"
>
</button>
<button
onClick={cancelRecording}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-full font-medium transition-colors"
>
</button>
</>
)}
</div>
{audioBlob && (
<div className="mt-4 space-y-4">
{/* 录音标题 */}
<div className="text-center">
{isRenaming ? (
<div className="flex items-center justify-center gap-2">
<input
type="text"
value={recordingTitle}
onChange={(e) => setRecordingTitle(e.target.value)}
placeholder="输入录音标题"
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-center min-w-0 flex-1 max-w-xs"
maxLength={100}
autoFocus
/>
<button
onClick={() => {
if (recordingTitle.trim()) {
setIsRenaming(false);
}
}}
className="px-3 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors"
>
</button>
<button
onClick={() => {
setRecordingTitle("");
setIsRenaming(false);
}}
className="px-3 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
</button>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
{recordingTitle || "录音"}
</h4>
<button
onClick={() => setIsRenaming(true)}
className="p-1 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
title="重命名"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
</div>
)}
</div>
<audio controls className="w-full max-w-md mx-auto">
<source
src={URL.createObjectURL(audioBlob)}
type="audio/webm"
/>
</audio>
<div className="flex justify-center gap-3">
<button
onClick={uploadRecording}
disabled={isUploading}
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white px-6 py-2 rounded-full font-medium transition-colors flex items-center justify-center gap-2"
>
{isUploading ? (
<>
<LoadingSpinner size="sm" color="white" />
...
</>
) : (
"保存录音"
)}
</button>
<button
onClick={() => {
setAudioBlob(null);
setRecordingTitle("");
}}
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-full font-medium transition-colors"
>
</button>
</div>
</div>
)}
</div>
</div>
</>
);
}

47
components/Header.tsx Normal file
View File

@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import UserMenu from "./UserMenu";
export default function Header() {
return (
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-40">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
{/* 应用标题 */}
<Link
href="/dashboard"
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
</div>
</Link>
{/* 用户菜单 */}
<UserMenu />
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,54 @@
"use client";
interface LoadingSpinnerProps {
size?: "sm" | "md" | "lg";
color?: "blue" | "red" | "green" | "gray" | "white";
text?: string;
}
export default function LoadingSpinner({
size = "md",
color = "blue",
text,
}: LoadingSpinnerProps) {
const sizeClasses = {
sm: "w-4 h-4",
md: "w-8 h-8",
lg: "w-12 h-12",
};
const colorClasses = {
blue: "text-blue-500",
red: "text-red-500",
green: "text-green-500",
gray: "text-gray-500",
white: "text-white",
};
return (
<div className="flex flex-col items-center justify-center">
<div
className={`${sizeClasses[size]} ${colorClasses[color]} animate-spin`}
>
<svg className="w-full h-full" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
{text && (
<div className="mt-2 text-sm text-gray-500 animate-pulse">{text}</div>
)}
</div>
);
}

View File

@ -0,0 +1,133 @@
"use client";
import { useEffect, useState } from "react";
import {
notificationManager,
AppNotification,
} from "@/lib/utils/notifications";
export default function NotificationToast() {
const [notifications, setNotifications] = useState<AppNotification[]>([]);
useEffect(() => {
const handleNotificationsChange = (newNotifications: AppNotification[]) => {
setNotifications(newNotifications);
};
notificationManager.addListener(handleNotificationsChange);
setNotifications(notificationManager.getNotifications());
return () => {
notificationManager.removeListener(handleNotificationsChange);
};
}, []);
const getIcon = (type: string) => {
switch (type) {
case "success":
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
);
case "error":
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
);
case "warning":
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
);
case "info":
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
);
default:
return null;
}
};
const getTypeClasses = (type: string) => {
switch (type) {
case "success":
return "bg-green-50 border-green-200 text-green-800";
case "error":
return "bg-red-50 border-red-200 text-red-800";
case "warning":
return "bg-yellow-50 border-yellow-200 text-yellow-800";
case "info":
return "bg-blue-50 border-blue-200 text-blue-800";
default:
return "bg-gray-50 border-gray-200 text-gray-800";
}
};
if (notifications.length === 0) {
return null;
}
return (
<div className="fixed top-4 right-4 z-50 space-y-2">
{notifications.map((notification) => (
<div
key={notification.id}
className={`max-w-sm w-full border rounded-lg shadow-lg p-4 transition-all duration-300 transform ${getTypeClasses(
notification.type
)}`}
>
<div className="flex items-start">
<div className="flex-shrink-0">{getIcon(notification.type)}</div>
<div className="ml-3 flex-1">
<p className="text-sm font-medium">{notification.title}</p>
<p className="text-sm mt-1 opacity-90">{notification.message}</p>
</div>
<div className="ml-4 flex-shrink-0">
<button
onClick={() =>
notificationManager.removeNotification(notification.id)
}
className="inline-flex text-gray-400 hover:text-gray-600 focus:outline-none focus:text-gray-600 transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,387 @@
"use client";
import { useState } from "react";
import LoadingSpinner from "./LoadingSpinner";
import AudioPlayer from "./AudioPlayer";
interface Recording {
id: string;
title: string;
duration: number;
createdAt: string;
audioUrl: string;
}
interface RecordingListProps {
recordings: Recording[];
onRecordingUpdated?: (updatedRecording: Recording) => void;
}
export default function RecordingList({
recordings,
onRecordingUpdated,
}: RecordingListProps) {
const [deletingId, setDeletingId] = useState<string | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renamingTitle, setRenamingTitle] = useState("");
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffInHours = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60 * 60)
);
if (diffInHours < 24) {
if (diffInHours < 1) {
return "刚刚";
} else if (diffInHours < 2) {
return "1小时前";
} else {
return `${diffInHours}小时前`;
}
} else {
return date.toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
};
const handleDelete = async (id: string) => {
if (!confirm("确定要删除这个录音吗?")) return;
setDeletingId(id);
try {
const response = await fetch(`/api/recordings/${id}`, {
method: "DELETE",
});
if (response.ok) {
// 触发父组件刷新
console.log("录音删除成功,触发刷新事件");
window.dispatchEvent(new CustomEvent("recording-deleted"));
} else {
throw new Error("删除失败");
}
} catch (error) {
console.error("删除错误:", error);
alert("删除失败,请重试。");
} finally {
setDeletingId(null);
}
};
const handleRename = async (id: string, newTitle: string) => {
if (!newTitle.trim()) {
alert("录音标题不能为空");
return;
}
console.log(`开始重命名录音: ${id}, 新标题: "${newTitle}"`);
try {
const response = await fetch(`/api/recordings/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title: newTitle.trim() }),
});
if (response.ok) {
const result = await response.json();
// 触发父组件刷新
console.log("录音重命名成功,触发刷新事件");
window.dispatchEvent(new CustomEvent("recording-renamed"));
// 强制刷新当前录音的显示
const updatedRecording = result.data;
// 调用父组件的更新回调
if (onRecordingUpdated && updatedRecording) {
onRecordingUpdated(updatedRecording);
}
setRenamingId(null);
setRenamingTitle("");
} else {
const errorData = await response.json().catch(() => ({}));
console.error(
`重命名失败,状态码: ${response.status}, 错误信息:`,
errorData
);
throw new Error(`重命名失败: ${response.status}`);
}
} catch (error) {
console.error("重命名错误:", error);
alert("重命名失败,请重试。");
}
};
const startRename = (recording: Recording) => {
setRenamingId(recording.id);
setRenamingTitle(recording.title);
};
const cancelRename = () => {
setRenamingId(null);
setRenamingTitle("");
};
if (!recordings || recordings.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4">
<div className="relative mb-8">
<div className="w-24 h-24 bg-gradient-to-br from-blue-100 to-purple-100 dark:from-blue-900/20 dark:to-purple-900/20 rounded-full flex items-center justify-center">
<svg
className="w-12 h-12 text-blue-500 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
/>
</svg>
</div>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-gradient-to-r from-green-400 to-blue-500 rounded-full flex items-center justify-center">
<svg
className="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
</div>
</div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
</h3>
<p className="text-gray-500 dark:text-gray-400 text-center max-w-md">
</p>
</div>
);
}
return (
<div className="grid gap-4 md:gap-6">
{recordings.map((recording) => (
<div
key={recording.id}
onMouseEnter={() => setHoveredId(recording.id)}
onMouseLeave={() => setHoveredId(null)}
className="group relative bg-white dark:bg-gray-800/50 backdrop-blur-sm rounded-2xl border border-gray-100 dark:border-gray-700/50 p-6 hover:shadow-lg hover:shadow-blue-500/5 dark:hover:shadow-blue-400/5 transition-all duration-300 hover:scale-[1.02] hover:border-blue-200 dark:hover:border-blue-700/50"
>
{/* 背景装饰 */}
<div className="absolute inset-0 bg-gradient-to-r from-blue-50/50 to-purple-50/50 dark:from-blue-900/10 dark:to-purple-900/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="relative z-10">
{/* 头部信息 */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<div className="w-3 h-3 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full animate-pulse" />
{renamingId === recording.id ? (
<div className="flex items-center gap-2 flex-1">
<input
type="text"
value={renamingTitle}
onChange={(e) => setRenamingTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleRename(recording.id, renamingTitle);
} else if (e.key === "Escape") {
cancelRename();
}
}}
className="flex-1 px-3 py-1 border border-blue-300 dark:border-blue-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-lg font-semibold"
maxLength={100}
autoFocus
/>
<button
onClick={() =>
handleRename(recording.id, renamingTitle)
}
className="p-1 text-green-500 hover:text-green-600 transition-colors"
title="确定"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</button>
<button
onClick={cancelRename}
className="p-1 text-gray-500 hover:text-gray-600 transition-colors"
title="取消"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
) : (
<h3 className="font-semibold text-gray-900 dark:text-white text-lg truncate group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{recording.title} {/* 当前标题: {recording.title} */}
</h3>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium">
{formatDuration(recording.duration)}
</span>
</div>
<div className="flex items-center gap-1.5">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>{formatDate(recording.createdAt)}</span>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{/* 重命名按钮 */}
<button
onClick={() => startRename(recording)}
className="p-2 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-all duration-200 group/btn"
title="重命名"
>
<svg
className="w-5 h-5 group-hover/btn:scale-110 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
{/* 删除按钮 */}
<button
onClick={() => handleDelete(recording.id)}
disabled={deletingId === recording.id}
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all duration-200 group/btn"
title="删除录音"
>
{deletingId === recording.id ? (
<LoadingSpinner size="sm" color="red" />
) : (
<svg
className="w-5 h-5 group-hover/btn:scale-110 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
)}
</button>
</div>
</div>
{/* 音频播放器 */}
<div className="relative">
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4 border border-gray-100 dark:border-gray-600/50">
<AudioPlayer
src={recording.audioUrl}
title={recording.title}
duration={recording.duration}
recordingId={recording.id}
/>
</div>
{/* 播放器装饰 */}
<div className="absolute -top-1 -right-1 w-6 h-6 bg-gradient-to-r from-green-400 to-blue-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<svg
className="w-3 h-3 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
clipRule="evenodd"
/>
</svg>
</div>
</div>
</div>
</div>
))}
</div>
);
}

180
components/UserMenu.tsx Normal file
View File

@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { useSession, signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
export default function UserMenu() {
const { data: session } = useSession();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [isSigningOut, setIsSigningOut] = useState(false);
const handleSignOut = async () => {
setIsSigningOut(true);
try {
await signOut({
callbackUrl: "/login",
redirect: true,
});
} catch (error) {
console.error("退出登录失败:", error);
setIsSigningOut(false);
}
};
if (!session?.user) {
return null;
}
return (
<div className="relative">
{/* 用户头像按钮 */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
{session.user.image ? (
<img
src={session.user.image}
alt={session.user.name || "用户头像"}
className="w-8 h-8 rounded-full"
/>
) : (
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-medium">
{session.user.name?.[0] || session.user.email?.[0] || "U"}
</div>
)}
<div className="hidden sm:block text-left">
<div className="text-sm font-medium text-gray-900">
{session.user.name || "用户"}
</div>
<div className="text-xs text-gray-500">{session.user.email}</div>
</div>
<svg
className={`w-4 h-4 text-gray-500 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{/* 下拉菜单 */}
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
{/* 用户信息 */}
<div className="px-4 py-3 border-b border-gray-100">
<div className="text-sm font-medium text-gray-900">
{session.user.name || "用户"}
</div>
<div className="text-xs text-gray-500 truncate">
{session.user.email}
</div>
</div>
{/* 菜单项 */}
<div className="py-1">
<button
onClick={() => {
setIsOpen(false);
router.push("/profile");
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-2">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
</button>
<button
onClick={() => {
setIsOpen(false);
router.push("/settings");
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-2">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
</button>
<div className="border-t border-gray-100 my-1"></div>
<button
onClick={handleSignOut}
disabled={isSigningOut}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50"
>
<div className="flex items-center gap-2">
{isSigningOut ? (
<div className="w-4 h-4 border-2 border-red-600 border-t-transparent rounded-full animate-spin"></div>
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
)}
{isSigningOut ? "退出中..." : "退出登录"}
</div>
</button>
</div>
</div>
)}
{/* 点击外部区域关闭菜单 */}
{isOpen && (
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
)}
</div>
);
}

63
components/ui/button.tsx Normal file
View File

@ -0,0 +1,63 @@
import React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "underline-offset-4 hover:underline text-primary",
},
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-3 rounded-md",
lg: "h-11 px-8 rounded-md",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className, variant, size, loading, children, disabled, ...props },
ref
) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading && (
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
)}
{children}
</button>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };