You need to sign in or sign up before continuing.
Commit c6f4ee01 authored by soilchalk's avatar soilchalk
Browse files

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

- 实现完整的全栈聊天室应用
- 支持用户认证、多房间聊天、实时消息推送
- 包含前端(Next.js)和后端(API Routes)代码
- 支持Docker部署和数据库管理
- 包含管理员功能和测试用户
parent dacd5539
# Dependencies
node_modules/
.pnpm-store/
.pnpm-debug.log*
# Production builds
.next/
out/
dist/
build/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Database
*.db
*.db-journal
prisma/dev.db*
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Editor directories and files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Docker
.dockerignore
# Testing
coverage/
# Misc
*.tgz
*.tar.gz
# 项目特定文件
# 完整的学习笔记和文档(保留精简版本)
note/
学习模型分析报告.md
DOCKER_DEMO.md
API_TESTING_GUIDE.md
demo-script.sh
.env.docker
# 前端文档
frontend/*.md
frontend/*.pdf
frontend/*.txt
!frontend/README.md
# 后端文档
fullstack/*.md
!fullstack/README.md
# 开发过程中的临时文件
*.tmp
*.temp
*.bak
*.backup
# 测试和演示文件
test-*.js
demo-*.sh
*-demo.*
# 个人配置和敏感信息
.vscode/settings.json
.idea/workspace.xml
*.key
*.pem
*.p12
# 日志和调试文件
debug.log
error.log
access.log
*.log.*
# 构建产物和缓存
.next/cache/
.next/static/
.next/server/
.next/standalone/
# 数据库文件
*.sqlite
*.sqlite3
*.db-wal
*.db-shm
# 环境变量文件(保留示例文件)
.env
.env.local
.env.development
.env.production
# 依赖锁定文件(可选,根据需要保留)
# pnpm-lock.yaml
# package-lock.json
# yarn.lock
# 系统文件
.DS_Store
Thumbs.db
desktop.ini
# IDE 和编辑器文件
.vscode/
.idea/
*.swp
*.swo
*~
# 临时目录
tmp/
temp/
.tmp/
.temp/
<<<<<<< HEAD
# 聊天室项目
一个基于 Next.js 的实时聊天室应用,支持多房间聊天、用户认证和实时消息推送。
## 🚀 快速开始
### 环境要求
- Node.js 18+
- pnpm 8+
- PostgreSQL 15+ (生产环境)
- SQLite (开发环境)
### 安装和运行
```bash
# 克隆项目
git clone <repository-url>
cd chatroom
# 安装依赖
cd fullstack
pnpm install
# 设置环境变量
cp .env.example .env
# 编辑 .env 文件,配置数据库连接
# 初始化数据库
npx prisma db push
pnpm db:seed
# 启动开发服务器
pnpm dev
```
访问 http://localhost:3000 查看应用。
## 📁 项目结构
```
chatroom/
├── fullstack/ # 主应用目录
│ ├── src/
│ │ ├── app/ # Next.js App Router
│ │ │ ├── api/ # API 路由
│ │ │ ├── admin/ # 管理员页面
│ │ │ └── page.tsx # 主页面
│ │ ├── components/ # React 组件
│ │ └── lib/ # 工具函数
│ ├── prisma/ # 数据库模式
│ ├── public/ # 静态资源
│ ├── docker-compose.yml # Docker 配置
│ ├── Dockerfile # 生产环境镜像
│ └── package.json # 项目配置
├── .gitignore # Git 忽略文件
└── README.md # 项目说明
```
## 🛠️ 技术栈
### 前端
- **Next.js 14** - React 框架
- **TypeScript** - 类型安全
- **SWR** - 数据获取和缓存
- **Ant Design** - UI 组件库
### 后端
- **Next.js API Routes** - 服务端 API
- **Prisma ORM** - 数据库操作
- **JWT** - 用户认证
- **Server-Sent Events** - 实时通信
### 数据库
- **PostgreSQL** - 生产环境
- **SQLite** - 开发环境
### 部署
- **Docker** - 容器化部署
- **Docker Compose** - 多服务编排
## 🔧 主要功能
- ✅ 用户注册和登录
- ✅ 多房间聊天
- ✅ 实时消息推送
- ✅ 管理员功能
- ✅ 响应式设计
- ✅ Docker 部署支持
## 📊 项目架构
```mermaid
graph TB
A[客户端浏览器] -->|HTTP/SSE| B[Next.js 应用]
B --> C[API 路由]
C --> D[Prisma ORM]
D --> E[(数据库)]
C --> F[SSE 发布器]
F --> G[房间事件总线]
G --> H[实时消息流]
```
## 🚀 部署
### Docker 部署
```bash
# 开发环境
docker-compose up frontend
# 生产环境
docker-compose --env-file .env.docker --profile production up -d
```
### 环境变量
创建 `.env` 文件:
```bash
# 数据库
DATABASE_URL="postgresql://user:password@localhost:5432/chatroom"
# JWT 密钥
JWT_SECRET="your-secret-key"
JWT_REFRESH_SECRET="your-refresh-secret"
# 管理员账户
ADMIN_USERNAME="admin"
ADMIN_PASSWORD="admin123"
```
## 🧪 测试
### API 测试
```bash
# 健康检查
curl http://localhost:3000/api/health
# 用户登录
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
```
### 内置测试用户
- **管理员**: admin / admin123
- **普通用户**: alice / alice123, bob / bob123
## 📝 开发指南
### 代码规范
- 使用 TypeScript 严格模式
- 遵循 ESLint 和 Prettier 配置
- 提交信息使用语义化格式
### 分支策略
- `main` - 主分支
- `develop` - 开发分支
- `feature/*` - 功能分支
## 🤝 贡献
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
## 📄 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
## 📞 联系方式
如有问题或建议,请通过以下方式联系:
- 创建 Issue
- 发送邮件至 [your-email@example.com]
---
> 💡 **提示**: 这是一个学习项目,展示了现代全栈 Web 开发的最佳实践。适合作为学习 Next.js、实时通信和容器化部署的参考。
=======
# chatroom
......@@ -90,3 +276,4 @@ For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
>>>>>>> dacd55393d9a1a6d828d3d94b8313044d1ed1794
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# 聊天室前端应用
这是一个基于 Next.js 和 Ant Design 构建的实时聊天室前端应用。
## 功能特性
- 🏠 **用户昵称设置** - 进入应用前设置个人昵称
- 🏘️ **房间管理** - 创建、删除聊天房间
- 💬 **实时聊天** - 支持实时消息发送和接收
- 📱 **响应式设计** - 适配桌面和移动设备
- 🎨 **现代化UI** - 基于 Ant Design 的美观界面
- 🔄 **自动轮询** - 每秒自动更新消息和房间列表
- 💾 **状态持久化** - 记住用户昵称和最后进入的房间
## 技术栈
- **框架**: Next.js 15.5.2 (App Router)
- **语言**: TypeScript
- **UI组件库**: Ant Design 5.27.1
- **状态管理**: React Hooks
- **数据获取**: SWR 2.3.6
- **样式**: CSS Modules
- **图标**: Ant Design Icons + React Icons
## 项目结构
```
src/
├── app/ # Next.js App Router 页面
│ ├── page.tsx # 首页 (昵称设置)
│ ├── chat/
│ │ └── page.tsx # 聊天室页面
│ ├── layout.tsx # 根布局
│ └── globals.css # 全局样式
├── components/ # React 组件
│ ├── ChatWindow.tsx # 聊天窗口组件
│ ├── MessageInput.tsx # 消息输入组件
│ ├── MessageItem.tsx # 消息项组件
│ └── RoomList.tsx # 房间列表组件
├── lib/ # 工具库
│ ├── AntdRegistry.tsx # Ant Design 配置
│ └── fetcher.ts # API 请求工具
└── types/ # TypeScript 类型定义
└── index.ts # 所有类型定义
```
## 快速开始
### 安装依赖
```bash
pnpm install
```
### 开发模式
```bash
pnpm dev
```
应用将在 [http://localhost:3000](http://localhost:3000) 启动。
### 构建生产版本
```bash
pnpm build
pnpm start
```
## 使用说明
### 1. 设置昵称
- 首次访问应用时,需要设置个人昵称
- 昵称长度限制:2-20个字符
- 设置完成后会自动跳转到聊天室
### 2. 聊天室功能
- **左侧房间列表**: 显示所有可用的聊天房间
- **创建房间**: 点击 "+" 按钮创建新房间
- **删除房间**: 点击房间右侧的删除按钮
- **选择房间**: 点击房间进入聊天
### 3. 聊天功能
- **发送消息**: 在底部输入框输入消息内容
- **快捷键**: Enter 发送,Shift + Enter 换行
- **实时更新**: 消息会自动更新,无需刷新页面
## API 接口
应用使用以下后端接口:
- `GET /api/room/list` - 获取房间列表
- `POST /api/room/add` - 创建新房间
- `POST /api/room/delete` - 删除房间
- `GET /api/room/message/list` - 获取房间消息
- `POST /api/message/add` - 发送消息
## 开发特性
### 类型安全
- 完整的 TypeScript 类型定义
- 基于接口文档的严格类型检查
- 自动补全和错误提示
### 状态管理
- 使用 React Hooks 管理组件状态
- SWR 处理数据获取和缓存
- 乐观更新提升用户体验
### 样式系统
- CSS Modules 避免样式冲突
- 响应式设计适配各种设备
- 自定义滚动条和动画效果
## 部署
### 环境要求
- Node.js 18+
- pnpm 8+
### 部署步骤
1. 构建项目: `pnpm build`
2. 启动服务: `pnpm start`
3. 配置环境变量 (如需要)
## 贡献指南
1. Fork 项目
2. 创建功能分支
3. 提交更改
4. 推送到分支
5. 创建 Pull Request
## 许可证
MIT License
## 联系方式
如有问题或建议,请通过以下方式联系:
- 提交 Issue
- 发送邮件
- 参与讨论
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async rewrites() {
// 将所有 /api 请求代理到本地全栈服务,避免跨域与预检
return [
{
source: "/api/:path*",
destination: "http://localhost:3001/api/:path*",
},
];
},
};
export default nextConfig;
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@ant-design/compatible": "^5.1.4",
"@ant-design/cssinjs": "^1.24.0",
"@ant-design/icons": "^6.0.0",
"antd": "^5.27.1",
"dayjs": "^1.11.18",
"next": "15.5.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"swr": "^2.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^9",
"eslint-config-next": "15.5.2",
"typescript": "^5"
}
}
This diff is collapsed.
.chatLayout {
height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: row;
}
.sider {
background: #fff;
border-right: 1px solid #e8e8e8;
overflow: hidden;
flex-shrink: 0;
min-width: 280px;
max-width: 350px;
transition: all 0.3s ease;
}
.content {
background: #fff;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-width: 0; /* 防止flex子项溢出 */
}
/* 响应式设计 */
@media (max-width: 1200px) {
.sider {
min-width: 250px;
max-width: 300px;
}
}
@media (max-width: 1024px) {
.sider {
min-width: 200px;
max-width: 250px;
}
}
@media (max-width: 900px) {
.sider {
min-width: 180px;
max-width: 220px;
}
}
@media (max-width: 768px) {
.chatLayout {
flex-direction: column;
height: 100vh;
}
.sider {
width: 100% !important;
max-width: 100% !important;
flex: none !important;
min-height: 80px !important; /* 大幅减少房间列表高度 */
max-height: 120px !important; /* 严格限制最大高度 */
border-right: none;
border-bottom: 1px solid #e8e8e8;
overflow: hidden;
}
.content {
flex: 1;
min-height: 0;
height: calc(100vh - 120px); /* 明确指定高度 */
}
}
@media (max-width: 640px) {
.sider {
min-height: 60px !important; /* 进一步减少 */
max-height: 80px !important;
}
.content {
height: calc(100vh - 80px);
}
}
@media (max-width: 480px) {
.sider {
min-height: 50px !important; /* 最小高度 */
max-height: 60px !important;
}
.content {
height: calc(100vh - 60px);
}
}
/* 大屏幕优化 */
@media (min-width: 1440px) {
.sider {
min-width: 320px;
max-width: 400px;
}
}
/* 超宽屏幕优化 */
@media (min-width: 1920px) {
.sider {
min-width: 350px;
max-width: 450px;
}
}
'use client';
import React, { useState, useEffect } from 'react';
import { Layout } from 'antd';
import RoomList from '../../components/RoomList';
import ChatWindow from '../../components/ChatWindow';
import styles from './page.module.css';
const { Sider, Content } = Layout;
export default function ChatPage() {
const [currentRoomId, setCurrentRoomId] = useState<number | null>(null);
const [nickname, setNickname] = useState<string | null>(null);
const [isClient, setIsClient] = useState(false);
// 客户端检测
useEffect(() => {
setIsClient(true);
}, []);
// 获取昵称
useEffect(() => {
if (isClient) {
const storedNickname = localStorage.getItem('nickname');
if (storedNickname) {
setNickname(storedNickname);
} else {
// 如果没有昵称,重定向到首页
window.location.href = '/';
return;
}
}
}, [isClient]);
// 尝试恢复上次进入的房间
useEffect(() => {
if (isClient) {
const lastRoomId = localStorage.getItem('lastRoomId');
if (lastRoomId) {
setCurrentRoomId(parseInt(lastRoomId));
}
}
}, [isClient]);
const handleSelectRoom = (roomId: number) => {
setCurrentRoomId(roomId);
if (isClient) {
localStorage.setItem('lastRoomId', roomId.toString());
}
};
// 在客户端渲染之前显示加载状态
if (!isClient || !nickname) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
fontSize: '16px',
color: '#666'
}}>
加载中...
</div>
);
}
return (
<Layout className={styles.chatLayout}>
<Sider width={300} className={styles.sider}>
<RoomList
onSelectRoom={handleSelectRoom}
currentRoomId={currentRoomId}
nickname={nickname}
/>
</Sider>
<Content className={styles.content}>
<ChatWindow roomId={currentRoomId} />
</Content>
</Layout>
);
}
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { AntdRegistry } from '../lib/AntdRegistry';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: '聊天室 - 实时聊天应用',
description: '一个基于Next.js构建的实时聊天应用',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body className={inter.className}>
<AntdRegistry>
{children}
</AntdRegistry>
</body>
</html>
);
}
.page {
--gray-rgb: 0, 0, 0;
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
display: grid;
grid-template-rows: 20px 1fr 20px;
align-items: center;
justify-items: center;
min-height: 100svh;
padding: 80px;
gap: 64px;
font-family: var(--font-geist-sans);
}
@media (prefers-color-scheme: dark) {
.page {
--gray-rgb: 255, 255, 255;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
}
}
.main {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.container {
width: 100%;
max-width: 500px;
text-align: center;
}
.logo {
margin-bottom: 40px;
color: white;
}
.logoIcon {
font-size: 64px;
margin-bottom: 16px;
display: block;
}
.title {
color: white !important;
margin: 0 !important;
font-weight: 700;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card {
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: none;
overflow: hidden;
}
.cardHeader {
text-align: center;
margin-bottom: 24px;
}
.userIcon {
font-size: 32px;
color: #1890ff;
margin-bottom: 16px;
display: block;
}
.nicknameInput {
margin-bottom: 16px;
border-radius: 8px;
}
.errorMessage {
margin-bottom: 16px;
text-align: left;
}
.enterButton {
width: 100%;
height: 48px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
}
.footer {
margin-top: 40px;
color: rgba(255, 255, 255, 0.8);
}
/* 响应式设计 */
@media (max-width: 768px) {
.main {
padding: 16px;
}
.container {
max-width: 100%;
}
.logoIcon {
font-size: 48px;
}
.title {
font-size: 32px !important;
}
}
.main ol {
font-family: var(--font-geist-mono);
padding-left: 0;
margin: 0;
font-size: 14px;
line-height: 24px;
letter-spacing: -0.01em;
list-style-position: inside;
}
.main li:not(:last-of-type) {
margin-bottom: 8px;
}
.main code {
font-family: inherit;
background: var(--gray-alpha-100);
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.ctas {
display: flex;
gap: 16px;
}
.ctas a {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
border: 1px solid transparent;
transition:
background 0.2s,
color 0.2s,
border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
}
a.primary {
background: var(--foreground);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--gray-alpha-200);
min-width: 158px;
}
.footer {
grid-row-start: 3;
display: flex;
gap: 24px;
}
.footer a {
display: flex;
align-items: center;
gap: 8px;
}
.footer img {
flex-shrink: 0;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
.footer a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
}
@media (max-width: 600px) {
.page {
padding: 32px;
padding-bottom: 80px;
}
.main {
align-items: center;
}
.main ol {
text-align: center;
}
.ctas {
flex-direction: column;
}
.ctas a {
font-size: 14px;
height: 40px;
padding: 0 16px;
}
a.secondary {
min-width: auto;
}
.footer {
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
}
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Input, Button, Card, Typography, App } from 'antd';
import { UserOutlined, MessageOutlined } from '@ant-design/icons';
import styles from './page.module.css';
const { Title, Text } = Typography;
export default function NicknamePage() {
const [nickname, setNickname] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isClient, setIsClient] = useState(false);
const router = useRouter();
const { message } = App.useApp();
// 客户端检测
useEffect(() => {
setIsClient(true);
}, []);
// 检查是否已经有昵称
useEffect(() => {
if (isClient) {
const existingNickname = localStorage.getItem('nickname');
if (existingNickname) {
router.push('/chat');
}
}
}, [isClient, router]);
const handleEnterChat = async () => {
if (!nickname.trim()) {
setError('昵称不能为空');
return;
}
if (nickname.trim().length < 2) {
setError('昵称至少需要2个字符');
return;
}
if (nickname.trim().length > 20) {
setError('昵称不能超过20个字符');
return;
}
setIsSubmitting(true);
try {
// 存储昵称到localStorage
localStorage.setItem('nickname', nickname.trim());
message.success('欢迎来到聊天室!');
// 延迟跳转,让用户看到成功消息
setTimeout(() => {
router.push('/chat');
}, 500);
} catch (error) {
console.error('设置昵称失败:', error);
message.error('设置昵称失败,请重试');
} finally {
setIsSubmitting(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleEnterChat();
}
};
// 在客户端渲染之前显示加载状态
if (!isClient) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
fontSize: '16px',
color: '#666'
}}>
加载中...
</div>
);
}
return (
<main className={styles.main}>
<div className={styles.container}>
<div className={styles.logo}>
<MessageOutlined className={styles.logoIcon} />
<Title level={1} className={styles.title}>聊天室</Title>
</div>
<Card className={styles.card}>
<div className={styles.cardHeader}>
<UserOutlined className={styles.userIcon} />
<Title level={3}>设置昵称</Title>
<Text type="secondary">请输入您的昵称开始聊天</Text>
</div>
<Input
size="large"
placeholder="请输入您的昵称"
value={nickname}
onChange={(e) => {
setNickname(e.target.value);
if (error) setError('');
}}
onKeyPress={handleKeyPress}
prefix={<UserOutlined />}
className={styles.nicknameInput}
maxLength={20}
showCount
/>
{error && (
<div className={styles.errorMessage}>
<Text type="danger">{error}</Text>
</div>
)}
<Button
type="primary"
size="large"
onClick={handleEnterChat}
loading={isSubmitting}
className={styles.enterButton}
icon={<MessageOutlined />}
>
进入聊天室
</Button>
</Card>
<div className={styles.footer}>
<Text type="secondary">实时聊天,连接你我</Text>
</div>
</div>
</main>
);
}
.chatWindow {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
min-height: 0; /* 确保flex子项能正确收缩 */
}
.roomHeader {
padding: 20px;
border-bottom: 1px solid #e8e8e8;
background: #fafafa;
flex-shrink: 0; /* 防止头部被压缩 */
min-height: 60px;
display: flex;
align-items: center;
}
.roomTitle {
margin: 0 !important;
color: #262626;
font-weight: 600;
font-size: 18px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.messageList {
flex: 1;
overflow-y: auto;
padding: 20px;
background: #f8f9fa;
min-height: 0; /* 确保能正确收缩 */
display: flex;
flex-direction: column;
}
.emptyMessages {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
flex: 1;
}
.chatFooter {
padding: 20px;
border-top: 1px solid #e8e8e8;
background: #fff;
flex-shrink: 0; /* 防止底部被压缩 */
min-height: 80px;
}
.placeholder {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
background: #f8f9fa;
flex: 1;
}
/* 自定义滚动条 */
.messageList::-webkit-scrollbar {
width: 6px;
}
.messageList::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.messageList::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
transition: background 0.2s ease;
}
.messageList::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.roomHeader {
padding: 18px 20px;
min-height: 58px;
}
.roomTitle {
font-size: 17px;
}
.messageList {
padding: 18px 20px;
}
.chatFooter {
padding: 18px 20px;
min-height: 78px;
}
}
@media (max-width: 1024px) {
.roomHeader {
padding: 16px 20px;
min-height: 55px;
}
.roomTitle {
font-size: 16px;
}
.messageList {
padding: 16px 20px;
}
.chatFooter {
padding: 16px 20px;
min-height: 75px;
}
}
@media (max-width: 900px) {
.roomHeader {
padding: 14px 18px;
min-height: 50px;
}
.roomTitle {
font-size: 15px;
}
.messageList {
padding: 14px 18px;
}
.chatFooter {
padding: 14px 18px;
min-height: 70px;
}
}
@media (max-width: 768px) {
.roomHeader {
padding: 8px 12px !important; /* 大幅减少头部高度 */
min-height: 35px !important; /* 强制减少头部高度 */
}
.roomTitle {
font-size: 14px;
}
.messageList {
padding: 8px 12px;
flex: 1; /* 确保消息列表充分利用剩余空间 */
}
.chatFooter {
padding: 8px 12px;
min-height: 55px !important; /* 减少底部高度 */
}
.emptyMessages {
height: 100px; /* 减少空状态高度 */
}
}
@media (max-width: 640px) {
.roomHeader {
padding: 6px 10px !important;
min-height: 30px !important;
}
.roomTitle {
font-size: 13px;
}
.messageList {
padding: 6px 10px;
}
.chatFooter {
padding: 6px 10px;
min-height: 45px !important;
}
.emptyMessages {
height: 80px;
}
}
@media (max-width: 480px) {
.roomHeader {
padding: 4px 8px !important;
min-height: 25px !important;
}
.roomTitle {
font-size: 12px;
}
.messageList {
padding: 4px 8px;
}
.chatFooter {
padding: 4px 8px;
min-height: 40px !important;
}
.emptyMessages {
height: 60px;
}
}
/* 大屏幕优化 */
@media (min-width: 1440px) {
.roomHeader {
padding: 24px 28px;
min-height: 70px;
}
.roomTitle {
font-size: 20px;
}
.messageList {
padding: 24px 28px;
}
.chatFooter {
padding: 24px 28px;
min-height: 90px;
}
}
/* 超宽屏幕优化 */
@media (min-width: 1920px) {
.roomHeader {
padding: 28px 32px;
min-height: 80px;
}
.roomTitle {
font-size: 22px;
}
.messageList {
padding: 28px 32px;
}
.chatFooter {
padding: 28px 32px;
min-height: 100px;
}
}
'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';
import MessageItem from './MessageItem';
import MessageInput from './MessageInput';
import styles from './ChatWindow.module.css';
const { Title } = Typography;
interface ChatWindowProps {
roomId: number | null;
}
export default function ChatWindow({ roomId }: ChatWindowProps) {
const messageListRef = useRef<HTMLDivElement>(null);
const [nickname, setNickname] = useState<string | null>(null);
const [isClient, setIsClient] = useState(false);
// 客户端检测
useEffect(() => {
setIsClient(true);
}, []);
// 获取昵称
useEffect(() => {
if (isClient) {
const storedNickname = localStorage.getItem('nickname');
setNickname(storedNickname);
}
}, [isClient]);
// 获取房间信息
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 || [];
// 自动滚动到底部
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: any) => ({
...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: any) => ({
...currentData,
messages: (currentData?.messages || []).filter((msg: Message) => msg.messageId !== optimisticNewMessage.messageId)
}),
false
);
}
};
if (!roomId) {
return (
<div className={styles.placeholder}>
<Empty
description="请选择一个聊天房间开始聊天"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
);
}
if (isLoading && !messages.length) {
return (
<div className={styles.placeholder}>
<Spin size="large" />
</div>
);
}
if (error) {
return (
<div className={styles.placeholder}>
<Alert
message="错误"
description="加载消息失败,请稍后重试"
type="error"
showIcon
/>
</div>
);
}
return (
<div className={styles.chatWindow}>
{/* 房间标题 */}
<div className={styles.roomHeader}>
<Title level={4} className={styles.roomTitle}>
{currentRoom?.roomName || `房间 ${roomId}`}
</Title>
</div>
{/* 消息列表 */}
<div ref={messageListRef} className={styles.messageList}>
{messages.length === 0 ? (
<div className={styles.emptyMessages}>
<Empty
description="暂无消息,开始聊天吧!"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
) : (
messages.map((msg) => (
<MessageItem
key={msg.messageId}
message={msg}
isOwnMessage={msg.sender === nickname}
/>
))
)}
</div>
{/* 消息输入框 */}
<footer className={styles.chatFooter}>
<MessageInput onSendMessage={handleSendMessage} />
</footer>
</div>
);
}
.inputContainer {
display: flex;
gap: 12px;
align-items: flex-end;
min-height: 60px;
padding: 0 4px;
}
.textArea {
flex: 1;
border-radius: 20px;
resize: none;
min-height: 40px;
max-height: 120px;
transition: all 0.2s ease;
}
.textArea:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.sendButton {
height: 40px;
border-radius: 20px;
padding: 0 20px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
flex-shrink: 0;
}
.sendButton:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
.sendButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.inputContainer {
gap: 10px;
min-height: 58px;
}
.textArea {
min-height: 38px;
max-height: 100px;
}
.sendButton {
height: 38px;
padding: 0 18px;
}
}
@media (max-width: 1024px) {
.inputContainer {
gap: 10px;
min-height: 55px;
}
.textArea {
min-height: 36px;
max-height: 90px;
}
.sendButton {
height: 36px;
padding: 0 16px;
}
}
@media (max-width: 900px) {
.inputContainer {
gap: 8px;
min-height: 50px;
}
.textArea {
min-height: 34px;
max-height: 80px;
}
.sendButton {
height: 34px;
padding: 0 14px;
}
}
@media (max-width: 768px) {
.inputContainer {
gap: 6px;
min-height: 40px !important; /* 强制减少高度 */
padding: 0 2px;
}
.textArea {
min-height: 30px !important; /* 强制减少高度 */
max-height: 60px;
border-radius: 18px;
}
.sendButton {
height: 30px !important; /* 强制减少高度 */
padding: 0 12px;
border-radius: 18px;
}
.sendButton span {
display: none;
}
}
@media (max-width: 640px) {
.inputContainer {
gap: 4px;
min-height: 35px !important;
padding: 0 1px;
}
.textArea {
min-height: 28px !important;
max-height: 50px;
border-radius: 16px;
}
.sendButton {
height: 28px !important;
padding: 0 10px;
border-radius: 16px;
}
}
@media (max-width: 480px) {
.inputContainer {
gap: 4px;
min-height: 30px !important; /* 最小高度 */
padding: 0 1px;
}
.textArea {
min-height: 25px !important;
max-height: 40px;
border-radius: 14px;
}
.sendButton {
height: 25px !important;
padding: 0 8px;
border-radius: 14px;
}
}
/* 大屏幕优化 */
@media (min-width: 1440px) {
.inputContainer {
gap: 14px;
min-height: 70px;
padding: 0 6px;
}
.textArea {
min-height: 44px;
max-height: 140px;
}
.sendButton {
height: 44px;
padding: 0 24px;
}
}
/* 超宽屏幕优化 */
@media (min-width: 1920px) {
.inputContainer {
gap: 16px;
min-height: 80px;
padding: 0 8px;
}
.textArea {
min-height: 48px;
max-height: 160px;
}
.sendButton {
height: 48px;
padding: 0 28px;
}
}
'use client';
import React, { useState } from 'react';
import { Input, Button, App } from 'antd';
import { SendOutlined } from '@ant-design/icons';
import styles from './MessageInput.module.css';
interface MessageInputProps {
onSendMessage: (message: string) => Promise<void>;
}
export default function MessageInput({ onSendMessage }: MessageInputProps) {
const [message, setMessage] = useState('');
const [isSending, setIsSending] = useState(false);
const { message: messageApi } = App.useApp();
const handleSend = async () => {
if (!message.trim() || isSending) return;
setIsSending(true);
try {
await onSendMessage(message.trim());
setMessage('');
} catch (error) {
console.error('发送消息失败:', error);
messageApi.error('发送消息失败,请稍后重试');
} finally {
setIsSending(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className={styles.inputContainer}>
<Input.TextArea
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入消息内容... (Shift + Enter 换行)"
autoSize={{ minRows: 1, maxRows: 4 }}
disabled={isSending}
className={styles.textArea}
/>
<Button
type="primary"
onClick={handleSend}
loading={isSending}
icon={<SendOutlined />}
className={styles.sendButton}
disabled={!message.trim()}
>
发送
</Button>
</div>
);
}
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