demo

Python简易聊天室项目需求

项目概述:
使用Python开发一个Web版的简易群聊聊天室,支持用户登录、实时消息发送与接收,并具备基本的日志记录功能。后端将利用异步框架和WebSocket技术提供高效的实时通信服务。

1 项目介绍

1-1 技术栈选择 (推荐)

  • 后端Web框架: Flask (Flask 需要结合 Flask-SocketIO,而且我正在使用flask框架)
    • 理由: 它们都是轻量级且高效的Python Web框架。FastAPI 基于ASGI (异步服务网关接口),天生支持异步和WebSocket,非常适合处理高并发实时通信。Flask 结合 Flask-SocketIO 也能提供强大的WebSocket功能。
  • 实时通信协议: WebSocket
    • 理由: 实现全双工、低延迟的双向通信,是 Web 实时聊天应用的黄金标准,远优于传统的 HTTP 轮询 (polling) 或长轮询 (long polling)。这将替换“多线程TCP连接服务”的原始意图,因为它在Web环境下更高效和现代化。
  • 前端技术: HTML, CSS, JavaScript (无需复杂框架,可使用原生JS或少量DOM操作库如jQuery)
  • 数据库模拟: .jsonl 文件
  • 并发: Flask-SocketIO 处理并发连接,其底层通过事件循环或线程池管理TCP连接。

1-2 详细功能需求

2.1 用户认证与管理 (后端)

  • 账户数据模拟:
    • 使用 users.jsonl 文件存储用户账户信息,每行一个JSON对象。
    • 每个用户记录包含 {"username": "...", "password_hash": "..."}
    • 优化: 用户密码必须进行哈希存储 (例如使用 bcryptpasslib) 而不是明文或简单编码,提高安全性。
  • 登录服务:
    • 接收网页端提交的用户名和密码。
    • users.jsonl 中查询用户,并验证提交的密码与存储的哈希密码是否匹配。
    • 优化: 成功登录后,生成并返回一个认证凭证 (session tokenJWT) 给前端,用于后续请求的身份验证,而不是每次都重新验证用户名密码,同时防止未授权访问。不成功的登录尝试需返回明确的错误信息。

2.2 网页端交互 (前端)

  • 登录页面:
    • HTML 界面,包含用户名输入框、密码输入框和登录按钮。
    • JavaScript 处理表单提交,通过 fetchXMLHttpRequest 向后端发送登录请求。
    • 根据后端响应显示登录成功或失败的信息。
  • 群聊界面:
    • 成功登录后通过后端重定向或前端路由跳转到群聊界面。
    • 界面布局简洁,包含:
      • 消息显示区域:滚动显示所有聊天消息。每条消息应包含发送者用户名和消息内容。
      • 消息输入框:用户输入消息。
      • 发送按钮:点击发送消息。
      • 优化: 实时更新在线用户列表 (可选,但会显著提升用户体验)。

2.3 实时群聊功能 (后端与WebSocket)

  • WebSocket连接:
    • 用户登录后,前端通过WebSocket连接后端。
    • 后端记录每个活跃的WebSocket连接以及对应的用户身份。
  • 消息发送:
    • 前端监听消息输入框和发送按钮,通过WebSocket发送用户输入的消息到后端。
    • 消息格式应包含 (事件类型, 消息内容, 发送者, 时间戳)。
  • 消息广播:
    • 后端接收到一条消息后,将其(包括发送者和时间戳)广播给所有当前在线的连接(即聊天室内的所有用户)。
    • 优化: 广播前可以对消息进行简单的内容转义(如防止XSS攻击),确保用户输入不会破坏页面结构。
  • 消息接收与显示:
    • 前端监听WebSocket接收到的消息,实时将其添加到消息显示区域。新消息自动滚动到底部。
    • 每条消息应清晰显示发送者、消息内容和发送时间。
  • 连接管理:
    • 当用户关闭页面或网络断开时,后端应检测到WebSocket连接的断开,并相应地清理连接信息。

2.4 日志记录

  • 消息日志:
    • 所有用户发送的聊天消息都必须保存在一个日志文件 (chatlog.jsonlchatlog.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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;').replace("'", '&#x27;')

# 简易内存在线用户管理(更健壮的方案可用 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>)&nbsp;&nbsp;<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 登录页面

image

4-2 聊天页面

image

image

4-3 多人聊天

image

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

image

多人聊天

image

image

4-4 聊天记录保存

image

posted @ 2026-02-21 10:57  edward-y  阅读(22)  评论(0)    收藏  举报