引言:为什么需要一个个性化留言板?
在当今的互联网环境中,用户互动是网站和应用成功的关键。无论是个人博客、企业官网还是社区论坛,一个功能完善、个性化的留言板都能显著提升用户参与度和粘性。传统的留言板往往功能单一、界面陈旧,无法满足现代用户对个性化、即时互动的需求。
本文将从零开始,教你如何搭建一个功能丰富、可定制化的个性化留言系统。我们将使用现代Web技术栈(Node.js + Express + MongoDB + React)来构建这个系统,确保它既易于扩展又具备良好的用户体验。通过这个教程,你将学会:
- 设计留言系统的数据结构和API接口
- 实现用户认证和权限管理
- 构建响应式的前端界面
- 添加实时通知和互动功能
- 部署和优化你的留言系统
第一部分:系统设计与技术选型
1.1 系统架构设计
一个完整的留言板系统通常包含以下几个核心模块:
- 用户模块:注册、登录、个人资料管理
- 留言模块:创建、编辑、删除、回复留言
- 互动模块:点赞、评论、@提及
- 通知模块:实时消息推送
- 管理模块:后台管理、数据统计
我们将采用前后端分离的架构:
前端 (React) → API网关 → 后端服务 (Node.js/Express) → 数据库 (MongoDB)
1.2 技术栈选择
- 前端:React 18 + TypeScript + Tailwind CSS
- 后端:Node.js 18 + Express 4 + MongoDB + Mongoose
- 实时通信:Socket.io
- 部署:Docker + Nginx
1.3 数据库设计
使用MongoDB作为文档型数据库,设计以下集合:
// 用户集合 (users)
{
_id: ObjectId,
username: String, // 用户名
email: String, // 邮箱
password: String, // 加密后的密码
avatar: String, // 头像URL
bio: String, // 个人简介
createdAt: Date,
updatedAt: Date
}
// 留言集合 (messages)
{
_id: ObjectId,
userId: ObjectId, // 发布者ID
content: String, // 留言内容
parentId: ObjectId, // 父留言ID(用于回复)
likes: [ObjectId], // 点赞用户ID数组
mentions: [ObjectId], // @提及的用户ID数组
createdAt: Date,
updatedAt: Date
}
// 通知集合 (notifications)
{
_id: ObjectId,
userId: ObjectId, // 接收者ID
type: String, // 通知类型(like, reply, mention)
sourceId: ObjectId, // 来源ID(留言ID)
read: Boolean, // 是否已读
createdAt: Date
}
第二部分:后端开发
2.1 项目初始化
首先创建项目目录并初始化Node.js项目:
mkdir message-board-backend
cd message-board-backend
npm init -y
npm install express mongoose bcryptjs jsonwebtoken socket.io cors dotenv
npm install --save-dev nodemon typescript @types/node @types/express
创建项目结构:
message-board-backend/
├── src/
│ ├── config/ # 配置文件
│ ├── controllers/ # 控制器
│ ├── models/ # 数据模型
│ ├── routes/ # 路由
│ ├── middleware/ # 中间件
│ ├── utils/ # 工具函数
│ └── app.ts # 应用入口
├── .env # 环境变量
└── package.json
2.2 数据库连接与模型定义
创建数据库连接配置:
// src/config/database.ts
import mongoose from 'mongoose';
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI as string);
console.log(`MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
console.error('Database connection error:', error);
process.exit(1);
}
};
export default connectDB;
定义用户模型:
// src/models/User.ts
import mongoose, { Schema, Document } from 'mongoose';
import bcrypt from 'bcryptjs';
export interface IUser extends Document {
username: string;
email: string;
password: string;
avatar: string;
bio: string;
comparePassword(candidatePassword: string): Promise<boolean>;
}
const UserSchema: Schema = new Schema({
username: { type: String, required: true, unique: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, required: true },
avatar: {
type: String,
default: 'https://api.dicebear.com/7.x/avataaars/svg?seed='
},
bio: { type: String, default: '' },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
// 密码加密中间件
UserSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
// 密码比较方法
UserSchema.methods.comparePassword = async function(candidatePassword: string) {
return await bcrypt.compare(candidatePassword, this.password);
};
export default mongoose.model<IUser>('User', UserSchema);
定义留言模型:
// src/models/Message.ts
import mongoose, { Schema, Document } from 'mongoose';
export interface IMessage extends Document {
userId: mongoose.Types.ObjectId;
content: string;
parentId?: mongoose.Types.ObjectId;
likes: mongoose.Types.ObjectId[];
mentions: mongoose.Types.ObjectId[];
createdAt: Date;
updatedAt: Date;
}
const MessageSchema: Schema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true },
content: { type: String, required: true, trim: true },
parentId: { type: Schema.Types.ObjectId, ref: 'Message' },
likes: [{ type: Schema.Types.ObjectId, ref: 'User' }],
mentions: [{ type: Schema.Types.ObjectId, ref: 'User' }],
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
// 索引优化
MessageSchema.index({ parentId: 1 });
MessageSchema.index({ createdAt: -1 });
MessageSchema.index({ userId: 1 });
export default mongoose.model<IMessage>('Message', MessageSchema);
2.3 用户认证中间件
创建JWT认证中间件:
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface AuthenticatedRequest extends Request {
user?: any;
}
export const authenticateToken = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ message: 'Access token required' });
}
jwt.verify(token, process.env.JWT_SECRET as string, (err, user) => {
if (err) {
return res.status(403).json({ message: 'Invalid token' });
}
req.user = user;
next();
});
};
2.4 控制器实现
用户控制器:
// src/controllers/userController.ts
import { Request, Response } from 'express';
import User from '../models/User';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
// 用户注册
export const register = async (req: Request, res: Response) => {
try {
const { username, email, password } = req.body;
// 检查用户是否已存在
const existingUser = await User.findOne({ $or: [{ email }, { username }] });
if (existingUser) {
return res.status(400).json({
message: '用户已存在'
});
}
// 创建新用户
const user = new User({ username, email, password });
await user.save();
// 生成JWT
const token = jwt.sign(
{ userId: user._id, username: user.username },
process.env.JWT_SECRET as string,
{ expiresIn: '7d' }
);
res.status(201).json({
message: '注册成功',
token,
user: {
id: user._id,
username: user.username,
email: user.email,
avatar: user.avatar
}
});
} catch (error) {
res.status(500).json({ message: '服务器错误', error });
}
};
// 用户登录
export const login = async (req: Request, res: Response) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ message: '邮箱或密码错误' });
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({ message: '邮箱或密码错误' });
}
const token = jwt.sign(
{ userId: user._id, username: user.username },
process.env.JWT_SECRET as string,
{ expiresIn: '7d' }
);
res.json({
message: '登录成功',
token,
user: {
id: user._id,
username: user.username,
email: user.email,
avatar: user.avatar
}
});
} catch (error) {
res.status(500).json({ message: '服务器错误', error });
}
};
// 获取用户信息
export const getUserProfile = async (req: AuthenticatedRequest, res: Response) => {
try {
const user = await User.findById(req.user.userId).select('-password');
if (!user) {
return res.status(404).json({ message: '用户不存在' });
}
res.json(user);
} catch (error) {
res.status(500).json({ message: '服务器错误', error });
}
};
留言控制器:
// src/controllers/messageController.ts
import { Request, Response } from 'express';
import Message from '../models/Message';
import User from '../models/User';
import { AuthenticatedRequest } from '../middleware/auth';
import Notification from '../models/Notification';
// 创建留言
export const createMessage = async (req: AuthenticatedRequest, res: Response) => {
try {
const { content, parentId } = req.body;
const userId = req.user.userId;
// 解析@提及
const mentions = this.parseMentions(content);
const message = new Message({
userId,
content,
parentId: parentId || null,
mentions
});
await message.save();
// 如果是回复,创建通知
if (parentId) {
const parentMessage = await Message.findById(parentId);
if (parentMessage && parentMessage.userId.toString() !== userId) {
await Notification.create({
userId: parentMessage.userId,
type: 'reply',
sourceId: message._id,
read: false
});
}
}
// 如果有@提及,创建通知
if (mentions.length > 0) {
for (const mentionId of mentions) {
if (mentionId.toString() !== userId) {
await Notification.create({
userId: mentionId,
type: 'mention',
sourceId: message._id,
read: false
});
}
}
}
// 获取完整消息数据(包含用户信息)
const populatedMessage = await Message.findById(message._id)
.populate('userId', 'username avatar')
.populate('mentions', 'username');
res.status(201).json(populatedMessage);
} catch (error) {
res.status(500).json({ message: '服务器错误', error });
}
};
// 解析@提及的辅助函数
const parseMentions = (content: string): mongoose.Types.ObjectId[] => {
const mentionRegex = /@(\w+)/g;
const mentions: mongoose.Types.ObjectId[] = [];
let match;
while ((match = mentionRegex.exec(content)) !== null) {
const username = match[1];
const user = await User.findOne({ username });
if (user) {
mentions.push(user._id);
}
}
return mentions;
};
// 获取留言列表
export const getMessages = async (req: Request, res: Response) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
const skip = (page - 1) * limit;
// 获取根留言(非回复)
const messages = await Message.find({ parentId: null })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.populate('userId', 'username avatar')
.populate({
path: 'replies',
options: { sort: { createdAt: 1 } },
populate: { path: 'userId', select: 'username avatar' }
});
const total = await Message.countDocuments({ parentId: null });
res.json({
messages,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
res.status(500).json({ message: '服务器错误', error });
}
};
// 点赞/取消点赞
export const toggleLike = async (req: AuthenticatedRequest, res: Response) => {
try {
const { messageId } = req.params;
const userId = req.user.userId;
const message = await Message.findById(messageId);
if (!message) {
return res.status(404).json({ message: '留言不存在' });
}
const likeIndex = message.likes.indexOf(userId);
if (likeIndex > -1) {
// 取消点赞
message.likes.splice(likeIndex, 1);
} else {
// 点赞
message.likes.push(userId);
// 如果不是自己给自己点赞,创建通知
if (message.userId.toString() !== userId) {
await Notification.create({
userId: message.userId,
type: 'like',
sourceId: message._id,
read: false
});
}
}
await message.save();
res.json({ likes: message.likes.length });
} catch (error) {
res.status(500).json({ message: '服务器错误', error });
}
};
2.5 路由配置
// src/routes/userRoutes.ts
import express from 'express';
import { register, login, getUserProfile } from '../controllers/userController';
import { authenticateToken } from '../middleware/auth';
const router = express.Router();
router.post('/register', register);
router.post('/login', login);
router.get('/profile', authenticateToken, getUserProfile);
export default router;
// src/routes/messageRoutes.ts
import express from 'express';
import {
createMessage,
getMessages,
toggleLike
} from '../controllers/messageController';
import { authenticateToken } from '../middleware/auth';
const router = express.Router();
router.post('/', authenticateToken, createMessage);
router.get('/', getMessages);
router.post('/:messageId/like', authenticateToken, toggleLike);
export default router;
2.6 应用入口与Socket.io集成
// src/app.ts
import express from 'express';
import http from 'http';
import cors from 'cors';
import dotenv from 'dotenv';
import { Server } from 'socket.io';
import connectDB from './config/database';
import userRoutes from './routes/userRoutes';
import messageRoutes from './routes/messageRoutes';
import notificationRoutes from './routes/notificationRoutes';
dotenv.config();
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
methods: ['GET', 'POST']
}
});
// 中间件
app.use(cors());
app.use(express.json());
// 路由
app.use('/api/users', userRoutes);
app.use('/api/messages', messageRoutes);
app.use('/api/notifications', notificationRoutes);
// Socket.io 实时通信
io.on('connection', (socket) => {
console.log('用户连接:', socket.id);
// 加入房间(基于用户ID)
socket.on('join', (userId) => {
socket.join(userId);
console.log(`用户 ${userId} 加入房间`);
});
// 处理新留言
socket.on('newMessage', (message) => {
// 广播给所有连接的用户
io.emit('messageAdded', message);
// 如果有@提及,发送给特定用户
if (message.mentions && message.mentions.length > 0) {
message.mentions.forEach((userId: string) => {
io.to(userId).emit('mentionNotification', {
message,
type: 'mention'
});
});
}
});
// 处理点赞
socket.on('like', (data) => {
io.emit('likeUpdated', data);
});
socket.on('disconnect', () => {
console.log('用户断开连接:', socket.id);
});
});
// 连接数据库
connectDB();
const PORT = process.env.PORT || 5000;
server.listen(PORT, () => {
console.log(`服务器运行在端口 ${PORT}`);
});
第三部分:前端开发
3.1 项目初始化
使用Create React App创建前端项目:
npx create-react-app message-board-frontend --template typescript
cd message-board-frontend
npm install axios socket.io-client react-router-dom tailwindcss
npm install --save-dev @types/socket.io-client
配置Tailwind CSS:
npx tailwindcss init -p
修改 tailwind.config.js:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
在 src/index.css 中添加:
@tailwind base;
@tailwind components;
@tailwind utilities;
3.2 项目结构
message-board-frontend/
├── src/
│ ├── components/ # 可复用组件
│ ├── pages/ # 页面组件
│ ├── hooks/ # 自定义Hook
│ ├── services/ # API服务
│ ├── types/ # TypeScript类型定义
│ ├── utils/ # 工具函数
│ ├── App.tsx # 应用入口
│ └── index.tsx
3.3 类型定义
// src/types/index.ts
export interface User {
id: string;
username: string;
email: string;
avatar: string;
bio?: string;
}
export interface Message {
_id: string;
userId: User;
content: string;
parentId?: string;
likes: string[];
mentions: User[];
replies?: Message[];
createdAt: string;
updatedAt: string;
}
export interface Notification {
_id: string;
userId: string;
type: 'like' | 'reply' | 'mention';
sourceId: string;
read: boolean;
createdAt: string;
}
export interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
}
3.4 API服务层
// src/services/api.ts
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器,添加JWT
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器,处理错误
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token过期或无效,清除本地存储并重定向到登录页
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export const authAPI = {
register: (data: { username: string; email: string; password: string }) =>
api.post('/users/register', data),
login: (data: { email: string; password: string }) =>
api.post('/users/login', data),
getProfile: () => api.get('/users/profile'),
};
export const messageAPI = {
create: (data: { content: string; parentId?: string }) =>
api.post('/messages', data),
getMessages: (page = 1, limit = 20) =>
api.get(`/messages?page=${page}&limit=${limit}`),
toggleLike: (messageId: string) =>
api.post(`/messages/${messageId}/like`),
};
export const notificationAPI = {
getNotifications: () => api.get('/notifications'),
markAsRead: (notificationId: string) =>
api.patch(`/notifications/${notificationId}/read`),
};
3.5 自定义Hook
// src/hooks/useAuth.ts
import { useState, useEffect, useContext, createContext } from 'react';
import { authAPI } from '../services/api';
import { User, AuthState } from '../types';
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string) => Promise<void>;
logout: () => void;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [authState, setAuthState] = useState<AuthState>({
user: null,
token: null,
isAuthenticated: false,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
// 检查本地存储中的token
const token = localStorage.getItem('token');
const userStr = localStorage.getItem('user');
if (token && userStr) {
const user = JSON.parse(userStr);
setAuthState({
user,
token,
isAuthenticated: true,
});
}
setLoading(false);
}, []);
const login = async (email: string, password: string) => {
setLoading(true);
try {
const response = await authAPI.login({ email, password });
const { token, user } = response.data;
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
setAuthState({
user,
token,
isAuthenticated: true,
});
} catch (error) {
console.error('登录失败:', error);
throw error;
} finally {
setLoading(false);
}
};
const register = async (username: string, email: string, password: string) => {
setLoading(true);
try {
const response = await authAPI.register({ username, email, password });
const { token, user } = response.data;
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
setAuthState({
user,
token,
isAuthenticated: true,
});
} catch (error) {
console.error('注册失败:', error);
throw error;
} finally {
setLoading(false);
}
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setAuthState({
user: null,
token: null,
isAuthenticated: false,
});
};
return (
<AuthContext.Provider value={{
user: authState.user,
isAuthenticated: authState.isAuthenticated,
login,
register,
logout,
loading,
}}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth必须在AuthProvider内使用');
}
return context;
};
3.6 主要页面组件
登录/注册页面
// src/pages/AuthPage.tsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
const AuthPage: React.FC = () => {
const [isLogin, setIsLogin] = useState(true);
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login, register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
if (isLogin) {
await login(formData.email, formData.password);
} else {
await register(formData.username, formData.email, formData.password);
}
navigate('/');
} catch (err: any) {
setError(err.response?.data?.message || '操作失败');
} finally {
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-lg w-full max-w-md">
<h2 className="text-2xl font-bold text-center mb-6">
{isLogin ? '登录' : '注册'}
</h2>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
{!isLogin && (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
用户名
</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
)}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
邮箱
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2">
密码
</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<button
type="submit"
disabled={loading}
className={`w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition ${
loading ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{loading ? '处理中...' : (isLogin ? '登录' : '注册')}
</button>
</form>
<div className="mt-4 text-center">
<button
onClick={() => setIsLogin(!isLogin)}
className="text-blue-500 hover:text-blue-700"
>
{isLogin ? '没有账号?立即注册' : '已有账号?立即登录'}
</button>
</div>
</div>
</div>
);
};
export default AuthPage;
留言板主页面
// src/pages/MessageBoardPage.tsx
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../hooks/useAuth';
import { messageAPI } from '../services/api';
import { Message } from '../types';
import MessageCard from '../components/MessageCard';
import MessageForm from '../components/MessageForm';
import { io, Socket } from 'socket.io-client';
const MessageBoardPage: React.FC = () => {
const { user, isAuthenticated } = useAuth();
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [socket, setSocket] = useState<Socket | null>(null);
const [notification, setNotification] = useState<string | null>(null);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
// 初始化Socket连接
const newSocket = io(process.env.REACT_APP_API_URL || 'http://localhost:5000');
socketRef.current = newSocket;
setSocket(newSocket);
// 加入用户房间
if (user?.id) {
newSocket.emit('join', user.id);
}
// 监听新留言
newSocket.on('messageAdded', (newMessage: Message) => {
setMessages((prev) => [newMessage, ...prev]);
showNotification('新留言已添加');
});
// 监听点赞更新
newSocket.on('likeUpdated', (data: { messageId: string; likes: number }) => {
setMessages((prev) =>
prev.map((msg) =>
msg._id === data.messageId
? { ...msg, likes: Array(data.likes).fill('') }
: msg
)
);
});
// 监听@提及通知
newSocket.on('mentionNotification', (data) => {
showNotification(`@${data.message.userId.username} 提及了你`);
});
return () => {
newSocket.disconnect();
};
}, [user]);
useEffect(() => {
loadMessages();
}, []);
const loadMessages = async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const response = await messageAPI.getMessages(page, 10);
const { messages: newMessages, pagination } = response.data;
setMessages((prev) => [...prev, ...newMessages]);
setPage(page + 1);
setHasMore(pagination.page < pagination.pages);
} catch (error) {
console.error('加载留言失败:', error);
} finally {
setLoading(false);
}
};
const handleNewMessage = (newMessage: Message) => {
setMessages((prev) => [newMessage, ...prev]);
// 通过Socket发送新留言
if (socket) {
socket.emit('newMessage', newMessage);
}
};
const handleLike = async (messageId: string) => {
try {
const response = await messageAPI.toggleLike(messageId);
const likesCount = response.data.likes;
// 更新本地状态
setMessages((prev) =>
prev.map((msg) =>
msg._id === messageId
? { ...msg, likes: Array(likesCount).fill('') }
: msg
)
);
// 通过Socket发送点赞更新
if (socket) {
socket.emit('like', { messageId, likes: likesCount });
}
} catch (error) {
console.error('点赞失败:', error);
}
};
const showNotification = (message: string) => {
setNotification(message);
setTimeout(() => setNotification(null), 3000);
};
return (
<div className="min-h-screen bg-gray-50">
{/* 通知提示 */}
{notification && (
<div className="fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg z-50 animate-fade-in">
{notification}
</div>
)}
<div className="max-w-3xl mx-auto p-4">
<header className="mb-8">
<h1 className="text-3xl font-bold text-gray-800">留言板</h1>
<p className="text-gray-600 mt-2">
{isAuthenticated
? `欢迎回来,${user?.username}!`
: '请先登录以参与互动'}
</p>
</header>
{/* 留言表单 */}
{isAuthenticated && (
<div className="mb-8">
<MessageForm
onSubmit={handleNewMessage}
parentId={null}
/>
</div>
)}
{/* 留言列表 */}
<div className="space-y-4">
{messages.map((message) => (
<MessageCard
key={message._id}
message={message}
onLike={handleLike}
isAuthenticated={isAuthenticated}
onReply={(parentId) => {
// 滚动到回复表单
const replyForm = document.getElementById(`reply-form-${parentId}`);
if (replyForm) {
replyForm.scrollIntoView({ behavior: 'smooth' });
}
}}
/>
))}
</div>
{/* 加载更多 */}
{hasMore && (
<div className="text-center mt-6">
<button
onClick={loadMessages}
disabled={loading}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
{loading ? '加载中...' : '加载更多'}
</button>
</div>
)}
{!loading && messages.length === 0 && (
<div className="text-center py-12 text-gray-500">
<p>暂无留言,快来发表你的第一条评论吧!</p>
</div>
)}
</div>
</div>
);
};
export default MessageBoardPage;
3.7 可复用组件
留言卡片组件
// src/components/MessageCard.tsx
import React, { useState } from 'react';
import { Message } from '../types';
import MessageForm from './MessageForm';
interface MessageCardProps {
message: Message;
onLike: (messageId: string) => void;
isAuthenticated: boolean;
onReply: (parentId: string) => void;
}
const MessageCard: React.FC<MessageCardProps> = ({
message,
onLike,
isAuthenticated,
onReply,
}) => {
const [showReplyForm, setShowReplyForm] = useState(false);
const [showReplies, setShowReplies] = useState(false);
const handleLikeClick = () => {
if (!isAuthenticated) {
alert('请先登录才能点赞');
return;
}
onLike(message._id);
};
const handleReplyClick = () => {
if (!isAuthenticated) {
alert('请先登录才能回复');
return;
}
setShowReplyForm(!showReplyForm);
onReply(message._id);
};
const handleNewReply = (newReply: Message) => {
// 更新本地状态,添加新回复
// 这里需要更复杂的逻辑来更新嵌套的回复
setShowReplyForm(false);
};
return (
<div className="bg-white rounded-lg shadow-md p-4 border border-gray-100">
<div className="flex items-start space-x-3">
{/* 头像 */}
<div className="flex-shrink-0">
<img
src={message.userId.avatar}
alt={message.userId.username}
className="w-10 h-10 rounded-full object-cover"
/>
</div>
{/* 内容区域 */}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<span className="font-semibold text-gray-800">
{message.userId.username}
</span>
<span className="text-xs text-gray-500">
{new Date(message.createdAt).toLocaleString()}
</span>
</div>
<div className="mt-2 text-gray-700 whitespace-pre-wrap">
{message.content}
</div>
{/* @提及显示 */}
{message.mentions && message.mentions.length > 0 && (
<div className="mt-2 text-sm text-blue-600">
提及: {message.mentions.map(m => `@${m.username}`).join(' ')}
</div>
)}
{/* 操作按钮 */}
<div className="mt-3 flex items-center space-x-4 text-sm">
<button
onClick={handleLikeClick}
className={`flex items-center space-x-1 hover:text-red-500 transition ${
message.likes.includes('') ? 'text-red-500' : 'text-gray-500'
}`}
>
<span>❤️</span>
<span>{message.likes.length}</span>
</button>
<button
onClick={handleReplyClick}
className="text-gray-500 hover:text-blue-500 transition"
>
💬 回复
</button>
{message.replies && message.replies.length > 0 && (
<button
onClick={() => setShowReplies(!showReplies)}
className="text-gray-500 hover:text-blue-500 transition"
>
{showReplies ? '收起回复' : `查看 ${message.replies.length} 条回复`}
</button>
)}
</div>
{/* 回复表单 */}
{showReplyForm && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg" id={`reply-form-${message._id}`}>
<MessageForm
parentId={message._id}
onSubmit={handleNewReply}
onCancel={() => setShowReplyForm(false)}
/>
</div>
)}
{/* 回复列表 */}
{showReplies && message.replies && (
<div className="mt-4 space-y-3 pl-4 border-l-2 border-gray-200">
{message.replies.map((reply) => (
<MessageCard
key={reply._id}
message={reply}
onLike={onLike}
isAuthenticated={isAuthenticated}
onReply={onReply}
/>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default MessageCard;
留言表单组件
// src/components/MessageForm.tsx
import React, { useState, useEffect, useRef } from 'react';
import { messageAPI } from '../services/api';
import { Message } from '../types';
interface MessageFormProps {
parentId?: string | null;
onSubmit: (message: Message) => void;
onCancel?: () => void;
initialContent?: string;
}
const MessageForm: React.FC<MessageFormProps> = ({
parentId,
onSubmit,
onCancel,
initialContent = '',
}) => {
const [content, setContent] = useState(initialContent);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
// 自动聚焦
if (textareaRef.current) {
textareaRef.current.focus();
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!content.trim()) {
setError('留言内容不能为空');
return;
}
setLoading(true);
try {
const response = await messageAPI.create({
content,
parentId: parentId || undefined,
});
setContent('');
onSubmit(response.data);
if (onCancel) {
onCancel();
}
} catch (err: any) {
setError(err.response?.data?.message || '提交失败');
} finally {
setLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Ctrl+Enter 提交
if (e.ctrlKey && e.key === 'Enter') {
handleSubmit(e);
}
};
return (
<div className="bg-white rounded-lg shadow-md p-4 border border-gray-100">
<form onSubmit={handleSubmit}>
<div className="mb-3">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={parentId ? '回复留言...' : '分享你的想法...'}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
rows={3}
maxLength={500}
/>
<div className="text-right text-sm text-gray-500 mt-1">
{content.length}/500
</div>
</div>
{error && (
<div className="text-red-500 text-sm mb-2">{error}</div>
)}
<div className="flex justify-end space-x-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition"
>
取消
</button>
)}
<button
type="submit"
disabled={loading}
className={`px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition ${
loading ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{loading ? '提交中...' : parentId ? '回复' : '发布'}
</button>
</div>
</form>
</div>
);
};
export default MessageForm;
3.8 应用路由配置
// src/App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './hooks/useAuth';
import AuthPage from './pages/AuthPage';
import MessageBoardPage from './pages/MessageBoardPage';
import NotificationPage from './pages/NotificationPage';
import Navbar from './components/Navbar';
// 受保护的路由组件
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <div className="flex items-center justify-center min-h-screen">加载中...</div>;
}
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
};
function App() {
return (
<AuthProvider>
<Router>
<Navbar />
<Routes>
<Route path="/login" element={<AuthPage />} />
<Route path="/register" element={<AuthPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<MessageBoardPage />
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
<ProtectedRoute>
<NotificationPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Router>
</AuthProvider>
);
}
export default App;
第四部分:高级功能实现
4.1 实时通知系统
通知控制器:
// src/controllers/notificationController.ts
import { Request, Response } from 'express';
import Notification from '../models/Notification';
import { AuthenticatedRequest } from '../middleware/auth';
// 获取用户通知
export const getUserNotifications = async (req: AuthenticatedRequest, res: Response) => {
try {
const notifications = await Notification.find({ userId: req.user.userId })
.sort({ createdAt: -1 })
.limit(50)
.populate('sourceId', 'content userId')
.populate('sourceId.userId', 'username');
res.json(notifications);
} catch (error) {
res.status(500).json({ message: '服务器错误', error });
}
};
// 标记通知为已读
export const markAsRead = async (req: AuthenticatedRequest, res: Response) => {
try {
const { notificationId } = req.params;
const notification = await Notification.findOneAndUpdate(
{ _id: notificationId, userId: req.user.userId },
{ read: true },
{ new: true }
);
if (!notification) {
return res.status(404).json({ message: '通知不存在' });
}
res.json(notification);
} catch (error) {
res.status(500).json({ message: '服务器错误', error });
}
};
// 标记所有通知为已读
export const markAllAsRead = async (req: AuthenticatedRequest, res: Response) => {
try {
await Notification.updateMany(
{ userId: req.user.userId, read: false },
{ read: true }
);
res.json({ message: '所有通知已标记为已读' });
} catch (error) {
res.status(500).json({ message: '服务器错误', error });
}
};
4.2 搜索功能
// src/controllers/searchController.ts
import { Request, Response } from 'express';
import Message from '../models/Message';
import User from '../models/User';
export const searchMessages = async (req: Request, res: Response) => {
try {
const { q, page = 1, limit = 20 } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(limit as string);
if (!q) {
return res.status(400).json({ message: '搜索关键词不能为空' });
}
// 使用MongoDB的全文搜索(需要创建文本索引)
const messages = await Message.find(
{ $text: { $search: q as string } },
{ score: { $meta: 'textScore' } }
)
.sort({ score: { $meta: 'textScore' } })
.skip(skip)
.limit(parseInt(limit as string))
.populate('userId', 'username avatar');
const total = await Message.countDocuments({ $text: { $search: q as string } });
res.json({
messages,
pagination: {
page: parseInt(page as string),
limit: parseInt(limit as string),
total,
pages: Math.ceil(total / parseInt(limit as string))
}
});
} catch (error) {
res.status(500).json({ message: '服务器错误', error });
}
};
4.3 数据统计与分析
// src/controllers/analyticsController.ts
import { Request, Response } from 'express';
import Message from '../models/Message';
import User from '../models/User';
import Notification from '../models/Notification';
export const getDashboardStats = async (req: Request, res: Response) => {
try {
const totalUsers = await User.countDocuments();
const totalMessages = await Message.countDocuments();
const totalReplies = await Message.countDocuments({ parentId: { $ne: null } });
const totalNotifications = await Notification.countDocuments();
// 最近7天的留言趋势
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const recentMessages = await Message.aggregate([
{
$match: {
createdAt: { $gte: sevenDaysAgo }
}
},
{
$group: {
_id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } },
count: { $sum: 1 }
}
},
{ $sort: { _id: 1 } }
]);
// 最活跃用户
const activeUsers = await Message.aggregate([
{
$group: {
_id: '$userId',
messageCount: { $sum: 1 }
}
},
{ $sort: { messageCount: -1 } },
{ $limit: 5 },
{
$lookup: {
from: 'users',
localField: '_id',
foreignField: '_id',
as: 'user'
}
},
{ $unwind: '$user' },
{
$project: {
username: '$user.username',
avatar: '$user.avatar',
messageCount: 1
}
}
]);
res.json({
totalUsers,
totalMessages,
totalReplies,
totalNotifications,
recentMessages,
activeUsers
});
} catch (error) {
res.status(500).json({ message: '服务器错误', error });
}
};
第五部分:部署与优化
5.1 Docker化部署
创建Dockerfile:
# 后端Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 5000
CMD ["node", "dist/app.js"]
创建docker-compose.yml:
version: '3.8'
services:
mongodb:
image: mongo:6
container_name: message-board-mongo
ports:
- "27017:27017"
volumes:
- mongodb_data:/data/db
environment:
- MONGO_INITDB_ROOT_USERNAME=admin
- MONGO_INITDB_ROOT_PASSWORD=password
backend:
build: ./backend
container_name: message-board-backend
ports:
- "5000:5000"
environment:
- MONGODB_URI=mongodb://admin:password@mongodb:27017/messageboard
- JWT_SECRET=your-super-secret-jwt-key
- NODE_ENV=production
depends_on:
- mongodb
frontend:
build: ./frontend
container_name: message-board-frontend
ports:
- "3000:3000"
environment:
- REACT_APP_API_URL=http://localhost:5000/api
depends_on:
- backend
volumes:
mongodb_data:
5.2 性能优化
后端优化
- 缓存策略:
// src/utils/cache.ts
import NodeCache from 'node-cache';
const cache = new NodeCache({ stdTTL: 300 }); // 5分钟缓存
export const cacheMiddleware = (key: string, ttl: number = 300) => {
return async (req: Request, res: Response, next: NextFunction) => {
const cacheKey = `${key}:${JSON.stringify(req.query)}`;
const cached = cache.get(cacheKey);
if (cached) {
return res.json(cached);
}
// 重写res.json来缓存结果
const originalJson = res.json.bind(res);
res.json = function(data) {
cache.set(cacheKey, data, ttl);
return originalJson(data);
};
next();
};
};
- 数据库索引优化:
// 在模型定义中添加复合索引
MessageSchema.index({ userId: 1, createdAt: -1 });
MessageSchema.index({ parentId: 1, createdAt: -1 });
MessageSchema.index({ mentions: 1 });
前端优化
- 代码分割:
// 使用React.lazy进行代码分割
const NotificationPage = React.lazy(() => import('./pages/NotificationPage'));
// 在路由中使用Suspense
<Suspense fallback={<div>加载中...</div>}>
<NotificationPage />
</Suspense>
- 虚拟滚动:
// 使用react-window进行长列表优化
import { FixedSizeList as List } from 'react-window';
const MessageList: React.FC<{ messages: Message[] }> = ({ messages }) => {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
<MessageCard message={messages[index]} />
</div>
);
return (
<List
height={600}
itemCount={messages.length}
itemSize={150}
width="100%"
>
{Row}
</List>
);
};
5.3 安全加固
- 输入验证:
// src/middleware/validation.ts
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
export const validateMessage = [
body('content')
.trim()
.isLength({ min: 1, max: 500 })
.withMessage('留言内容长度必须在1-500字符之间')
.escape(), // 防止XSS攻击
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
}
];
- 速率限制:
// src/middleware/rateLimit.ts
import rateLimit from 'express-rate-limit';
export const createAccountLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 5, // 每个IP最多5次请求
message: '请求过于频繁,请稍后再试'
});
export const messageLimiter = rateLimit({
windowMs: 60 * 1000, // 1分钟
max: 10, // 每分钟最多10条留言
message: '留言过于频繁,请稍后再试'
});
第六部分:扩展与定制
6.1 主题定制系统
// src/contexts/ThemeContext.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
type Theme = 'light' | 'dark' | 'auto';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
isDark: boolean;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
// 从localStorage加载主题
const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme) {
setTheme(savedTheme);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('auto');
}
}, []);
useEffect(() => {
// 应用主题
const root = document.documentElement;
if (theme === 'auto') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.classList.toggle('dark', isDark);
} else {
root.classList.toggle('dark', theme === 'dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
const isDark = theme === 'dark' || (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
return (
<ThemeContext.Provider value={{ theme, setTheme, isDark }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme必须在ThemeProvider内使用');
}
return context;
};
6.2 插件系统架构
// src/plugins/PluginManager.ts
interface Plugin {
name: string;
version: string;
description: string;
init: (app: any) => void;
cleanup?: () => void;
}
class PluginManager {
private plugins: Map<string, Plugin> = new Map();
register(plugin: Plugin) {
if (this.plugins.has(plugin.name)) {
console.warn(`插件 ${plugin.name} 已存在`);
return;
}
this.plugins.set(plugin.name, plugin);
plugin.init(this);
}
unregister(pluginName: string) {
const plugin = this.plugins.get(pluginName);
if (plugin && plugin.cleanup) {
plugin.cleanup();
}
this.plugins.delete(pluginName);
}
// 示例:表情符号插件
static emojiPlugin: Plugin = {
name: 'emoji-plugin',
version: '1.0.0',
description: '添加表情符号支持',
init: (app) => {
// 扩展消息模型,添加emoji字段
app.MessageSchema.add({
emojis: [{ type: String }]
});
// 添加emoji选择器到前端
console.log('表情符号插件已初始化');
}
};
}
export default PluginManager;
第七部分:测试与监控
7.1 单元测试示例
// __tests__/messageController.test.ts
import request from 'supertest';
import app from '../src/app';
import mongoose from 'mongoose';
import User from '../src/models/User';
import Message from '../src/models/Message';
describe('Message Controller', () => {
let token: string;
let userId: string;
beforeAll(async () => {
await mongoose.connect(process.env.MONGODB_TEST_URI as string);
// 创建测试用户
const user = new User({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
});
await user.save();
userId = user._id.toString();
// 获取token
const response = await request(app)
.post('/api/users/login')
.send({ email: 'test@example.com', password: 'password123' });
token = response.body.token;
});
afterAll(async () => {
await mongoose.connection.close();
});
beforeEach(async () => {
await Message.deleteMany({});
});
test('创建留言', async () => {
const response = await request(app)
.post('/api/messages')
.set('Authorization', `Bearer ${token}`)
.send({ content: '测试留言' });
expect(response.status).toBe(201);
expect(response.body.content).toBe('测试留言');
expect(response.body.userId).toBe(userId);
});
test('获取留言列表', async () => {
// 先创建一些留言
await Message.create([
{ userId, content: '留言1' },
{ userId, content: '留言2' }
]);
const response = await request(app)
.get('/api/messages')
.query({ page: 1, limit: 10 });
expect(response.status).toBe(200);
expect(response.body.messages).toHaveLength(2);
expect(response.body.pagination.total).toBe(2);
});
test('点赞留言', async () => {
const message = await Message.create({ userId, content: '测试留言' });
const response = await request(app)
.post(`/api/messages/${message._id}/like`)
.set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(200);
expect(response.body.likes).toBe(1);
});
});
7.2 监控与日志
// src/utils/logger.ts
import winston from 'winston';
import path from 'path';
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
// 错误日志
new winston.transports.File({
filename: path.join(__dirname, '../../logs/error.log'),
level: 'error'
}),
// 所有日志
new winston.transports.File({
filename: path.join(__dirname, '../../logs/combined.log')
})
]
});
// 开发环境添加控制台输出
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
export default logger;
第八部分:总结与展望
通过本教程,我们从零开始构建了一个功能完整的个性化留言系统。这个系统包含了:
- 用户认证系统:安全的注册、登录和JWT令牌管理
- 留言核心功能:创建、回复、编辑、删除留言
- 互动功能:点赞、@提及、实时通知
- 高级功能:搜索、数据统计、主题定制
- 部署方案:Docker容器化部署
- 性能优化:缓存、索引、代码分割
- 安全措施:输入验证、速率限制、XSS防护
未来扩展方向
- AI集成:使用自然语言处理自动分类留言,智能回复建议
- 多媒体支持:图片、视频、文件上传
- 社交集成:分享到社交媒体,第三方登录
- 移动端优化:PWA应用,离线功能
- 数据分析:用户行为分析,情感分析
- 多语言支持:国际化(i18n)支持
部署检查清单
- [ ] 环境变量配置完成
- [ ] 数据库备份策略
- [ ] SSL证书配置
- [ ] 监控告警设置
- [ ] 性能基准测试
- [ ] 安全扫描
- [ ] 用户文档编写
这个留言系统不仅解决了用户互动的基本需求,还提供了丰富的扩展可能性。你可以根据具体需求,添加更多个性化功能,打造独一无二的互动体验。
记住,最好的系统是持续迭代和优化的系统。定期收集用户反馈,分析使用数据,不断改进你的留言系统,让它成为用户真正喜爱的互动平台。
