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 # chatroom
...@@ -90,3 +276,4 @@ For open source projects, say how it is licensed. ...@@ -90,3 +276,4 @@ For open source projects, say how it is licensed.
## Project status ## 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. 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