Commit c6f4ee01 authored by soilchalk's avatar soilchalk
Browse files

feat: 聊天室项目核心代码 v1.0.0

- 实现完整的全栈聊天室应用
- 支持用户认证、多房间聊天、实时消息推送
- 包含前端(Next.js)和后端(API Routes)代码
- 支持Docker部署和数据库管理
- 包含管理员功能和测试用户
parent dacd5539
@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 { App } from "antd";
import "./globals.css";
import "antd/dist/reset.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`}
>
<App>
{children}
</App>
</body>
</html>
);
}
'use client';
import React, { useState } from 'react';
import { Form, Input, Button, Card, message, Space } from 'antd';
import { UserOutlined, LockOutlined, LoginOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { fetcher } from '../../lib/fetcher';
import { LoginRequest } from '../../types/auth';
export default function LoginPage() {
const [loading, setLoading] = useState(false);
const [usernameError, setUsernameError] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
const router = useRouter();
const handleLogin = async (values: LoginRequest) => {
// 清空旧错误
setUsernameError('');
setPasswordError('');
setLoading(true);
try {
const response = await fetcher('/api/auth/login', {
method: 'POST',
body: JSON.stringify(values),
});
message.success('登录成功!');
// 保存用户信息到localStorage
localStorage.setItem('user', JSON.stringify({
id: response.user.id,
username: response.user.username,
nickname: response.user.nickname,
email: response.user.email,
token: response.accessToken,
refreshToken: response.refreshToken
}));
// 跳转到聊天页面
router.push('/chat');
} catch (error) {
console.error('登录失败:', error);
if (error instanceof Error) {
if (error.message.includes('用户名或密码错误')) {
setPasswordError('用户名或密码错误,请检查后重试');
} else if (error.message.includes('用户不存在')) {
setUsernameError('用户不存在,请先注册');
} else {
message.error(error.message || '登录失败,请检查用户名和密码');
}
} else {
message.error('登录失败,请检查用户名和密码');
}
} finally {
setLoading(false);
}
};
const handleRegister = () => {
router.push('/register');
};
return (
<div className="font-sans flex items-center justify-center min-h-screen p-8 bg-gray-50">
<main className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">用户登录</h1>
<p className="text-gray-600">欢迎回到聊天室</p>
</div>
<Card className="shadow-lg">
<Form
name="login"
onFinish={handleLogin}
autoComplete="off"
layout="vertical"
size="large"
>
<Form.Item
name="username"
label="用户名"
rules={[
{ required: true, message: '请输入用户名' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入用户名或邮箱"
status={usernameError ? 'error' : undefined}
onChange={() => setUsernameError('')}
/>
</Form.Item>
{usernameError && (
<div className="text-red-500 text-xs mt-1 mb-4">{usernameError}</div>
)}
<Form.Item
name="password"
label="密码"
rules={[
{ required: true, message: '请输入密码' }
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码"
status={passwordError ? 'error' : undefined}
onChange={() => setPasswordError('')}
/>
</Form.Item>
{passwordError && (
<div className="text-red-500 text-xs mt-1 mb-4">{passwordError}</div>
)}
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
icon={<LoginOutlined />}
className="w-full h-12"
>
登录
</Button>
</Form.Item>
<div className="text-center">
<Space>
<span className="text-gray-600">还没有账户?</span>
<Button type="link" onClick={handleRegister} className="p-0">
立即注册
</Button>
</Space>
</div>
</Form>
</Card>
</main>
</div>
);
}
'use client';
import React, { useState, useEffect } from 'react';
import { Input, Button, Card, message, Space } from 'antd';
import { UserOutlined, MessageOutlined, LoginOutlined, UserAddOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
export default function Home() {
const [nickname, setNickname] = useState<string>('');
const [isClient, setIsClient] = useState(false);
const router = useRouter();
// 客户端检测
useEffect(() => {
setIsClient(true);
}, []);
// 获取已保存的昵称
useEffect(() => {
if (isClient) {
const savedNickname = localStorage.getItem('nickname');
if (savedNickname) {
setNickname(savedNickname);
}
}
}, [isClient]);
const handleSetNickname = () => {
if (!nickname.trim()) {
message.error('请输入昵称');
return;
}
if (nickname.length < 2 || nickname.length > 20) {
message.error('昵称长度必须在2-20个字符之间');
return;
}
// 检查是否为admin昵称
if (nickname.toLowerCase() === 'admin') {
message.error('admin昵称已被保留,请选择其他昵称');
return;
}
localStorage.setItem('nickname', nickname.trim());
message.success('昵称设置成功!');
};
const handleEnterChat = () => {
if (!nickname.trim()) {
message.error('请先设置昵称');
return;
}
// 检查是否为admin昵称
if (nickname.toLowerCase() === 'admin') {
message.error('admin昵称已被保留,请选择其他昵称');
return;
}
router.push('/chat');
};
const handleLogin = () => {
router.push('/login');
};
const handleRegister = () => {
router.push('/register');
};
if (!isClient) {
return <div>加载中...</div>;
}
return (
<div className="font-sans flex items-center justify-center min-h-screen p-8 bg-gray-50">
<main className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">聊天室</h1>
<p className="text-gray-600">选择您的登录方式</p>
</div>
<Card className="shadow-lg mb-4">
<div className="space-y-4">
<Button
type="primary"
size="large"
icon={<LoginOutlined />}
onClick={handleLogin}
className="w-full h-12"
>
用户登录
</Button>
<Button
size="large"
icon={<UserAddOutlined />}
onClick={handleRegister}
className="w-full h-12"
>
用户注册
</Button>
</div>
</Card>
<Card className="shadow-lg">
<div className="text-center mb-4">
<h3 className="text-lg font-medium text-gray-700">或使用昵称快速进入</h3>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">昵称</label>
<Input
prefix={<UserOutlined />}
placeholder="请输入昵称 (2-20字符)"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
onPressEnter={handleSetNickname}
maxLength={20}
showCount
size="large"
/>
</div>
<Button
type="default"
size="large"
icon={<MessageOutlined />}
onClick={handleEnterChat}
disabled={!nickname.trim()}
className="w-full h-12"
>
快速进入聊天室
</Button>
{!nickname.trim() && (
<p className="text-sm text-gray-500 text-center">
请先设置昵称
</p>
)}
</div>
</Card>
</main>
</div>
);
}
'use client';
import React, { useState } from 'react';
import { Form, Input, Button, Card, message, Space } from 'antd';
import { UserOutlined, LockOutlined, MailOutlined, UserAddOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { fetcher } from '../../lib/fetcher';
import { RegisterRequest } from '../../types/auth';
export default function RegisterPage() {
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const [emailError, setEmailError] = useState<string>('');
const [usernameError, setUsernameError] = useState<string>('');
const router = useRouter();
const handleRegister = async (values: RegisterRequest) => {
// 清除之前的错误
setEmailError('');
setUsernameError('');
setLoading(true);
try {
const response = await fetcher('/api/auth/register', {
method: 'POST',
body: JSON.stringify(values),
});
message.success('注册成功!');
// 保存用户信息到localStorage
localStorage.setItem('user', JSON.stringify({
id: response.user.id,
username: response.user.username,
nickname: response.user.nickname,
email: response.user.email,
token: response.accessToken,
refreshToken: response.refreshToken
}));
// 跳转到聊天页面
router.push('/chat');
} catch (error) {
console.error('注册失败:', error);
console.log('错误类型:', typeof error);
console.log('错误消息:', error instanceof Error ? error.message : '非Error类型');
if (error instanceof Error) {
console.log('错误消息包含检查:');
console.log('包含"用户名已存在":', error.message.includes('用户名已存在'));
console.log('包含"邮箱已被注册":', error.message.includes('邮箱已被注册'));
console.log('包含"admin用户名已被保留":', error.message.includes('admin用户名已被保留'));
console.log('包含"admin昵称已被保留":', error.message.includes('admin昵称已被保留'));
if (error.message.includes('用户名已存在')) {
setUsernameError('用户名已存在,请选择其他用户名');
} else if (error.message.includes('邮箱已被注册')) {
setEmailError('邮箱已被注册,请使用其他邮箱');
} else if (error.message.includes('admin用户名已被保留')) {
setUsernameError('admin用户名已被保留,请选择其他用户名');
} else if (error.message.includes('admin昵称已被保留')) {
message.error('admin昵称已被保留,请选择其他昵称');
} else {
message.error(error.message || '注册失败,请重试');
}
} else {
message.error('注册失败,请重试');
}
} finally {
setLoading(false);
}
};
const handleLogin = () => {
router.push('/login');
};
return (
<div className="font-sans flex items-center justify-center min-h-screen p-8 bg-gray-50">
<main className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">用户注册</h1>
<p className="text-gray-600">创建您的聊天室账户</p>
</div>
<Card className="shadow-lg">
<Form
name="register"
onFinish={handleRegister}
autoComplete="off"
layout="vertical"
size="large"
>
<Form.Item
name="username"
label="用户名"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, max: 20, message: '用户名长度必须在3-20个字符之间' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线' },
{
validator: async (_, value) => {
if (value && value.toLowerCase() === 'admin') {
throw new Error('admin用户名已被保留,请选择其他用户名');
}
}
}
]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入用户名 (3-20字符,仅限字母、数字、下划线)"
maxLength={20}
showCount
status={usernameError ? 'error' : undefined}
onChange={() => setUsernameError('')}
/>
</Form.Item>
{usernameError && (
<div className="text-red-500 text-xs mt-1 mb-4">
{usernameError}
</div>
)}
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' }
]}
>
<Input
prefix={<MailOutlined />}
placeholder="请输入邮箱"
type="email"
status={emailError ? 'error' : undefined}
onChange={() => setEmailError('')}
/>
</Form.Item>
{emailError && (
<div className="text-red-500 text-xs mt-1 mb-4">
{emailError}
</div>
)}
<Form.Item
name="nickname"
label="昵称"
rules={[
{ required: true, message: '请输入昵称' },
{ min: 2, max: 20, message: '昵称长度必须在2-20个字符之间' },
{
validator: async (_, value) => {
if (value && value.toLowerCase() === 'admin') {
throw new Error('admin昵称已被保留,请选择其他昵称');
}
}
}
]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入昵称 (2-20字符,支持中文、英文、数字)"
maxLength={20}
showCount
/>
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度至少6个字符' },
{ pattern: /^(?=.*[a-zA-Z])(?=.*\d)/, message: '密码必须包含字母和数字' }
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码 (至少6位,必须包含字母和数字)"
minLength={6}
/>
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认密码"
dependencies={['password']}
rules={[
{ required: true, message: '请确认密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请再次输入密码"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
icon={<UserAddOutlined />}
className="w-full h-12"
>
注册
</Button>
</Form.Item>
<div className="text-center">
<Space>
<span className="text-gray-600">已有账户?</span>
<Button type="link" onClick={handleLogin} className="p-0">
立即登录
</Button>
</Space>
</div>
</Form>
</Card>
</main>
</div>
);
}
.chatWindow {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
}
.roomHeader {
padding: 16px 24px;
border-bottom: 1px solid #e8e8e8;
background: #fff;
display: flex;
justify-content: space-between;
align-items: center;
}
.roomTitle {
margin: 0 !important;
color: #262626;
}
.roomInfo {
display: flex;
align-items: center;
gap: 16px;
}
.messageCount {
color: #8c8c8c;
font-size: 14px;
}
.messageList {
flex: 1;
overflow-y: auto;
padding: 16px 24px;
background: #fafafa;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.emptyMessages {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.emptyState {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
background: #fafafa;
}
.errorAlert {
margin: 24px;
}
.messageInput {
padding: 16px 24px;
border-top: 1px solid #e8e8e8;
background: #fff;
}
'use client';
import React, { useEffect, useRef, useState } from 'react';
import useSWR, { mutate } from 'swr';
import { Spin, Alert, Typography, Empty } from 'antd';
import { fetcher } from '../lib/fetcher';
import { Message, RoomPreviewInfo } from '../types/chat';
import MessageItem from './MessageItem';
import MessageInput from './MessageInput';
import styles from './ChatWindow.module.css';
const { Title } = Typography;
interface ChatWindowProps {
roomId: number | null;
user?: any;
userRole?: 'guest' | 'user' | 'admin';
}
export default function ChatWindow({ roomId, user, userRole = 'guest' }: ChatWindowProps) {
const messageListRef = useRef<HTMLDivElement>(null);
const [nickname, setNickname] = useState<string | null>(null);
const [isClient, setIsClient] = useState(false);
// 客户端检测
useEffect(() => {
setIsClient(true);
}, []);
// 获取昵称
useEffect(() => {
if (isClient) {
if (user) {
// 使用登录用户的昵称
setNickname(user.nickname);
} else {
// 使用游客昵称
const storedNickname = localStorage.getItem('nickname');
setNickname(storedNickname);
}
}
}, [isClient, user]);
// 获取房间信息
const { data: roomData } = useSWR<{ rooms: RoomPreviewInfo[] }>('/api/room/list', fetcher);
const currentRoom = roomData?.rooms?.find(room => room.roomId === roomId);
// 获取消息列表
const {
data: messageData,
error,
isLoading,
} = useSWR<{ messages: Message[] }>(
roomId ? `/api/room/message/list?roomId=${roomId}` : null,
fetcher,
{
refreshInterval: 1000, // 每秒轮询一次
}
);
const messages = messageData?.messages || [];
// SSE 订阅:根据当前房间建立事件流
useEffect(() => {
if (!isClient || !roomId) return;
const es = new EventSource(`/api/room/message/stream?roomId=${roomId}`);
es.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload?.type === 'message' && payload?.payload) {
const incoming = payload.payload as Message;
// 增量更新:若已存在相同 messageId 则忽略
mutate(
`/api/room/message/list?roomId=${roomId}`,
(current: { messages?: Message[] } | undefined) => {
const currentList = current?.messages || [];
if (currentList.some((m) => m.messageId === incoming.messageId)) {
return current;
}
return { messages: [...currentList, incoming] };
},
false
);
}
} catch (e) {
console.error('解析SSE消息失败:', e);
}
};
es.onerror = (err) => {
console.error('SSE连接错误:', err);
// 出错时关闭,由浏览器/页面切换策略决定是否重连
es.close();
};
return () => {
es.close();
};
}, [isClient, roomId]);
// 自动滚动到底部
useEffect(() => {
if (messageListRef.current) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
}
}, [messages]);
const handleSendMessage = async (content: string) => {
if (!roomId || !nickname) return;
const optimisticNewMessage: Message = {
messageId: Date.now(), // 临时ID
roomId,
sender: nickname,
content,
time: Date.now(),
};
// 乐观更新UI
mutate(
(currentData: { messages?: Message[] } | undefined) => ({
...currentData,
messages: [...(currentData?.messages || []), optimisticNewMessage]
}),
false
);
try {
await fetcher('/api/message/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
roomId,
sender: nickname,
content,
}),
});
// 触发重新获取以获取服务器端的实际消息
mutate(`/api/room/message/list?roomId=${roomId}`);
} catch (e) {
console.error('发送消息失败:', e);
// 发送失败时回滚乐观更新
mutate(
(currentData: { messages?: Message[] } | undefined) => ({
...currentData,
messages: (currentData?.messages || []).filter((msg: Message) => msg.messageId !== optimisticNewMessage.messageId)
}),
false
);
}
};
if (!isClient) {
return <div>加载中...</div>;
}
if (!roomId) {
return (
<div className={styles.emptyState}>
<Empty
description="请选择一个聊天房间"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
);
}
if (error) {
return (
<Alert
message="加载失败"
description="无法加载消息,请刷新页面重试"
type="error"
showIcon
className={styles.errorAlert}
/>
);
}
return (
<div className={styles.chatWindow}>
{/* 房间标题 */}
<div className={styles.roomHeader}>
<Title level={4} className={styles.roomTitle}>
{currentRoom?.roomName || `房间 ${roomId}`}
</Title>
<div className={styles.roomInfo}>
<span className={styles.messageCount}>
{messages.length} 条消息
</span>
</div>
</div>
{/* 消息列表 */}
<div className={styles.messageList} ref={messageListRef}>
{isLoading ? (
<div className={styles.loading}>
<Spin size="large" />
</div>
) : messages.length === 0 ? (
<div className={styles.emptyMessages}>
<Empty
description="暂无消息"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
) : (
messages.map((message) => (
<MessageItem
key={message.messageId}
message={message}
isOwn={message.sender === nickname}
/>
))
)}
</div>
{/* 消息输入框 */}
<div className={styles.messageInput}>
<MessageInput
onSendMessage={handleSendMessage}
disabled={!nickname}
placeholder={nickname ? "输入消息..." : "请先设置昵称"}
/>
</div>
</div>
);
}
.messageInput {
width: 100%;
}
.inputContainer {
display: flex;
gap: 12px;
align-items: flex-end;
}
.textarea {
flex: 1;
border-radius: 8px;
resize: none;
font-size: 14px;
line-height: 1.4;
}
.textarea:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.sendButton {
height: 40px;
border-radius: 8px;
font-weight: 500;
flex-shrink: 0;
}
.sendButton:disabled {
opacity: 0.6;
}
.tips {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
color: #8c8c8c;
}
.tips span {
opacity: 0.8;
}
/* 响应式设计 */
@media (max-width: 768px) {
.inputContainer {
gap: 8px;
}
.sendButton {
height: 36px;
padding: 0 12px;
}
.tips {
flex-direction: column;
gap: 4px;
align-items: center;
}
}
'use client';
import React, { useState, KeyboardEvent } from 'react';
import { Input, Button } from 'antd';
import { SendOutlined } from '@ant-design/icons';
import styles from './MessageInput.module.css';
const { TextArea } = Input;
interface MessageInputProps {
onSendMessage: (content: string) => void;
disabled?: boolean;
placeholder?: string;
}
export default function MessageInput({ onSendMessage, disabled = false, placeholder = "输入消息..." }: MessageInputProps) {
const [message, setMessage] = useState('');
const [isSending, setIsSending] = useState(false);
const handleSend = async () => {
if (!message.trim() || disabled || isSending) return;
setIsSending(true);
try {
await onSendMessage(message.trim());
setMessage('');
} catch (error) {
console.error('发送消息失败:', error);
} finally {
setIsSending(false);
}
};
const handleKeyPress = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value);
};
return (
<div className={styles.messageInput}>
<div className={styles.inputContainer}>
<TextArea
value={message}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
placeholder={placeholder}
disabled={disabled}
autoSize={{ minRows: 1, maxRows: 4 }}
className={styles.textarea}
maxLength={500}
showCount
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
disabled={!message.trim() || disabled || isSending}
loading={isSending}
className={styles.sendButton}
>
发送
</Button>
</div>
<div className={styles.tips}>
<span>按 Enter 发送,Shift + Enter 换行</span>
<span>最多 500 字符</span>
</div>
</div>
);
}
.messageItem {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
gap: 8px;
}
.messageItem.own {
justify-content: flex-end; /* 整个容器右对齐 */
}
.avatar {
flex-shrink: 0;
}
.messageContent {
flex: 1;
max-width: 70%;
min-width: 0; /* 允许内容收缩 */
}
.messageItem.own .messageContent {
display: flex;
flex-direction: column;
align-items: flex-end; /* 消息内容右对齐 */
}
.senderInfo {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.senderName {
font-size: 12px;
color: #8c8c8c;
font-weight: 500;
}
.messageBubble {
padding: 8px 12px;
border-radius: 12px;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap; /* 保持换行和空格 */
position: relative;
display: inline-block;
max-width: 100%;
}
.ownBubble {
background: #1890ff;
color: #fff;
border-bottom-right-radius: 4px;
}
.otherBubble {
background: #fff;
color: #262626;
border: 1px solid #e8e8e8;
border-bottom-left-radius: 4px;
}
.messageText {
font-size: 14px;
line-height: 1.4;
margin: 0;
}
.ownBubble .messageText {
color: #fff;
}
.otherBubble .messageText {
color: #262626;
}
.messageTime {
font-size: 11px;
color: #bfbfbf;
margin-top: 4px;
text-align: center;
}
.ownTime {
text-align: right;
}
.otherTime {
text-align: left;
}
/* 响应式设计 */
@media (max-width: 768px) {
.messageContent {
max-width: 85%;
}
.messageBubble {
padding: 6px 10px;
}
.messageText {
font-size: 13px;
}
}
'use client';
import React from 'react';
import { Avatar, Typography } from 'antd';
import { Message } from '../types/chat';
import styles from './MessageItem.module.css';
const { Text } = Typography;
interface MessageItemProps {
message: Message;
isOwn: boolean;
}
export default function MessageItem({ message, isOwn }: MessageItemProps) {
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
// 如果是今天,显示时间
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
}
// 如果是昨天,显示"昨天"
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) {
return '昨天 ' + date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
}
// 其他显示日期和时间
return date.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className={`${styles.messageItem} ${isOwn ? styles.own : styles.other}`}>
{!isOwn && (
<Avatar
size="small"
className={styles.avatar}
style={{ backgroundColor: '#1890ff' }}
>
{message.sender.charAt(0).toUpperCase()}
</Avatar>
)}
<div className={styles.messageContent}>
{!isOwn && (
<div className={styles.senderInfo}>
<div className={styles.senderName}>
{message.sender}
</div>
</div>
)}
{isOwn && (
<div className={styles.senderInfo}>
<Avatar
size="small"
className={styles.avatar}
style={{ backgroundColor: '#52c41a' }}
>
{message.sender.charAt(0).toUpperCase()}
</Avatar>
<div className={styles.senderName}>
{message.sender}
</div>
</div>
)}
<div className={`${styles.messageBubble} ${isOwn ? styles.ownBubble : styles.otherBubble}`}>
<Text className={styles.messageText}>
{message.content}
</Text>
</div>
<div className={`${styles.messageTime} ${isOwn ? styles.ownTime : styles.otherTime}`}>
{formatTime(message.time)}
</div>
</div>
</div>
);
}
.roomList {
height: 100%;
display: flex;
flex-direction: column;
background: #fafafa;
}
.header {
padding: 16px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: flex-start;
background: #fff;
}
.userInfo {
flex: 1;
}
.userInfo h3 {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
.userDetails {
display: flex;
flex-direction: column;
gap: 2px;
}
.username {
font-size: 12px;
color: #1890ff;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
}
.adminBadge {
background: #ff4d4f;
color: white;
font-size: 10px;
padding: 1px 4px;
border-radius: 4px;
font-weight: normal;
}
.nickname {
font-size: 11px;
color: #8c8c8c;
}
.guestMode {
font-size: 11px;
color: #faad14;
font-style: italic;
}
.headerActions {
display: flex;
gap: 8px;
align-items: center;
}
.logoutButton {
color: #ff4d4f;
font-size: 12px;
}
.logoutButton:hover {
color: #ff7875;
}
.loading {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
.list {
flex: 1;
overflow-y: auto;
background: #fff;
}
.roomItem {
padding: 12px 16px !important;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid #f0f0f0;
}
.roomItem:hover {
background: #f5f5f5;
}
.roomItem.active {
background: #e6f7ff;
border-right: 3px solid #1890ff;
}
.roomItem .ant-list-item-meta-title {
margin-bottom: 4px !important;
font-size: 14px;
font-weight: 500;
color: #262626;
}
.roomItem .ant-list-item-meta-description {
color: #8c8c8c;
font-size: 12px;
}
.lastMessage {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
}
.sender {
color: #1890ff;
font-weight: 500;
white-space: nowrap;
}
.content {
color: #8c8c8c;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.deleteButton {
opacity: 0;
transition: opacity 0.2s ease;
}
.roomItem:hover .deleteButton {
opacity: 1;
}
.deleteButton:hover {
color: #ff4d4f;
background: #fff2f0;
}
'use client';
import React, { useState, useEffect } from 'react';
import useSWR, { mutate } from 'swr';
import { List, Button, Spin, Alert, Modal, Input, App } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { fetcher } from '../lib/fetcher';
import { RoomPreviewInfo, RoomAddArgs, RoomDeleteArgs } from '../types/chat';
import styles from './RoomList.module.css';
interface RoomListProps {
onSelectRoom: (roomId: number) => void;
currentRoomId: number | null;
nickname: string;
user?: any;
userRole?: 'guest' | 'user' | 'admin';
onLogout?: () => void;
}
export default function RoomList({ onSelectRoom, currentRoomId, nickname, user, userRole = 'guest', onLogout }: RoomListProps) {
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [newRoomName, setNewRoomName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [isClient, setIsClient] = useState(false);
const [roomNameError, setRoomNameError] = useState<string>('');
const { message, modal } = App.useApp();
// 客户端检测
useEffect(() => {
setIsClient(true);
}, []);
const { data, error, isLoading } = useSWR<{ rooms: RoomPreviewInfo[] }>('/api/room/list', fetcher, {
refreshInterval: 1000, // 每秒轮询一次
});
const rooms = data?.rooms || [];
const handleCreateRoom = async () => {
// 清除之前的错误
setRoomNameError('');
if (!newRoomName.trim()) {
setRoomNameError('房间名称不能为空');
return;
}
if (newRoomName.length < 2) {
setRoomNameError('房间名称至少需要2个字符');
return;
}
if (newRoomName.length > 50) {
setRoomNameError('房间名称不能超过50个字符');
return;
}
// 检查是否包含特殊字符
if (!/^[\u4e00-\u9fa5a-zA-Z0-9_\s]+$/.test(newRoomName)) {
setRoomNameError('房间名称只能包含中文、英文、数字、下划线和空格');
return;
}
setIsCreating(true);
try {
const args: RoomAddArgs = {
user: nickname,
roomName: newRoomName.trim(),
};
const response = await fetcher('/api/room/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
message.success('房间创建成功');
setIsCreateModalVisible(false);
setNewRoomName('');
setRoomNameError('');
// 刷新房间列表
mutate('/api/room/list');
// 自动进入新创建的房间
if (response.roomId) {
onSelectRoom(response.roomId);
}
} catch (error) {
console.error('创建房间失败:', error);
if (error instanceof Error) {
if (error.message.includes('房间名称已存在')) {
setRoomNameError('房间名称已存在,请选择其他名称');
} else {
setRoomNameError(error.message || '创建房间失败');
}
} else {
setRoomNameError('创建房间失败');
}
} finally {
setIsCreating(false);
}
};
const handleDeleteRoom = async (roomId: number, event: React.MouseEvent) => {
event.stopPropagation(); // 防止触发房间选择
// 权限检查
if (userRole === 'guest') {
message.error('游客无法删除房间');
return;
}
// 找到要删除的房间信息
const roomToDelete = rooms.find(room => room.roomId === roomId);
if (!roomToDelete) {
message.error('房间不存在');
return;
}
// 检查删除权限
const isCreator = roomToDelete.creator === nickname;
const isAdmin = userRole === 'admin';
if (!isCreator && !isAdmin) {
message.error('只有房间创建者或管理员可以删除房间');
return;
}
modal.confirm({
title: '确认删除',
content: `确定要删除房间"${roomToDelete.roomName}"吗?删除后无法恢复。`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
const args: RoomDeleteArgs = {
user: nickname,
roomId,
};
await fetcher('/api/room/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
message.success('房间删除成功');
// 如果删除的是当前房间,清空选择
if (currentRoomId === roomId) {
onSelectRoom(0);
}
// 刷新房间列表
mutate('/api/room/list');
} catch (error) {
console.error('删除房间失败:', error);
message.error('删除房间失败');
}
},
});
};
const handleRoomClick = (roomId: number) => {
onSelectRoom(roomId);
};
if (!isClient) {
return <div>加载中...</div>;
}
if (error) {
return (
<Alert
message="加载失败"
description="无法加载房间列表,请刷新页面重试"
type="error"
showIcon
/>
);
}
return (
<div className={styles.roomList}>
<div className={styles.header}>
<div className={styles.userInfo}>
<h3>聊天房间</h3>
{user && (
<div className={styles.userDetails}>
<span className={styles.username}>
{user.username}
{userRole === 'admin' && (
<span className={styles.adminBadge}>管理员</span>
)}
</span>
<span className={styles.nickname}>({user.nickname})</span>
</div>
)}
{!user && (
<div className={styles.userDetails}>
<span className={styles.guestMode}>游客模式</span>
<span className={styles.nickname}>({nickname})</span>
</div>
)}
</div>
<div className={styles.headerActions}>
{userRole !== 'guest' && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsCreateModalVisible(true)}
size="small"
>
创建房间
</Button>
)}
{onLogout && (
<Button
type="text"
size="small"
onClick={onLogout}
className={styles.logoutButton}
>
退出
</Button>
)}
</div>
</div>
{isLoading ? (
<div className={styles.loading}>
<Spin size="large" />
</div>
) : (
<List
className={styles.list}
dataSource={rooms}
renderItem={(room) => (
<List.Item
className={`${styles.roomItem} ${currentRoomId === room.roomId ? styles.active : ''}`}
onClick={() => handleRoomClick(room.roomId)}
actions={[
// 只有非游客用户才显示删除按钮
userRole !== 'guest' && (
<Button
key="delete"
type="text"
icon={<DeleteOutlined />}
size="small"
onClick={(e) => handleDeleteRoom(room.roomId, e)}
className={styles.deleteButton}
/>
),
].filter(Boolean)}
>
<List.Item.Meta
title={room.roomName}
description={
room.lastMessage ? (
<div className={styles.lastMessage}>
<span className={styles.sender}>{room.lastMessage.sender}:</span>
<span className={styles.content}>{room.lastMessage.content}</span>
</div>
) : (
'暂无消息'
)
}
/>
</List.Item>
)}
/>
)}
<Modal
title="创建新房间"
open={isCreateModalVisible}
onOk={handleCreateRoom}
onCancel={() => {
setIsCreateModalVisible(false);
setNewRoomName('');
setRoomNameError('');
}}
confirmLoading={isCreating}
okText="创建"
cancelText="取消"
>
<div className="space-y-4">
<div>
<Input
placeholder="请输入房间名称 (2-50字符,支持中文、英文、数字、下划线、空格)"
value={newRoomName}
onChange={(e) => {
setNewRoomName(e.target.value);
setRoomNameError(''); // 清除错误提示
}}
onPressEnter={handleCreateRoom}
autoFocus
maxLength={50}
showCount
status={roomNameError ? 'error' : undefined}
/>
{roomNameError && (
<div className="text-red-500 text-xs mt-1">
{roomNameError}
</div>
)}
</div>
</div>
</Modal>
</div>
);
}
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { JWTPayload } from '@/types/auth';
// 获取环境变量
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-key';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'fallback-refresh-secret-key';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m';
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '7d';
const BCRYPT_SALT_ROUNDS = parseInt(process.env.BCRYPT_SALT_ROUNDS || '12');
/**
* 密码加密
* @param password 原始密码
* @returns 加密后的密码哈希
*/
export async function hashPassword(password: string): Promise<string> {
try {
const salt = await bcrypt.genSalt(BCRYPT_SALT_ROUNDS);
const hash = await bcrypt.hash(password, salt);
return hash;
} catch (error) {
console.error('密码加密失败:', error);
throw new Error('密码加密失败');
}
}
/**
* 密码验证
* @param password 原始密码
* @param hash 加密后的密码哈希
* @returns 是否匹配
*/
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
try {
return await bcrypt.compare(password, hash);
} catch (error) {
console.error('密码验证失败:', error);
return false;
}
}
/**
* 生成访问令牌
* @param payload JWT载荷
* @returns 访问令牌
*/
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
try {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
issuer: 'chatroom-app',
audience: 'chatroom-users'
} as jwt.SignOptions);
} catch (error) {
console.error('生成访问令牌失败:', error);
throw new Error('生成访问令牌失败');
}
}
/**
* 生成刷新令牌
* @param userId 用户ID
* @returns 刷新令牌
*/
export function generateRefreshToken(userId: string): string {
try {
return jwt.sign(
{ userId, type: 'refresh' },
JWT_REFRESH_SECRET,
{
expiresIn: JWT_REFRESH_EXPIRES_IN,
issuer: 'chatroom-app',
audience: 'chatroom-users'
} as jwt.SignOptions
);
} catch (error) {
console.error('生成刷新令牌失败:', error);
throw new Error('生成刷新令牌失败');
}
}
/**
* 验证访问令牌
* @param token 访问令牌
* @returns JWT载荷或null
*/
export function verifyAccessToken(token: string): JWTPayload | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, {
issuer: 'chatroom-app',
audience: 'chatroom-users'
}) as JWTPayload;
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
console.log('访问令牌已过期');
} else if (error instanceof jwt.JsonWebTokenError) {
console.log('访问令牌无效');
} else {
console.error('验证访问令牌失败:', error);
}
return null;
}
}
/**
* 验证刷新令牌
* @param token 刷新令牌
* @returns 用户ID或null
*/
export function verifyRefreshToken(token: string): string | null {
try {
const decoded = jwt.verify(token, JWT_REFRESH_SECRET, {
issuer: 'chatroom-app',
audience: 'chatroom-users'
}) as { userId: string; type: string };
if (decoded.type !== 'refresh') {
console.log('令牌类型错误');
return null;
}
return decoded.userId;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
console.log('刷新令牌已过期');
} else if (error instanceof jwt.JsonWebTokenError) {
console.log('刷新令牌无效');
} else {
console.error('验证刷新令牌失败:', error);
}
return null;
}
}
/**
* 从请求头中提取令牌
* @param authorizationHeader Authorization请求头
* @returns 令牌或null
*/
export function extractTokenFromHeader(authorizationHeader: string | null): string | null {
if (!authorizationHeader) {
return null;
}
const parts = authorizationHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return null;
}
return parts[1];
}
/**
* 生成随机密码
* @param length 密码长度
* @returns 随机密码
*/
export function generateRandomPassword(length: number = 12): string {
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
let password = '';
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * charset.length);
password += charset[randomIndex];
}
return password;
}
import { PrismaClient } from '@prisma/client';
// 全局变量声明,用于开发环境的热重载
declare global {
var __prisma: PrismaClient | undefined;
}
// 创建 Prisma 客户端实例
export const prisma = globalThis.__prisma || new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
// 开发环境下保存实例到全局变量
if (process.env.NODE_ENV === 'development') {
globalThis.__prisma = prisma;
}
// 数据库连接测试
export async function testDatabaseConnection() {
try {
await prisma.$connect();
console.log('✅ 数据库连接成功');
return true;
} catch (error) {
console.error('❌ 数据库连接失败:', error);
return false;
}
}
// 优雅关闭数据库连接
export async function closeDatabaseConnection() {
try {
await prisma.$disconnect();
console.log('✅ 数据库连接已关闭');
} catch (error) {
console.error('❌ 关闭数据库连接失败:', error);
}
}
// 数据库健康检查
export async function checkDatabaseHealth() {
try {
// 执行简单查询测试连接
await prisma.$queryRaw`SELECT 1`;
return {
status: 'healthy',
timestamp: new Date().toISOString(),
message: '数据库连接正常'
};
} catch (error) {
return {
status: 'unhealthy',
timestamp: new Date().toISOString(),
message: '数据库连接异常',
error: error instanceof Error ? error.message : '未知错误'
};
}
}
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
export const fetcher = async (path: string, args?: RequestInit) => {
const url = `${API_BASE_URL}${path}`;
try {
const res = await fetch(url, {
...args,
headers: {
'Content-Type': 'application/json',
...args?.headers,
},
});
if (!res.ok && res.status !== 201 && res.status !== 409 && res.status !== 401) {
const error = new Error(`HTTP error! status: ${res.status}`);
(error as Error & { status?: number }).status = res.status;
throw error;
}
const json = await res.json();
if (json.code !== 0) {
const error = new Error(json.message || 'API返回错误');
(error as Error & { info?: unknown; code?: number }).info = json.data;
(error as Error & { info?: unknown; code?: number }).code = json.code;
throw error;
}
return json.data;
} catch (error: unknown) {
// 如果是网络错误
if (error instanceof Error && error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('网络连接失败,请检查网络连接');
}
// 如果是API错误
if (error instanceof Error && (error as Error & { code?: number }).code !== undefined) {
throw error;
}
// 其他错误
if (error instanceof Error) {
throw new Error(error.message || '请求失败');
}
throw new Error('未知错误');
}
};
import { EventEmitter } from 'events';
// 每个房间一个事件总线,避免全局广播
const roomEmitters: Map<number, EventEmitter> = new Map();
function getRoomEmitter(roomId: number): EventEmitter {
let emitter = roomEmitters.get(roomId);
if (!emitter) {
emitter = new EventEmitter();
// 避免内存泄漏警告(聊天室场景允许更多监听者)
emitter.setMaxListeners(1000);
roomEmitters.set(roomId, emitter);
}
return emitter;
}
export type SseMessage = {
type: 'message' | 'system';
payload: unknown;
};
export function publishToRoom(roomId: number, data: SseMessage) {
const emitter = getRoomEmitter(roomId);
emitter.emit('event', data);
}
export function subscribeRoom(roomId: number, onEvent: (data: SseMessage) => void) {
const emitter = getRoomEmitter(roomId);
const handler = (data: SseMessage) => onEvent(data);
emitter.on('event', handler);
return () => {
emitter.off('event', handler);
};
}
// 用户认证相关类型定义
// 用户基础信息
export interface User {
id: string;
username: string;
email: string;
nickname: string;
avatar?: string;
status: UserStatus;
createdAt: Date;
updatedAt: Date;
}
// 用户状态枚举
export enum UserStatus {
ONLINE = 'ONLINE',
OFFLINE = 'OFFLINE',
AWAY = 'AWAY'
}
// 用户注册请求
export interface RegisterRequest {
username: string;
email: string;
password: string;
nickname: string;
}
// 用户登录请求
export interface LoginRequest {
username: string;
password: string;
}
// 认证响应
export interface AuthResponse {
user: User;
accessToken: string;
refreshToken: string;
}
// JWT 载荷
export interface JWTPayload {
userId: string;
username: string;
email: string;
iat: number;
exp: number;
}
// 刷新令牌
export interface RefreshToken {
token: string;
userId: string;
expiresAt: Date;
}
// API 响应格式
export interface ApiResponse<T = unknown> {
message: string;
code: number;
data: T | null;
}
// 错误响应
export interface ErrorResponse {
message: string;
code: number;
error: string;
details?: Record<string, unknown>;
}
// 聊天相关类型定义 - 与现有接口保持一致
// 用户角色枚举
export enum UserRole {
ADMIN = 'ADMIN',
USER = 'USER'
}
// 用户状态枚举
export enum UserStatus {
ONLINE = 'ONLINE',
OFFLINE = 'OFFLINE',
AWAY = 'AWAY'
}
// 用户信息
export interface User {
id: string;
username: string;
email: string;
nickname: string;
avatar?: string;
status: UserStatus;
role: UserRole;
createdAt: Date;
updatedAt: Date;
}
// 消息类型
export interface Message {
messageId: number; // 消息 id
roomId: number; // 房间 id
sender: string; // 发送人的 username
content: string; // 消息内容
time: number; // 消息发送时间戳
}
// 房间信息
export interface Room {
roomId: number; // 房间 id
roomName: string; // 房间名称
creator: string; // 创建者
createdAt: Date;
updatedAt: Date;
}
// 房间预览信息
export interface RoomPreviewInfo {
roomId: number;
roomName: string;
lastMessage: Message | null;
}
// API 接口参数类型
export interface RoomAddArgs {
user: string;
roomName: string;
}
export interface RoomAddRes {
roomId: number;
}
export interface RoomListRes {
rooms: RoomPreviewInfo[];
}
export interface RoomDeleteArgs {
user: string;
roomId: number;
}
export interface MessageAddArgs {
roomId: number;
content: string;
sender: string;
}
export interface RoomMessageListArgs {
roomId: number;
}
export interface RoomMessageListRes {
messages: Message[];
}
export interface RoomMessageGetUpdateArgs {
roomId: number;
sinceMessageId: number;
}
export interface RoomMessageGetUpdateRes {
messages: Message[];
}
{
"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"]
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment