import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { getUserFromRequest } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
const user = await getUserFromRequest(request);
if (!user) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
const rooms = await prisma.room.findMany({
include: {
messages: {
orderBy: {
createdAt: 'desc',
},
take: 1,
include: {
sender: true
}
},
},
orderBy: {
createdAt: 'desc'
}
});
const formattedRooms = rooms.map(room => {
const lastMessage = room.messages[0];
return {
roomId: room.roomId,
roomName: room.roomName,
lastMessage: lastMessage ? {
messageId: lastMessage.messageId,
roomId: lastMessage.roomId,
sender: lastMessage.sender.username,
content: lastMessage.content,
time: lastMessage.createdAt.getTime(),
} : null,
};
});
return NextResponse.json({ message: "获取成功", data: { rooms: formattedRooms } });
} catch (error) {
console.error('获取房间列表时出错:', error);
return NextResponse.json({ message: '服务器内部错误' }, { status: 500 });
}
}
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { getUserFromRequest } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
const user = await getUserFromRequest(request);
if (!user) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const roomId = searchParams.get('roomId');
if (!roomId) {
return NextResponse.json({ message: 'Room ID 是必需的' }, { status: 400 });
}
const messages = await prisma.message.findMany({
where: {
roomId: parseInt(roomId),
},
include: {
sender: true,
},
orderBy: {
createdAt: 'asc',
},
});
const formattedMessages = messages.map(msg => ({
messageId: msg.messageId,
roomId: msg.roomId,
sender: msg.sender.username,
content: msg.content,
time: msg.createdAt.getTime(),
}));
return NextResponse.json({ message: "获取成功", data: { messages: formattedMessages } });
} catch (error) {
console.error('获取消息列表时出错:', error);
return NextResponse.json({ message: '服务器内部错误' }, { status: 500 });
}
}
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--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;
}
import type { Metadata } from "next";
import { Geist, Geist_Mono } 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"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
'use client';
import React, { useState, useEffect, useRef } from 'react';
import useSWR from 'swr';
import useSWRMutation from 'swr/mutation';
interface User {
id: number;
username: string;
}
interface ApiResponse<T> {
message: string;
code?: number;
data: T;
}
interface Message {
messageId: number;
roomId: number;
sender: string;
content: string;
time: number;
}
interface Room {
roomId: number;
roomName: string;
lastMessage: Message | null;
}
type RoomListRes = { rooms: Room[] };
type RoomMessageListRes = { messages: Message[] };
const fetcherOptions = {
credentials: 'include' as RequestCredentials
};
const getFetcher = async (key: string) => {
const res = await fetch(key, fetcherOptions);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.message || '获取数据失败');
}
const data: ApiResponse<any> = await res.json();
return data.data;
};
const mutationFetcher = async (key: string, { arg }: { arg: Record<string, unknown> }) => {
const res = await fetch(key, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(arg),
...fetcherOptions
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.message || '操作失败');
}
const data: ApiResponse<any> = await res.json();
return data.data;
};
const RoomEntry = ({ room, isSelected, onClick, onDelete }: { room: Room, isSelected: boolean, onClick: () => void, onDelete: (e: React.MouseEvent) => void }) => (
<div
onClick={onClick}
onContextMenu={(e) => { e.preventDefault(); onDelete(e); }}
className={`relative group p-3 my-1 flex justify-between items-center rounded-lg cursor-pointer transition-all duration-200 ${isSelected ? 'bg-blue-500 text-white' : 'hover:bg-gray-700'}`}
title="右键点击删除"
>
<div className="truncate">
<p className="font-semibold">{room.roomName}</p>
<p className={`text-xs truncate ${isSelected ? 'text-blue-200' : 'text-gray-400'}`}>
{room.lastMessage ? `${room.lastMessage.sender}: ${room.lastMessage.content}` : '暂无消息'}
</p>
</div>
</div>
);
const MessageItem = ({ message, isOwnMessage }: { message: Message, isOwnMessage: boolean }) => {
const avatarInitial = message.sender ? message.sender.charAt(0).toUpperCase() : '?';
const messageDate = new Date(message.time);
const timeString = messageDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return (
<div className={`flex items-start my-4 gap-3 ${isOwnMessage ? 'flex-row-reverse' : ''}`}>
<div className="w-10 h-10 rounded-full bg-gray-600 flex-shrink-0 flex items-center justify-center font-bold text-white">
{avatarInitial}
</div>
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className="flex items-center gap-2">
{!isOwnMessage && <span className="font-semibold text-sm text-gray-300">{message.sender}</span>}
<span className="text-xs text-gray-500">{timeString}</span>
</div>
<div className={`mt-1 p-3 rounded-lg max-w-xs md:max-w-md break-words ${isOwnMessage ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-200'}`}>
{message.content}
</div>
</div>
</div>
);
};
const ChatRoomPage = ({ user, onLogout }: { user: User, onLogout: () => void }) => {
const [selectedRoomId, setSelectedRoomId] = useState<number | null>(null);
const [newMessage, setNewMessage] = useState('');
const [newRoomName, setNewRoomName] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const { data: roomsData, error: roomsError, isLoading: roomsLoading, mutate: mutateRooms } = useSWR<RoomListRes>('/api/room/list', getFetcher, { refreshInterval: 2000 });
const { data: messagesData, error: messagesError, isLoading: messagesLoading, mutate: mutateMessages } = useSWR<RoomMessageListRes>(
() => selectedRoomId !== null ? `/api/room/message/list?roomId=${selectedRoomId}` : null,
getFetcher,
{ refreshInterval: 1000 }
);
const { trigger: addRoomTrigger } = useSWRMutation('/api/room/add', mutationFetcher);
const { trigger: deleteRoomTrigger } = useSWRMutation('/api/room/delete', mutationFetcher);
const { trigger: addMessageTrigger } = useSWRMutation('/api/message/add', mutationFetcher);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messagesData]);
const handleRoomSelect = (roomId: number) => setSelectedRoomId(roomId);
const handleAddRoom = async () => {
if (!newRoomName.trim()) return;
try {
await addRoomTrigger({ roomName: newRoomName });
setNewRoomName('');
mutateRooms();
} catch (e) {
console.error("创建房间失败:", (e as Error).message);
alert(`创建房间失败: ${(e as Error).message}`);
}
};
const handleDeleteRoom = async (e: React.MouseEvent, roomId: number) => {
e.stopPropagation();
if (window.confirm('你确定要删除这个房间吗?')) {
try {
await deleteRoomTrigger({ roomId });
if (selectedRoomId === roomId) setSelectedRoomId(null);
mutateRooms();
} catch (e) {
console.error("删除房间失败:", (e as Error).message);
alert(`删除房间失败: ${(e as Error).message}`);
}
}
};
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || selectedRoomId === null) return;
const optimisticMessage = newMessage;
setNewMessage('');
try {
await addMessageTrigger({ roomId: selectedRoomId, content: optimisticMessage });
mutateMessages();
} catch (error) {
console.error("发送消息失败:", (error as Error).message);
setNewMessage(optimisticMessage);
alert(`发送消息失败: ${(error as Error).message}`);
}
};
const currentRoomName = roomsData?.rooms.find(r => r.roomId === selectedRoomId)?.roomName || '聊天室';
return (
<div className="flex h-screen bg-gray-900 text-white font-sans">
<aside className="w-1/4 min-w-[280px] bg-gray-800 flex flex-col p-4 border-r border-gray-700">
<header className="mb-4">
<h1 className="text-2xl font-bold">聊天室</h1>
<p className="text-sm text-gray-400">你好, {user.username} <button onClick={onLogout} className="ml-2 text-red-400 hover:underline text-xs">[退出]</button></p>
</header>
<div className="flex gap-2 mb-4">
<input value={newRoomName} onChange={(e) => setNewRoomName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAddRoom()} placeholder="新房间名称" className="flex-grow p-2 bg-gray-700 rounded-lg"/>
<button onClick={handleAddRoom} className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700">+</button>
</div>
<div className="flex-1 overflow-y-auto pr-1">
{roomsLoading && <p>加载中...</p>}
{roomsError && <p className="text-red-400">{roomsError.message}</p>}
{roomsData?.rooms.map(room => (
<RoomEntry key={room.roomId} room={room} isSelected={room.roomId === selectedRoomId} onClick={() => handleRoomSelect(room.roomId)} onDelete={(e) => handleDeleteRoom(e, room.roomId)} />
))}
</div>
</aside>
<main className="flex-1 flex flex-col">
{selectedRoomId === null ? (
<div className="flex-1 flex items-center justify-center"><p className="text-gray-500">选择一个房间开始聊天</p></div>
) : (
<>
<header className="p-4 border-b border-gray-700"><h2 className="text-xl font-semibold">{currentRoomName}</h2></header>
<div className="flex-1 p-4 overflow-y-auto">
{messagesLoading && <p>加载中...</p>}
{messagesError && <p className="text-red-400">{messagesError.message}</p>}
{messagesData?.messages.map(msg => (
<MessageItem key={msg.messageId} message={msg} isOwnMessage={msg.sender === user.username} />
))}
<div ref={messagesEndRef} />
</div>
<div className="p-4 border-t border-gray-700">
<form onSubmit={handleSendMessage} className="flex gap-4">
<input value={newMessage} onChange={(e) => setNewMessage(e.target.value)} placeholder="输入消息..." className="flex-grow p-3 bg-gray-800 rounded-lg"/>
<button type="submit" className="px-6 py-3 bg-blue-600 rounded-lg" disabled={!newMessage.trim()}>发送</button>
</form>
</div>
</>
)}
</main>
</div>
);
};
const AuthPage = ({ onAuthSuccess }: { onAuthSuccess: (user: User) => void }) => {
const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { trigger: authTrigger } = useSWRMutation(isLogin ? '/api/auth/login' : '/api/auth/register', mutationFetcher);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await authTrigger({ username, password });
onAuthSuccess(result);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
return (
<div className="flex items-center justify-center h-screen bg-gray-900 text-white">
<div className="w-full max-w-sm p-8 bg-gray-800 rounded-2xl shadow-lg border border-gray-700">
<h1 className="text-3xl font-bold text-center mb-2">{isLogin ? '登录' : '注册'}</h1>
<p className="text-center text-gray-400 mb-8">欢迎来到聊天室</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="用户名" className="p-3 bg-gray-700 rounded-lg" required />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="密码" className="p-3 bg-gray-700 rounded-lg" required />
{error && <p className="text-red-400 text-sm">{error}</p>}
<button type="submit" disabled={loading} className="p-3 font-bold bg-blue-600 rounded-lg hover:bg-blue-700 disabled:bg-gray-600">{loading ? '处理中...' : (isLogin ? '登录' : '注册')}</button>
</form>
<button onClick={() => { setIsLogin(!isLogin); setError(''); }} className="w-full mt-4 text-sm text-center text-gray-400 hover:underline">
{isLogin ? '没有账户?点击注册' : '已有账户?点击登录'}
</button>
</div>
</div>
);
};
export default function Home() {
const [user, setUser] = useState<User | null>(null);
const [authLoading, setAuthLoading] = useState(true);
useEffect(() => {
const storedUser = localStorage.getItem('chat-user');
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch(e) {
console.error("解析用户信息失败", e);
localStorage.removeItem('chat-user');
}
}
setAuthLoading(false);
}, []);
const handleAuthSuccess = (authedUser: User) => {
localStorage.setItem('chat-user', JSON.stringify(authedUser));
setUser(authedUser);
};
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST', ...fetcherOptions });
} catch (e) {
console.error("登出失败", e);
} finally {
localStorage.removeItem('chat-user');
setUser(null);
}
}
if (authLoading) {
return <div className="h-screen bg-gray-900 flex items-center justify-center text-white">加载中...</div>;
}
if (!user) {
return <AuthPage onAuthSuccess={handleAuthSuccess} />;
}
return <ChatRoomPage user={user} onLogout={handleLogout}/>;
}
import { NextRequest } from 'next/server';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'jinitaimeijinitaimeijinitaimei';
export interface UserPayload {
userId: number;
}
export function getUserFromRequest(req: NextRequest): UserPayload | null {
const token = req.cookies.get('auth_token')?.value;
if (!token) {
return null;
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as UserPayload;
return decoded;
} catch (error) {
console.error('JWT 验证失败:', error);
return null;
}
}
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ['query'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
export default prisma;
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}