demo
Python简易聊天室项目需求
项目概述:
使用Python开发一个Web版的简易群聊聊天室,支持用户登录、实时消息发送与接收,并具备基本的日志记录功能。后端将利用异步框架和WebSocket技术提供高效的实时通信服务。
1 项目介绍
1-1 技术栈选择 (推荐)
- 后端Web框架:
Flask(Flask需要结合Flask-SocketIO,而且我正在使用flask框架)- 理由: 它们都是轻量级且高效的Python Web框架。
FastAPI基于ASGI(异步服务网关接口),天生支持异步和WebSocket,非常适合处理高并发实时通信。Flask结合Flask-SocketIO也能提供强大的WebSocket功能。
- 理由: 它们都是轻量级且高效的Python Web框架。
- 实时通信协议: WebSocket
- 理由: 实现全双工、低延迟的双向通信,是 Web 实时聊天应用的黄金标准,远优于传统的 HTTP 轮询 (
polling) 或长轮询 (long polling)。这将替换“多线程TCP连接服务”的原始意图,因为它在Web环境下更高效和现代化。
- 理由: 实现全双工、低延迟的双向通信,是 Web 实时聊天应用的黄金标准,远优于传统的 HTTP 轮询 (
- 前端技术: HTML, CSS, JavaScript (无需复杂框架,可使用原生JS或少量DOM操作库如jQuery)
- 数据库模拟:
.jsonl文件 - 并发:
Flask-SocketIO处理并发连接,其底层通过事件循环或线程池管理TCP连接。
1-2 详细功能需求
2.1 用户认证与管理 (后端)
- 账户数据模拟:
- 使用
users.jsonl文件存储用户账户信息,每行一个JSON对象。 - 每个用户记录包含
{"username": "...", "password_hash": "..."}。 - 优化: 用户密码必须进行哈希存储 (例如使用
bcrypt或passlib) 而不是明文或简单编码,提高安全性。
- 使用
- 登录服务:
- 接收网页端提交的用户名和密码。
- 从
users.jsonl中查询用户,并验证提交的密码与存储的哈希密码是否匹配。 - 优化: 成功登录后,生成并返回一个认证凭证 (
session token或JWT) 给前端,用于后续请求的身份验证,而不是每次都重新验证用户名密码,同时防止未授权访问。不成功的登录尝试需返回明确的错误信息。
2.2 网页端交互 (前端)
- 登录页面:
- HTML 界面,包含用户名输入框、密码输入框和登录按钮。
- JavaScript 处理表单提交,通过
fetch或XMLHttpRequest向后端发送登录请求。 - 根据后端响应显示登录成功或失败的信息。
- 群聊界面:
- 成功登录后通过后端重定向或前端路由跳转到群聊界面。
- 界面布局简洁,包含:
- 消息显示区域:滚动显示所有聊天消息。每条消息应包含发送者用户名和消息内容。
- 消息输入框:用户输入消息。
- 发送按钮:点击发送消息。
- 优化: 实时更新在线用户列表 (可选,但会显著提升用户体验)。
2.3 实时群聊功能 (后端与WebSocket)
- WebSocket连接:
- 用户登录后,前端通过WebSocket连接后端。
- 后端记录每个活跃的WebSocket连接以及对应的用户身份。
- 消息发送:
- 前端监听消息输入框和发送按钮,通过WebSocket发送用户输入的消息到后端。
- 消息格式应包含 (
事件类型,消息内容,发送者,时间戳)。
- 消息广播:
- 后端接收到一条消息后,将其(包括发送者和时间戳)广播给所有当前在线的连接(即聊天室内的所有用户)。
- 优化: 广播前可以对消息进行简单的内容转义(如防止XSS攻击),确保用户输入不会破坏页面结构。
- 消息接收与显示:
- 前端监听WebSocket接收到的消息,实时将其添加到消息显示区域。新消息自动滚动到底部。
- 每条消息应清晰显示发送者、消息内容和发送时间。
- 连接管理:
- 当用户关闭页面或网络断开时,后端应检测到WebSocket连接的断开,并相应地清理连接信息。
2.4 日志记录
- 消息日志:
- 所有用户发送的聊天消息都必须保存在一个日志文件 (
chatlog.jsonl或chatlog.txt) 中。 - 优化: 每条日志记录应包含 (
时间戳,发送者,消息内容)。
- 所有用户发送的聊天消息都必须保存在一个日志文件 (
- 系统事件日志:
- 记录用户登录/登出事件。
- 记录WebSocket连接/断开事件。
- 记录重要的错误或警告信息。
- 优化: 使用Python的
logging模块进行统一的日志管理,可以配置日志级别、输出格式等,提高可维护性。
2.5 性能与安全性
- 并发处理: 采用异步I/O模型(如
FastAPI结合Uvicorn,默认就支持高效并发)来处理Web请求和WebSocket连接,确保可以同时服务多个用户。这替代了原始需求的“多线程TCP连接服务”的具体实现方式,使其更适合Web环境。 - 安全性 (进阶基础):
- HTTPS: 建议在生产环境中部署时使用HTTPS。
- 密码哈希: (已提及)使用强哈希算法存储用户密码。
- XSS 防护: (已提及)对用户生成的内容进行清理或转义,防止跨站脚本攻击。
- CSRF 防护: 针对HTTP请求(如登录)可以集成CSRF令牌保护。
- Session/Token管理: 确保认证令牌安全传输(如HTTP-only cookies)、验证和过期处理。
2 项目结构
chatroom/
├── templates/
│ ├── login.html
│ └── chat.html
| └── register.thml
├── static/
│ ├── css
| ├── style.css
| └── js
| ├── main.js
| └── img
| ├── bg.jpg
├── data_files
| ├── chat_log.jsonl
| └── users.jsonl
├── app.py
├── config.py
└── README.md
开发环境
Python : 3.10.9
操作系统:Windows
python管理:uv
第三方库:flask flask-socketio bcrypt python-dotenv eventlet
3 具体项目文档
3-1 定义config.py --- 配置信息 (日志文件路径,用户数据文件路径等)
import os
from datetime import timedelta
# import time
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess'
# JWT 相关 (可选,本项目用Session)
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
# 日志与数据路径
USERS_FILE = 'data_files/users.jsonl'
CHATLOG_FILE = 'data_files/chat_log.jsonl'
3-2 定义app.py --- flask主应用程序
# flask_chatroom/app.py
from flask import Flask, request, render_template, session, redirect, url_for, flash # 新增 flash, get_flashed_messages
from flask_socketio import SocketIO, emit, join_room
import json
import os
import bcrypt
import logging
from datetime import datetime
from config import Config
# 初始化 Flask 和 SocketIO
app = Flask(__name__)
app.config.from_object(Config)
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='eventlet') # async_mode='eventlet' 推荐用于生产环境
# 设置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler("app.log"), # 写入文件
logging.StreamHandler() # 同时输出到控制台
]
)
logger = logging.getLogger(__name__)
# --- 工具函数 & 数据加载 ---
def load_users():
"""从 users.jsonl 加载用户数据"""
users = {}
if os.path.exists(Config.USERS_FILE):
try:
with open(Config.USERS_FILE, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line:
user = json.loads(line)
users[user['username']] = user['password_hash']
logger.info(f"Loaded {len(users)} users from {Config.USERS_FILE}")
except Exception as e:
logger.error(f"Error loading users from {Config.USERS_FILE}: {e}")
return users
def save_new_user(username, password_hash):
"""新增用户到 users.jsonl(确保文件和目录存在)"""
username = username.lower() # 存储小写用户名,避免大小写敏感问题
# 确保文件目录存在
os.makedirs(os.path.dirname(Config.USERS_FILE), exist_ok=True)
with open(Config.USERS_FILE, 'a', encoding='utf-8') as f:
f.write(json.dumps({'username': username, 'password_hash': password_hash}, ensure_ascii=False) + '\n')
logger.info(f"User '{username}' saved to {Config.USERS_FILE}")
def log_chat_message(sender, message):
"""写入聊天日志(jsonl格式)"""
record = {
"timestamp": datetime.utcnow().isoformat(),
"sender": sender,
"message": message
}
try:
os.makedirs(os.path.dirname(Config.CHATLOG_FILE), exist_ok=True) # 确保目录存在
with open(Config.CHATLOG_FILE, 'a', encoding='utf-8') as f:
f.write(json.dumps(record, ensure_ascii=False) + '\n')
except Exception as e:
logger.error(f"Error logging chat message from {sender}: {e}")
def sanitize_html_input(text):
"""简单XSS防护:转义HTML特殊字符"""
return text.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''')
# 简易内存在线用户管理(更健壮的方案可用 Redis 等)
active_connections = {} # WebSocket SID to username mapping
online_users = set() # Set of unique usernames currently online
# --- HTTP 路由 ---
@app.route('/', methods=['GET', 'POST'])
def login():
"""登录页面及登录逻辑"""
if 'username' in session: # 如果已登录,直接跳转聊天页
return redirect(url_for('chat'))
if request.method == 'POST':
username = request.form.get('username', '').strip().lower() # 将输入用户名转为小写
password = request.form.get('password', '').strip()
if not username or not password:
flash("用户名和密码不能为空。", 'error') # 使用 flash
logger.warning("Login attempt with empty username or password.")
else:
users_db = load_users()
stored_hash = users_db.get(username) # 使用小写用户名查找
if stored_hash and bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')):
session['username'] = username
session.permanent = True # 使session会话持久化
logger.info(f"User '{username}' logged in successfully.")
return redirect(url_for('chat'))
else:
flash("用户名或密码错误。", 'error') # 使用 flash
logger.warning(f"Failed login attempt for user '{username}'.")
return render_template('login.html')
# ******************* 新增注册路由 *******************
@app.route('/register', methods=['GET', 'POST'])
def register():
"""用户注册页面及逻辑"""
if 'username' in session: # 如果已登录,引导到聊天页
return redirect(url_for('chat'))
if request.method == 'POST':
username = request.form.get('username', '').strip().lower() # 注册时也转小写
password = request.form.get('password', '').strip()
confirm_password = request.form.get('confirm_password', '').strip()
# ----------------- 注册输入验证 -----------------
if not username or not password or not confirm_password:
flash("所有字段都是必填项!", 'error')
return redirect(url_for('register'))
if len(username) < 3 or len(username) > 20:
flash("用户名长度必须在 3 到 20 个字符之间。", 'error')
return redirect(url_for('register'))
# 简单验证用户名只包含字母数字、下划线
if not username.replace('_', '').isalnum():
flash("用户名只能包含字母、数字和下划线。", 'error')
return redirect(url_for('register'))
if password != confirm_password:
flash("两次输入的密码不一致!", 'error')
return redirect(url_for('register'))
if len(password) < 6:
flash("密码长度至少为 6 个字符。", 'error')
return redirect(url_for('register'))
# 检查用户名是否已存在
users_db = load_users()
if username in users_db:
flash("该用户名已被注册,请尝试其他用户名。", 'error')
return redirect(url_for('register'))
# ----------------- 结束注册输入验证 -----------------
# 哈希密码并保存
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
save_new_user(username, hashed_password)
logger.info(f"New user '{username}' registered successfully.")
flash("注册成功!您现在可以登录了。", 'success')
return redirect(url_for('login')) # 注册成功后重定向到登录页
return render_template('register.html')
# ******************* 结束新增注册路由 *******************
@app.route('/logout', methods=['POST'])
def logout():
"""退出登录"""
username = session.pop('username', None)
if username:
logger.info(f"User '{username}' explicitly logged out.")
return redirect(url_for('login'))
@app.route('/chat')
def chat():
"""群聊页面,需登录才能访问"""
if 'username' not in session:
return redirect(url_for('login'))
return render_template('chat.html', username=session['username'])
# --- SocketIO 事件处理 ---
# ... (保持不变,或根据你的最新版本复制粘贴) ...
@socketio.on('connect')
def handle_connect():
username = session.get('username')
if not username:
logger.warning(f"Unauthenticated WebSocket connection attempted: {request.sid}")
return False
active_connections[request.sid] = username
initial_joint = False
if username not in online_users: # 检查用户名是否是首次上线
online_users.add(username)
initial_joint = True # 首次连接才发送user_joined
join_room('general') # 所有用户都加入'general'房间
if initial_joint:
# 通知所有客户端有新用户加入及更新在线列表
# 使用copy()避免concurrent modification during iteration and change
emit('user_joined', {
'user': username,
'message': f"用户 {username} 已加入群聊。",
'online': list(online_users)
}, room='general', broadcast=True)
else:
# 如果是同用户在另一tab重新连接,只更新在线列表给他自己
emit('update_online_users', {'online': list(online_users)})
logger.info(f"User '{username}' ({request.sid}) connected to WebSocket. Current online count: {len(online_users)}")
@socketio.on('disconnect')
def handle_disconnect():
username = active_connections.pop(request.sid, None)
if username:
# 检查该用户名是否还有其他在线连接
if username not in active_connections.values():
online_users.discard(username) # 只有当该用户所有连接都断开时才从在线列表中移除并通知
emit('user_left', {
'user': username,
'message': f"用户 {username} 已离开群聊。",
'online': list(online_users)
}, room='general', broadcast=True)
logger.info(f"User '{username}' ({request.sid}) disconnected. All sessions for '{username}' ended. Online users: {len(online_users)}")
else:
logger.info(f"User '{username}' ({request.sid}) disconnected, but still has other active sessions. Online users: {len(online_users)}")
else:
logger.warning(f"Unknown SID ({request.sid}) disconnected from WebSocket.")
@socketio.on('send_message')
def handle_send_message(data):
username = session.get('username')
if not username:
logger.warning(f"Unauthorized send_message attempt from {request.sid}")
return # 未登录用户不能发送消息
raw_msg = data.get('message', '').strip()
if not raw_msg:
logger.warning(f"Empty message sent by {username}.")
return # 空消息不处理
# XSS 防护
safe_msg = sanitize_html_input(raw_msg)
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') # 使用服务器本地时间显示
# 广播消息给所有连接的客户端
emit('receive_message', {
'sender': username,
'message': safe_msg,
'timestamp': timestamp,
'color': '#ff0000' if username == 'admin' else '' # 简单的按用户名字着色示例,根据需要移除
}, room='general', broadcast=True)
# 记录原始消息到日志
log_chat_message(username, raw_msg)
logger.info(f"[CHAT] {timestamp} {username}: {raw_msg}")
# --- 应用启动 ---
if __name__ == '__main__':
# 确保 data_files 目录存在
os.makedirs(os.path.dirname(Config.USERS_FILE), exist_ok=True)
os.makedirs(os.path.dirname(Config.CHATLOG_FILE), exist_ok=True)
# 初始化默认用户(首次运行时才会创建 "admin" 账户)
users_data = load_users()
if not users_data or 'admin' not in users_data: # 检查是否没有用户数据或者admin用户不存在
default_username = "admin"
default_password = "admin123"
hashed_password = bcrypt.hashpw(default_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
save_new_user(default_username, hashed_password)
logger.info(f"No users found or admin user missing, created default user: '{default_username}' with password '{default_password}'")
# 启动 Flask-SocketIO 服务器
socketio.run(app, host='0.0.0.0', port=5000, debug=True)
3-3 定义templates/login.html --- 编辑登录界面
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - Web 聊天室</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="auth-container">
<h2>欢迎登录 Web 聊天室</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
<div>
<label for="username">用户名:</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username">
</div>
<div>
<label for="password">密码:</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit">登录</button>
</form>
<p style="text-align: center; margin-top: 20px;">还没有账户?<a href="{{ url_for('register') }}">注册新账户</a></p>
</div>
</body>
</html>
3-4 定义templates/chat.html --- 编辑聊天页面
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>群聊室 - {{ username }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body data-username="{{ username }}">
<div class="container">
<div class="header">
<h1>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" class="feather feather-message-square" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
<span>欢迎 {{ username }}!</span>
</h1>
{# 新增的在线用户展示区域(会有一个span用于显示总人数,和一个隐藏的列表用于显示用户名字) #}
<div id="online-users">
<strong><span id="online-users-title">当前在线 </span>(<span id="online-count">1</span>) <span class="online-user-list-toggle">👤</span></strong>
<div class="online-user-list">
<!-- JS will populate: <div>User1</div><div>User2</div>... -->
<div>{{ username }}</div> {# 初始时只显示当前用户 #}
</div>
</div>
<button id="logout-btn">退出登录</button>
</div>
<div id="messages" class="messages">
<div class="message-item system">
<div class="message-content">欢迎来到聊天室!请理性发言。</div>
</div>
</div>
<div class="input-area">
<textarea id="message-input" placeholder="输入消息 (Enter 发送)" rows="1" autocomplete="off"></textarea>
<button id="send-btn">发送</button>
</div>
</div>
<!-- 导入 Socket.IO 客户端库 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.min.js"></script>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>
3-5 定义templates/register.html --- 编辑注册页面
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>注册新账户 - Web 聊天室</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="auth-container">
<h2>注册新账户</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
<div>
<label for="username">用户名:</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username" minlength="3" maxlength="20" pattern="[a-zA-Z0-9_]+" title="用户名只能包含字母、数字和下划线, 长度3-20。">
</div>
<div>
<label for="password">密码:</label>
<input type="password" id="password" name="password" required autocomplete="new-password" minlength="6">
</div>
<div>
<label for="confirm_password">确认密码:</label>
<input type="password" id="confirm_password" name="confirm_password" required autocomplete="new-password" minlength="6">
</div>
<button type="submit">注册</button>
</form>
<p style="text-align: center; margin-top: 20px;">已有账户?<a href="{{ url_for('login') }}">立即登录</a></p>
</div>
</body>
</html>
3-6 定义static/css/style.css --- css样式
/* flask_chatroom/static/css/style.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Hiragino Sans GB", "Microsoft YaHei", sans-serif; /* 增加中文字体支持 */
color: #333;
line-height: 1.6;
display: flex; /* 便于居中内容 */
justify-content: center;
align-items: center;
min-height: 100vh; /* 至少占满整个视口高度 */
/* ---------------- 背景图片样式 - 仅对登录页生效 ---------------- */
background-image: url('../img/bg.jpg'); /* 指向背景图片路径 */
background-size: cover; /* 确保图片覆盖整个背景,可能会裁剪 */
background-position: center center; /* 图片居中显示 */
background-repeat: no-repeat; /* 不重复图片 */
background-attachment: fixed; /* 背景图片在滚动时固定(登录页通常不滚)*/
background-color: rgba(0, 0, 0, 0.4); /* 暗化背景,提升前景文本可读性 */
background-blend-mode: multiply; /* 混合模式,使图片和颜色融合 */
/* ------------------------------------------------------------- */
}
/* Auth Page Container (Login/Register) */
.auth-container {
background-color: rgba(255, 255, 255, 0.95); /* 略带透明的白色背景 */
padding: 30px;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0,0,0,0.25); /* 更明显的阴影效果 */
width: 100%;
max-width: 450px; /* 限制登录页宽度 */
animation: fadeIn 0.8s ease-out; /* 稍微慢一点的进入动画 */
transform-origin: center;
}
/* Main Chat Page Container */
.container {
max-width: 800px; /* 聊天界面的最大宽度 */
max-height: 95vh; /* 聊天界面的最大高度 */
width: 100%;
height: 95vh; /* 设为视窗高度的95% */
min-height: 600px;
background-color: white; /* 聊天容器保持不透明 */
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0,0,0,0.15); /* 聊天界面也有合适的阴影 */
display: flex;
flex-direction: column;
overflow: hidden; /* 确保内容不会溢出圆角 */
animation: fadeIn 0.8s ease-out; /* 聊天界面也有进入动画 */
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
h2, h1 {
text-align: center;
color: #2c3e50;
margin-bottom: 25px;
font-size: 1.8rem;
font-weight: 600;
}
/* General form element styling */
form div {
margin-bottom: 20px;
}
form label {
display: block;
margin-bottom: 10px;
font-weight: 600;
color: #444;
}
form input[type="text"],
form input[type="password"] {
width: 100%;
padding: 12px 15px;
border: 1px solid #cceeff;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
form input[type="text"]:focus,
form input[type="password"]:focus {
border-color: #5cb85c;
outline: none;
box-shadow: 0 0 0 4px rgba(92, 184, 92, 0.25);
}
/* General button styling */
button[type="submit"], #send-btn, #logout-btn {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 1.1rem;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease, box-shadow 0.3s ease;
font-weight: 600;
margin-top: 10px;
}
button[type="submit"] {
background: #5cb85c; /* 清新绿色 */
color: white;
}
button[type="submit"]:hover {
background: #4cae4c;
transform: translateY(-2px); /* 悬停时轻微上浮 */
box-shadow: 0 6px 15px rgba(92, 184, 92, 0.4);
}
.error {
color: #d9534f;
background: #fdf5f5;
border: 1px solid #d9534f40;
padding: 15px;
margin-bottom: 25px;
border-radius: 8px;
text-align: center;
font-size: 0.95rem;
}
/* ---------------- Chat Page Specific Styles ---------------- */
.header {
background: #5cb85c; /* 清新绿色,与主题色保持一致 */
color: white;
padding: 18px 25px;
/* border-radius: 12px 12px 0 0; */ /* 容器已经有圆角,这里不需要 */
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
min-height: 70px; /* 确保头部高度 */
}
.header h1 {
margin: 0;
font-size: 1.4rem;
display: flex;
align-items: center;
color: white; /* 标题颜色改为白色 */
font-weight: 600;
}
.header h1 svg {
margin-right: 10px;
width: 28px;
height: 28px;
}
/* 对 header 内部的在线人数显示进行优化 */
.header #online-users {
flex-grow: 1; /* 让它在标题和按钮之间占据更多空间 */
text-align: center; /* 居中显示在线列表的标题 */
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.8);
position: relative; /* 允许在其中定位列表 */
}
.header #online-users strong {
color: white;
margin-right: 5px;
}
.header #online-users .online-count {
font-weight: bold;
color: #ffeb3b; /* 强调显示在线人数,使用亮黄色 */
margin-left: 5px;
}
/* 在线用户列表的实际用户名称div */
.header #online-users .online-user-list-toggle { /* Trigger for the dropdown */
cursor: pointer;
text-decoration: underline;
margin-left: 5px;
}
.header #online-users .online-user-list { /* Hidden by default */
display: none;
position: absolute;
top: 100%; /* Below the "在线用户" text */
left: 50%;
transform: translateX(-50%);
background: rgba(4, 38, 12, 0.95); /* Darker, slightly transparent background */
border-radius: 8px;
padding: 10px 15px;
z-index: 100;
right: auto;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 4px 15px rgba(0,0,0,0.4);
min-width: 150px;
text-align: left;
white-space: nowrap; /* Prevent names from wrapping */
}
.header #online-users:hover .online-user-list { /* Show on hover */
display: block;
}
.header #online-users .online-user-list div {
padding: 3px 0;
color: #a7d08f; /* Light green for online names */
}
.header #online-users .online-user-list div:hover {
color: #ffeb3b; /* Highlight on hover */
}
#logout-btn {
width: auto;
padding: 10px 18px;
background: #f0ad4e; /* 橙色表示退出,与主题色协调 */
color: white;
font-size: 0.95rem;
margin-left: 20px; /* 与在线用户列表保持距离 */
}
#logout-btn:hover {
background: #ec971f;
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.messages {
flex-grow: 1; /* 占据可用空间 */
overflow-y: auto; /* 消息滚动 */
padding: 20px 25px;
background: #fbfbfb; /* 消息区背景 */
/* border-left: 1px solid #eceff1;
border-right: 1px solid #eceff1; */ /* 外部容器已处理阴影,这里可以移除这些边框 */
scroll-behavior: smooth;
}
.messages::-webkit-scrollbar {
width: 8px; /* 调整滚动条宽度 */
}
.messages::-webkit-scrollbar-track {
background: #f1f1f1; /* 滚动条背景 track */
border-radius: 10px;
}
.messages::-webkit-scrollbar-thumb {
background: #c0c0c0; /* 滚动条滑块 thumb */
border-radius: 10px;
}
.messages::-webkit-scrollbar-thumb:hover {
background: #888; /* 滑块悬停颜色 */
}
.message-item {
margin-bottom: 15px;
clear: both; /* 清除浮动,确保消息独立显示 */
}
.message-meta {
font-size: 0.8em;
color: #999;
margin-bottom: 5px;
display: block; /* 确保时间在发送者下方或旁边独占一行 */
}
.message-meta strong {
color: #333;
font-weight: 600;
}
.message-content {
padding: 10px 15px;
border-radius: 18px; /* 圆角气泡 */
max-width: 70%; /* 消息气泡最大宽度 */
word-wrap: break-word;
white-space: pre-wrap; /* Preserve whitespace and breaks */
box-shadow: 0 1px 3px rgba(0,0,0,0.08); /* 轻微阴影 */
line-height: 1.4;
font-size: 0.95rem;
}
.message-item.sent { /* 我的消息 */
text-align: right; /* Meta 和 Content 都右对齐 */
}
.message-item.sent .message-meta {
text-align: right;
color: #777;
margin-right: 15px; /* 推开一点距离,与气泡边界对齐 */
}
.message-item.sent .message-content {
background-color: #dcf8c6; /* 清新淡绿色 */
float: right;
border-bottom-right-radius: 4px; /* 稍微削平一个角,形成聊天气泡“尾巴” */
}
.message-item.received { /* 其他人的消息 */
text-align: left;
}
.message-item.received .message-meta {
text-align: left;
margin-left: 15px; /* 推开一点距离 */
}
.message-item.received .message-content {
background-color: #e0e0e0; /* 淡灰色 */
float: left;
border-bottom-left-radius: 4px; /* 削平一个角 */
}
/* System messages (用户加入/离开、通知等) */
.message-item.system {
text-align: center;
margin: 15px 0; /* 控制系统消息上下间距 */
}
.message-item.system .message-content {
display: inline-block; /* 居中显示 */
background: #ffeeb3; /* 浅黄色,作为提示 */
color: #6a5a41; /* 暗文本颜色 */
font-style: italic;
font-size: 0.85em;
padding: 8px 15px;
border-radius: 20px;
max-width: fit-content;
box-shadow: none;
text-align: center;
}
.input-area {
display: flex;
padding: 15px 25px;
background: white;
/* border-radius: 0 0 12px 12px; */ /* 容器圆角已处理 */
border-top: 1px solid #eceff1;
box-shadow: 0 -2px 10px rgba(0,0,0,0.05); /* 底部阴影 */
min-height: 70px; /* 确保输入区域高度 */
}
#message-input {
flex-grow: 1; /* 占据剩余空间 */
padding: 12px 15px;
border: 1px solid #cceeff;
border-radius: 8px; /* 圆角 */
font-size: 1rem;
margin-right: 15px; /* 按钮与输入框间距 */
resize: none; /* 防止用户调整文本框大小 */
min-height: 48px; /* 最小高度 */
max-height: 120px; /* 最大高度,自动换行时会扩展 */
height: auto; /* 初始高度根据内容自适应 */
overflow-y: auto; /* 超过 max-height 后出现滚动条 */
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
#message-input:focus {
border-color: #5cb85c;
outline: none;
box-shadow: 0 0 0 4px rgba(92, 184, 92, 0.25);
}
#send-btn {
width: auto;
padding: 12px 28px;
background: #5cb85c; /* 和头部及主题色一致 */
color: white;
font-size: 1.05rem;
}
#send-btn:hover {
background: #4cae4c;
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(92, 184, 92, 0.3);
}
/* @media for smaller screens */
@media (max-width: 768px) {
body {
padding: 0;
align-items: flex-start; /* 小屏幕从顶部对齐 */
}
.auth-container {
border-radius: 0;
box-shadow: none;
padding: 20px;
margin: auto;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
}
.container {
border-radius: 0;
box-shadow: none;
max-width: 100%;
max-height: 100vh;
height: 100vh;
min-height: auto; /* 在小屏幕上取消min-height限制 */
}
h2, h1 {
font-size: 1.4rem;
margin-bottom: 20px;
}
.header {
flex-wrap: wrap; /* 允许在小屏幕上换行 */
padding: 15px;
min-height: auto;
}
.header h1 {
width: 100%; /* 占据一行 */
margin-bottom: 10px;
font-size: 1.3rem;
justify-content: center; /* 标题居中 */
}
.header #online-users {
width: 100%;
padding: 5px 0;
text-align: center;
order: 2; /* 放到标题和按钮之间 */
}
.header #online-users:hover .online-user-list {
display: none; /* Prevent hover from opening on touch devices */
}
#logout-btn {
width: 100%;
margin-top: 10px;
margin-left: 0;
order: 3; /* 将登出按钮放到最后 */
padding: 8px;
font-size: 0.9rem;
}
.messages {
padding: 15px;
}
.input-area {
padding: 10px 15px;
min-height: auto;
}
#message-input {
padding: 10px;
font-size: 0.95rem;
margin-right: 10px;
max-height: 80px; /* 小屏幕降低 maxHeight */
}
#send-btn {
padding: 10px 20px;
font-size: 0.95rem;
}
.message-item.sent .message-content,
.message-item.received .message-content {
max-width: 85%; /* 小屏幕增加气泡宽度 */
}
.message-meta {
font-size: 0.75em;
}
}
/* Flash Messages */
.flash-message {
padding: 12px 20px;
margin-bottom: 25px;
border-radius: 8px;
font-size: 0.95rem;
text-align: center;
border: 1px solid transparent;
animation: slideIn 0.5s ease-out;
}
.flash-message.error {
background-color: #ffdce0; /* 淡红色背景 */
color: #cc0000; /* 深红色文字 */
border-color: #ffb3b8;
}
.flash-message.success {
background-color: #e6ffef; /* 淡绿色背景 */
color: #008800; /* 深绿色文字 */
border-color: #c9f0d8;
}
/* 可以在这里添加一个 warn/info 类似的样式 */
.flash-message.info,
.flash-message.warning {
background-color: #fffbd6; /* 淡黄色背景 */
color: #b38b00; /* 深黄色文字 */
border-color: #ffd85d;
}
3-7 定义static/js/main.js --- js样式
// flask_chatroom/static/js/main.js
document.addEventListener('DOMContentLoaded', () => {
const chatBody = document.body; // 获取body元素
const username = chatBody.dataset.username || 'unknown'; // 从body的data属性获取username
// UI 元素
const messagesDiv = document.getElementById('messages');
const input = document.getElementById('message-input');
const sendBtn = document.getElementById('send-btn');
const logoutBtn = document.getElementById('logout-btn');
// 在线用户相关元素
const onlineCountSpan = document.getElementById('online-count');
const onlineUsersListDiv = document.querySelector('#online-users .online-user-list');
// 连接 Socket.IO
const socket = io();
// 辅助函数:显示消息
function displayMessage(data, isSystem = false) {
const msgItem = document.createElement('div');
msgItem.className = 'message-item';
if (isSystem) {
msgItem.classList.add('system');
const content = document.createElement('div');
content.className = 'message-content';
content.textContent = data.message;
msgItem.appendChild(content);
} else {
msgItem.classList.add(data.sender === username ? 'sent' : 'received');
const meta = document.createElement('div');
meta.className = 'message-meta';
// 添加时间戳显示
meta.innerHTML = `<strong>${data.sender}</strong> <span style="font-size:0.9em; color:#777; margin-left: 8px;">${data.timestamp}</span>`;
const content = document.createElement('div');
content.className = 'message-content';
content.style.backgroundColor = data.color || ''; // 应用发送者颜色 (如果后端提供)
content.innerHTML = data.message; // 注意这里用 innerHTML,因为后端已经安全转义了
msgItem.appendChild(meta);
msgItem.appendChild(content);
}
messagesDiv.appendChild(msgItem);
// 消息区域自动滚动到底部
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
// 辅助函数:更新在线用户列表
function updateOnlineUsersDisplay(onlineUsersArray) {
if (!onlineCountSpan || !onlineUsersListDiv) return; // 元素不存在则退出
onlineCountSpan.textContent = onlineUsersArray.length; // 更新总人数
// 清空并重新填充在线用户列表(悬停显示的部分)
onlineUsersListDiv.innerHTML = '';
if (onlineUsersArray.length === 0) {
const noUser = document.createElement('div');
noUser.textContent = "暂无其他用户在线";
onlineUsersListDiv.appendChild(noUser);
} else {
onlineUsersArray.forEach(user => {
const userDiv = document.createElement('div');
userDiv.textContent = user;
onlineUsersListDiv.appendChild(userDiv);
});
}
}
// 发送消息处理
function sendMessage() {
const text = input.value.trim();
if (text) {
socket.emit('send_message', { message: text });
input.value = ''; // 清空输入框
input.style.height = 'auto'; // 重置高度
input.focus(); // 保持焦点
}
}
// 自动调整输入框高度
function adjustTextareaHeight() {
input.style.height = 'auto'; // 重置高度以获取正确内容高度
// 设置新的高度,不超过 max-height
input.style.height = Math.min(input.scrollHeight, parseInt(getComputedStyle(input).maxHeight)) + 'px';
}
// ------------------- 事件监听 -------------------
if (sendBtn) {
sendBtn.addEventListener('click', sendMessage);
}
if (input) {
// 回车发送消息,Shift+Enter 换行
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { // 阻止 Shift + Enter 换行
e.preventDefault(); // 阻止默认的回车行为
sendMessage();
}
});
// 输入时调整高度
input.addEventListener('input', adjustTextareaHeight);
}
// 退出登录
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
fetch('/logout', { method: 'POST' })
.then(() => {
sessionStorage.clear(); // 清除客户端存储以防万一
window.location.href = '/'; // 退出后重定向到登录页
})
.catch(error => console.error('Error logging out:', error));
});
}
// ------------------- Socket.IO 事件监听 -------------------
socket.on('connect', () => {
console.log('Connected to WebSocket server!');
// 确保连接后立即更新在线列表 (服务器会在连接时emit 'user_joined' 或 'update_online_users')
});
socket.on('disconnect', () => {
console.log('Disconnected from WebSocket server.');
displayMessage({ message: '与服务器断开连接,尝试重新连接...' }, true);
onlineCountSpan.textContent = '0';
onlineUsersListDiv.innerHTML = '<div>已断开连接</div>';
});
socket.on('reconnect', () => {
console.log('Reconnected to WebSocket server.');
displayMessage({ message: '已重新连接到服务器!' }, true);
});
socket.on('receive_message', (data) => {
displayMessage(data);
});
socket.on('user_joined', (data) => {
// 如果是当前用户自己加入,通常不需要显示 "xxx 加入"的系统消息(因为connect时已经知道,且主要用于通知其他人)
if (data.user !== username) {
displayMessage({ message: `用户 ${data.user} 已加入群聊。` }, true);
}
updateOnlineUsersDisplay(data.online);
});
socket.on('user_left', (data) => {
displayMessage({ message: `用户 ${data.user} 已离开群聊。` }, true);
updateOnlineUsersDisplay(data.online);
});
socket.on('update_online_users', (data) => {
// 主要用于处理同一用户多开窗口,或初始加载时同步在线列表
updateOnlineUsersDisplay(data.online);
});
// 初始聚焦和高度调整
if (input) {
setTimeout(() => {
input.focus();
adjustTextareaHeight(); // 首次加载可能需要调整一次高度,以兼容初始 placeholder
}, 100);
}
});
3-8 定义static/img/bg.jpg 添加 bg.jpg的图片
4 项目运行
运行 python app.py 项目
4-1 登录页面

4-2 聊天页面


4-3 多人聊天

还可以查看在线的人数 记得无痕浏览器 不保存cookie

多人聊天


4-4 聊天记录保存


浙公网安备 33010602011771号