Initial commit
This commit is contained in:
6
app/api/auth/[...nextauth]/route.ts
Normal file
6
app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
71
app/api/recordings/[id]/check-access/route.ts
Normal file
71
app/api/recordings/[id]/check-access/route.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { RecordingService } from "@/lib/services/recording.service";
|
||||
import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: process.env.AWS_REGION || "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return Response.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: recordingId } = await params;
|
||||
const recording = await RecordingService.getRecordingById(recordingId);
|
||||
|
||||
if (!recording) {
|
||||
return Response.json({ error: "录音不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 检查用户权限
|
||||
if (recording.userId !== session.user.id) {
|
||||
return Response.json({ error: "无权限访问" }, { status: 403 });
|
||||
}
|
||||
|
||||
// 从 S3 URL 提取 bucket 和 key
|
||||
const url = new URL(recording.audioUrl);
|
||||
const pathParts = url.pathname.split("/");
|
||||
const bucket = url.hostname.split(".")[0];
|
||||
const key = pathParts.slice(1).join("/");
|
||||
|
||||
try {
|
||||
// 检查文件是否存在且可访问
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
await s3.send(command);
|
||||
|
||||
return Response.json({
|
||||
accessible: true,
|
||||
url: recording.audioUrl,
|
||||
size: recording.fileSize,
|
||||
mimeType: recording.mimeType,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("S3 文件访问检查失败:", error);
|
||||
return Response.json({
|
||||
accessible: false,
|
||||
error: "文件无法访问",
|
||||
url: recording.audioUrl,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("检查文件访问失败:", error);
|
||||
return Response.json({ error: "检查文件访问失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
98
app/api/recordings/[id]/route.ts
Normal file
98
app/api/recordings/[id]/route.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { RecordingService } from "@/lib/services/recording.service";
|
||||
import { ApiResponseHandler } from "@/lib/utils/api-response";
|
||||
import {
|
||||
AuthenticationError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from "@/lib/errors/app-error";
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
throw new NotFoundError("录音ID不能为空");
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title } = body;
|
||||
|
||||
console.log(`PUT /api/recordings/${id} - 请求数据:`, {
|
||||
title,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
if (!title || typeof title !== "string" || title.trim().length === 0) {
|
||||
throw new ValidationError("录音标题不能为空");
|
||||
}
|
||||
|
||||
if (title.length > 100) {
|
||||
throw new ValidationError("录音标题不能超过100个字符");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Attempting to update recording: ${id} for user: ${
|
||||
session.user.id
|
||||
} with title: "${title.trim()}"`
|
||||
);
|
||||
|
||||
const updatedRecording = await RecordingService.updateRecording(
|
||||
id,
|
||||
session.user.id,
|
||||
{ title: title.trim() }
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Successfully updated recording: ${id}, new title: "${updatedRecording.title}"`
|
||||
);
|
||||
|
||||
return ApiResponseHandler.success(updatedRecording);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update recording ${params}:`, error);
|
||||
return ApiResponseHandler.error(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
throw new NotFoundError("录音ID不能为空");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Attempting to delete recording: ${id} for user: ${session.user.id}`
|
||||
);
|
||||
|
||||
await RecordingService.deleteRecording(id, session.user.id);
|
||||
|
||||
console.log(`Successfully deleted recording: ${id}`);
|
||||
|
||||
return ApiResponseHandler.noContent();
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete recording ${params}:`, error);
|
||||
return ApiResponseHandler.error(error as Error);
|
||||
}
|
||||
}
|
||||
75
app/api/recordings/[id]/stream/route.ts
Normal file
75
app/api/recordings/[id]/stream/route.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { RecordingService } from "@/lib/services/recording.service";
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: process.env.AWS_REGION || "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return Response.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: recordingId } = await params;
|
||||
const recording = await RecordingService.getRecordingById(recordingId);
|
||||
|
||||
if (!recording) {
|
||||
return Response.json({ error: "录音不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 检查用户权限
|
||||
if (recording.userId !== session.user.id) {
|
||||
return Response.json({ error: "无权限访问" }, { status: 403 });
|
||||
}
|
||||
|
||||
// 从 S3 URL 提取 bucket 和 key
|
||||
const url = new URL(recording.audioUrl);
|
||||
const pathParts = url.pathname.split("/");
|
||||
const bucket = url.hostname.split(".")[0];
|
||||
const key = pathParts.slice(1).join("/");
|
||||
|
||||
try {
|
||||
// 从 S3 获取文件
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const response = await s3.send(command);
|
||||
const stream = response.Body as ReadableStream;
|
||||
|
||||
if (!stream) {
|
||||
return Response.json({ error: "文件不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 返回音频流
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": recording.mimeType,
|
||||
"Content-Length": recording.fileSize.toString(),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("S3 文件获取失败:", error);
|
||||
return Response.json({ error: "文件无法访问" }, { status: 500 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("音频流获取失败:", error);
|
||||
return Response.json({ error: "音频流获取失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
40
app/api/recordings/route.ts
Normal file
40
app/api/recordings/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { RecordingService } from "@/lib/services/recording.service";
|
||||
import { ApiResponseHandler } from "@/lib/utils/api-response";
|
||||
import { AuthenticationError } from "@/lib/errors/app-error";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const limit = parseInt(searchParams.get("limit") || "20");
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const recordings = await RecordingService.getRecordingsByUserId(
|
||||
session.user.id,
|
||||
{
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: "desc" },
|
||||
}
|
||||
);
|
||||
|
||||
// 转换日期格式以匹配前端期望的类型
|
||||
const formattedRecordings = recordings.map((recording) => ({
|
||||
...recording,
|
||||
createdAt: recording.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
return ApiResponseHandler.success(formattedRecordings);
|
||||
} catch (error) {
|
||||
return ApiResponseHandler.error(error as Error);
|
||||
}
|
||||
}
|
||||
141
app/api/recordings/upload/route.ts
Normal file
141
app/api/recordings/upload/route.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { RecordingService } from "@/lib/services/recording.service";
|
||||
import { ApiResponseHandler } from "@/lib/utils/api-response";
|
||||
import { AuthenticationError, ValidationError } from "@/lib/errors/app-error";
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: process.env.AWS_REGION || "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const audioFile = formData.get("audio") as File;
|
||||
const audioUrl = formData.get("audioUrl") as string;
|
||||
const duration = formData.get("duration") as string;
|
||||
const title = formData.get("title") as string;
|
||||
const fileSize = formData.get("fileSize") as string;
|
||||
const mimeType = formData.get("mimeType") as string;
|
||||
|
||||
let finalAudioUrl: string;
|
||||
let finalFileSize: number;
|
||||
let finalMimeType: string;
|
||||
|
||||
// 如果有音频文件,先上传到 S3
|
||||
if (audioFile) {
|
||||
if (!duration) {
|
||||
throw new ValidationError("缺少录音时长");
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
if (!audioFile.type.startsWith("audio/")) {
|
||||
throw new ValidationError("文件类型必须是音频");
|
||||
}
|
||||
|
||||
// 验证文件大小 (50MB 限制)
|
||||
const maxSize = 50 * 1024 * 1024;
|
||||
if (audioFile.size > maxSize) {
|
||||
throw new ValidationError("文件大小不能超过 50MB");
|
||||
}
|
||||
|
||||
// 生成唯一的文件名
|
||||
const userId = session.user.id;
|
||||
const timestamp = Date.now();
|
||||
const uniqueFileName = `recordings/${userId}/${timestamp}-recording.webm`;
|
||||
|
||||
// 上传到 S3
|
||||
const arrayBuffer = await audioFile.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: process.env.AWS_S3_BUCKET!,
|
||||
Key: uniqueFileName,
|
||||
Body: buffer,
|
||||
ContentType: audioFile.type,
|
||||
});
|
||||
|
||||
await s3.send(command);
|
||||
|
||||
// 生成 S3 URL - 修复格式
|
||||
const region = process.env.AWS_REGION || "us-east-1";
|
||||
const bucketName = process.env.AWS_S3_BUCKET!;
|
||||
|
||||
// 根据区域生成正确的 S3 URL
|
||||
let s3Url: string;
|
||||
if (region === "us-east-1") {
|
||||
// us-east-1 使用特殊格式
|
||||
s3Url = `https://${bucketName}.s3.amazonaws.com/${uniqueFileName}`;
|
||||
} else {
|
||||
// 其他区域使用标准格式
|
||||
s3Url = `https://${bucketName}.s3.${region}.amazonaws.com/${uniqueFileName}`;
|
||||
}
|
||||
|
||||
finalAudioUrl = s3Url;
|
||||
finalFileSize = audioFile.size;
|
||||
finalMimeType = audioFile.type;
|
||||
} else if (audioUrl) {
|
||||
// 如果提供了 S3 URL,直接使用
|
||||
if (!fileSize || !mimeType) {
|
||||
throw new ValidationError("缺少文件信息");
|
||||
}
|
||||
|
||||
// 验证 S3 URL 格式
|
||||
if (
|
||||
!audioUrl.startsWith("https://") ||
|
||||
!audioUrl.includes("amazonaws.com")
|
||||
) {
|
||||
throw new ValidationError("无效的音频文件URL");
|
||||
}
|
||||
|
||||
finalAudioUrl = audioUrl;
|
||||
finalFileSize = parseInt(fileSize);
|
||||
finalMimeType = mimeType;
|
||||
} else {
|
||||
throw new ValidationError("缺少音频文件或URL");
|
||||
}
|
||||
|
||||
if (!duration) {
|
||||
throw new ValidationError("缺少录音时长");
|
||||
}
|
||||
|
||||
// 验证文件大小 (50MB 限制)
|
||||
const maxSize = 50 * 1024 * 1024;
|
||||
if (finalFileSize > maxSize) {
|
||||
throw new ValidationError("文件大小不能超过 50MB");
|
||||
}
|
||||
|
||||
// 验证标题
|
||||
const recordingTitle =
|
||||
title?.trim() || `录音 ${new Date().toLocaleString("zh-CN")}`;
|
||||
if (recordingTitle.length > 100) {
|
||||
throw new ValidationError("录音标题不能超过100个字符");
|
||||
}
|
||||
|
||||
// 创建录音记录
|
||||
const recording = await RecordingService.createRecording({
|
||||
title: recordingTitle,
|
||||
audioUrl: finalAudioUrl,
|
||||
duration: parseInt(duration),
|
||||
fileSize: finalFileSize,
|
||||
mimeType: finalMimeType,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return ApiResponseHandler.created(recording);
|
||||
} catch (error) {
|
||||
return ApiResponseHandler.error(error as Error);
|
||||
}
|
||||
}
|
||||
42
app/api/register/route.ts
Normal file
42
app/api/register/route.ts
Normal file
@ -0,0 +1,42 @@
|
||||
// app/api/register/route.ts
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { UserService } from "@/lib/services/user.service";
|
||||
import { ApiResponseHandler } from "@/lib/utils/api-response";
|
||||
import { ValidationError } from "@/lib/errors/app-error";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, name, password } = body;
|
||||
|
||||
// 验证数据完整性
|
||||
if (!email || !name || !password) {
|
||||
throw new ValidationError("缺少邮箱、姓名或密码");
|
||||
}
|
||||
|
||||
// 验证密码长度
|
||||
if (password.length < 6) {
|
||||
throw new ValidationError("密码长度至少6位");
|
||||
}
|
||||
|
||||
// 使用服务层创建用户
|
||||
const user = await UserService.createUser({
|
||||
email,
|
||||
name,
|
||||
password,
|
||||
});
|
||||
|
||||
// 返回成功响应(不包含敏感信息)
|
||||
const userProfile = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
|
||||
return ApiResponseHandler.created(userProfile);
|
||||
} catch (error) {
|
||||
return ApiResponseHandler.error(error as Error);
|
||||
}
|
||||
}
|
||||
50
app/api/upload/presign/route.ts
Normal file
50
app/api/upload/presign/route.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: process.env.AWS_REGION || "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 验证用户身份
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return Response.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { fileName, fileType } = await req.json();
|
||||
|
||||
if (!fileName || !fileType) {
|
||||
return Response.json({ error: "缺少必要参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 生成唯一的文件名,包含用户ID和时间戳
|
||||
const userId = session.user.id || session.user.email;
|
||||
const timestamp = Date.now();
|
||||
const uniqueFileName = `recordings/${userId}/${timestamp}-${fileName}`;
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: process.env.AWS_S3_BUCKET!,
|
||||
Key: uniqueFileName,
|
||||
ContentType: fileType,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5分钟有效
|
||||
|
||||
return Response.json({
|
||||
url,
|
||||
fileName: uniqueFileName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("生成上传凭证失败:", error);
|
||||
return Response.json({ error: "生成上传凭证失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
60
app/api/user/export/route.ts
Normal file
60
app/api/user/export/route.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { RecordingService } from "@/lib/services/recording.service";
|
||||
import { UserSettingsService } from "@/lib/services/user-settings.service";
|
||||
import { UserService } from "@/lib/services/user.service";
|
||||
import { ApiResponseHandler } from "@/lib/utils/api-response";
|
||||
import { AuthenticationError } from "@/lib/errors/app-error";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
// 获取用户数据
|
||||
const [userProfile, userSettings, recordings] = await Promise.all([
|
||||
UserService.getUserById(session.user.id),
|
||||
UserSettingsService.getUserSettings(session.user.id),
|
||||
RecordingService.getRecordingsByUserId(session.user.id),
|
||||
]);
|
||||
|
||||
// 构建导出数据
|
||||
const exportData = {
|
||||
user: {
|
||||
id: userProfile?.id,
|
||||
name: userProfile?.name,
|
||||
email: userProfile?.email,
|
||||
createdAt: userProfile?.createdAt,
|
||||
updatedAt: userProfile?.updatedAt,
|
||||
},
|
||||
settings: userSettings,
|
||||
recordings: recordings.map((recording) => ({
|
||||
id: recording.id,
|
||||
title: recording.title,
|
||||
audioUrl: recording.audioUrl,
|
||||
duration: recording.duration,
|
||||
fileSize: recording.fileSize,
|
||||
mimeType: recording.mimeType,
|
||||
createdAt: recording.createdAt.toISOString(),
|
||||
})),
|
||||
exportDate: new Date().toISOString(),
|
||||
version: "1.0.0",
|
||||
};
|
||||
|
||||
// 返回 JSON 文件
|
||||
const response = new Response(JSON.stringify(exportData, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="recorder-export-${Date.now()}.json"`,
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
return ApiResponseHandler.error(error as Error);
|
||||
}
|
||||
}
|
||||
51
app/api/user/profile/route.ts
Normal file
51
app/api/user/profile/route.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { UserService } from "@/lib/services/user.service";
|
||||
import { ApiResponseHandler } from "@/lib/utils/api-response";
|
||||
import { AuthenticationError, ValidationError } from "@/lib/errors/app-error";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const userProfile = await UserService.getUserById(session.user.id);
|
||||
|
||||
if (!userProfile) {
|
||||
throw new AuthenticationError("用户不存在");
|
||||
}
|
||||
|
||||
return ApiResponseHandler.success(userProfile);
|
||||
} catch (error) {
|
||||
return ApiResponseHandler.error(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name } = body;
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
throw new ValidationError("名称不能为空");
|
||||
}
|
||||
|
||||
const updatedProfile = await UserService.updateUser(session.user.id, {
|
||||
name,
|
||||
});
|
||||
|
||||
return ApiResponseHandler.success(updatedProfile);
|
||||
} catch (error) {
|
||||
return ApiResponseHandler.error(error as Error);
|
||||
}
|
||||
}
|
||||
81
app/api/user/settings/route.ts
Normal file
81
app/api/user/settings/route.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { UserSettingsService } from "@/lib/services/user-settings.service";
|
||||
import { ApiResponseHandler } from "@/lib/utils/api-response";
|
||||
import { AuthenticationError, ValidationError } from "@/lib/errors/app-error";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
// 获取或创建用户设置
|
||||
const settings = await UserSettingsService.getOrCreateUserSettings(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
return ApiResponseHandler.success(settings);
|
||||
} catch (error) {
|
||||
return ApiResponseHandler.error(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const { defaultQuality, publicProfile, allowDownload } =
|
||||
await request.json();
|
||||
|
||||
// 验证音频质量
|
||||
if (
|
||||
defaultQuality &&
|
||||
!["low", "medium", "high", "lossless"].includes(defaultQuality)
|
||||
) {
|
||||
throw new ValidationError("无效的音频质量设置");
|
||||
}
|
||||
|
||||
// 更新用户设置
|
||||
const settings = await UserSettingsService.updateUserSettings(
|
||||
session.user.id,
|
||||
{
|
||||
defaultQuality,
|
||||
publicProfile:
|
||||
typeof publicProfile === "boolean" ? publicProfile : undefined,
|
||||
allowDownload:
|
||||
typeof allowDownload === "boolean" ? allowDownload : undefined,
|
||||
}
|
||||
);
|
||||
|
||||
return ApiResponseHandler.success(settings);
|
||||
} catch (error) {
|
||||
return ApiResponseHandler.error(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
// 重置用户设置为默认值
|
||||
const settings = await UserSettingsService.resetUserSettings(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
return ApiResponseHandler.success(settings);
|
||||
} catch (error) {
|
||||
return ApiResponseHandler.error(error as Error);
|
||||
}
|
||||
}
|
||||
159
app/dashboard/page.tsx
Normal file
159
app/dashboard/page.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
import AudioRecorder from "@/components/AudioRecorder";
|
||||
import RecordingList from "@/components/RecordingList";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
|
||||
interface Recording {
|
||||
id: string;
|
||||
title: string;
|
||||
duration: number;
|
||||
createdAt: string;
|
||||
audioUrl: string;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [recordings, setRecordings] = useState<Recording[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 处理录音更新
|
||||
const handleRecordingUpdated = useCallback((updatedRecording: Recording) => {
|
||||
setRecordings((prevRecordings) =>
|
||||
prevRecordings.map((recording) =>
|
||||
recording.id === updatedRecording.id ? updatedRecording : recording
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 获取录音列表
|
||||
const fetchRecordings = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/recordings");
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && Array.isArray(result.data)) {
|
||||
setRecordings(result.data);
|
||||
} else {
|
||||
console.error("API 返回数据格式错误:", result);
|
||||
setRecordings([]);
|
||||
}
|
||||
} else {
|
||||
console.error("获取录音列表失败:", response.status);
|
||||
setRecordings([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取录音列表失败:", error);
|
||||
setRecordings([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 监听录音上传和删除事件
|
||||
useEffect(() => {
|
||||
const handleRecordingUploaded = () => {
|
||||
// 延迟一点时间确保服务器处理完成
|
||||
setTimeout(() => {
|
||||
fetchRecordings();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleRecordingDeleted = () => {
|
||||
// 延迟一点时间确保服务器处理完成
|
||||
setTimeout(() => {
|
||||
fetchRecordings();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleRecordingRenamed = () => {
|
||||
// 立即清除可能的缓存
|
||||
// 延迟一点时间确保服务器处理完成
|
||||
setTimeout(() => {
|
||||
fetchRecordings();
|
||||
}, 100); // 减少延迟时间
|
||||
};
|
||||
|
||||
window.addEventListener("recording-uploaded", handleRecordingUploaded);
|
||||
window.addEventListener("recording-deleted", handleRecordingDeleted);
|
||||
window.addEventListener("recording-renamed", handleRecordingRenamed);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("recording-uploaded", handleRecordingUploaded);
|
||||
window.removeEventListener("recording-deleted", handleRecordingDeleted);
|
||||
window.removeEventListener("recording-renamed", handleRecordingRenamed);
|
||||
};
|
||||
}, [fetchRecordings]);
|
||||
|
||||
// 初始加载录音列表
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
fetchRecordings();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// 检查认证状态
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<LoadingSpinner size="lg" color="blue" text="加载中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto p-4 md:p-8 max-w-6xl">
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
你好, {session.user.name || session.user.email}!
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||
准备好录制你的下一个杰作了吗?点击下方按钮开始。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-16">
|
||||
<AudioRecorder />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-2xl md:text-3xl font-semibold text-gray-900 dark:text-white">
|
||||
我的录音
|
||||
</h3>
|
||||
<button
|
||||
onClick={fetchRecordings}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
刷新列表
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<LoadingSpinner size="lg" color="blue" text="加载录音中..." />
|
||||
</div>
|
||||
) : (
|
||||
<RecordingList recordings={recordings} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
app/debug/audio-test/page.tsx
Normal file
55
app/debug/audio-test/page.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import AudioPlayer from "@/components/AudioPlayer";
|
||||
|
||||
export default function AudioTestPage() {
|
||||
const [testUrl, setTestUrl] = useState("");
|
||||
const [testRecordingId, setTestRecordingId] = useState("test-id");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-4xl">
|
||||
<h1 className="text-2xl font-bold mb-6">音频播放测试</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
音频文件 URL:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={testUrl}
|
||||
onChange={(e) => setTestUrl(e.target.value)}
|
||||
placeholder="https://your-bucket.s3.region.amazonaws.com/path/to/file.webm"
|
||||
className="w-full p-3 border border-gray-300 rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
录音 ID (用于访问检查):
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={testRecordingId}
|
||||
onChange={(e) => setTestRecordingId(e.target.value)}
|
||||
placeholder="recording-id"
|
||||
className="w-full p-3 border border-gray-300 rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{testUrl && (
|
||||
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<h3 className="font-semibold mb-4">音频播放器测试:</h3>
|
||||
<AudioPlayer
|
||||
src={testUrl}
|
||||
title="测试音频"
|
||||
duration={0}
|
||||
recordingId={testRecordingId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
app/debug/s3-test/page.tsx
Normal file
130
app/debug/s3-test/page.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import AudioPlayer from "@/components/AudioPlayer";
|
||||
|
||||
export default function S3TestPage() {
|
||||
const [testUrl, setTestUrl] = useState("");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showAudioPlayer, setShowAudioPlayer] = useState(false);
|
||||
|
||||
const testS3Access = async () => {
|
||||
if (!testUrl) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(testUrl, {
|
||||
method: "HEAD",
|
||||
});
|
||||
|
||||
setResult({
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
accessible: response.ok,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setShowAudioPlayer(true);
|
||||
}
|
||||
} catch (error) {
|
||||
setResult({
|
||||
error: error instanceof Error ? error.message : "未知错误",
|
||||
accessible: false,
|
||||
});
|
||||
setShowAudioPlayer(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testAudioPlayback = async () => {
|
||||
if (!testUrl) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(testUrl);
|
||||
const blob = await response.blob();
|
||||
|
||||
setResult({
|
||||
...result,
|
||||
audioTest: {
|
||||
blobSize: blob.size,
|
||||
blobType: blob.type,
|
||||
accessible: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
setResult({
|
||||
...result,
|
||||
audioTest: {
|
||||
error: error instanceof Error ? error.message : "未知错误",
|
||||
accessible: false,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-4xl">
|
||||
<h1 className="text-2xl font-bold mb-6">S3 文件访问测试</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">S3 文件 URL:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={testUrl}
|
||||
onChange={(e) => setTestUrl(e.target.value)}
|
||||
placeholder="https://your-bucket.s3.region.amazonaws.com/path/to/file.webm"
|
||||
className="w-full p-3 border border-gray-300 rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={testS3Access}
|
||||
disabled={loading || !testUrl}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "测试中..." : "测试访问"}
|
||||
</button>
|
||||
|
||||
{result?.accessible && (
|
||||
<button
|
||||
onClick={testAudioPlayback}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "测试中..." : "测试音频播放"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">测试结果:</h3>
|
||||
<pre className="text-sm overflow-auto">
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAudioPlayer && testUrl && (
|
||||
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<h3 className="font-semibold mb-4">音频播放器测试:</h3>
|
||||
<AudioPlayer
|
||||
src={testUrl}
|
||||
title="S3 测试音频"
|
||||
duration={0}
|
||||
recordingId="test-id"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,11 @@
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@ -12,15 +17,67 @@
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* 确保暗色模式下的背景色 */
|
||||
.dark body {
|
||||
background-color: #0a0a0a;
|
||||
color: #ededed;
|
||||
}
|
||||
|
||||
/* 暗色模式下的组件样式 */
|
||||
.dark .bg-white {
|
||||
background-color: #1f1f1f;
|
||||
}
|
||||
|
||||
.dark .bg-gray-50 {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.dark .bg-gray-100 {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.dark .border-gray-200 {
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.dark .border-gray-300 {
|
||||
border-color: #505050;
|
||||
}
|
||||
|
||||
.dark .text-gray-900 {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.dark .text-gray-600 {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.dark .text-gray-500 {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .text-gray-400 {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .text-gray-300 {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.dark .text-gray-200 {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark .text-gray-100 {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.dark .text-gray-50 {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
@ -1,33 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import Providers from "./providers";
|
||||
import NotificationToast from "@/components/NotificationToast";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "录音应用",
|
||||
description: "一个现代化的录音应用",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="zh-CN">
|
||||
<body>
|
||||
<Providers>
|
||||
{children}
|
||||
<NotificationToast />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
111
app/login/page.tsx
Normal file
111
app/login/page.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
|
||||
// 如果已登录,重定向到仪表板
|
||||
if (session) {
|
||||
router.push("/dashboard");
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.ok) {
|
||||
router.push("/dashboard");
|
||||
} else {
|
||||
alert("登录失败,请检查邮箱和密码");
|
||||
}
|
||||
} catch {
|
||||
alert("登录失败,请重试");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
signIn("google", { callbackUrl: "/dashboard" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
登录账户
|
||||
</h2>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="邮箱地址"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
使用 Google 登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="/register"
|
||||
className="font-medium text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
还没有账户?注册
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
app/page.tsx
110
app/page.tsx
@ -1,103 +1,13 @@
|
||||
import Image from "next/image";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
export default async function HomePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
if (session?.user) {
|
||||
redirect("/dashboard");
|
||||
} else {
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
|
||||
213
app/profile/page.tsx
Normal file
213
app/profile/page.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import Header from "@/components/Header";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
|
||||
interface UserProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
|
||||
// 获取用户资料
|
||||
const fetchUserProfile = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/user/profile");
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
const data = result.data;
|
||||
setUserProfile(data);
|
||||
setName(data.name || "");
|
||||
} else {
|
||||
console.error("API 返回数据格式错误:", result);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取用户资料失败:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
fetchUserProfile();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch("/api/user/profile", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
const updatedProfile = result.data;
|
||||
setUserProfile(updatedProfile);
|
||||
setIsEditing(false);
|
||||
// 刷新 session 以更新显示名称
|
||||
window.location.reload();
|
||||
} else {
|
||||
throw new Error("API 返回数据格式错误");
|
||||
}
|
||||
} else {
|
||||
throw new Error("保存失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("保存失败:", error);
|
||||
alert("保存失败,请重试");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading" || isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<LoadingSpinner size="lg" color="blue" text="加载中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session?.user || !userProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto p-4 md:p-8 max-w-4xl">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 md:p-8">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center text-white text-2xl font-bold">
|
||||
{userProfile.name?.[0] || userProfile.email?.[0] || "U"}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">个人资料</h1>
|
||||
<p className="text-gray-600">管理你的账户信息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
基本信息
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
显示名称
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="请输入显示名称"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
|
||||
{userProfile.name || "未设置"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
邮箱地址
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
|
||||
{userProfile.email}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
账户类型
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
|
||||
{userProfile.email?.includes("@gmail.com")
|
||||
? "Google 账户"
|
||||
: "邮箱账户"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
注册时间
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
|
||||
{new Date(userProfile.createdAt).toLocaleDateString(
|
||||
"zh-CN"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-3 pt-6 border-t border-gray-200">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSaving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setName(userProfile.name || "");
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
编辑资料
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
app/providers.tsx
Normal file
12
app/providers.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { ThemeProvider } from "@/lib/contexts/theme-context";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
106
app/register/page.tsx
Normal file
106
app/register/page.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email, name, password }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert("注册成功!请登录");
|
||||
router.push("/login");
|
||||
} else {
|
||||
const result = await response.json();
|
||||
const errorMessage =
|
||||
result.error?.message || result.error || "注册失败,请重试";
|
||||
alert(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("注册失败,请重试");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
注册账户
|
||||
</h2>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="姓名"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="邮箱地址"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? "注册中..." : "注册"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="/login"
|
||||
className="font-medium text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
已有账户?登录
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
374
app/settings/page.tsx
Normal file
374
app/settings/page.tsx
Normal file
@ -0,0 +1,374 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTheme } from "@/lib/contexts/theme-context";
|
||||
import { notificationManager } from "@/lib/utils/notifications";
|
||||
import Header from "@/components/Header";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
|
||||
interface UserSettings {
|
||||
defaultQuality: string;
|
||||
publicProfile: boolean;
|
||||
allowDownload: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const [defaultQuality, setDefaultQuality] = useState("medium");
|
||||
const [publicProfile, setPublicProfile] = useState(false);
|
||||
const [allowDownload, setAllowDownload] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// 获取用户设置
|
||||
const fetchUserSettings = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/user/settings");
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
const data: UserSettings = result.data;
|
||||
setDefaultQuality(data.defaultQuality);
|
||||
setPublicProfile(data.publicProfile);
|
||||
setAllowDownload(data.allowDownload);
|
||||
} else {
|
||||
console.error("API 返回数据格式错误:", result);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取用户设置失败:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
fetchUserSettings();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch("/api/user/settings", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
defaultQuality,
|
||||
publicProfile,
|
||||
allowDownload,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log("设置已保存");
|
||||
notificationManager.success("设置已保存", "您的设置已成功保存");
|
||||
} else {
|
||||
throw new Error("保存设置失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("保存设置失败:", error);
|
||||
notificationManager.error("保存失败", "设置保存失败,请重试");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportData = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const response = await fetch("/api/user/export");
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `recorder-export-${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
notificationManager.success("导出成功", "数据已成功导出");
|
||||
} else {
|
||||
throw new Error("导出失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("导出失败:", error);
|
||||
notificationManager.error("导出失败", "数据导出失败,请重试");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSettings = async () => {
|
||||
if (!confirm("确定要重置所有设置为默认值吗?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/user/settings", {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await fetchUserSettings();
|
||||
notificationManager.success("重置成功", "设置已重置为默认值");
|
||||
} else {
|
||||
throw new Error("重置失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("重置设置失败:", error);
|
||||
notificationManager.error("重置失败", "设置重置失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading" || isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<LoadingSpinner size="lg" color="blue" text="加载中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto p-4 md:p-8 max-w-4xl">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 md:p-8">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-12 h-12 bg-gray-500 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-white"
|
||||
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>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
设置
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
自定义你的应用体验
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* 录音设置 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
录音设置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
默认音频质量
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
选择默认的录音质量
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={defaultQuality}
|
||||
onChange={(e) => setDefaultQuality(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="low">低质量</option>
|
||||
<option value="medium">中等质量</option>
|
||||
<option value="high">高质量</option>
|
||||
<option value="lossless">无损质量</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 外观设置 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
外观设置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
主题模式
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
选择你喜欢的主题
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) =>
|
||||
setTheme(e.target.value as "light" | "dark" | "auto")
|
||||
}
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="light">浅色模式</option>
|
||||
<option value="dark">深色模式</option>
|
||||
<option value="auto">跟随系统</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 隐私设置 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
隐私设置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
公开个人资料
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
允许其他用户查看你的个人资料
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={publicProfile}
|
||||
onChange={(e) => setPublicProfile(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
允许下载
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
允许其他用户下载你的录音
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowDownload}
|
||||
onChange={(e) => setAllowDownload(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据管理 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
数据管理
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
导出数据
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
导出你的所有录音数据
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportData}
|
||||
disabled={isExporting}
|
||||
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" color="white" />
|
||||
导出中...
|
||||
</>
|
||||
) : (
|
||||
"导出"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
重置设置
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
将所有设置重置为默认值
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleResetSettings}
|
||||
className="px-4 py-2 bg-yellow-500 hover:bg-yellow-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
删除账户
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
永久删除你的账户和所有录音
|
||||
</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 text-red-600 hover:text-red-700 font-medium">
|
||||
删除账户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<div className="flex gap-3 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
disabled={isSaving}
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSaving ? "保存中..." : "保存设置"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user