Commit c6f4ee01 authored by soilchalk's avatar soilchalk
Browse files

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

- 实现完整的全栈聊天室应用
- 支持用户认证、多房间聊天、实时消息推送
- 包含前端(Next.js)和后端(API Routes)代码
- 支持Docker部署和数据库管理
- 包含管理员功能和测试用户
parent dacd5539
.messageRow {
display: flex;
margin-bottom: 16px;
align-items: flex-end;
}
.messageRow.ownMessage {
justify-content: flex-end;
}
.messageBubble {
max-width: 70%;
padding: 12px 16px;
border-radius: 18px;
position: relative;
word-wrap: break-word;
}
.messageRow:not(.ownMessage) .messageBubble {
background: #fff;
border: 1px solid #e8e8e8;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.messageRow.ownMessage .messageBubble {
background: #1890ff;
color: white;
}
.senderName {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 4px;
font-weight: 500;
}
.messageContent {
font-size: 14px;
line-height: 1.5;
margin-bottom: 4px;
}
.messageTime {
font-size: 11px;
opacity: 0.7;
text-align: right;
}
.messageRow:not(.ownMessage) .messageTime {
color: #8c8c8c;
}
.messageRow.ownMessage .messageTime {
color: rgba(255, 255, 255, 0.8);
}
/* 响应式设计 */
@media (max-width: 768px) {
.messageBubble {
max-width: 85%;
padding: 10px 14px;
}
.messageContent {
font-size: 13px;
}
.senderName {
font-size: 11px;
}
.messageTime {
font-size: 10px;
}
}
'use client';
import React from 'react';
import { Message } from '../types';
import styles from './MessageItem.module.css';
interface MessageItemProps {
message: Message;
isOwnMessage: boolean;
}
export default function MessageItem({ message, isOwnMessage }: MessageItemProps) {
const messageDate = new Date(message.time).toLocaleTimeString();
return (
<div className={`${styles.messageRow} ${isOwnMessage ? styles.ownMessage : ''}`}>
<div className={styles.messageBubble}>
{!isOwnMessage && <div className={styles.senderName}>{message.sender}</div>}
<div className={styles.messageContent}>{message.content}</div>
<div className={styles.messageTime}>{messageDate}</div>
</div>
</div>
);
}
.roomListContainer {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
min-height: 0; /* 确保flex子项能正确收缩 */
}
.header {
padding: 20px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
background: #fafafa;
flex-shrink: 0; /* 防止头部被压缩 */
min-height: 60px;
}
.header h2 {
margin: 0;
color: #262626;
font-weight: 600;
font-size: 18px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list {
flex: 1;
overflow-y: auto;
padding: 0;
min-height: 0; /* 确保能正确收缩 */
}
.roomItem {
padding: 16px 20px !important;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid #f0f0f0;
min-height: 60px;
display: flex;
align-items: center;
}
.roomItem:hover {
background: #f5f5f5;
}
.roomItem.selectedRoom {
background: #e6f7ff;
border-right: 3px solid #1890ff;
}
.roomItem.selectedRoom:hover {
background: #e6f7ff;
}
.centered {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
flex: 1;
}
/* 自定义滚动条 */
.list::-webkit-scrollbar {
width: 6px;
}
.list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.list::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
transition: background 0.2s ease;
}
.list::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.header {
padding: 18px 20px;
min-height: 58px;
}
.header h2 {
font-size: 17px;
}
.roomItem {
padding: 15px 18px !important;
min-height: 58px;
}
}
@media (max-width: 1024px) {
.header {
padding: 16px 18px;
min-height: 55px;
}
.header h2 {
font-size: 16px;
}
.roomItem {
padding: 14px 16px !important;
min-height: 55px;
}
}
@media (max-width: 900px) {
.header {
padding: 14px 16px;
min-height: 50px;
}
.header h2 {
font-size: 15px;
}
.roomItem {
padding: 12px 14px !important;
min-height: 50px;
}
}
@media (max-width: 768px) {
.header {
padding: 8px 12px !important; /* 大幅减少头部高度 */
min-height: 40px !important; /* 强制减少头部高度 */
border-bottom: 1px solid #e8e8e8;
}
.header h2 {
font-size: 14px;
}
.roomItem {
padding: 6px 10px !important; /* 大幅减少房间项高度 */
min-height: 30px !important; /* 强制减少房间项高度 */
}
.centered {
height: 60px; /* 减少空状态高度 */
}
}
@media (max-width: 640px) {
.header {
padding: 6px 10px !important;
min-height: 35px !important;
}
.header h2 {
font-size: 13px;
}
.roomItem {
padding: 4px 8px !important;
min-height: 25px !important;
}
.centered {
height: 40px;
}
}
@media (max-width: 480px) {
.header {
padding: 4px 8px !important;
min-height: 30px !important;
}
.header h2 {
font-size: 12px;
}
.roomItem {
padding: 3px 6px !important;
min-height: 20px !important; /* 最小高度 */
}
.centered {
height: 30px;
}
}
/* 大屏幕优化 */
@media (min-width: 1440px) {
.header {
padding: 24px 28px;
min-height: 70px;
}
.header h2 {
font-size: 20px;
}
.roomItem {
padding: 18px 24px !important;
min-height: 65px;
}
}
/* 超宽屏幕优化 */
@media (min-width: 1920px) {
.header {
padding: 28px 32px;
min-height: 80px;
}
.header h2 {
font-size: 22px;
}
.roomItem {
padding: 20px 28px !important;
min-height: 70px;
}
}
'use client';
import React, { useState, useEffect } from 'react';
import useSWR, { mutate } from 'swr';
import { List, Button, Spin, Alert, Modal, Input, App } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { fetcher } from '../lib/fetcher';
import { RoomPreviewInfo, RoomAddArgs, RoomDeleteArgs } from '../types';
import styles from './RoomList.module.css';
interface RoomListProps {
onSelectRoom: (roomId: number) => void;
currentRoomId: number | null;
nickname: string;
}
export default function RoomList({ onSelectRoom, currentRoomId, nickname }: RoomListProps) {
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [newRoomName, setNewRoomName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [isClient, setIsClient] = useState(false);
const { message, modal } = App.useApp();
// 客户端检测
useEffect(() => {
setIsClient(true);
}, []);
const { data, error, isLoading } = useSWR<{ rooms: RoomPreviewInfo[] }>('/api/room/list', fetcher, {
refreshInterval: 1000, // 每秒轮询一次
});
const rooms = data?.rooms || [];
const handleCreateRoom = async () => {
if (!newRoomName.trim()) {
message.error('房间名称不能为空');
return;
}
setIsCreating(true);
try {
const args: RoomAddArgs = {
user: nickname,
roomName: newRoomName.trim(),
};
const response = await fetcher('/api/room/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
message.success('房间创建成功');
setIsCreateModalVisible(false);
setNewRoomName('');
// 刷新房间列表
mutate('/api/room/list');
// 自动进入新创建的房间
if (response.roomId) {
onSelectRoom(response.roomId);
}
} catch (error) {
console.error('创建房间失败:', error);
message.error('创建房间失败');
} finally {
setIsCreating(false);
}
};
const handleDeleteRoom = async (roomId: number, event: React.MouseEvent) => {
event.stopPropagation(); // 防止触发房间选择
modal.confirm({
title: '确认删除',
content: '确定要删除这个房间吗?删除后无法恢复。',
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
const args: RoomDeleteArgs = {
user: nickname,
roomId,
};
await fetcher('/api/room/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
message.success('房间删除成功');
// 如果删除的是当前房间,清空选择
if (currentRoomId === roomId) {
onSelectRoom(0);
}
// 刷新房间列表
mutate('/api/room/list');
} catch (error) {
console.error('删除房间失败:', error);
message.error('删除房间失败');
}
},
});
};
if (isLoading) {
return <div className={styles.centered}><Spin size="large" /></div>;
}
if (error) {
return <Alert message="错误" description="加载房间列表失败" type="error" showIcon />;
}
return (
<div className={styles.roomListContainer}>
<div className={styles.header}>
<h2>聊天房间</h2>
<Button
type="primary"
shape="circle"
icon={<PlusOutlined />}
onClick={() => setIsCreateModalVisible(true)}
/>
</div>
<List
className={styles.list}
dataSource={rooms}
renderItem={(room) => (
<List.Item
className={`${styles.roomItem} ${currentRoomId === room.roomId ? styles.selectedRoom : ''}`}
onClick={() => onSelectRoom(room.roomId)}
actions={[
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={(e) => handleDeleteRoom(room.roomId, e)}
/>,
]}
>
<List.Item.Meta
title={room.roomName}
description={
room.lastMessage
? `${room.lastMessage.sender}: ${room.lastMessage.content.substring(0, 20)}${room.lastMessage.content.length > 20 ? '...' : ''}`
: '暂无消息'
}
/>
</List.Item>
)}
/>
<Modal
title="创建新房间"
open={isCreateModalVisible}
onOk={handleCreateRoom}
onCancel={() => {
setIsCreateModalVisible(false);
setNewRoomName('');
}}
confirmLoading={isCreating}
okText="创建"
cancelText="取消"
>
<Input
placeholder="请输入房间名称"
value={newRoomName}
onChange={(e) => setNewRoomName(e.target.value)}
onPressEnter={handleCreateRoom}
autoFocus
/>
</Modal>
</div>
);
}
'use client';
import React from 'react';
import { createCache, extractStyle, StyleProvider } from '@ant-design/cssinjs';
import { ConfigProvider, App } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { useServerInsertedHTML } from 'next/navigation';
const StyledComponentsRegistry = ({ children }: { children: React.ReactNode }) => {
const cache = createCache();
useServerInsertedHTML(() => (
<style id="antd" dangerouslySetInnerHTML={{ __html: extractStyle(cache, true) }} />
));
return <StyleProvider cache={cache}>{children}</StyleProvider>;
};
export function AntdRegistry({ children }: { children: React.ReactNode }) {
return (
<StyledComponentsRegistry>
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#1890ff',
borderRadius: 6,
},
}}
>
<App>
{children}
</App>
</ConfigProvider>
</StyledComponentsRegistry>
);
}
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
export const fetcher = async (path: string, args?: RequestInit) => {
// 使用相对路径 + next.config.ts 的 rewrites 在本地开发同源代理到 3001
const url = API_BASE_URL ? `${API_BASE_URL}${path}` : path;
try {
const res = await fetch(url, {
...args,
headers: {
'Content-Type': 'application/json',
...args?.headers,
},
});
// 放行 201/409/401 等业务分支,由下方 json.code 决定是否报错
if (!res.ok && res.status !== 201 && res.status !== 409 && res.status !== 401) {
const err: Error & { status?: number } = new Error(`HTTP error! status: ${res.status}`);
err.status = res.status;
throw err;
}
const json = await res.json();
if (json.code !== 0) {
const err: Error & { info?: unknown; code?: number } = new Error(json.message || 'API返回错误');
err.info = json.data;
err.code = json.code;
throw err;
}
return json.data;
} catch (error: unknown) {
// 如果是网络错误
if (error instanceof Error && error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('网络连接失败,请检查网络连接');
}
// 如果是API错误
if (error instanceof Error && (error as Error & { code?: number }).code !== undefined) {
throw error;
}
// 其他错误
if (error instanceof Error) {
throw new Error(error.message || '请求失败');
}
throw new Error('未知错误');
}
};
export interface Message {
messageId: number; // 消息 id
roomId: number; // 房间 id
sender: string; // 发送人的 username
content: string; // 消息内容
time: number; // 消息发送时间戳
}
export interface RoomPreviewInfo {
roomId: number;
roomName: string;
lastMessage: Message | null;
}
// API 接口参数类型
export interface RoomAddArgs {
user: string;
roomName: string;
}
export interface RoomAddRes {
roomId: number;
}
export interface RoomListRes {
rooms: RoomPreviewInfo[];
}
export interface RoomDeleteArgs {
user: string;
roomId: number;
}
export interface MessageAddArgs {
roomId: number;
content: string;
sender: string;
}
export interface RoomMessageListArgs {
roomId: number;
}
export interface RoomMessageListRes {
messages: Message[];
}
export interface RoomMessageGetUpdateArgs {
roomId: number;
sinceMessageId: number;
}
export interface RoomMessageGetUpdateRes {
messages: Message[];
}
// 统一返回模式
export interface ApiResponse<T> {
message: string;
code: number;
data: T | null;
}
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
# --- Runtime Secrets (do NOT commit real values) ---
JWT_SECRET=
JWT_REFRESH_SECRET=
# --- Database ---
# Dev (SQLite): prisma/schema.prisma already points to file:./dev.db
# Prod (PostgreSQL): set full URL
DATABASE_URL=
POSTGRES_DB=chatroom_db
POSTGRES_USER=postgres
POSTGRES_PASSWORD=
# --- Seeds / Admin (gated) ---
ALLOW_TEST_SEED=false
ALLOW_ADMIN_SEED=false
ADMIN_USERNAME=
ADMIN_EMAIL=
ADMIN_NICKNAME=admin
ADMIN_PASSWORD=
# --- App ---
NODE_ENV=development
PORT=3000
# Dependencies
node_modules/
.pnpm-store/
# Production builds
.next/
out/
dist/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Database
*.db
*.db-journal
prisma/migrations/
# Logs
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
# 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
# Uploads
uploads/
# 项目文档 (保留README.md)
开发进度技术文档.md
技术设计文档.md
文档维护指南.md
新人引导Prompt.md
环境配置说明.md
项目指导文件.md
项目概览图.md
README-nextjs.md
\ No newline at end of file
# 生产环境 Dockerfile
FROM node:18-alpine AS base
# 安装 pnpm
RUN npm install -g pnpm
# 设置工作目录
WORKDIR /app
# 复制 package.json 和 pnpm-lock.yaml
COPY package.json pnpm-lock.yaml ./
# 安装依赖
RUN pnpm install --frozen-lockfile
# 复制源代码
COPY . .
# 生成 Prisma 客户端
RUN pnpm prisma generate
# 构建应用
RUN pnpm build
# 生产环境
FROM node:18-alpine AS production
# 安装 pnpm
RUN npm install -g pnpm
# 设置工作目录
WORKDIR /app
# 复制 package.json 和 pnpm-lock.yaml
COPY package.json pnpm-lock.yaml ./
# 只安装生产依赖
RUN pnpm install --frozen-lockfile --prod
# 从构建阶段复制构建结果
COPY --from=base /app/.next ./.next
COPY --from=base /app/public ./public
COPY --from=base /app/prisma ./prisma
COPY --from=base /app/node_modules/.prisma ./node_modules/.prisma
# 创建非 root 用户
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
# 更改文件所有权
RUN chown -R nextjs:nodejs /app
USER nextjs
# 暴露端口
EXPOSE 3000
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
# 启动应用
CMD ["pnpm", "start"]
# 开发环境 Dockerfile
FROM node:18-alpine
# 安装 pnpm
RUN npm install -g pnpm
# 设置工作目录
WORKDIR /app
# 复制 package.json 和 pnpm-lock.yaml
COPY package.json pnpm-lock.yaml ./
# 安装依赖
RUN pnpm install
# 复制源代码
COPY . .
# 生成 Prisma 客户端
RUN pnpm prisma generate
# 暴露端口
EXPOSE 3000
# 启动开发服务器
CMD ["pnpm", "dev"]
# 聊天室全栈项目
基于 Next.js + TypeScript + Prisma 构建的现代化聊天室应用,支持实时通信、用户认证、房间管理等功能。
## 🚀 快速开始
### 环境要求
- Node.js 18+
- pnpm 8+
- Docker (可选,用于容器化部署)
### 本地开发
#### 1. 安装依赖
```bash
pnpm install
```
#### 2. 数据库设置
```bash
# 生成 Prisma 客户端
npx prisma generate
# 创建数据库 (SQLite)
npx prisma db push
```
#### 3. 启动开发服务器
```bash
pnpm dev
```
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
### Docker 部署
#### 开发模式 (SQLite)
```bash
# 启动开发容器
docker compose up -d frontend
# 访问应用
open http://localhost:3001
```
#### 生产模式 (PostgreSQL)
```bash
# 配置环境变量
cp .env.example .env
# 编辑 .env 文件,设置数据库密码等
# 启动生产服务
docker compose --profile production up -d database frontend_prod
```
### 内置管理员账户
```bash
# 创建管理员账户 (需要设置环境变量)
curl -X POST http://localhost:3000/api/auth/seed-admin
```
## 🏗️ 技术栈
- **前端**: Next.js 15.5.2 + TypeScript + Ant Design
- **后端**: Next.js API Routes
- **数据库**: SQLite (开发) / PostgreSQL (生产)
- **ORM**: Prisma
- **状态管理**: SWR
- **部署**: Docker + docker-compose
## 📁 项目结构
```
src/
├── app/ # Next.js App Router
│ ├── api/ # API 路由
│ │ ├── auth/ # 认证相关API
│ │ ├── room/ # 房间相关API
│ │ └── message/ # 消息相关API
│ ├── chat/ # 聊天页面
│ └── page.tsx # 首页
├── components/ # React 组件
├── lib/ # 工具库
└── types/ # TypeScript 类型
```
## 🔧 主要功能
### 用户认证
- ✅ 用户注册/登录 (JWT + bcrypt)
- ✅ 权限控制 (游客/用户/管理员)
- ✅ 字段级错误提示
- ✅ 内置管理员账户
### 房间管理
- ✅ 房间创建/删除
- ✅ 房间列表显示
- ✅ 权限控制 (创建者/管理员可删除)
- ✅ 房间信息展示
### 实时通信
- ✅ 消息发送/接收
- ✅ Server-Sent Events (SSE) 实时推送
- ✅ 消息历史记录
- ✅ 自动滚动到底部
### 界面设计
- ✅ 响应式界面设计
- ✅ Ant Design 组件库
- ✅ 用户头像和昵称显示
- ✅ 消息气泡样式
## 🐳 Docker 部署
```bash
# 开发模式(使用 SQLite、本地热更新)
docker compose up -d frontend
# 生产模式(使用 PostgreSQL)
docker compose --profile production up -d database frontend_prod
# 查看日志
docker compose logs -f
```
说明:
- 开发环境默认使用 SQLite(`prisma/dev.db`);生产建议切换 PostgreSQL,并设置 `DATABASE_URL`
- 前端/后端合一(Next.js App Router),容器内仅需暴露 3000 端口。
首次运行建议执行:
```bash
# 进入容器后生成 Prisma 客户端并推送 schema(生产容器构建阶段已执行)
docker compose exec frontend pnpm prisma generate
docker compose exec frontend npx prisma db push
```
## 🔔 实时通信(SSE)
### 服务端事件流
- 事件流路由:`GET /api/room/message/stream?roomId=<number>`
- 发送消息后服务端会推送事件到对应房间
- 前端自动订阅当前房间事件流
### 本地快速验证
```bash
# 1) 打开事件流
open "http://localhost:3000/api/room/message/stream?roomId=15"
# 2) 发送一条消息
curl -X POST http://localhost:3000/api/message/add \
-H 'Content-Type: application/json' \
-d '{"roomId":15,"sender":"test","content":"sse ping"}' | cat
```
预期:事件流页面应即时出现 `data: { ... }` 推送。
## 🎯 演示指南
### 功能演示流程
1. **用户注册/登录**
- 访问注册页面,创建新用户
- 或使用内置管理员账户登录
2. **房间管理**
- 创建新房间
- 查看房间列表
- 测试房间删除权限
3. **实时聊天**
- 进入房间发送消息
- 多窗口测试实时推送
- 验证消息历史记录
4. **权限控制**
- 测试游客/用户/管理员不同权限
- 验证房间删除权限
### API 测试
```bash
# 健康检查
curl http://localhost:3000/api/health
# 房间列表
curl http://localhost:3000/api/room/list
# 创建管理员账户
curl -X POST http://localhost:3000/api/auth/seed-admin
```
## 📝 开发规范
- 使用 TypeScript 进行类型安全开发
- 遵循 Next.js App Router 最佳实践
- 使用 Prisma 进行数据库操作
- 采用 SWR 进行数据获取和缓存
- 保持代码简洁和模块化
## 🤝 贡献指南
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) 文件了解详情。
version: '3.8'
services:
# PostgreSQL 数据库服务
database:
image: postgres:15-alpine
container_name: chatroom_db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-chatroom_db}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- chatroom_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# Redis 缓存服务
redis:
image: redis:7-alpine
container_name: chatroom_redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- chatroom_network
command: redis-server --appendonly yes --requirepass redis123
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 5s
retries: 5
# 前端应用服务(开发环境,使用 SQLite)
frontend:
build:
context: .
dockerfile: Dockerfile.dev
container_name: chatroom_frontend
restart: unless-stopped
ports:
- "3001:3000"
environment:
- NODE_ENV=development
# 使用项目内置 SQLite(与 prisma/schema.prisma 一致)
- DATABASE_URL=file:./dev.db
- ALLOW_TEST_SEED=${ALLOW_TEST_SEED:-false}
- ALLOW_ADMIN_SEED=${ALLOW_ADMIN_SEED:-false}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_NICKNAME=${ADMIN_NICKNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
volumes:
- .:/app
- /app/node_modules
# 开发默认不强依赖 Postgres/Redis,如需使用请启用相应 profile 并配置 DATABASE_URL
networks:
- chatroom_network
command: pnpm dev
# 前端应用服务(生产环境,使用 PostgreSQL)
frontend_prod:
build:
context: .
dockerfile: Dockerfile
container_name: chatroom_frontend_prod
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- ALLOW_TEST_SEED=${ALLOW_TEST_SEED:-false}
- ALLOW_ADMIN_SEED=${ALLOW_ADMIN_SEED:-false}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_NICKNAME=${ADMIN_NICKNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
depends_on:
database:
condition: service_healthy
redis:
condition: service_healthy
networks:
- chatroom_network
profiles:
- production
volumes:
postgres_data:
driver: local
redis_data:
driver: local
networks:
chatroom_network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
-- 聊天室数据库初始化脚本
-- 创建数据库(如果不存在)
-- 注意:这个脚本在容器启动时自动执行
-- 设置时区
SET timezone = 'UTC';
-- 创建扩展(如果需要)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- 创建自定义类型(如果需要)
-- 这些类型在 Prisma schema 中定义,这里只是示例
-- 设置默认权限
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO postgres;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres;
-- 创建索引(如果需要)
-- 这些索引在 Prisma schema 中定义,这里只是示例
-- 插入初始数据(如果需要)
-- 例如:默认用户、系统配置等
-- 设置搜索路径
SET search_path TO public;
-- 完成初始化
SELECT 'Database initialization completed successfully!' as status;
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;
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
{
"name": "chatroom-fullstack",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:reset": "prisma migrate reset",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@prisma/client": "^6.15.0",
"@swc/helpers": "^0.5.17",
"@types/bcryptjs": "^3.0.0",
"@types/jsonwebtoken": "^9.0.10",
"antd": "^5.27.1",
"bcryptjs": "^3.0.2",
"jsonwebtoken": "^9.0.2",
"next": "15.5.2",
"prisma": "^6.15.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"swr": "^2.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^9",
"eslint-config-next": "15.5.2",
"tailwindcss": "^4",
"tsx": "^4.7.0",
"typescript": "^5"
}
}
This diff is collapsed.
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