From b948b8c2070dbe2c8ebe9d6dab853d4fddd4171a Mon Sep 17 00:00:00 2001 From: p6fbkl32v Date: Fri, 15 May 2026 16:28:34 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20SimpleChat=20=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E9=80=9A=E4=BF=A1=E7=B3=BB=E7=BB=9F=20v2.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 Python + PyQt5 的局域网即时通信系统,支持私聊、群聊、好友管理和历史记录。 --- .gitignore | 9 + README.md | 182 +++ client/client_core.py | 217 +++ client/config.json | 10 + client/config.py | 45 + client/config/client_config.json | 11 + client/main.py | 606 ++++++++ client/ui/group_detail_window.py | 192 +++ client/ui/history_window.py | 193 +++ client/ui/login_window.py | 375 +++++ client/ui/main_window.py | 1557 ++++++++++++++++++++ client/ui/register_window.py | 300 ++++ client/ui/search_window.py | 244 +++ client/ui/widgets.py | 407 +++++ client/utils.py | 46 + config.json | 10 + database/images/20260514172153_1_test.png | Bin 0 -> 70 bytes database/images/20260514172232_1_hello.png | Bin 0 -> 70 bytes database/init_db.sql | 101 ++ requirements.txt | 2 + server/config.py | 45 + server/database.py | 603 ++++++++ server/main.py | 37 + server/server_core.py | 624 ++++++++ 软件文档.md | 432 ++++++ 25 files changed, 6248 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 client/client_core.py create mode 100644 client/config.json create mode 100644 client/config.py create mode 100644 client/config/client_config.json create mode 100644 client/main.py create mode 100644 client/ui/group_detail_window.py create mode 100644 client/ui/history_window.py create mode 100644 client/ui/login_window.py create mode 100644 client/ui/main_window.py create mode 100644 client/ui/register_window.py create mode 100644 client/ui/search_window.py create mode 100644 client/ui/widgets.py create mode 100644 client/utils.py create mode 100644 config.json create mode 100644 database/images/20260514172153_1_test.png create mode 100644 database/images/20260514172232_1_hello.png create mode 100644 database/init_db.sql create mode 100644 requirements.txt create mode 100644 server/config.py create mode 100644 server/database.py create mode 100644 server/main.py create mode 100644 server/server_core.py create mode 100644 软件文档.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6eec0df --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +*.log +logs/ +build/ +dist/ +=5.15.0 +*.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..289e5a5 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# SimpleChat + +基于 Python + PyQt5 的局域网即时通信系统,支持私聊、群聊、好友管理和历史记录。 + +## 环境要求 + +- Python 3.8+ +- PyQt5 >= 5.15.0 + +```bash +pip install PyQt5>=5.15.0 +``` + +## 快速启动 + +### 方式一:直接运行(需 Python 环境) + +**启动服务器**: + +```bash +cd server +python main.py +# python main.py --host 0.0.0.0 --port 8888 +``` + +**启动客户端**: + +```bash +cd client +python main.py +``` + +### 方式二:打包运行(无需 Python 环境) + +打包后的可执行文件位于 `dist/SimpleChat/`: + +``` +dist/SimpleChat/ +├── SimpleChat-Server.exe # 服务器(8.5 MB) +├── SimpleChat-Client.exe # 客户端(37 MB,含 PyQt5) +├── 启动服务器.bat # 一键启动服务器 +├── 使用说明.txt # 使用指南 +├── database/ # 数据自动创建于此 +├── avatars/ +├── messages/ +└── logs/ +``` + +1. 双击 `启动服务器.bat` 或 `SimpleChat-Server.exe` +2. 双击 `SimpleChat-Client.exe` + +自定义打包: + +```bash +pip install pyinstaller +pyinstaller --onefile --console --name SimpleChat-Server server/main.py +pyinstaller --onefile --noconsole --name SimpleChat-Client --hidden-import PyQt5.sip client/main.py +``` + +## 默认账户 + +| 用户名 | 密码 | 昵称 | +|--------|--------|------------| +| admin | 123456 | 系统管理员 | +| user1 | 123456 | 用户一 | +| user2 | 123456 | 用户二 | +| user3 | 123456 | 用户三 | + +## 功能 + +- 用户注册/登录 +- 个人资料修改:用户名、昵称、密码(入口:主窗口右上角齿轮按钮) +- 私聊:实时消息 + 历史记录 + 消息搜索 +- 群聊:创建/加入/退出群组、群内广播、邀请好友 +- 好友管理:添加/删除好友、在线状态实时更新 +- 会话列表:最近聊天、未读标记、消息预览 +- 用户搜索与发现 +- 表情选择器 +- 通知浮层与任务栏闪烁提醒 +- 心跳保活 +- 多账号互踢 +- 北京时间同步:所有消息时间戳统一使用 UTC+8 + +## 测试数据 + +首次启动服务器时自动填充测试数据,包括: +- 4 个用户之间的 14 条私聊对话 +- "技术交流群"群组及 9 条群聊消息 +- 5 组好友关系 + +## 界面设计 + +- 主色调:Indigo-Violet 紫蓝渐变(`#6366f1` → `#8b5cf6`) +- 侧边栏:深色渐变背景 + 半透明列表 +- 聊天区:淡紫气泡(本人)/ 白色气泡(对方) +- 欢迎页:紫蓝粉三色渐变卡片 +- 响应式布局:窗口大小自适应 + +## 项目结构 + +``` +├── client/ +│ ├── main.py # 客户端入口,ChatApplication 协调层 +│ ├── client_core.py # TCP 通信核心 +│ ├── config.py # 配置管理 +│ ├── utils.py # 工具函数(校验、配置初始化) +│ └── ui/ +│ ├── login_window.py # 登录窗口(响应式) +│ ├── register_window.py # 注册窗口(含实时校验) +│ ├── main_window.py # 主窗口(会话、好友、群组、发现) +│ ├── widgets.py # 共享组件(校验器、通知浮层、表情、设置) +│ ├── history_window.py # 聊天记录浏览器 +│ ├── search_window.py # 消息搜索窗口 +│ └── group_detail_window.py # 群组详情窗口 +├── server/ +│ ├── main.py # 服务器入口 +│ ├── server_core.py # 服务器核心(多线程 TCP + 消息路由) +│ ├── database.py # SQLite 数据库操作(WAL 模式) +│ └── config.py # 服务器配置 +├── database/ +│ └── init_db.sql # 数据库初始化脚本 +├── messages/ # 本地消息缓存目录 +├── avatars/ # 头像文件目录 +├── logs/ # 日志目录 +└── requirements.txt +``` + +## 数据库 + +- 引擎:SQLite(WAL 日志模式,支持并发读写) +- 外键约束已启用 +- 用户密码使用 MD5 哈希存储 +- 所有时间戳统一使用北京时间(UTC+8) +- 首次启动自动创建 4 个测试用户并填充示例聊天记录 +- 数据持久化:用户名、密码、昵称、好友关系、聊天记录、群组信息全部保存至 `database/chat.db` + +### 数据表 + +| 表名 | 说明 | +|------|------| +| users | 用户表(含 updated_at 时间戳) | +| friendships | 好友关系表(双向存储) | +| messages | 消息表(私聊/群聊) | +| groups | 群组表 | +| group_members | 群组成员表 | + +## 通信协议 + +消息格式:`[4字节大端长度][JSON数据]` + +所有消息均为 JSON,通过 `type` 字段区分类型。主要类型: + +| type | 说明 | +|-------------------|-------------| +| login/register | 登录/注册 | +| change_username | 修改用户名 | +| change_nickname | 修改昵称 | +| change_password | 修改密码 | +| chat/group_chat | 私聊/群聊消息 | +| get_users/friends | 获取用户/好友列表| +| create/join/leave_group | 群组操作 | +| get_history | 获取聊天记录 | +| search_messages | 搜索消息 | +| heartbeat | 心跳保活 | + +## 更新日志 + +### v2.3 +- 所有时间戳统一为北京时间(UTC+8),消除时区偏差 +- 首次启动自动填充测试数据(好友、群组、聊天记录) +- 数据库 init_database 集成 seed 逻辑 + +### v2.2 +- 新增用户名、昵称、密码修改功能 +- 数据库启用 WAL 模式,性能提升 +- 新增 updated_at 时间戳字段 +- 全面升级 UI 配色(Indigo-Violet 主题) +- 默认内置 4 个测试用户 +- SettingsDialog 重构,支持多类型资料修改 + +### v2.1 +- 初始版本:注册登录、私聊、群聊、好友管理、历史记录、消息搜索 diff --git a/client/client_core.py b/client/client_core.py new file mode 100644 index 0000000..6338b45 --- /dev/null +++ b/client/client_core.py @@ -0,0 +1,217 @@ +""" +客户端核心 —— 负责与服务器的 TCP 通信 +""" +import socket +import struct +import threading +import json +import time +from datetime import datetime + + +class ChatClient: + def __init__(self, host='127.0.0.1', port=8888): + self.host = host + self.port = port + self.sock = None + self.connected = False + self._running = False + self._lock = threading.Lock() + + # 用户信息(登录成功后填充) + self.user_id = None + self.username = None + self.nickname = None + + # 在线用户缓存 {user_id: username} + self.online_users = {} + + # 回调(由 ChatApplication 设置) + self.on_message = None # fn(message_dict) + self.on_connected = None # fn(bool) + + # ── 连接管理 ────────────────────────────────────────── + def connect(self): + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(5) + self.sock.connect((self.host, self.port)) + self.sock.settimeout(5) # 短超时,让 recv_loop 能及时响应 _running=False + self.connected = True + self._running = True + threading.Thread(target=self._recv_loop, daemon=True).start() + threading.Thread(target=self._heartbeat_loop, daemon=True).start() + return True, "连接成功" + except Exception as e: + return False, f"连接失败: {e}" + + def disconnect(self): + if not self._running: + return + self._running = False + self.connected = False + try: + self.sock.close() + except Exception: + pass + if self.on_connected: + self.on_connected(False) + + # ── 收发 ────────────────────────────────────────────── + def _recv_exact(self, n): + buf = b'' + while len(buf) < n: + if not self._running: + return None + try: + chunk = self.sock.recv(n - len(buf)) + if not chunk: + return None + buf += chunk + except socket.timeout: + continue + return buf + + def _recv_loop(self): + while self._running: + try: + header = self._recv_exact(4) + if not header: + break + msg_len = struct.unpack('>I', header)[0] + data = self._recv_exact(msg_len) + if not data: + break + try: + msg = json.loads(data.decode('utf-8')) + self._handle_incoming(msg) + except json.JSONDecodeError: + print("[client] 收到无效JSON") + except socket.timeout: + continue + except Exception as e: + if self._running: + print(f"[client] 接收出错: {e}") + break + self.disconnect() + + def send(self, message): + if not self.connected: + return False, "未连接到服务器" + try: + data = json.dumps(message, ensure_ascii=False).encode('utf-8') + with self._lock: + self.sock.sendall(struct.pack('>I', len(data)) + data) + return True, "ok" + except Exception as e: + return False, str(e) + + def _heartbeat_loop(self): + while self._running: + time.sleep(25) + if self._running: + self.send({'type': 'heartbeat', 'timestamp': time.time()}) + + # ── 消息处理 ────────────────────────────────────────── + def _handle_incoming(self, msg): + t = msg.get('type') + if t == 'login_response' and msg.get('success'): + info = msg.get('user_info', {}) + self.user_id = info.get('id') + self.username = info.get('username') + self.nickname = info.get('nickname', self.username) + elif t == 'user_status': + uid = msg.get('user_id') + if msg.get('is_online'): + self.online_users[uid] = msg.get('username') + else: + self.online_users.pop(uid, None) + elif t == 'heartbeat_response': + return # 静默处理 + if self.on_message: + self.on_message(msg) + + # ── 业务接口 ────────────────────────────────────────── + def login(self, username, password): + return self.send({'type': 'login', 'username': username, 'password': password}) + + def register(self, username, password, nickname=''): + return self.send({'type': 'register', 'username': username, + 'password': password, 'nickname': nickname}) + + def send_chat(self, receiver, content): + return self.send({'type': 'chat', 'receiver': receiver, 'content': content}) + + def send_group_chat(self, group_id, content): + return self.send({'type': 'group_chat', 'group_id': group_id, 'content': content}) + + def get_users(self): + return self.send({'type': 'get_users'}) + + def search_users(self, keyword): + return self.send({'type': 'search_users', 'keyword': keyword}) + + def add_friend(self, username): + return self.send({'type': 'add_friend', 'username': username}) + + def remove_friend(self, friend_id): + return self.send({'type': 'remove_friend', 'friend_id': friend_id}) + + def get_friends(self): + return self.send({'type': 'get_friends'}) + + def get_history(self, friend_username, limit=50): + return self.send({'type': 'get_history', 'username': friend_username, 'limit': limit}) + + def get_group_history(self, group_id, limit=50): + return self.send({'type': 'get_group_history', 'group_id': group_id, 'limit': limit}) + + def create_group(self, group_name): + return self.send({'type': 'create_group', 'group_name': group_name}) + + def get_groups(self): + return self.send({'type': 'get_groups'}) + + def get_all_groups(self): + return self.send({'type': 'get_all_groups'}) + + def join_group(self, group_id): + return self.send({'type': 'join_group', 'group_id': group_id}) + + def get_group_members(self, group_id): + return self.send({'type': 'get_group_members', 'group_id': group_id}) + + def leave_group(self, group_id): + return self.send({'type': 'leave_group', 'group_id': group_id}) + + def invite_to_group(self, group_id, username): + return self.send({'type': 'invite_to_group', 'group_id': group_id, 'username': username}) + + def search_messages(self, chat_type, target_id, keyword): + return self.send({'type': 'search_messages', 'chat_type': chat_type, + 'target_id': target_id, 'keyword': keyword}) + + def get_all_history(self, limit=200): + return self.send({'type': 'get_all_history', 'limit': limit}) + + def get_recent_history(self, chat_type, target_id, limit=50): + return self.send({'type': 'get_recent_history', 'chat_type': chat_type, + 'target_id': target_id, 'limit': limit}) + + def change_username(self, new_username): + return self.send({'type': 'change_username', 'new_username': new_username}) + + def change_nickname(self, new_nickname): + return self.send({'type': 'change_nickname', 'new_nickname': new_nickname}) + + def change_password(self, old_password, new_password): + return self.send({'type': 'change_password', 'old_password': old_password, 'new_password': new_password}) + + def send_chat_image(self, receiver, filename, file_data_b64): + return self.send({'type': 'chat_image', 'receiver': receiver, + 'filename': filename, 'data': file_data_b64}) + + def send_group_chat_image(self, group_id, filename, file_data_b64): + return self.send({'type': 'group_chat_image', 'group_id': group_id, + 'filename': filename, 'data': file_data_b64}) + diff --git a/client/config.json b/client/config.json new file mode 100644 index 0000000..2d4e73e --- /dev/null +++ b/client/config.json @@ -0,0 +1,10 @@ +{ + "server_host": "127.0.0.1", + "server_port": 8888, + "buffer_size": 4096, + "timeout": 30, + "auto_login": false, + "username": "admin", + "password": "123456", + "save_password": true +} \ No newline at end of file diff --git a/client/config.py b/client/config.py new file mode 100644 index 0000000..e2ff3bb --- /dev/null +++ b/client/config.py @@ -0,0 +1,45 @@ +""" +客户端配置文件 +""" +import json +import os + +class Config: + def __init__(self, config_file='config.json'): + self.config_file = config_file + self.default_config = { + 'server_host': '127.0.0.1', + 'server_port': 8888, + 'buffer_size': 4096, + 'timeout': 30, + 'auto_login': False, + 'username': '', + 'password': '', + 'save_password': False + } + self.config = self.load_config() + + def load_config(self): + """加载配置文件""" + if os.path.exists(self.config_file): + try: + with open(self.config_file, 'r', encoding='utf-8') as f: + return {**self.default_config, **json.load(f)} + except: + return self.default_config + return self.default_config + + def get(self, key, default=None): + """获取配置项""" + return self.config.get(key, default) + + def set(self, key, value): + """设置配置项""" + self.config[key] = value + + def save(self): + """保存配置""" + with open(self.config_file, 'w', encoding='utf-8') as f: + json.dump(self.config, f, indent=4, ensure_ascii=False) + +config = Config() \ No newline at end of file diff --git a/client/config/client_config.json b/client/config/client_config.json new file mode 100644 index 0000000..9ff85cb --- /dev/null +++ b/client/config/client_config.json @@ -0,0 +1,11 @@ +{ + "server_host": "127.0.0.1", + "server_port": 8888, + "auto_login": false, + "username": "", + "save_password": false, + "theme": "light", + "font_size": 12, + "notify_new_message": true, + "notify_sound": true +} \ No newline at end of file diff --git a/client/main.py b/client/main.py new file mode 100644 index 0000000..d40f582 --- /dev/null +++ b/client/main.py @@ -0,0 +1,606 @@ +""" +客户端主程序 —— ChatApplication 负责协调 UI 与网络层 +""" +import sys +import os +import traceback +from PyQt5.QtWidgets import QApplication, QMessageBox +from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QObject +from PyQt5.QtGui import QFont + +from config import config +from client_core import ChatClient +from ui.login_window import LoginWindow +from ui.register_window import RegisterWindow +from ui.main_window import MainWindow +from utils import validate_username, validate_password, create_default_config + + +class ChatApplication(QObject): + # 跨线程消息信号:接收线程 → 主线程 + _server_message_signal = pyqtSignal(dict) + + def __init__(self): + super().__init__() + self.app = QApplication(sys.argv) + self.app.setApplicationName("SimpleChat") + font = QFont("Microsoft YaHei" if sys.platform == "win32" else "PingFang SC", 10) + self.app.setFont(font) + + for d in ('messages', 'messages/images', 'avatars', 'logs'): + os.makedirs(d, exist_ok=True) + + self.client: ChatClient = None + self.window = None + self.user_info = None + + # 跨线程信号 → 主线程安全 dispatch + self._server_message_signal.connect(self._safe_dispatch) + + # ── 窗口切换 ────────────────────────────────────────── + def _switch(self, new_window): + if self.window: + try: + self.window.close() + except Exception: + pass + self.window = new_window + self.window.show() + + def show_login(self): + try: + w = LoginWindow(config) + w.login_requested.connect(self._do_login) + w.show_register.connect(self.show_register) + self._switch(w) + except Exception as e: + traceback.print_exc() + QMessageBox.critical(None, "错误", f"无法打开登录窗口: {e}") + + def show_register(self): + try: + w = RegisterWindow() + w.register_requested.connect(self._do_register) + w.show_login.connect(self.show_login) + self._switch(w) + except Exception as e: + traceback.print_exc() + QMessageBox.critical(None, "错误", f"无法打开注册窗口: {e}") + + def show_main(self, user_info): + try: + self.user_info = user_info + w = MainWindow(user_info['username'], + user_info.get('nickname', user_info['username']), + user_info) + + # 连接核心信号 + w.chat_requested.connect(self._send_chat) + w.group_chat_requested.connect(self._send_group_chat) + w.image_chat_requested.connect(self._send_image_chat) + w.image_group_chat_requested.connect(self._send_group_image_chat) + w.add_friend_requested.connect(lambda u: self.client.add_friend(u)) + w.remove_friend_requested.connect(lambda fid: self.client.remove_friend(fid)) + w.create_group_requested.connect(lambda n: self.client.create_group(n)) + w.join_group_requested.connect(lambda gid: self.client.join_group(gid)) + w.get_users_requested.connect(lambda: self.client.get_users()) + w.get_friends_requested.connect(lambda: self.client.get_friends()) + w.get_groups_requested.connect(lambda: self.client.get_groups()) + w.get_all_groups_requested.connect(lambda: self.client.get_all_groups()) + w.get_history_requested.connect(lambda u: self.client.get_history(u)) + w.get_group_history_requested.connect(lambda gid: self.client.get_group_history(gid)) + w.load_more_history_requested.connect( + lambda target_name, target_id: ( + self.client.get_group_history(target_id, 100) if isinstance(target_id, int) + else self.client.get_history(target_name, 100) + )) + w.logout_requested.connect(self._logout) + w.leave_group_requested.connect(lambda gid: self.client.leave_group(gid)) + w.invite_to_group_requested.connect( + lambda gid, uname: self.client.invite_to_group(gid, uname)) + w.all_history_requested.connect(lambda: self.client.get_all_history()) + w.search_messages_requested.connect( + lambda ct, tid, kw: self.client.search_messages(ct, tid, kw)) + w.profile_update_requested.connect(lambda nick: self._update_profile(nick)) + w.change_username_requested.connect(lambda uname: self.client.change_username(uname)) + w.change_nickname_requested.connect(lambda nick: self.client.change_nickname(nick)) + w.change_password_requested.connect(lambda old_pw, new_pw: self.client.change_password(old_pw, new_pw)) + w.get_group_members_requested.connect(lambda gid: self.client.get_group_members(gid)) + + self.client.on_message = self._on_server_message + self.client.on_connected = self._on_connection_changed + + self._switch(w) + w.update_status("已连接到服务器") + + QTimer.singleShot(600, lambda: self.client.get_friends()) + QTimer.singleShot(900, lambda: self.client.get_groups()) + QTimer.singleShot(1200, lambda: self.client.get_users()) + QTimer.singleShot(2000, lambda: self._auto_open_first_chat()) + except Exception as e: + traceback.print_exc() + QMessageBox.critical(None, "错误", f"无法打开主窗口: {e}") + self.show_login() + + # ── 登录 / 注册 ─────────────────────────────────────── + def _do_login(self, username, password, host, port): + try: + ok, msg = validate_username(username) + if not ok: + self.window.show_error(f"用户名无效: {msg}") + return + ok, msg = validate_password(password) + if not ok: + self.window.show_error(f"密码无效: {msg}") + return + except Exception as e: + self.window.show_error(f"验证失败: {e}") + return + + if self.client: + try: + self.client.disconnect() + except Exception: + pass + self.client = None + + self.client = ChatClient(host, port) + self.client.on_message = self._on_server_message + self.client.on_connected = self._on_connection_changed + + import threading as _threading + def _connect_and_login(): + try: + ok, err = self.client.connect() + if not ok: + self._emit_error(f"连接失败: {err}") + return + self.client.login(username, password) + # Schedule timeout check on main thread via signal + self._server_message_signal.emit({ + 'type': '_schedule_login_timeout' + }) + except Exception as e: + self._emit_error(f"登录异常: {e}") + + _threading.Thread(target=_connect_and_login, daemon=True).start() + + def _check_login_timeout(self): + try: + from ui.login_window import LoginWindow as _LW + if isinstance(self.window, _LW): + if not self.window.login_btn.isEnabled(): + self.window.show_error("登录超时,请检查服务器地址或网络连接") + except Exception: + pass + + def _do_register(self, username, password, nickname): + try: + if hasattr(self.window, 'show_loading'): + self.window.show_loading() + except Exception: + pass + + host = config.get('server_host', '127.0.0.1') + port = config.get('server_port', 8888) + + if self.client: + try: + self.client.disconnect() + except Exception: + pass + self.client = None + + import threading as _threading + def _connect_and_register(): + try: + self.client = ChatClient(host, port) + self.client.on_message = self._on_server_message + self.client.on_connected = self._on_connection_changed + ok, err = self.client.connect() + if not ok: + self._emit_register_error(f"连接失败: {err}") + return + self.client.register(username, password, nickname) + # Schedule timeout check on main thread via signal + self._server_message_signal.emit({ + 'type': '_schedule_register_timeout' + }) + except Exception as e: + self._emit_register_error(f"注册异常: {e}") + + _threading.Thread(target=_connect_and_register, daemon=True).start() + + def _emit_error(self, message): + """从子线程安全地发送错误到主线程""" + self._server_message_signal.emit({ + 'type': '_internal_error', 'message': message + }) + + def _emit_register_error(self, message): + self._server_message_signal.emit({ + 'type': '_internal_register_error', 'message': message + }) + + def _on_register_error(self, message): + try: + from ui.register_window import RegisterWindow as _RW + if isinstance(self.window, _RW): + self.window.show_error(message) + except Exception: + pass + if self.client: + try: + self.client.disconnect() + except Exception: + pass + self.client = None + + def _check_register_timeout(self): + try: + from ui.register_window import RegisterWindow as _RW + if isinstance(self.window, _RW): + if not self.window.register_btn.isEnabled(): + self.window.show_error("注册超时,请检查服务器地址或网络连接") + except Exception: + pass + + def _goto_login_after_register(self): + self.show_login() + + def _auto_open_first_chat(self): + """After login, auto-open chat with first online friend if any exist.""" + try: + if isinstance(self.window, MainWindow) and self.window.friends: + online = [f for f in self.window.friends if f.get('is_online')] + first = online[0] if online else self.window.friends[0] + self.window.open_chat('private', first.get('username'), first.get('username')) + except Exception: + pass + + def _logout(self): + if self.client: + try: + self.client.disconnect() + except Exception: + pass + self.client = None + self.show_login() + + def _update_profile(self, nickname): + try: + self.window.nickname = nickname + self.window.setWindowTitle( + f"SimpleChat — {nickname} (@{self.window.username})") + except Exception: + pass + + # ── 发消息 ──────────────────────────────────────────── + def _send_chat(self, content, receiver): + if self.client and self.client.connected: + self.client.send_chat(receiver, content) + + def _send_group_chat(self, group_id, content): + if self.client and self.client.connected: + self.client.send_group_chat(group_id, content) + + def _send_image_chat(self, receiver, filename, file_data_b64): + if self.client and self.client.connected: + self.client.send_chat_image(receiver, filename, file_data_b64) + + def _send_group_image_chat(self, group_id, filename, file_data_b64): + if self.client and self.client.connected: + self.client.send_group_chat_image(group_id, filename, file_data_b64) + + # ── 服务器消息接收(接收线程调用,通过信号转到主线程)─── + def _on_server_message(self, msg): + """在接收线程中调用,通过 pyqtSignal 安全转到主线程""" + self._server_message_signal.emit(msg) + + def _safe_dispatch(self, msg): + """在主线程中执行,安全更新 UI""" + try: + self._dispatch(msg) + except Exception as e: + traceback.print_exc() + if self.window and hasattr(self.window, 'show_error'): + try: + self.window.show_error(f"处理消息时出错: {e}") + except Exception: + pass + + def _dispatch(self, msg): + t = msg.get('type') + + # ── 内部错误消息 ── + if t == '_internal_error': + if self.window and hasattr(self.window, 'show_error'): + self.window.show_error(msg.get('message', '未知错误')) + return + elif t == '_internal_register_error': + self._on_register_error(msg.get('message', '未知错误')) + return + elif t == '_connection_changed': + self._handle_connection(msg.get('connected', False)) + return + elif t == '_schedule_login_timeout': + QTimer.singleShot(10000, self._check_login_timeout) + return + elif t == '_schedule_register_timeout': + QTimer.singleShot(10000, self._check_register_timeout) + return + + # ── 登录响应 ── + if t == 'login_response': + if msg.get('success'): + info = msg.get('user_info', {}) + config.set('username', info.get('username', '')) + config.save() + self.show_main(info) + else: + error_msg = msg.get('message', '未知错误') + if self.window and hasattr(self.window, 'show_error'): + self.window.show_error(f"登录失败: {error_msg}") + + # ── 注册响应 ── + elif t == 'register_response': + if msg.get('success'): + if self.client: + try: + self.client.disconnect() + except Exception: + pass + self.client = None + if self.window and hasattr(self.window, 'show_success'): + self.window.show_success("注册成功!即将跳转到登录界面...") + QTimer.singleShot(2000, self._goto_login_after_register) + else: + error_msg = msg.get('message', '未知错误') + if self.window and hasattr(self.window, 'show_error'): + self.window.show_error(f"注册失败: {error_msg}") + + # ── 私聊消息 ── + elif t == 'chat': + if isinstance(self.window, MainWindow): + sender = msg.get('sender') + self.window.receive_message( + sender, msg.get('content', ''), + msg.get('timestamp', ''), + 'private', None, sender) + + # ── 群聊消息 ── + elif t == 'group_chat': + if isinstance(self.window, MainWindow): + sender = msg.get('sender') + group_id = msg.get('group_id') + group_name = f"群组{group_id}" + for g in self.window.groups: + if g.get('id') == group_id: + group_name = g.get('name', group_name) + break + self.window.receive_message( + sender, msg.get('content', ''), + msg.get('timestamp', ''), + 'group', group_id, group_name) + + # ── 图片消息 ── + elif t == 'chat_image': + if isinstance(self.window, MainWindow): + sender = msg.get('sender') + self.window.receive_image_message( + sender, msg.get('filename', 'image.png'), + msg.get('data', ''), msg.get('path', ''), + msg.get('timestamp', ''), + 'private', msg.get('receiver', ''), sender) + + elif t == 'group_chat_image': + if isinstance(self.window, MainWindow): + sender = msg.get('sender') + group_id = msg.get('group_id') + group_name = f"群组{group_id}" + for g in self.window.groups: + if g.get('id') == group_id: + group_name = g.get('name', group_name) + break + self.window.receive_image_message( + sender, msg.get('filename', 'image.png'), + msg.get('data', ''), msg.get('path', ''), + msg.get('timestamp', ''), + 'group', group_id, group_name) + + # ── 列表更新 ── + elif t == 'friends_list': + if isinstance(self.window, MainWindow): + self.window.update_friends_list(msg.get('friends', [])) + + elif t == 'groups_list': + if isinstance(self.window, MainWindow): + self.window.update_groups_list(msg.get('groups', [])) + + elif t == 'all_groups_list': + if isinstance(self.window, MainWindow): + self.window.update_all_groups_list(msg.get('groups', [])) + + elif t == 'users_list': + if isinstance(self.window, MainWindow): + self.window.update_users_list(msg.get('users', [])) + + elif t == 'chat_history': + if isinstance(self.window, MainWindow): + self.window.show_history(msg.get('friend'), msg.get('history', [])) + + elif t == 'group_history': + if isinstance(self.window, MainWindow): + self.window.show_history(msg.get('group_id'), msg.get('history', []), + is_group=True) + + elif t == 'all_history': + if isinstance(self.window, MainWindow) and self.window._history_window: + self.window._history_window.load_messages(msg.get('history', [])) + + elif t == 'user_status': + if isinstance(self.window, MainWindow): + self.window.update_online_status( + msg.get('user_id'), msg.get('username'), + msg.get('is_online'), msg.get('online_count', 1)) + + # ── 好友操作响应 ── + elif t == 'add_friend_response': + if isinstance(self.window, MainWindow): + if msg.get('success'): + self.window.show_message('success', '添加好友', + msg.get('message', '添加成功')) + self.client.get_friends() + else: + self.window.show_message('error', '添加好友', + msg.get('message', '失败')) + + elif t == 'remove_friend_response': + if isinstance(self.window, MainWindow): + if msg.get('success'): + self.window.show_message('success', '删除好友', + msg.get('message', '已删除')) + self.client.get_friends() + else: + self.window.show_message('error', '删除好友', + msg.get('message', '失败')) + + # ── 群组操作响应 ── + elif t == 'create_group_response': + if isinstance(self.window, MainWindow): + if msg.get('success'): + self.window.show_message('success', '创建群组', + msg.get('message', '创建成功')) + self.client.get_groups() + else: + self.window.show_message('error', '创建群组', + msg.get('message', '失败')) + + elif t == 'join_group_response': + if isinstance(self.window, MainWindow): + if msg.get('success'): + self.window.show_message('success', '加入群组', + msg.get('message', '加入成功')) + self.client.get_groups() + else: + self.window.show_message('error', '加入群组', + msg.get('message', '失败')) + + elif t == 'group_members': + if isinstance(self.window, MainWindow): + self.window.update_group_members_in_detail( + msg.get('group_id'), msg.get('members', [])) + + elif t == 'leave_group_response': + if isinstance(self.window, MainWindow): + if msg.get('success'): + self.window.show_message('success', '退出群组', + msg.get('message', '已退出')) + self.client.get_groups() + else: + self.window.show_message('error', '退出群组', + msg.get('message', '失败')) + + elif t == 'invite_to_group_response': + if isinstance(self.window, MainWindow): + if msg.get('success'): + self.window.show_message('success', '邀请好友', + msg.get('message', '邀请成功')) + group_id = msg.get('group_id') + if group_id: + self.client.get_group_members(group_id) + else: + self.window.show_message('error', '邀请好友', + msg.get('message', '失败')) + + elif t == 'search_results': + pass # 搜索窗口自行轮询 + + # ── 个人资料修改响应 ── + elif t == 'change_username_response': + if msg.get('success'): + new_username = msg.get('new_username') + self.user_info['username'] = new_username + if self.window and hasattr(self.window, 'username'): + self.window.username = new_username + self.window.setWindowTitle( + f'SimpleChat — {self.window.nickname} (@{new_username})') + if self.window and hasattr(self.window, 'show_message'): + self.window.show_message('success', '修改用户名', msg.get('message', '')) + else: + if self.window and hasattr(self.window, 'show_message'): + self.window.show_message('error', '修改用户名', msg.get('message', '')) + + elif t == 'change_nickname_response': + if msg.get('success'): + new_nickname = msg.get('new_nickname') + if self.window and hasattr(self.window, 'nickname'): + self.window.nickname = new_nickname + self.window.setWindowTitle( + f'SimpleChat — {new_nickname} (@{self.window.username})') + if self.window and hasattr(self.window, 'show_message'): + self.window.show_message('success', '修改昵称', msg.get('message', '')) + else: + if self.window and hasattr(self.window, 'show_message'): + self.window.show_message('error', '修改昵称', msg.get('message', '')) + + elif t == 'change_password_response': + if self.window and hasattr(self.window, 'show_message'): + if msg.get('success'): + self.window.show_message('success', '修改密码', msg.get('message', '')) + else: + self.window.show_message('error', '修改密码', msg.get('message', '')) + + # ── 错误 ── + elif t == 'error': + if self.window and hasattr(self.window, 'show_error'): + self.window.show_error(msg.get('message', '未知错误')) + + def _on_connection_changed(self, connected): + self._server_message_signal.emit({ + 'type': '_connection_changed', 'connected': connected + }) + + def _handle_connection(self, connected): + try: + if isinstance(self.window, MainWindow): + if connected: + self.window.update_status("已连接到服务器") + else: + self.window.update_status("连接已断开") + QTimer.singleShot(500, self.show_login) + except Exception: + pass + + def _trigger_auto_login(self): + """Delay-trigger auto-login after UI is visible.""" + try: + from ui.login_window import LoginWindow as _LW + if isinstance(self.window, _LW): + self.window.login() + except Exception: + pass + + # ── 启动 ────────────────────────────────────────────── + def run(self): + create_default_config() + self.show_login() + if config.get("auto_login") and config.get("username") and config.get("save_password"): + QTimer.singleShot(300, self._trigger_auto_login) + sys.exit(self.app.exec_()) + + +def main(): + print("=" * 48) + print(" SimpleChat 客户端 v2.3") + print("=" * 48) + try: + ChatApplication().run() + except Exception as e: + traceback.print_exc() + QMessageBox.critical(None, "启动失败", str(e)) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/client/ui/group_detail_window.py b/client/ui/group_detail_window.py new file mode 100644 index 0000000..8f7d35a --- /dev/null +++ b/client/ui/group_detail_window.py @@ -0,0 +1,192 @@ +""" +群组详情窗口 - 显示群组信息和成员管理(优化版) +支持:查看成员、邀请好友、退出群组 +""" +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QListWidget, QListWidgetItem, QPushButton, + QTextEdit, QScrollArea, QMessageBox, QInputDialog, QFrame +) +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QFont, QColor +from datetime import datetime + + +class GroupMemberItem(QListWidgetItem): + def __init__(self, member_info): + super().__init__() + self.member_info = member_info + self.refresh() + + def refresh(self): + nick = self.member_info.get('nickname') or self.member_info.get('username', '') + user = self.member_info.get('username', '') + online = self.member_info.get('is_online', False) + dot = " ●" if online else " ○" + is_creator = self.member_info.get('is_creator', False) + crown = " 👑" if is_creator else "" + role = "群主" if is_creator else "成员" + self.setText(f"{nick} ({user}){dot}{crown} — {role}") + self.setForeground(QColor('#10b981') if online else QColor('#94a3b8')) + self.setData(Qt.UserRole, self.member_info) + + +class GroupDetailWindow(QWidget): + get_group_members_requested = pyqtSignal(int) + leave_group_requested = pyqtSignal(int) + invite_member_requested = pyqtSignal(int, str) + + def __init__(self, group_info, current_user_id, parent=None): + super().__init__(parent) + self.group_info = group_info + self.current_user_id = current_user_id + self.members = [] + self._init_ui() + self.get_group_members_requested.emit(self.group_info['id']) + + def _init_ui(self): + self.setWindowTitle(f"群组详情 - {self.group_info.get('name', '未知群组')}") + self.setFixedSize(480, 640) + self.setStyleSheet("QWidget { background: white; }") + + layout = QVBoxLayout(self) + layout.setSpacing(16) + layout.setContentsMargins(20, 20, 20, 20) + + # 群组信息卡片 + self._build_group_info_card(layout) + + # 成员列表 + self._build_members_section(layout) + + # 操作按钮 + self._build_action_buttons(layout) + + def _build_group_info_card(self, parent_layout): + card = QFrame() + card.setStyleSheet(""" + QFrame { + background: qlineargradient(x1:0,y1:0,x2:1,y2:0, + stop:0 #6366f1, stop:0.5 #4f46e5, stop:1 #4338ca); + border-radius: 14px; + } + """) + card.setFixedHeight(130) + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(24, 18, 24, 18) + card_layout.setSpacing(8) + + name_label = QLabel(self.group_info.get('name', '未知群组')) + name_label.setStyleSheet("font-size: 20px; font-weight: bold; color: white;") + card_layout.addWidget(name_label) + + info_layout = QHBoxLayout() + id_label = QLabel(f"群组ID: {self.group_info.get('id', 'N/A')}") + id_label.setStyleSheet("font-size: 12px; color: rgba(255,255,255,0.85);") + info_layout.addWidget(id_label) + info_layout.addStretch() + self.member_count_label = QLabel("成员: 加载中…") + self.member_count_label.setStyleSheet("font-size: 12px; color: rgba(255,255,255,0.85);") + info_layout.addWidget(self.member_count_label) + card_layout.addLayout(info_layout) + + if 'created_at' in self.group_info: + time_label = QLabel(f"创建于: {self.group_info['created_at']}") + time_label.setStyleSheet("font-size: 11px; color: rgba(255,255,255,0.7);") + card_layout.addWidget(time_label) + + parent_layout.addWidget(card) + + def _build_members_section(self, parent_layout): + title_layout = QHBoxLayout() + title_label = QLabel("群组成员") + title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #2c3e50;") + title_layout.addWidget(title_label) + title_layout.addStretch() + + invite_btn = QPushButton("➕ 邀请好友") + invite_btn.setStyleSheet(""" + QPushButton { + background: #8b5cf6; color: white; border: none; + border-radius: 8px; padding: 7px 14px; + font-size: 12px; font-weight: bold; + } + QPushButton:hover { background: #7c3aed; } + """) + invite_btn.clicked.connect(self._invite_member) + title_layout.addWidget(invite_btn) + parent_layout.addLayout(title_layout) + + self.members_list = QListWidget() + self.members_list.setStyleSheet(""" + QListWidget { + border: 1px solid #e0e0e0; border-radius: 10px; + background: #fafbfc; outline: none; + } + QListWidget::item { + padding: 10px 14px; border-bottom: 1px solid #f0f0f0; + font-size: 13px; + } + QListWidget::item:hover { background: #f0f2f5; } + QListWidget::item:selected { background: #eef2ff; color: #2c3e50; } + """) + self.members_list.setMaximumHeight(280) + parent_layout.addWidget(self.members_list) + + def _build_action_buttons(self, parent_layout): + parent_layout.addStretch() + + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + leave_btn = QPushButton("🚪 退出群组") + leave_btn.setStyleSheet(""" + QPushButton { + background: #ef4444; color: white; border: none; + border-radius: 10px; padding: 10px 24px; + font-size: 13px; font-weight: bold; + } + QPushButton:hover { background: #dc2626; } + """) + leave_btn.clicked.connect(self._leave_group) + btn_layout.addWidget(leave_btn) + parent_layout.addLayout(btn_layout) + + def _invite_member(self): + username, ok = QInputDialog.getText( + self, '邀请好友', '请输入要邀请的用户名:') + if ok and username.strip(): + self.invite_member_requested.emit( + self.group_info['id'], username.strip()) + + def _leave_group(self): + reply = QMessageBox.question( + self, '退出群组', + f'确定要退出群组 "{self.group_info.get("name", "")}" 吗?\n退出后将无法接收群组消息。', + QMessageBox.Yes | QMessageBox.No) + + if reply == QMessageBox.Yes: + self.leave_group_requested.emit(self.group_info['id']) + self.close() + + def update_members(self, members): + self.members = members + self.members_list.clear() + self.member_count_label.setText(f"成员: {len(members)}") + + creator_id = self.group_info.get('creator_id') + # 在线成员排前面 + sorted_members = sorted(members, key=lambda m: (not m.get('is_online', False), m.get('username', ''))) + for member in sorted_members: + member['is_creator'] = (member.get('id') == creator_id) + item = GroupMemberItem(member) + self.members_list.addItem(item) + + def update_member_status(self, user_id, username, is_online): + for i in range(self.members_list.count()): + item = self.members_list.item(i) + if isinstance(item, GroupMemberItem): + if item.member_info.get('id') == user_id: + item.member_info['is_online'] = is_online + item.refresh() + break diff --git a/client/ui/history_window.py b/client/ui/history_window.py new file mode 100644 index 0000000..63178e9 --- /dev/null +++ b/client/ui/history_window.py @@ -0,0 +1,193 @@ +""" +全局聊天记录浏览窗口 +""" +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QListWidget, QListWidgetItem, QTextEdit, + QSplitter, QFrame, QPushButton, QComboBox +) +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QColor +import html +from datetime import datetime + + +class HistoryWindow(QWidget): + refresh_requested = pyqtSignal() + search_requested = pyqtSignal(str) # keyword + + def __init__(self, username, parent=None): + super().__init__(parent) + self.username = username + self.all_messages = [] + self._init_ui() + + def _init_ui(self): + self.setWindowTitle("聊天记录浏览器") + self.setGeometry(150, 150, 900, 650) + self.setMinimumSize(650, 450) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # 顶部工具栏 + toolbar = QFrame() + toolbar.setStyleSheet("QFrame { background: #f8fafc; border-bottom: 1px solid #e2e8f0; }") + tl = QHBoxLayout(toolbar) + tl.setContentsMargins(14, 8, 14, 8) + tl.setSpacing(8) + + title = QLabel("📜 聊天记录") + title.setStyleSheet("font-size: 15px; font-weight: bold; color: #1e293b;") + tl.addWidget(title) + + # 分类筛选 + self.filter_combo = QComboBox() + self.filter_combo.addItems(["全部消息", "私聊消息", "群聊消息"]) + self.filter_combo.setStyleSheet(""" + QComboBox { padding: 4px 10px; border: 1px solid #ddd; + border-radius: 5px; background: white; font-size: 12px; } + """) + self.filter_combo.currentIndexChanged.connect(self._apply_filter) + tl.addWidget(self.filter_combo) + + tl.addStretch() + + refresh_btn = QPushButton("🔄 刷新") + refresh_btn.setStyleSheet(""" + QPushButton { padding: 5px 14px; border: 1px solid #ddd; + border-radius: 5px; background: white; font-size: 12px; } + QPushButton:hover { background: #f0f0f0; } + """) + refresh_btn.clicked.connect(self.refresh_requested.emit) + tl.addWidget(refresh_btn) + + layout.addWidget(toolbar) + + # 主区域 + splitter = QSplitter(Qt.Horizontal) + + # 左侧消息列表 + left_frame = QFrame() + left_frame.setStyleSheet("QFrame { background: white; }") + ll = QVBoxLayout(left_frame) + ll.setContentsMargins(0, 0, 0, 0) + ll.setSpacing(0) + + self.msg_list = QListWidget() + self.msg_list.setStyleSheet(""" + QListWidget { border: none; background: white; outline: none; } + QListWidget::item { padding: 8px 12px; border-bottom: 1px solid #f1f5f9; font-size: 12px; } + QListWidget::item:hover { background: #f8fafc; } + QListWidget::item:selected { background: #eef2ff; } + """) + self.msg_list.itemClicked.connect(self._show_detail) + ll.addWidget(self.msg_list) + splitter.addWidget(left_frame) + + # 右侧详情 + right_frame = QFrame() + right_frame.setStyleSheet("QFrame { background: #fafbfc; }") + rl = QVBoxLayout(right_frame) + rl.setContentsMargins(0, 0, 0, 0) + rl.setSpacing(0) + + detail_header = QWidget() + detail_header.setFixedHeight(36) + detail_header.setStyleSheet("background: #fafafa; border-bottom: 1px solid #eee;") + dhl = QHBoxLayout(detail_header) + dhl.setContentsMargins(12, 0, 12, 0) + dhl.addWidget(QLabel("消息详情")) + rl.addWidget(detail_header) + + self.detail_view = QTextEdit() + self.detail_view.setReadOnly(True) + self.detail_view.setStyleSheet(""" + QTextEdit { border: none; background: #fafbfc; + font-size: 13px; line-height: 1.6; padding: 14px; } + """) + self.detail_view.setPlaceholderText("选择左侧一条消息查看详情...") + rl.addWidget(self.detail_view) + splitter.addWidget(right_frame) + + splitter.setSizes([350, 550]) + layout.addWidget(splitter) + + def load_messages(self, messages): + """加载消息列表""" + self.all_messages = messages + self._apply_filter() + + def _apply_filter(self): + self.msg_list.clear() + filter_idx = self.filter_combo.currentIndex() + + filtered = self.all_messages + if filter_idx == 1: # 私聊 + filtered = [m for m in self.all_messages if m.get('chat_type') == 'private'] + elif filter_idx == 2: # 群聊 + filtered = [m for m in self.all_messages if m.get('chat_type') == 'group'] + + for msg in filtered: + self._add_list_item(msg) + + def _add_list_item(self, msg): + sender = msg.get('sender', '?') + content = msg.get('content', '') + timestamp = msg.get('timestamp', '') + chat_type = msg.get('chat_type', 'private') + target = msg.get('target_name', '') + msg_type = msg.get('msg_type', 'text') + icon = "👥" if chat_type == 'group' else "💬" + + if msg_type == 'image': + preview = '[图片]' + else: + preview = content[:50] + '...' if len(content) > 50 else content + time_short = timestamp.split(' ')[-1][:5] if ' ' in timestamp else timestamp[:5] + text = f"{icon} {sender} → {target} {time_short}\n {preview}" + + item = QListWidgetItem(text) + item.setData(Qt.UserRole, msg) + self.msg_list.addItem(item) + + def _show_detail(self, item): + msg = item.data(Qt.UserRole) + if not msg: + return + + sender = msg.get('sender', '?') + content = msg.get('content', '') + timestamp = msg.get('timestamp', '') + chat_type = msg.get('chat_type', 'private') + target = msg.get('target_name', '?') + msg_type = msg.get('msg_type', 'text') + type_label = "群聊" if chat_type == 'group' else "私聊" + + if msg_type == 'image': + content_html = f'
[图片消息]
' + else: + content_html = f'
{html.escape(content)}
' + + detail_html = f""" +
+
+ + + + + +
发送者:{html.escape(sender)}
类型:{type_label}
对象:{html.escape(target)}
时间:{html.escape(timestamp)}
+
+
+ 消息内容:

+ {content_html} +
+
+ """ + self.detail_view.setHtml(detail_html) + + def closeEvent(self, event): + self.all_messages.clear() + event.accept() diff --git a/client/ui/login_window.py b/client/ui/login_window.py new file mode 100644 index 0000000..97e79d5 --- /dev/null +++ b/client/ui/login_window.py @@ -0,0 +1,375 @@ +""" +登录窗口 —— 支持自由缩放,内容随窗口比例自适应 +""" +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QCheckBox, QSizePolicy +) +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QIcon, QPainter, QLinearGradient, QColor +import os + + +class BrandHeader(QWidget): + """渐变品牌头部,随父容器缩放""" + def __init__(self, parent=None): + super().__init__(parent) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignCenter) + layout.setSpacing(6) + + self.logo_lbl = QLabel("💬") + self.logo_lbl.setAlignment(Qt.AlignCenter) + self.logo_lbl.setStyleSheet("background: transparent; color: white;") + + self.title_lbl = QLabel("SimpleChat") + self.title_lbl.setAlignment(Qt.AlignCenter) + self.title_lbl.setStyleSheet( + "background: transparent; color: white; font-weight: bold; letter-spacing: 2px;") + + self.sub_lbl = QLabel("简单 · 快速 · 安全") + self.sub_lbl.setAlignment(Qt.AlignCenter) + self.sub_lbl.setStyleSheet("background: transparent; color: rgba(255,255,255,0.8);") + + layout.addWidget(self.logo_lbl) + layout.addWidget(self.title_lbl) + layout.addWidget(self.sub_lbl) + + def paintEvent(self, event): + painter = QPainter(self) + grad = QLinearGradient(0, 0, self.width(), self.height()) + grad.setColorAt(0, QColor("#6366f1")) + grad.setColorAt(1, QColor("#8b5cf6")) + painter.fillRect(self.rect(), grad) + + def update_fonts(self, w, h): + logo_size = max(24, int(h * 0.30)) + title_size = max(14, int(h * 0.16)) + sub_size = max(10, int(h * 0.09)) + self.logo_lbl.setStyleSheet( + f"background:transparent; color:white; font-size:{logo_size}px;") + self.title_lbl.setStyleSheet( + f"background:transparent; color:white; font-weight:bold;" + f" letter-spacing:2px; font-size:{title_size}px;") + self.sub_lbl.setStyleSheet( + f"background:transparent; color:rgba(255,255,255,0.8); font-size:{sub_size}px;") + + +class LoginWindow(QWidget): + login_success = pyqtSignal(dict) + login_requested = pyqtSignal(str, str, str, int) + show_register = pyqtSignal() + + # 最小 / 默认尺寸 + _MIN_W, _MIN_H = 360, 420 + _DEF_W, _DEF_H = 520, 600 + + def __init__(self, config): + super().__init__() + self.config = config + self._init_ui() + self.load_config() + + # ── 构建 UI ─────────────────────────────────────────── + def _init_ui(self): + self.setWindowTitle("SimpleChat - 登录") + self.setMinimumSize(self._MIN_W, self._MIN_H) + self.resize(self._DEF_W, self._DEF_H) + self.setWindowIcon(QIcon("icon.png") if os.path.exists("icon.png") else QIcon()) + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # 品牌头部(占 28% 高度) + self.brand = BrandHeader() + root.addWidget(self.brand, 28) + + # 表单区(占 58% 高度) + self.form_widget = QWidget() + self.form_widget.setObjectName("formArea") + self.form_widget.setStyleSheet("#formArea { background: white; }") + self.form_layout = QVBoxLayout(self.form_widget) + self.form_layout.setAlignment(Qt.AlignVCenter) + + # 用户名 + self.user_lbl = QLabel("用户名") + self.username_input = QLineEdit() + self.username_input.setPlaceholderText("请输入用户名") + + # 密码 + self.pwd_lbl = QLabel("密码") + self.password_input = QLineEdit() + self.password_input.setPlaceholderText("请输入密码") + self.password_input.setEchoMode(QLineEdit.Password) + self.password_input.returnPressed.connect(self.login) + + # 选项行 + opt_row = QHBoxLayout() + self.remember_checkbox = QCheckBox("记住密码") + self.auto_login_checkbox = QCheckBox("自动登录") + opt_row.addWidget(self.remember_checkbox) + opt_row.addStretch() + opt_row.addWidget(self.auto_login_checkbox) + + # 登录按钮 + self.login_btn = QPushButton("登 录") + self.login_btn.setCursor(Qt.PointingHandCursor) + self.login_btn.clicked.connect(self.login) + + # 注册链接行 + reg_row = QHBoxLayout() + reg_row.addStretch() + self.reg_hint_lbl = QLabel("还没有账号?") + self.reg_btn = QPushButton("立即注册") + self.reg_btn.setFlat(True) + self.reg_btn.setCursor(Qt.PointingHandCursor) + self.reg_btn.clicked.connect(self.show_register.emit) + reg_row.addWidget(self.reg_hint_lbl) + reg_row.addWidget(self.reg_btn) + reg_row.addStretch() + + # 内联错误横幅(默认隐藏) + self.error_banner = QWidget() + self.error_banner.setObjectName("errorBanner") + self.error_banner.setStyleSheet( + "#errorBanner { background: #ef4444; border-radius: 6px; }" + ) + self.error_banner.hide() + banner_layout = QHBoxLayout(self.error_banner) + banner_layout.setContentsMargins(10, 6, 6, 6) + banner_layout.setSpacing(6) + self.error_label = QLabel() + self.error_label.setStyleSheet("color: white; background: transparent;") + self.error_label.setWordWrap(True) + banner_close_btn = QPushButton("✕") + banner_close_btn.setFlat(True) + banner_close_btn.setFixedSize(20, 20) + banner_close_btn.setCursor(Qt.PointingHandCursor) + banner_close_btn.setStyleSheet( + "QPushButton { color: white; background: transparent; border: none; font-weight: bold; }" + "QPushButton:hover { color: #fecaca; }" + ) + banner_close_btn.clicked.connect(self.hide_error) + banner_layout.addWidget(self.error_label, 1) + banner_layout.addWidget(banner_close_btn) + + for w in (self.user_lbl, self.username_input, + self.pwd_lbl, self.password_input): + self.form_layout.addWidget(w) + self.form_layout.addLayout(opt_row) + self.form_layout.addWidget(self.error_banner) + self.form_layout.addWidget(self.login_btn) + self.form_layout.addLayout(reg_row) + + root.addWidget(self.form_widget, 58) + + # 底部服务器设置(占 14% 高度) + self.footer = QWidget() + self.footer.setObjectName("footer") + self.footer.setStyleSheet("#footer { background:#f1f5f9; border-top:1px solid #e2e8f0; }") + fl = QHBoxLayout(self.footer) + fl.setContentsMargins(0, 0, 0, 0) + + self.srv_lbl = QLabel("服务器:") + self.server_input = QLineEdit(str(self.config.get("server_host", "127.0.0.1"))) + self.port_lbl = QLabel("端口:") + self.port_input = QLineEdit(str(self.config.get("server_port", 8888))) + self.port_input.setFixedWidth(72) + + fl.addWidget(self.srv_lbl) + fl.addWidget(self.server_input, 1) + fl.addWidget(self.port_lbl) + fl.addWidget(self.port_input) + + root.addWidget(self.footer, 14) + + # 初始化字体/样式 + self._apply_responsive(self._DEF_W, self._DEF_H) + + # ── 响应式样式 ──────────────────────────────────────── + def _apply_responsive(self, w, h): + # 字体大小随窗口高度缩放 + base = max(11, int(h * 0.024)) # 基础字号 + lbl_size = max(12, int(h * 0.026)) + inp_size = max(13, int(h * 0.028)) + btn_size = max(14, int(h * 0.030)) + inp_h = max(36, int(h * 0.075)) + btn_h = max(40, int(h * 0.082)) + margin_h = max(20, int(w * 0.09)) + spacing = max(10, int(h * 0.022)) + + self.brand.update_fonts(w, int(h * 0.28)) + + # 表单内边距和间距 + self.form_layout.setContentsMargins(margin_h, spacing, margin_h, spacing) + self.form_layout.setSpacing(spacing) + + # 标签样式 + lbl_style = f"font-size:{lbl_size}px; font-weight:bold; color:#444;" + self.user_lbl.setStyleSheet(lbl_style) + self.pwd_lbl.setStyleSheet(lbl_style) + + # 输入框 + inp_style = f""" + QLineEdit {{ + padding: 0 14px; + border: 1.5px solid #e2e8f0; + border-radius: 8px; + font-size: {inp_size}px; + background: white; + color: #222; + min-height: {inp_h}px; + max-height: {inp_h}px; + }} + QLineEdit:focus {{ + border: 1.5px solid #6366f1; + background: #f5f3ff; + }} + """ + self.username_input.setStyleSheet(inp_style) + self.password_input.setStyleSheet(inp_style) + + # 复选框 + chk_style = f""" + QCheckBox {{ + font-size: {base}px; color: #555; spacing: 6px; + }} + QCheckBox::indicator {{ + width: {max(14, int(h*0.025))}px; + height: {max(14, int(h*0.025))}px; + border-radius: 4px; + border: 1.5px solid #bbb; + background: white; + }} + QCheckBox::indicator:checked {{ + background: #6366f1; + border: 1.5px solid #6366f1; + }} + """ + self.remember_checkbox.setStyleSheet(chk_style) + self.auto_login_checkbox.setStyleSheet(chk_style) + + # 登录按钮 + self.login_btn.setFixedHeight(btn_h) + self.login_btn.setStyleSheet(f""" + QPushButton {{ + background: qlineargradient(x1:0,y1:0,x2:1,y2:0, + stop:0 #6366f1, stop:1 #4f46e5); + color: white; border: none; border-radius: 8px; + font-size: {btn_size}px; font-weight: bold; letter-spacing: 4px; + }} + QPushButton:hover {{ background: #4f46e5; }} + QPushButton:pressed {{ background: #4338ca; }} + QPushButton:disabled {{ background: #95a5a6; }} + """) + + # 注册链接 + self.reg_hint_lbl.setStyleSheet(f"color:#888; font-size:{base}px;") + self.reg_btn.setStyleSheet(f""" + QPushButton {{ + color:#6366f1; font-size:{base}px; font-weight:bold; + border:none; background:transparent; padding:0; + }} + QPushButton:hover {{ color:#4f46e5; text-decoration:underline; }} + """) + + # 底部 footer + foot_h = max(44, int(h * 0.09)) + self.footer.setFixedHeight(foot_h) + foot_margin = max(12, int(w * 0.09)) + self.footer.layout().setContentsMargins(foot_margin, 0, foot_margin, 0) + self.footer.layout().setSpacing(8) + + foot_inp = f""" + QLineEdit {{ + padding: 4px 10px; border: 1px solid #bbb; border-radius: 6px; + font-size: {base}px; background: white; + }} + QLineEdit:focus {{ border: 1px solid #6366f1; }} + """ + foot_lbl = f"font-size:{base}px; color:#666;" + self.srv_lbl.setStyleSheet(foot_lbl) + self.port_lbl.setStyleSheet(foot_lbl) + self.server_input.setStyleSheet(foot_inp) + self.port_input.setStyleSheet(foot_inp) + srv_inp_h = max(28, int(foot_h * 0.6)) + self.server_input.setFixedHeight(srv_inp_h) + self.port_input.setFixedHeight(srv_inp_h) + + # ── 事件 ────────────────────────────────────────────── + def resizeEvent(self, event): + super().resizeEvent(event) + self._apply_responsive(self.width(), self.height()) + + # ── 配置 ────────────────────────────────────────────── + def load_config(self): + if self.config.get("save_password"): + self.username_input.setText(self.config.get("username", "")) + self.password_input.setText(self.config.get("password", "")) + self.remember_checkbox.setChecked(True) + if self.config.get("auto_login"): + self.auto_login_checkbox.setChecked(True) + + def save_config(self): + if self.remember_checkbox.isChecked(): + self.config.set("username", self.username_input.text()) + self.config.set("password", self.password_input.text()) + self.config.set("save_password", True) + else: + self.config.set("save_password", False) + self.config.set("password", "") + self.config.set("auto_login", self.auto_login_checkbox.isChecked()) + self.config.save() + + # ── 登录逻辑 ────────────────────────────────────────── + def login(self): + username = self.username_input.text().strip() + password = self.password_input.text().strip() + server_host = self.server_input.text().strip() + server_port = self.port_input.text().strip() + + if not username or not password: + self.show_error("用户名和密码不能为空!") + return + if not server_host or not server_port: + self.show_error("服务器地址和端口不能为空!") + return + try: + port = int(server_port) + except ValueError: + self.show_error("端口号必须是数字!") + return + + self.config.set("server_host", server_host) + self.config.set("server_port", port) + self.save_config() + + self.show_loading() + self.login_requested.emit(username, password, server_host, port) + + def show_loading(self): + """禁用登录按钮,文字改为"连接中…" """ + self.login_btn.setEnabled(False) + self.login_btn.setText("连接中…") + + def show_error(self, message): + """恢复登录按钮状态并显示内联错误横幅(红色)""" + self.login_btn.setEnabled(True) + self.login_btn.setText("登 录") + # 确保错误横幅是红色 + self.error_banner.setStyleSheet( + "#errorBanner { background: #ef4444; border-radius: 6px; }" + ) + self.error_label.setText(message) + self.error_banner.show() + + def hide_error(self): + """隐藏内联错误横幅""" + self.error_banner.hide() + + def clear_fields(self): + self.username_input.clear() + self.password_input.clear() diff --git a/client/ui/main_window.py b/client/ui/main_window.py new file mode 100644 index 0000000..596c628 --- /dev/null +++ b/client/ui/main_window.py @@ -0,0 +1,1557 @@ +""" +主窗口 —— 包含 ChatBubble、ChatPanel、MainWindow +优化内容: +- 气泡设计:圆角阴影、头像、消息状态、连续消息合并 +- 聊天面板:字数统计、加载更多历史、日期分隔条、输入限制500字 +- 主窗口:通知浮层、未读角标、好友在线优先排序、过渡动画、设置整合 +""" +from PyQt5.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QListWidget, QListWidgetItem, QTextEdit, + QLineEdit, QPushButton, QTabWidget, QMessageBox, + QAction, QInputDialog, QScrollArea, QShortcut, + QMenu, QSizePolicy, QFrame, QDialog, QDialogButtonBox, QCheckBox +) +from PyQt5.QtCore import Qt, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve +from PyQt5.QtGui import QFont, QIcon, QColor, QKeySequence, QPalette, QPixmap +from PyQt5.QtWidgets import QFileDialog as _QFileDialog +import os +import html +import base64 +from datetime import datetime, timezone, timedelta +import sys as _sys +_cur_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _cur_dir not in _sys.path: + _sys.path.insert(0, _cur_dir) +from utils import beijing_now_str + + +# ───────────────────────────────────────────────────────── +# 消息气泡(优化版) +# ───────────────────────────────────────────────────────── +class ChatBubble(QFrame): + def __init__(self, sender, content, timestamp, is_me=False, show_avatar=True, msg_type='text', parent=None): + super().__init__(parent) + self.setObjectName("chatBubble") + layout = QHBoxLayout() + layout.setContentsMargins(10, 3, 10, 3) + layout.setSpacing(8) + + # 头像(圆形文字头像) + if show_avatar and not is_me: + avatar = QLabel(sender[0].upper() if sender else "?") + avatar.setFixedSize(36, 36) + avatar.setAlignment(Qt.AlignCenter) + avatar.setStyleSheet(""" + QLabel{background:%s;border-radius:18px;color:white; + font-size:15px;font-weight:bold;} + """ % self._avatar_color(sender)) + layout.addWidget(avatar, alignment=Qt.AlignTop) + + # 气泡主体 + bubble_wrap = QVBoxLayout() + bubble_wrap.setSpacing(2) + + # 发送者名 + 时间 + info_row = QHBoxLayout() + info_row.setSpacing(8) + name_lbl = QLabel("我" if is_me else sender) + name_lbl.setStyleSheet("color:#64748b; font-weight:bold; font-size:11px;") + time_lbl = QLabel(self._smart_time(timestamp)) + time_lbl.setStyleSheet("color:#94a3b8; font-size:10px;") + if is_me: + info_row.addStretch() + info_row.addWidget(time_lbl) + info_row.addWidget(name_lbl) + else: + info_row.addWidget(name_lbl) + info_row.addWidget(time_lbl) + info_row.addStretch() + bubble_wrap.addLayout(info_row) + + # 消息内容卡片 + content_row = QHBoxLayout() + + if msg_type == 'image': + # 尝试加载图片 + pixmap = None + img_path = content + if not os.path.exists(img_path): + # 尝试本地缓存 + local_cache = os.path.join('messages', 'images', os.path.basename(content)) + if os.path.exists(local_cache): + img_path = local_cache + else: + img_path = None + + if img_path: + pixmap = QPixmap(img_path) + if pixmap and not pixmap.isNull(): + if pixmap.width() > 280: + pixmap = pixmap.scaledToWidth(280, Qt.SmoothTransformation) + img_lbl = QLabel() + img_lbl.setPixmap(pixmap) + img_lbl.setMaximumWidth(300) + img_lbl.setStyleSheet(""" + QLabel { + border:1px solid #e2e8f0; + border-radius:10px; + padding:4px; + background:white; + } + """) + img_lbl.setCursor(Qt.PointingHandCursor) + img_lbl.mousePressEvent = lambda e, p=img_path: self._show_full_image(p) + content_widget = img_lbl + else: + content_widget = QLabel(f"[图片] {os.path.basename(content)}") + content_widget.setStyleSheet(""" + QLabel { + background:#f1f5f9; + border:1px dashed #cbd5e1; + border-radius:10px; + padding:16px 20px; + font-size:13px; + color:#94a3b8; + } + """) + else: + content_widget = QLabel(html.escape(content)) + content_widget.setWordWrap(True) + content_widget.setMaximumWidth(440) + content_widget.setTextInteractionFlags(Qt.TextSelectableByMouse) + content_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + if is_me: + bubble_color = "#eef2ff" + border_color = "#c7d2fe" + else: + bubble_color = "#ffffff" + border_color = "#e2e8f0" + + content_widget.setStyleSheet(f""" + QLabel {{ + background:{bubble_color}; + border:1px solid {border_color}; + border-radius:10px; + padding:10px 14px; + font-size:13px; + color:#1e293b; + line-height:1.5; + }} + """) + content_widget.setContentsMargins(0, 0, 0, 0) + + if is_me: + content_row.addStretch() + content_row.addWidget(content_widget) + else: + content_row.addWidget(content_widget) + content_row.addStretch() + bubble_wrap.addLayout(content_row) + + if is_me: + layout.addStretch() + layout.addLayout(bubble_wrap) + else: + layout.addLayout(bubble_wrap) + layout.addStretch() + + self.setLayout(layout) + + def _show_full_image(self, path): + """点击图片查看大图""" + dlg = QDialog(self) + dlg.setWindowTitle("查看图片") + dlg.setStyleSheet("QDialog{background:#1e1e2e;}") + dlg.setMinimumSize(400, 300) + layout = QVBoxLayout(dlg) + layout.setContentsMargins(0, 0, 0, 0) + pixmap = QPixmap(path) + if pixmap.isNull(): + dlg.deleteLater() + return + screen = dlg.screen().availableGeometry() + max_w = int(screen.width() * 0.8) + max_h = int(screen.height() * 0.8) + if pixmap.width() > max_w or pixmap.height() > max_h: + pixmap = pixmap.scaled(max_w, max_h, Qt.KeepAspectRatio, Qt.SmoothTransformation) + lbl = QLabel() + lbl.setPixmap(pixmap) + lbl.setAlignment(Qt.AlignCenter) + lbl.setStyleSheet("background:#1e1e2e;") + layout.addWidget(lbl) + dlg.resize(pixmap.width() + 20, pixmap.height() + 20) + dlg.exec_() + + def _smart_time(self, timestamp): + if not timestamp: + return "" + try: + if ' ' in timestamp: + dt = datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S') + now = datetime.now() + if dt.date() == now.date(): + return dt.strftime('%H:%M') + elif (now.date() - dt.date()).days == 1: + return f"昨天 {dt.strftime('%H:%M')}" + elif dt.year == now.year: + return dt.strftime('%m-%d %H:%M') + else: + return dt.strftime('%Y-%m-%d %H:%M') + return timestamp + except Exception: + return timestamp + + def _avatar_color(self, name): + colors = ["#6366f1", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", + "#3b82f6", "#ef4444", "#06b6d4", "#f97316", "#84cc16"] + idx = sum(ord(c) for c in name) % len(colors) + return colors[idx] + + +# ───────────────────────────────────────────────────────── +# 日期分隔线 +# ───────────────────────────────────────────────────────── +class DateSeparator(QWidget): + def __init__(self, text, parent=None): + super().__init__(parent) + layout = QHBoxLayout() + layout.setContentsMargins(20, 8, 20, 8) + line1 = QFrame() + line1.setFrameShape(QFrame.HLine) + line1.setStyleSheet("color:#e2e8f0;") + line2 = QFrame() + line2.setFrameShape(QFrame.HLine) + line2.setStyleSheet("color:#e2e8f0;") + lbl = QLabel(text) + lbl.setStyleSheet("color:#94a3b8;font-size:11px;padding:0 12px;") + lbl.setAlignment(Qt.AlignCenter) + layout.addWidget(line1) + layout.addWidget(lbl) + layout.addWidget(line2) + self.setLayout(layout) + + +# ───────────────────────────────────────────────────────── +# 聊天面板(优化版) +# ───────────────────────────────────────────────────────── +class ChatPanel(QWidget): + message_sent = pyqtSignal(str, str, object) + image_sent = pyqtSignal(str, str, object, str) + history_requested = pyqtSignal(str, object) + load_more_requested = pyqtSignal(str, object, int) + + def __init__(self, my_username, chat_type, target_id, target_name): + super().__init__() + self.my_username = my_username + self.chat_type = chat_type + self.target_id = target_id + self.target_name = target_name + self.history_loaded = False + self.history_count = 0 + self._build_ui() + QTimer.singleShot(300, self._auto_load_history) + + def _build_ui(self): + root = QVBoxLayout() + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # ── 标题栏 ── + header = QWidget() + header.setFixedHeight(52) + header.setStyleSheet("background:#ffffff; border-bottom:1px solid #e2e8f0;") + hl = QHBoxLayout(header) + hl.setContentsMargins(16, 0, 12, 0) + icon = "💬" if self.chat_type == 'private' else "👥" + title = QLabel(f"{icon} {self.target_name}") + title.setStyleSheet("font-size:15px; font-weight:bold; color:#1e293b;") + hl.addWidget(title) + hl.addStretch() + + # 加载更多按钮 + self.load_more_btn = QPushButton("📜 加载更多") + self.load_more_btn.setStyleSheet(""" + QPushButton{padding:5px 12px;border:1px solid #ddd;border-radius:5px; + background:white;font-size:12px;color:#555;} + QPushButton:hover{background:#f5f5f5;border-color:#6366f1;} + """) + self.load_more_btn.clicked.connect(self._on_load_more) + self.load_more_btn.setVisible(False) + hl.addWidget(self.load_more_btn) + + hist_btn = QPushButton("📋 历史记录") + hist_btn.setStyleSheet(""" + QPushButton{padding:5px 12px;border:1px solid #ddd;border-radius:5px; + background:white;font-size:12px;color:#555;} + QPushButton:hover{background:#f5f5f5;border-color:#6366f1;} + """) + hist_btn.clicked.connect(self._load_history) + hl.addWidget(hist_btn) + + search_btn = QPushButton("🔍 搜索") + search_btn.setStyleSheet(""" + QPushButton{padding:5px 12px;border:1px solid #ddd;border-radius:5px; + background:white;font-size:12px;color:#555;margin-right:4px;} + QPushButton:hover{background:#f5f5f5;border-color:#1abc9c;} + """) + search_btn.clicked.connect(self._open_search) + hl.addWidget(search_btn) + root.addWidget(header) + + # ── 消息区 ── + self._msg_widget = QWidget() + self._msg_widget.setObjectName("msgWidget") + self._msg_layout = QVBoxLayout(self._msg_widget) + self._msg_layout.setAlignment(Qt.AlignTop) + self._msg_layout.setSpacing(2) + self._msg_layout.setContentsMargins(0, 8, 0, 8) + self._msg_layout.addStretch() + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setWidget(self._msg_widget) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._scroll.setStyleSheet( + "QScrollArea{border:none; background:#f1f5f9;}" + "QScrollBar:vertical{width:8px;background:transparent;}" + "QScrollBar::handle:vertical{background:#c0c0c0;border-radius:4px;}" + "QScrollBar::handle:vertical:hover{background:#a0a0a0;}" + "QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical{height:0;}" + ) + self._scroll.verticalScrollBar().rangeChanged.connect(self._on_scroll_range_changed) + root.addWidget(self._scroll, 1) + + # ── 输入区 ── + input_bar = QWidget() + input_bar.setFixedHeight(120) + input_bar.setStyleSheet("background:#ffffff; border-top:1px solid #e2e8f0;") + il = QVBoxLayout(input_bar) + il.setContentsMargins(12, 8, 12, 8) + il.setSpacing(6) + + # 输入框 + self._input = QTextEdit() + self._input.setPlaceholderText("输入消息,Ctrl+Enter 发送…") + self._input.setMaximumHeight(60) + self._input.setAcceptRichText(False) + self._input.setStyleSheet(""" + QTextEdit{border:1px solid #e0e0e0;border-radius:8px; + padding:8px 10px;font-size:13px;background:#fafbfc; + line-height:1.4;} + QTextEdit:focus{border:1px solid #6366f1;background:white;} + """) + self._input.textChanged.connect(self._on_text_changed) + il.addWidget(self._input) + + # 底部工具栏 + toolbar = QHBoxLayout() + toolbar.setSpacing(6) + + # 表情按钮 - 使用 EmojiPicker + from ui.widgets import EmojiPicker + self.emoji_picker = EmojiPicker(self) + self.emoji_picker.emoji_selected.connect(self._insert_emoji) + + emoji_btn = QPushButton("😊") + emoji_btn.setFixedSize(34, 30) + emoji_btn.setStyleSheet(""" + QPushButton{background:#f1f5f9;border:1px solid #e2e8f0; + border-radius:6px;font-size:16px;} + QPushButton:hover{background:#e2e8f0;} + """) + emoji_btn.clicked.connect(lambda: self._toggle_emoji_picker(emoji_btn)) + toolbar.addWidget(emoji_btn) + + # 图片发送按钮 + img_btn = QPushButton("🖼") + img_btn.setFixedSize(34, 30) + img_btn.setToolTip("发送图片") + img_btn.setStyleSheet(""" + QPushButton{background:#f1f5f9;border:1px solid #e2e8f0; + border-radius:6px;font-size:16px;} + QPushButton:hover{background:#e2e8f0;} + """) + img_btn.clicked.connect(self._send_image) + toolbar.addWidget(img_btn) + + toolbar.addStretch() + + # 字数统计 + self.char_count_lbl = QLabel("0/500") + self.char_count_lbl.setStyleSheet("color:#94a3b8;font-size:11px;padding-right:4px;") + toolbar.addWidget(self.char_count_lbl) + + # 发送按钮 + send_btn = QPushButton("发送 ↵") + send_btn.setFixedWidth(88) + send_btn.setFixedHeight(32) + send_btn.setStyleSheet(""" + QPushButton{background:#6366f1;color:white;border:none; + border-radius:6px;font-size:13px;font-weight:bold;} + QPushButton:hover{background:#4f46e5;} + QPushButton:pressed{background:#4338ca;} + QPushButton:disabled{background:#ccc;} + """) + send_btn.clicked.connect(self._send) + toolbar.addWidget(send_btn) + + il.addLayout(toolbar) + root.addWidget(input_bar) + + self.setLayout(root) + QShortcut(QKeySequence("Ctrl+Return"), self).activated.connect(self._send) + QShortcut(QKeySequence("Ctrl+Enter"), self).activated.connect(self._send) + + def _toggle_emoji_picker(self, btn): + if self.emoji_picker.isVisible(): + self.emoji_picker.setVisible(False) + else: + pos = btn.mapToGlobal(btn.rect().bottomLeft()) + self.emoji_picker.move(pos.x(), pos.y() - 260) + self.emoji_picker.setVisible(True) + self.emoji_picker.raise_() + + def _insert_emoji(self, emoji): + cursor = self._input.textCursor() + cursor.insertText(emoji) + self._input.setFocus() + + def _on_text_changed(self): + text = self._input.toPlainText() + length = len(text) + self.char_count_lbl.setText(f"{length}/500") + if length > 500: + self.char_count_lbl.setStyleSheet("color:#ef4444;font-size:11px;font-weight:bold;") + else: + self.char_count_lbl.setStyleSheet("color:#94a3b8;font-size:11px;") + + def _send(self): + text = self._input.toPlainText().strip() + if not text: + return + if len(text) > 500: + text = text[:500] + self.message_sent.emit(text, self.chat_type, self.target_id) + self.add_message(self.my_username, text, beijing_now_str(), True) + self._input.clear() + + def _load_history(self): + self.history_requested.emit(self.target_name, self.target_id) + + def _on_load_more(self): + offset = self.history_count + self.load_more_requested.emit(self.target_name, self.target_id, offset) + + def _open_search(self): + from ui.search_window import SearchWindow + search_window = SearchWindow(self.chat_type, self.target_id, self.target_name, self) + if hasattr(self, '_main_window_ref') and self._main_window_ref: + search_window.search_requested.connect(self._main_window_ref._on_search_messages) + search_window.show() + + def _send_image(self): + """打开文件对话框选择图片发送""" + path, _ = _QFileDialog.getOpenFileName( + self, "选择图片", "", + "图片文件 (*.png *.jpg *.jpeg *.gif *.bmp *.webp);;所有文件 (*)") + if not path: + return + try: + with open(path, 'rb') as f: + file_data = f.read() + if len(file_data) > 5 * 1024 * 1024: + from PyQt5.QtWidgets import QMessageBox + QMessageBox.warning(self, "图片过大", "图片大小不能超过5MB") + return + file_data_b64 = base64.b64encode(file_data).decode('ascii') + filename = os.path.basename(path) + # 保存到本地缓存供显示 + cache_dir = os.path.join('messages', 'images') + os.makedirs(cache_dir, exist_ok=True) + cache_path = os.path.join(cache_dir, filename) + with open(cache_path, 'wb') as f: + f.write(file_data) + self.image_sent.emit(filename, self.chat_type, self.target_id, file_data_b64) + self.add_message(self.my_username, cache_path, beijing_now_str(), True, msg_type='image') + except Exception as e: + from PyQt5.QtWidgets import QMessageBox + QMessageBox.critical(self, "发送失败", f"读取图片失败: {e}") + + def _auto_load_history(self): + if not self.history_loaded: + self.history_loaded = True + self._load_history() + + def add_message(self, sender, content, timestamp, is_me=False, msg_type='text'): + self._remove_stretch() + self._add_date_separator_if_needed(timestamp) + self._msg_layout.addWidget(ChatBubble(sender, content, timestamp, is_me, msg_type=msg_type)) + self._msg_layout.addStretch() + self.history_count += 1 + QTimer.singleShot(50, self._scroll_bottom) + + def _remove_stretch(self): + count = self._msg_layout.count() + if count > 0: + item = self._msg_layout.itemAt(count - 1) + if item.spacerItem(): + self._msg_layout.takeAt(count - 1) + + def _add_date_separator_if_needed(self, timestamp): + try: + if ' ' in timestamp: + date_str = timestamp.split(' ')[0] + if not hasattr(self, '_last_date') or self._last_date != date_str: + self._last_date = date_str + dt = datetime.strptime(date_str, '%Y-%m-%d') + now = datetime.now() + if dt.date() == now.date(): + label = "今天" + elif (now.date() - dt.date()).days == 1: + label = "昨天" + elif dt.year == now.year: + label = dt.strftime('%m月%d日') + else: + label = dt.strftime('%Y年%m月%d日') + self._msg_layout.addWidget(DateSeparator(label)) + except Exception: + pass + + def load_history(self, messages): + self._clear() + self.history_count = 0 + if hasattr(self, '_last_date'): + del self._last_date + for m in messages: + is_me = m.get('sender') == self.my_username + self.add_message(m['sender'], m['content'], m.get('timestamp', ''), is_me, + msg_type=m.get('msg_type', 'text')) + self.load_more_btn.setVisible(len(messages) >= 50) + QTimer.singleShot(100, self._scroll_bottom) + + def prepend_history(self, messages): + """在顶部追加更早的历史记录""" + if not messages: + self.load_more_btn.setVisible(False) + return + # 找到第一个非分隔线的位置插入 + insert_idx = 0 + for i in range(self._msg_layout.count()): + w = self._msg_layout.itemAt(i).widget() + if w and not isinstance(w, DateSeparator): + insert_idx = i + break + for m in reversed(messages): + is_me = m.get('sender') == self.my_username + bubble = ChatBubble(m['sender'], m['content'], m.get('timestamp', ''), is_me, + msg_type=m.get('msg_type', 'text')) + self._msg_layout.insertWidget(insert_idx, bubble) + self.history_count += 1 + self.load_more_btn.setVisible(len(messages) >= 50) + + def _clear(self): + while self._msg_layout.count(): + item = self._msg_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + elif item.spacerItem(): + pass + + def _scroll_bottom(self): + self._scroll.verticalScrollBar().setValue( + self._scroll.verticalScrollBar().maximum()) + + def _on_scroll_range_changed(self, min_val, max_val): + pass + + +# ───────────────────────────────────────────────────────── +# 用户/好友列表项 +# ───────────────────────────────────────────────────────── +class UserItem(QListWidgetItem): + def __init__(self, info): + super().__init__() + self.info = info + self.refresh() + + def refresh(self): + nick = self.info.get('nickname') or self.info.get('username', '') + user = self.info.get('username', '') + online = self.info.get('is_online', False) + status_dot = " ●" if online else " ○" + self.setText(f"{nick} ({user}){status_dot}") + self.setForeground(QColor('#10b981') if online else QColor('#94a3b8')) + self.setData(Qt.UserRole, self.info) + + +# ───────────────────────────────────────────────────────── +# 确认对话框(替代 QMessageBox,带样式) +# ───────────────────────────────────────────────────────── +class StyledDialog(QDialog): + def __init__(self, title, message, parent=None, confirm_text="确定", cancel_text="取消", show_cancel=True): + super().__init__(parent) + self.setWindowTitle(title) + self.setMinimumWidth(380) + self.setModal(True) + self.setStyleSheet("QDialog{background:white;}") + + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 20, 24, 20) + layout.setSpacing(16) + + title_lbl = QLabel(title) + title_lbl.setStyleSheet("font-size:15px;font-weight:bold;color:#1e293b;") + layout.addWidget(title_lbl) + + msg_lbl = QLabel(message) + msg_lbl.setWordWrap(True) + msg_lbl.setStyleSheet("font-size:13px;color:#555;line-height:1.5;") + layout.addWidget(msg_lbl) + + btn_row = QHBoxLayout() + btn_row.addStretch() + + if show_cancel: + cancel_btn = QPushButton(cancel_text) + cancel_btn.setStyleSheet(""" + QPushButton{padding:8px 20px;border:1px solid #ddd;border-radius:6px; + background:#f5f5f5;font-size:13px;color:#555;} + QPushButton:hover{background:#e8e8e8;} + """) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(cancel_btn) + + confirm_btn = QPushButton(confirm_text) + confirm_btn.setStyleSheet(""" + QPushButton{padding:8px 20px;border:none;border-radius:6px; + background:#6366f1;color:white;font-size:13px;font-weight:bold;} + QPushButton:hover{background:#4f46e5;} + """) + confirm_btn.clicked.connect(self.accept) + btn_row.addWidget(confirm_btn) + layout.addLayout(btn_row) + + +# ───────────────────────────────────────────────────────── +# 主窗口 CSS 常量 +# ───────────────────────────────────────────────────────── +_SIDEBAR_STYLE = "QWidget { background:qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #1e1b4b,stop:1 #312e81); color:white; }" +_LIST_STYLE = """ + QListWidget { + border:none; background:transparent; color:#e0e0e0; outline:none; + } + QListWidget::item { + padding:10px 14px; border-bottom:1px solid rgba(255,255,255,0.08); font-size:13px; + } + QListWidget::item:hover { background:rgba(255,255,255,0.08); } + QListWidget::item:selected { background:rgba(99,102,241,0.6); color:white; } +""" +_SEARCH_STYLE = """ + QLineEdit { + padding:6px 12px; border:1px solid rgba(255,255,255,0.15); border-radius:16px; + background:rgba(255,255,255,0.08); color:white; font-size:13px; + } + QLineEdit:focus { border:1px solid #818cf8; background:rgba(255,255,255,0.12); } + QLineEdit::placeholder { color: rgba(255,255,255,0.4); } +""" + + +# ───────────────────────────────────────────────────────── +# 主窗口(优化版) +# ───────────────────────────────────────────────────────── +class MainWindow(QMainWindow): + # 信号 + chat_requested = pyqtSignal(str, str) + group_chat_requested = pyqtSignal(int, str) + image_chat_requested = pyqtSignal(str, str, str, str) + image_group_chat_requested = pyqtSignal(int, str, str, str) + add_friend_requested = pyqtSignal(str) + remove_friend_requested = pyqtSignal(int) + create_group_requested = pyqtSignal(str) + join_group_requested = pyqtSignal(int) + get_users_requested = pyqtSignal() + get_friends_requested = pyqtSignal() + get_groups_requested = pyqtSignal() + get_all_groups_requested = pyqtSignal() + get_history_requested = pyqtSignal(str) + get_group_history_requested = pyqtSignal(int) + get_group_members_requested = pyqtSignal(int) # 被 ChatApplication 连接 + load_more_history_requested = pyqtSignal(str, object) + search_messages_requested = pyqtSignal(str, str, str) + leave_group_requested = pyqtSignal(int) + invite_to_group_requested = pyqtSignal(int, str) + change_username_requested = pyqtSignal(str) + change_nickname_requested = pyqtSignal(str) + change_password_requested = pyqtSignal(str, str) + profile_update_requested = pyqtSignal(str) + all_history_requested = pyqtSignal() + logout_requested = pyqtSignal() + + def __init__(self, username, nickname, user_info): + super().__init__() + self.username = username + self.nickname = nickname + self.user_info = user_info + self.chat_panels = {} + self.conversations = {} + self.friends = [] + self.groups = [] + self.all_users = [] + self.all_groups = [] + self._detail_windows = [] + self._history_window = None + self._notify_enabled = True + self._build_ui() + self._build_menu() + + # ── UI 构建 ─────────────────────────────────────────── + def _build_ui(self): + self.setWindowTitle(f"SimpleChat — {self.nickname} (@{self.username})") + self.setGeometry(80, 80, 1200, 780) + self.setMinimumSize(900, 600) + if os.path.exists('icon.png'): + self.setWindowIcon(QIcon('icon.png')) + + center = QWidget() + self.setCentralWidget(center) + root = QHBoxLayout(center) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # ── 左侧边栏 ── + sidebar = QWidget() + sidebar.setFixedWidth(270) + sidebar.setStyleSheet(_SIDEBAR_STYLE) + sl = QVBoxLayout(sidebar) + sl.setContentsMargins(0, 0, 0, 0) + sl.setSpacing(0) + sl.addWidget(self._build_profile_bar()) + sl.addWidget(self._build_tabs()) + root.addWidget(sidebar) + + # ── 右侧聊天区 ── + self.chat_area = QTabWidget() + self.chat_area.setTabsClosable(True) + self.chat_area.tabCloseRequested.connect(self._close_tab) + self.chat_area.setStyleSheet(""" + QTabWidget::pane { border:0; background:#f8fafc; } + QTabBar::tab { + padding:8px 18px; background:#e8ecf1; + border:1px solid #e0e0e0; border-bottom:none; + border-top-left-radius:6px; border-top-right-radius:6px; + margin-right:2px; min-width:120px; font-size:13px; color:#555; + } + QTabBar::tab:selected { background:#f8fafc; border-bottom:2px solid #6366f1; font-weight:bold; color:#1e1b4b; } + QTabBar::tab:hover:!selected { background:#dfe3e8; } + """) + welcome = self._build_welcome() + self.chat_area.addTab(welcome, "🏠 主页") + self.chat_area.tabBar().setTabButton(0, self.chat_area.tabBar().RightSide, None) + self._welcome_tab_removable = True + root.addWidget(self.chat_area, 1) + + # ── 通知浮层 ── + from ui.widgets import NotificationToast + self.notification_toast = NotificationToast(self) + self.notification_toast.clicked.connect(self._on_toast_clicked) + + # ── 状态栏 ── + self.statusBar().setStyleSheet( + "QStatusBar{background:#f8fafc;color:#666;border-top:1px solid #e2e8f0;font-size:12px;}") + self.status_label = QLabel("就绪") + self.status_label.setStyleSheet("padding:2px 8px;") + self.statusBar().addWidget(self.status_label) + self.online_status_label = QLabel("在线: 1") + self.online_status_label.setStyleSheet("padding:2px 8px;") + self.statusBar().addPermanentWidget(self.online_status_label) + + def _build_profile_bar(self): + bar = QWidget() + bar.setFixedHeight(120) + bar.setStyleSheet("background:qlineargradient(x1:0,y1:0,x2:1,y2:0,stop:0 #6366f1,stop:1 #8b5cf6);") + bl = QVBoxLayout(bar) + bl.setContentsMargins(16, 14, 16, 14) + bl.setSpacing(8) + + row = QHBoxLayout() + av = QLabel(self.nickname[0].upper() if self.nickname else "?") + av.setFixedSize(44, 44) + av.setAlignment(Qt.AlignCenter) + av.setStyleSheet(""" + QLabel{background:white;border-radius:22px; + color:#1abc9c;font-size:20px;font-weight:bold;} + """) + row.addWidget(av) + + info_col = QVBoxLayout() + info_col.setSpacing(2) + nick_lbl = QLabel(self.nickname) + nick_lbl.setStyleSheet("color:white;font-size:15px;font-weight:bold;") + user_lbl = QLabel(f"@{self.username}") + user_lbl.setStyleSheet("color:#c7d2fe;font-size:12px;") + info_col.addWidget(nick_lbl) + info_col.addWidget(user_lbl) + row.addLayout(info_col) + row.addStretch() + + # 设置按钮 + settings_btn = QPushButton("⚙") + settings_btn.setFixedSize(28, 28) + settings_btn.setStyleSheet(""" + QPushButton{background:rgba(255,255,255,0.15);border:none;border-radius:14px; + color:white;font-size:14px;} + QPushButton:hover{background:rgba(255,255,255,0.3);} + """) + settings_btn.clicked.connect(self._open_settings) + row.addWidget(settings_btn) + bl.addLayout(row) + + status_row = QHBoxLayout() + dot = QLabel("●") + dot.setStyleSheet("color:#10b981;font-size:14px;") + online_txt = QLabel("在线") + online_txt.setStyleSheet("color:white;font-size:12px;") + self.online_label = QLabel("在线: 1") + self.online_label.setStyleSheet("color:white;font-size:12px;") + status_row.addWidget(dot) + status_row.addWidget(online_txt) + status_row.addStretch() + status_row.addWidget(self.online_label) + bl.addLayout(status_row) + return bar + + def _build_tabs(self): + tabs = QTabWidget() + tabs.setStyleSheet(""" + QTabWidget::pane{border:0;background:transparent;} + QTabBar::tab{background:transparent;color:rgba(255,255,255,0.7);padding:10px 12px; + border:none;font-size:12px;min-width:50px;} + QTabBar::tab:selected{background:rgba(99,102,241,0.5);color:white;font-weight:bold;} + QTabBar::tab:hover:!selected{background:rgba(255,255,255,0.08);} + """) + tabs.addTab(self._build_conversations_tab(), "💬 会话") + tabs.addTab(self._build_friends_tab(), "👥 好友") + tabs.addTab(self._build_groups_tab(), "👥 群组") + tabs.addTab(self._build_discover_tab(), "🌍 发现") + return tabs + + def _build_conversations_tab(self): + w = QWidget() + l = QVBoxLayout(w) + l.setContentsMargins(0, 0, 0, 0) + l.setSpacing(0) + + bar = QWidget() + bar.setFixedHeight(46) + bar.setStyleSheet("background:rgba(255,255,255,0.05);") + bl = QHBoxLayout(bar) + bl.setContentsMargins(8, 8, 8, 8) + self.conversation_search = QLineEdit() + self.conversation_search.setPlaceholderText("搜索会话…") + self.conversation_search.setStyleSheet(_SEARCH_STYLE) + self.conversation_search.textChanged.connect(self._filter_conversations) + bl.addWidget(self.conversation_search) + l.addWidget(bar) + + self.conversations_list = QListWidget() + self.conversations_list.setStyleSheet(_LIST_STYLE) + self.conversations_list.itemDoubleClicked.connect(self._on_conversation_dbl) + self.conversations_list.setContextMenuPolicy(Qt.CustomContextMenu) + self.conversations_list.customContextMenuRequested.connect(self._conversation_context_menu) + l.addWidget(self.conversations_list) + return w + + def _build_friends_tab(self): + w = QWidget() + l = QVBoxLayout(w) + l.setContentsMargins(0, 0, 0, 0) + l.setSpacing(0) + + bar = QWidget() + bar.setFixedHeight(46) + bar.setStyleSheet("background:rgba(255,255,255,0.05);") + bl = QHBoxLayout(bar) + bl.setContentsMargins(8, 8, 8, 8) + self.friend_search = QLineEdit() + self.friend_search.setPlaceholderText("搜索好友…") + self.friend_search.setStyleSheet(_SEARCH_STYLE) + self.friend_search.textChanged.connect(self._filter_friends) + bl.addWidget(self.friend_search) + l.addWidget(bar) + + self.friends_list = QListWidget() + self.friends_list.setStyleSheet(_LIST_STYLE) + self.friends_list.itemDoubleClicked.connect(self._on_friend_dbl) + self.friends_list.setContextMenuPolicy(Qt.CustomContextMenu) + self.friends_list.customContextMenuRequested.connect(self._friend_context_menu) + l.addWidget(self.friends_list) + return w + + def _build_groups_tab(self): + w = QWidget() + l = QVBoxLayout(w) + l.setContentsMargins(0, 0, 0, 0) + l.setSpacing(0) + + bar = QWidget() + bar.setFixedHeight(46) + bar.setStyleSheet("background:rgba(255,255,255,0.05);") + bl = QHBoxLayout(bar) + bl.setContentsMargins(8, 8, 8, 8) + create_btn = QPushButton("➕ 创建群组") + create_btn.setStyleSheet(""" + QPushButton{background:#6366f1;color:white;border:none; + border-radius:14px;padding:5px 14px;font-size:12px;} + QPushButton:hover{background:#4f46e5;} + """) + create_btn.clicked.connect(self._create_group) + bl.addWidget(create_btn) + join_btn = QPushButton("🔍 加入") + join_btn.setStyleSheet(""" + QPushButton{background:#8b5cf6;color:white;border:none; + border-radius:14px;padding:5px 10px;font-size:12px;} + QPushButton:hover{background:#7c3aed;} + """) + join_btn.clicked.connect(self._join_group_dialog) + bl.addWidget(join_btn) + bl.addStretch() + l.addWidget(bar) + + self.groups_list = QListWidget() + self.groups_list.setStyleSheet(_LIST_STYLE) + self.groups_list.itemDoubleClicked.connect(self._on_group_dbl) + self.groups_list.setContextMenuPolicy(Qt.CustomContextMenu) + self.groups_list.customContextMenuRequested.connect(self._group_context_menu) + l.addWidget(self.groups_list) + return w + + def _build_discover_tab(self): + w = QWidget() + l = QVBoxLayout(w) + l.setContentsMargins(0, 0, 0, 0) + l.setSpacing(0) + + bar = QWidget() + bar.setFixedHeight(46) + bar.setStyleSheet("background:rgba(255,255,255,0.05);") + bl = QHBoxLayout(bar) + bl.setContentsMargins(8, 8, 8, 8) + self.user_search = QLineEdit() + self.user_search.setPlaceholderText("搜索用户…") + self.user_search.setStyleSheet(_SEARCH_STYLE) + self.user_search.textChanged.connect(self._filter_users) + refresh_btn = QPushButton("🔄") + refresh_btn.setFixedSize(28, 28) + refresh_btn.setStyleSheet(""" + QPushButton{background:#6366f1;color:white;border:none; + border-radius:14px;font-size:13px;} + QPushButton:hover{background:#4f46e5;} + """) + refresh_btn.clicked.connect(self.get_users_requested.emit) + bl.addWidget(self.user_search) + bl.addWidget(refresh_btn) + l.addWidget(bar) + + self.users_list = QListWidget() + self.users_list.setStyleSheet(_LIST_STYLE) + self.users_list.itemDoubleClicked.connect(self._on_user_dbl) + self.users_list.setContextMenuPolicy(Qt.CustomContextMenu) + self.users_list.customContextMenuRequested.connect(self._user_context_menu) + l.addWidget(self.users_list) + return w + + def _build_welcome(self): + w = QWidget() + w.setStyleSheet("background:qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 #6366f1,stop:0.4 #8b5cf6,stop:1 #ec4899);") + l = QVBoxLayout(w) + l.setAlignment(Qt.AlignCenter) + + card = QWidget() + card.setFixedSize(500, 400) + card.setStyleSheet("background:rgba(255,255,255,0.95);border-radius:20px;") + cl = QVBoxLayout(card) + cl.setAlignment(Qt.AlignCenter) + cl.setSpacing(16) + + icon = QLabel("💬") + icon.setStyleSheet("font-size:64px;") + icon.setAlignment(Qt.AlignCenter) + + title = QLabel("欢迎使用 SimpleChat") + title.setAlignment(Qt.AlignCenter) + title.setStyleSheet("font-size:24px;font-weight:bold;color:#1e293b;") + + subtitle = QLabel("安全、快速、易用的即时通信平台") + subtitle.setAlignment(Qt.AlignCenter) + subtitle.setStyleSheet("color:#94a3b8;font-size:13px;") + + tips = QLabel( + "💬 双击好友或群组即可开始聊天\n" + "👥 在「发现」中搜索并添加新朋友\n" + "📋 点击「历史记录」回顾过往消息\n" + "🔍 在聊天面板中搜索关键词\n" + "⚙ 点击右上角齿轮进入个人设置" + ) + tips.setAlignment(Qt.AlignCenter) + tips.setStyleSheet("color:#64748b;font-size:13px;line-height:2.0;") + + cl.addWidget(icon) + cl.addWidget(title) + cl.addWidget(subtitle) + cl.addWidget(tips) + l.addWidget(card) + return w + + def _build_menu(self): + mb = self.menuBar() + mb.setStyleSheet(""" + QMenuBar{background:#f8fafc;color:#333;border-bottom:1px solid #e2e8f0;} + QMenuBar::item{padding:6px 14px;} + QMenuBar::item:selected{background:#eef2ff;} + QMenu{background:white;border:1px solid #e2e8f0;padding:4px;border-radius:6px;} + QMenu::item{padding:7px 24px;border-radius:4px;} + QMenu::item:selected{background:#6366f1;color:white;} + """) + + fm = mb.addMenu("文件") + fm.addAction(QAction("🔄 刷新列表", self, triggered=self._refresh_all)) + fm.addAction(QAction("⚙ 个人设置", self, triggered=self._open_settings)) + fm.addSeparator() + fm.addAction(QAction("🚪 退出登录", self, triggered=self._logout)) + + cm = mb.addMenu("聊天") + cm.addAction(QAction("➕ 添加好友", self, triggered=self._add_friend_dialog)) + cm.addAction(QAction("👥 创建群组", self, triggered=self._create_group)) + cm.addAction(QAction("🔍 加入群组", self, triggered=self._join_group_dialog)) + cm.addSeparator() + cm.addAction(QAction("📜 聊天记录", self, triggered=self._open_history_browser)) + + hm = mb.addMenu("帮助") + hm.addAction(QAction("ℹ️ 关于", self, triggered=self._show_about)) + + # ── 设置对话框 ──────────────────────────────────────── + def _open_settings(self): + from ui.widgets import SettingsDialog + dlg = SettingsDialog(self.username, self.nickname, self) + dlg.profile_update_requested.connect(self.profile_update_requested.emit) + dlg.change_username_requested.connect(self.change_username_requested.emit) + dlg.change_nickname_requested.connect(self.change_nickname_requested.emit) + dlg.change_password_requested.connect(self.change_password_requested.emit) + dlg.notify_toggled.connect(lambda enabled: setattr(self, '_notify_enabled', enabled)) + if hasattr(dlg, '_notify_check'): + dlg._notify_check.setChecked(self._notify_enabled) + dlg.exec_() + + def _open_history_browser(self): + from ui.history_window import HistoryWindow + if self._history_window is None: + self._history_window = HistoryWindow(self.username) + self._history_window.refresh_requested.connect(self.all_history_requested.emit) + self._history_window.show() + self._history_window.raise_() + self._history_window.activateWindow() + self.all_history_requested.emit() + + # ── 会话列表管理 ────────────────────────────────────── + def _update_conversation(self, chat_type, target_id, target_name, last_message='', last_time='', increment_unread=False): + if chat_type == 'private': + chat_id = f"private_{target_name}" + else: + chat_id = f"group_{target_id}" + + if chat_id in self.conversations: + conv = self.conversations[chat_id] + conv['last_message'] = last_message[:40] + '...' if len(last_message) > 40 else last_message + conv['last_time'] = last_time + if increment_unread: + conv['unread_count'] = conv.get('unread_count', 0) + 1 + else: + self.conversations[chat_id] = { + 'chat_id': chat_id, + 'chat_type': chat_type, + 'target_id': target_id, + 'target_name': target_name, + 'last_message': last_message[:40] + '...' if len(last_message) > 40 else last_message, + 'last_time': last_time, + 'unread_count': 1 if increment_unread else 0 + } + + self._refresh_conversations_list() + + def _refresh_conversations_list(self): + self.conversations_list.clear() + sorted_convs = sorted(self.conversations.values(), + key=lambda x: x.get('last_time', ''), + reverse=True) + + for conv in sorted_convs: + icon = "💬" if conv['chat_type'] == 'private' else "👥" + # 显示未读角标 + unread = conv.get('unread_count', 0) + unread_str = f" [{unread}]" if unread > 0 else "" + text = f"{icon} {conv['target_name']}{unread_str}\n" + if conv['last_message']: + text += f" {conv['last_message']}\n" + if conv['last_time']: + time_str = conv['last_time'].split(' ')[-1] if ' ' in conv['last_time'] else conv['last_time'] + text += f" 🕐 {time_str}" + + item = QListWidgetItem(text) + item.setData(Qt.UserRole, conv) + if unread > 0: + item.setForeground(QColor('#f59e0b')) + font = item.font() + font.setBold(True) + item.setFont(font) + else: + item.setForeground(QColor('white')) + self.conversations_list.addItem(item) + + def _mark_conversation_read(self, chat_id): + if chat_id in self.conversations: + self.conversations[chat_id]['unread_count'] = 0 + self._refresh_conversations_list() + + def _on_conversation_dbl(self, item): + conv = item.data(Qt.UserRole) + if conv: + self._mark_conversation_read(conv['chat_id']) + self.open_chat(conv['chat_type'], conv['target_id'], conv['target_name']) + + def _filter_conversations(self, text): + t = text.lower() + for i in range(self.conversations_list.count()): + item = self.conversations_list.item(i) + item.setHidden(bool(t) and t not in item.text().lower()) + + def _conversation_context_menu(self, pos): + item = self.conversations_list.itemAt(pos) + if not item: + return + conv = item.data(Qt.UserRole) + menu = QMenu(self) + menu.setStyleSheet("QMenu{background:white;border:1px solid #ddd;}" + "QMenu::item{padding:6px 18px;}" + "QMenu::item:selected{background:#1abc9c;color:white;}") + menu.addAction("💬 打开会话", lambda: self.open_chat(conv['chat_type'], conv['target_id'], conv['target_name'])) + menu.addAction("📜 查看历史", lambda: self._load_conversation_history(conv)) + menu.addAction("🔍 搜索消息", lambda: self._open_search_for_conv(conv)) + menu.addSeparator() + menu.addAction("🗑 删除会话", lambda: self._remove_conversation(conv['chat_id'])) + menu.exec_(self.conversations_list.mapToGlobal(pos)) + + def _load_conversation_history(self, conv): + if conv['chat_type'] == 'private': + self.get_history_requested.emit(conv['target_name']) + else: + self.get_group_history_requested.emit(conv['target_id']) + + def _open_search_for_conv(self, conv): + from ui.search_window import SearchWindow + search_window = SearchWindow(conv['chat_type'], conv['target_id'], conv['target_name'], self) + search_window.search_requested.connect(self._on_search_messages) + search_window.show() + + def _remove_conversation(self, chat_id): + if chat_id in self.conversations: + del self.conversations[chat_id] + self._refresh_conversations_list() + + # ── 列表更新 ────────────────────────────────────────── + def update_friends_list(self, friends): + self.friends = friends + self.friends_list.clear() + # 在线好友排前面 + sorted_friends = sorted(friends, key=lambda f: (not f.get('is_online', False), f.get('username', ''))) + for f in sorted_friends: + self.friends_list.addItem(UserItem(f)) + + def update_groups_list(self, groups): + self.groups = groups + self.groups_list.clear() + for g in groups: + text = f"👥 {g.get('name','?')}" + item = QListWidgetItem(text) + item.setData(Qt.UserRole, g) + item.setForeground(QColor('white')) + self.groups_list.addItem(item) + + def update_users_list(self, users): + self.all_users = users + self.users_list.clear() + # 在线用户排前面 + sorted_users = sorted(users, key=lambda u: (not u.get('is_online', False), u.get('username', ''))) + for u in sorted_users: + if u.get('username') != self.username: + self.users_list.addItem(UserItem(u)) + + def update_all_groups_list(self, groups): + self.all_groups = groups + + def update_online_status(self, user_id, username, is_online, online_count): + self.online_label.setText(f"在线: {online_count}") + self.online_status_label.setText(f"在线: {online_count}") + for lst in [self.friends_list, self.users_list]: + for i in range(lst.count()): + item = lst.item(i) + if isinstance(item, UserItem) and item.info.get('username') == username: + item.info['is_online'] = is_online + item.refresh() + + # 刷新列表排序 + if self.friends: + self.update_friends_list(self.friends) + if self.all_users: + self.update_users_list(self.all_users) + + # 更新群组详情窗口 + for dw in self._detail_windows: + if hasattr(dw, 'update_member_status'): + dw.update_member_status(user_id, username, is_online) + + def update_group_members_in_detail(self, group_id, members): + for dw in self._detail_windows: + if hasattr(dw, 'group_info') and dw.group_info.get('id') == group_id: + dw.update_members(members) + + # ── 聊天面板管理 ────────────────────────────────────── + def open_chat(self, chat_type, target_id, target_name): + if chat_type == 'private': + key = f"private_{target_name}" + target_id = target_name + else: + key = f"group_{target_id}" + + if key in self.chat_panels: + for i in range(self.chat_area.count()): + if self.chat_area.widget(i) is self.chat_panels[key]: + self.chat_area.setCurrentIndex(i) + return self.chat_panels[key] + + panel = ChatPanel(self.username, chat_type, target_id, target_name) + panel._main_window_ref = self + panel.message_sent.connect(self._on_panel_send) + panel.image_sent.connect(self._on_panel_image_send) + panel.history_requested.connect(self._on_history_requested) + panel.load_more_requested.connect(self._on_load_more_requested) + self.chat_panels[key] = panel + + icon = "💬" if chat_type == 'private' else "👥" + # Remove welcome tab (index 0) when first real chat opens + if self.chat_area.count() > 0 and hasattr(self, '_welcome_tab_removable'): + self.chat_area.removeTab(0) + del self._welcome_tab_removable + idx = self.chat_area.addTab(panel, f"{icon} {target_name}") + self.chat_area.setCurrentIndex(idx) + + # 标记已读 + self._mark_conversation_read(key) + return panel + + def receive_message(self, sender, content, timestamp, chat_type, target_id, target_name): + panel = self.open_chat(chat_type, target_id, target_name) + panel.add_message(sender, content, timestamp, False) + + is_active_tab = False + current = self.chat_area.currentWidget() + if current is panel: + is_active_tab = True + + # 更新会话列表 + self._update_conversation(chat_type, target_id, target_name, content, timestamp, + increment_unread=not is_active_tab) + + self.status_label.setText(f"新消息来自 {sender}") + + # 通知浮层 + if not is_active_tab or not self.isActiveWindow(): + self._show_notification(sender, content, chat_type, target_id) + + # 任务栏闪烁 + if not self.isActiveWindow(): + self._flash_taskbar() + + def receive_image_message(self, sender, filename, file_data_b64, file_path, timestamp, + chat_type, target_id, target_name): + """接收并显示图片消息""" + # 保存到本地缓存 + cache_dir = os.path.join('messages', 'images') + os.makedirs(cache_dir, exist_ok=True) + cache_path = os.path.join(cache_dir, filename) + try: + file_data = base64.b64decode(file_data_b64) + with open(cache_path, 'wb') as f: + f.write(file_data) + except Exception: + cache_path = file_path # fallback + + panel = self.open_chat(chat_type, target_id, target_name) + panel.add_message(sender, cache_path, timestamp, False, msg_type='image') + + is_active_tab = False + current = self.chat_area.currentWidget() + if current is panel: + is_active_tab = True + + self._update_conversation(chat_type, target_id, target_name, '[图片]', timestamp, + increment_unread=not is_active_tab) + self.status_label.setText(f"新图片来自 {sender}") + + if not is_active_tab or not self.isActiveWindow(): + self._show_notification(sender, '[图片]', chat_type, target_id) + if not self.isActiveWindow(): + self._flash_taskbar() + + def _show_notification(self, sender, content, chat_type, target_id): + if not self._notify_enabled: + return + self.notification_toast.show_message(sender, content, chat_type, target_id) + + def _on_toast_clicked(self, chat_type, target_id): + self.open_chat(chat_type, target_id, target_id if chat_type == 'private' else f"群组{target_id}") + self.setWindowState(self.windowState() & ~Qt.WindowMinimized | Qt.WindowActive) + self.activateWindow() + self.raise_() + + def show_history(self, key_name, messages, is_group=False): + if is_group: + key = f"group_{key_name}" + else: + key = f"private_{key_name}" + if key in self.chat_panels: + self.chat_panels[key].load_history(messages) + + def show_search_results(self, results, keyword, chat_type, target_id): + """显示搜索结果的响应处理""" + # 如果搜索窗口打开,更新结果 + pass + + def _close_tab(self, index): + if hasattr(self, '_welcome_tab_removable') and index == 0: + return + widget = self.chat_area.widget(index) + for k, p in list(self.chat_panels.items()): + if p is widget: + del self.chat_panels[k] + break + self.chat_area.removeTab(index) + widget.deleteLater() + + def _on_panel_send(self, content, chat_type, target_id): + if chat_type == 'private': + self.chat_requested.emit(content, str(target_id)) + self._update_conversation('private', target_id, str(target_id), content, + beijing_now_str('%H:%M:%S')) + else: + self.group_chat_requested.emit(int(target_id), content) + group_name = f"群组{target_id}" + for g in self.groups: + if g.get('id') == target_id: + group_name = g.get('name', group_name) + break + self._update_conversation('group', target_id, group_name, content, + beijing_now_str('%H:%M:%S')) + + def _on_panel_image_send(self, filename, chat_type, target_id, file_data_b64): + if chat_type == 'private': + self.image_chat_requested.emit(filename, str(target_id), filename, file_data_b64) + self._update_conversation('private', target_id, str(target_id), '[图片]', + beijing_now_str('%H:%M:%S')) + else: + self.image_group_chat_requested.emit(int(target_id), filename, filename, file_data_b64) + group_name = f"群组{target_id}" + for g in self.groups: + if g.get('id') == target_id: + group_name = g.get('name', group_name) + break + self._update_conversation('group', target_id, group_name, '[图片]', + beijing_now_str('%H:%M:%S')) + + def _on_history_requested(self, target_name, target_id): + if isinstance(target_id, int): + self.get_group_history_requested.emit(target_id) + else: + self.get_history_requested.emit(target_name) + + def _on_load_more_requested(self, target_name, target_id, offset): + self.load_more_history_requested.emit(target_name, target_id) + + def _on_search_messages(self, chat_type, target_id, keyword): + self.search_messages_requested.emit(chat_type, str(target_id), keyword) + + # ── 列表交互 ────────────────────────────────────────── + def _on_friend_dbl(self, item): + info = item.data(Qt.UserRole) + if info: + self.open_chat('private', info.get('id'), info.get('username')) + + def _on_group_dbl(self, item): + info = item.data(Qt.UserRole) + if info: + self.open_chat('group', info.get('id'), info.get('name')) + + def _on_user_dbl(self, item): + info = item.data(Qt.UserRole) + if not info: + return + username = info.get('username', '') + if username == self.username: + return + is_friend = any(f.get('username') == username for f in self.friends) + if is_friend: + self.open_chat('private', info.get('id'), username) + else: + dlg = StyledDialog("添加好友", f'是否将 "{username}" 添加为好友?', self, + confirm_text="添加好友") + if dlg.exec_() == QDialog.Accepted: + self.add_friend_requested.emit(username) + + def _friend_context_menu(self, pos): + item = self.friends_list.itemAt(pos) + if not item: + return + info = item.data(Qt.UserRole) + menu = QMenu(self) + menu.setStyleSheet("QMenu{background:white;border:1px solid #ddd;}" + "QMenu::item{padding:6px 18px;}" + "QMenu::item:selected{background:#1abc9c;color:white;}") + menu.addAction("💬 发送消息", lambda: self.open_chat('private', info.get('id'), info.get('username'))) + menu.addAction("📜 查看历史", lambda: self.get_history_requested.emit(info.get('username'))) + menu.addSeparator() + menu.addAction("🗑 删除好友", lambda: self._confirm_remove_friend(info)) + menu.exec_(self.friends_list.mapToGlobal(pos)) + + def _group_context_menu(self, pos): + item = self.groups_list.itemAt(pos) + if not item: + return + info = item.data(Qt.UserRole) + menu = QMenu(self) + menu.setStyleSheet("QMenu{background:white;border:1px solid #ddd;}" + "QMenu::item{padding:6px 18px;}" + "QMenu::item:selected{background:#1abc9c;color:white;}") + menu.addAction("💬 进入群聊", lambda: self.open_chat('group', info.get('id'), info.get('name'))) + menu.addAction("📜 查看历史", lambda: self.get_group_history_requested.emit(info.get('id'))) + menu.addSeparator() + menu.addAction("ℹ️ 群组详情", lambda: self._show_group_detail(info)) + menu.addAction("🚪 退出群组", lambda: self._leave_group(info.get('id'))) + menu.exec_(self.groups_list.mapToGlobal(pos)) + + def _show_group_detail(self, group_info): + from ui.group_detail_window import GroupDetailWindow + detail_window = GroupDetailWindow(group_info, self.user_info['id'], self) + detail_window.get_group_members_requested.connect( + lambda gid: self._request_group_members(gid)) + detail_window.leave_group_requested.connect( + lambda gid: self.leave_group_requested.emit(gid)) + detail_window.invite_member_requested.connect( + lambda gid, username: self.invite_to_group_requested.emit(gid, username)) + detail_window.show() + self._detail_windows.append(detail_window) + + def _request_group_members(self, group_id): + self.get_group_members_requested.emit(group_id) + + def _leave_group(self, group_id): + dlg = StyledDialog("退出群组", "确定要退出该群组吗?退出后将无法接收群组消息。", self, + confirm_text="退出") + if dlg.exec_() == QDialog.Accepted: + self.leave_group_requested.emit(group_id) + + def _user_context_menu(self, pos): + item = self.users_list.itemAt(pos) + if not item: + return + info = item.data(Qt.UserRole) + username = info.get('username', '') + is_friend = any(f.get('username') == username for f in self.friends) + menu = QMenu(self) + menu.setStyleSheet("QMenu{background:white;border:1px solid #ddd;}" + "QMenu::item{padding:6px 18px;}" + "QMenu::item:selected{background:#1abc9c;color:white;}") + if is_friend: + menu.addAction("💬 发送消息", lambda: self.open_chat('private', info.get('id'), username)) + else: + menu.addAction("➕ 添加好友", lambda: self.add_friend_requested.emit(username)) + menu.exec_(self.users_list.mapToGlobal(pos)) + + def _confirm_remove_friend(self, info): + dlg = StyledDialog("删除好友", f'确定删除好友 "{info.get("username")}" 吗?', self, + confirm_text="删除") + if dlg.exec_() == QDialog.Accepted: + self.remove_friend_requested.emit(info.get('id')) + + # ── 过滤 ────────────────────────────────────────────── + def _filter_friends(self, text): + t = text.lower() + for i in range(self.friends_list.count()): + item = self.friends_list.item(i) + item.setHidden(bool(t) and t not in item.text().lower()) + + def _filter_users(self, text): + t = text.lower() + for i in range(self.users_list.count()): + item = self.users_list.item(i) + item.setHidden(bool(t) and t not in item.text().lower()) + + # ── 对话框 ──────────────────────────────────────────── + def _add_friend_dialog(self): + name, ok = QInputDialog.getText(self, '添加好友', '请输入用户名:') + if ok and name.strip(): + self.add_friend_requested.emit(name.strip()) + + def _create_group(self): + name, ok = QInputDialog.getText(self, '创建群组', '请输入群组名称:') + if ok and name.strip(): + self.create_group_requested.emit(name.strip()) + + def _join_group_dialog(self): + self.get_all_groups_requested.emit() + gid, ok = QInputDialog.getInt(self, '加入群组', '请输入群组ID:', min=1, max=999999) + if ok and gid > 0: + self.join_group_requested.emit(gid) + + def _refresh_all(self): + self.get_friends_requested.emit() + self.get_groups_requested.emit() + self.get_users_requested.emit() + self.status_label.setText("列表已刷新") + + def _logout(self): + dlg = StyledDialog("退出登录", "确定要退出登录吗?", self, confirm_text="退出") + if dlg.exec_() == QDialog.Accepted: + self.logout_requested.emit() + + def _show_about(self): + QMessageBox.about(self, '关于 SimpleChat', + 'SimpleChat 即时通信系统 v2.3\n\n' + '功能:注册登录 · 私聊 · 群聊\n' + '好友管理 · 在线状态 · 历史记录\n' + '消息搜索 · 通知提醒 · 未读标记\n\n' + '© 2026 SimpleChat Team') + + def _flash_taskbar(self): + try: + if hasattr(self, 'winId'): + import ctypes + user32 = ctypes.windll.user32 + user32.FlashWindow(int(self.winId()), True) + except Exception: + pass + + # ── 公共接口 ────────────────────────────────────────── + def update_status(self, text): + self.status_label.setText(text) + + def show_message(self, kind, title, text): + if kind == 'error': + QMessageBox.critical(self, title, text) + elif kind == 'warning': + QMessageBox.warning(self, title, text) + else: + QMessageBox.information(self, title, text) + + def show_toast(self, sender, preview, chat_type, target_id): + self.notification_toast.show_message(sender, preview, chat_type, target_id) diff --git a/client/ui/register_window.py b/client/ui/register_window.py new file mode 100644 index 0000000..0c1e155 --- /dev/null +++ b/client/ui/register_window.py @@ -0,0 +1,300 @@ +""" +注册窗口 —— 支持自由缩放,内容随窗口比例自适应 +""" +import re +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QSizePolicy +) +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QIcon +import os + +from ui.login_window import BrandHeader +from ui.widgets import InlineValidator + + +class RegisterWindow(QWidget): + register_requested = pyqtSignal(str, str, str) # username, password, nickname + show_login = pyqtSignal() + + _MIN_W, _MIN_H = 400, 480 + _DEF_W, _DEF_H = 520, 640 + + def __init__(self): + super().__init__() + self._init_ui() + + # ── 构建 UI ─────────────────────────────────────────── + def _init_ui(self): + self.setWindowTitle("SimpleChat - 注册") + self.setMinimumSize(self._MIN_W, self._MIN_H) + self.resize(self._DEF_W, self._DEF_H) + self.setWindowIcon(QIcon("icon.png") if os.path.exists("icon.png") else QIcon()) + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # 品牌头部(占 28% 高度) + self.brand = BrandHeader() + root.addWidget(self.brand, 28) + + # 表单区(占 58% 高度) + self.form_widget = QWidget() + self.form_widget.setObjectName("formArea") + self.form_widget.setStyleSheet("#formArea { background: white; }") + self.form_layout = QVBoxLayout(self.form_widget) + self.form_layout.setAlignment(Qt.AlignVCenter) + + # ── 用户名 ── + self.user_lbl = QLabel("用户名") + self.username_input = QLineEdit() + self.username_input.setPlaceholderText("请输入用户名(4-20 位字母/数字/下划线)") + self.username_validator = InlineValidator() + self.username_input.editingFinished.connect(self._validate_username) + + # ── 密码 ── + self.pwd_lbl = QLabel("密码") + self.password_input = QLineEdit() + self.password_input.setPlaceholderText("请输入密码(6-20 位)") + self.password_input.setEchoMode(QLineEdit.Password) + self.password_validator = InlineValidator() + self.password_input.editingFinished.connect(self._validate_password) + + # ── 确认密码 ── + self.confirm_lbl = QLabel("确认密码") + self.confirm_input = QLineEdit() + self.confirm_input.setPlaceholderText("请再次输入密码") + self.confirm_input.setEchoMode(QLineEdit.Password) + self.confirm_validator = InlineValidator() + self.confirm_input.editingFinished.connect(self._validate_confirm) + self.confirm_input.returnPressed.connect(self.register) + + # ── 昵称(可选,无验证器)── + self.nick_lbl = QLabel("昵称(可选)") + self.nickname_input = QLineEdit() + self.nickname_input.setPlaceholderText("请输入昵称(留空则使用用户名)") + + # ── 内联错误横幅 ── + self.error_banner = QWidget() + self.error_banner.setObjectName("errorBanner") + banner_layout = QHBoxLayout(self.error_banner) + banner_layout.setContentsMargins(12, 8, 12, 8) + banner_layout.setSpacing(8) + self.error_label = QLabel() + self.error_label.setWordWrap(True) + self.error_label.setStyleSheet("color: white; font-size: 13px;") + self.error_close_btn = QPushButton("✕") + self.error_close_btn.setFlat(True) + self.error_close_btn.setFixedSize(20, 20) + self.error_close_btn.setCursor(Qt.PointingHandCursor) + self.error_close_btn.setStyleSheet( + "QPushButton { color: white; font-size: 12px; border: none; background: transparent; }" + "QPushButton:hover { color: #fecaca; }" + ) + self.error_close_btn.clicked.connect(self.hide_error) + banner_layout.addWidget(self.error_label, 1) + banner_layout.addWidget(self.error_close_btn) + self.error_banner.setStyleSheet( + "QWidget#errorBanner { background: #ef4444; border-radius: 6px; }" + ) + self.error_banner.setVisible(False) + + # ── 注册按钮 ── + self.register_btn = QPushButton("注 册") + self.register_btn.setCursor(Qt.PointingHandCursor) + self.register_btn.clicked.connect(self.register) + + # ── 返回登录链接行 ── + back_row = QHBoxLayout() + back_row.addStretch() + self.back_hint_lbl = QLabel("已有账号?") + self.back_btn = QPushButton("返回登录") + self.back_btn.setFlat(True) + self.back_btn.setCursor(Qt.PointingHandCursor) + self.back_btn.clicked.connect(self.show_login.emit) + back_row.addWidget(self.back_hint_lbl) + back_row.addWidget(self.back_btn) + back_row.addStretch() + + # 将所有控件加入表单布局 + for w in ( + self.user_lbl, self.username_input, self.username_validator, + self.pwd_lbl, self.password_input, self.password_validator, + self.confirm_lbl, self.confirm_input, self.confirm_validator, + self.nick_lbl, self.nickname_input, + self.error_banner, + self.register_btn, + ): + self.form_layout.addWidget(w) + self.form_layout.addLayout(back_row) + + root.addWidget(self.form_widget, 58) + + # 初始化字体/样式 + self._apply_responsive(self._DEF_W, self._DEF_H) + + # ── 响应式样式 ──────────────────────────────────────── + def _apply_responsive(self, w: int, h: int) -> None: + base = max(11, int(h * 0.024)) + lbl_size = max(12, int(h * 0.026)) + inp_size = max(13, int(h * 0.028)) + btn_size = max(14, int(h * 0.030)) + inp_h = max(36, int(h * 0.075)) + btn_h = max(40, int(h * 0.082)) + margin_h = max(20, int(w * 0.09)) + spacing = max(6, int(h * 0.016)) + + self.brand.update_fonts(w, int(h * 0.28)) + + self.form_layout.setContentsMargins(margin_h, spacing, margin_h, spacing) + self.form_layout.setSpacing(spacing) + + lbl_style = f"font-size:{lbl_size}px; font-weight:bold; color:#444;" + for lbl in (self.user_lbl, self.pwd_lbl, self.confirm_lbl, self.nick_lbl): + lbl.setStyleSheet(lbl_style) + + inp_style = f""" + QLineEdit {{ + padding: 0 14px; + border: 1.5px solid #d0d7de; + border-radius: 8px; + font-size: {inp_size}px; + background: white; + color: #222; + min-height: {inp_h}px; + max-height: {inp_h}px; + }} + QLineEdit:focus {{ + border: 1.5px solid #6366f1; + background: #f5f3ff; + }} + """ + for inp in (self.username_input, self.password_input, + self.confirm_input, self.nickname_input): + inp.setStyleSheet(inp_style) + + self.register_btn.setFixedHeight(btn_h) + self.register_btn.setStyleSheet(f""" + QPushButton {{ + background: qlineargradient(x1:0,y1:0,x2:1,y2:0, + stop:0 #6366f1, stop:1 #4f46e5); + color: white; border: none; border-radius: 8px; + font-size: {btn_size}px; font-weight: bold; letter-spacing: 4px; + }} + QPushButton:hover {{ background: #4f46e5; }} + QPushButton:pressed {{ background: #4338ca; }} + QPushButton:disabled {{ background: #95a5a6; }} + """) + + self.back_hint_lbl.setStyleSheet(f"color:#888; font-size:{base}px;") + self.back_btn.setStyleSheet(f""" + QPushButton {{ + color:#6366f1; font-size:{base}px; font-weight:bold; + border:none; background:transparent; padding:0; + }} + QPushButton:hover {{ color:#4f46e5; text-decoration:underline; }} + """) + + # ── 事件 ────────────────────────────────────────────── + def resizeEvent(self, event): + super().resizeEvent(event) + self._apply_responsive(self.width(), self.height()) + + # ── 验证方法 ────────────────────────────────────────── + def _validate_username(self) -> bool: + text = self.username_input.text().strip() + pattern = re.compile(r'^[A-Za-z0-9_]{4,20}$') + if pattern.match(text): + self.username_validator.set_valid("✓ 用户名可用") + return True + else: + self.username_validator.set_invalid("用户名需 4-20 位,只含字母/数字/下划线") + return False + + def _validate_password(self) -> bool: + text = self.password_input.text() + if 6 <= len(text) <= 20: + self.password_validator.set_valid("✓ 密码强度合格") + return True + else: + self.password_validator.set_invalid("密码需 6-20 位") + return False + + def _validate_confirm(self) -> bool: + if self.confirm_input.text() == self.password_input.text(): + self.confirm_validator.set_valid("✓ 密码一致") + return True + else: + self.confirm_validator.set_invalid("两次密码不一致") + return False + + # ── 注册逻辑 ────────────────────────────────────────── + def register(self) -> None: + """执行全字段验证,失败则聚焦第一个无效字段,成功则 emit register_requested""" + self.hide_error() + + valid_username = self._validate_username() + valid_password = self._validate_password() + valid_confirm = self._validate_confirm() + + if not valid_username: + self.username_input.setFocus() + return + if not valid_password: + self.password_input.setFocus() + return + if not valid_confirm: + self.confirm_input.setFocus() + return + + username = self.username_input.text().strip() + password = self.password_input.text() + nickname = self.nickname_input.text().strip() or username + + self.register_requested.emit(username, password, nickname) + + # ── 反馈方法 ────────────────────────────────────────── + def show_loading(self) -> None: + """禁用按钮,文字改为"注册中…" """ + self.register_btn.setEnabled(False) + self.register_btn.setText("注册中…") + + def show_error(self, message: str) -> None: + """显示内联错误横幅(非 QMessageBox),红色背景""" + # 恢复按钮状态 + self.register_btn.setEnabled(True) + self.register_btn.setText("注 册") + # 确保错误横幅是红色 + self.error_banner.setStyleSheet( + "QWidget#errorBanner { background: #ef4444; border-radius: 6px; }" + ) + self.error_label.setText(message) + self.error_banner.setVisible(True) + + def hide_error(self) -> None: + self.error_banner.setVisible(False) + + def show_success(self, message: str) -> None: + """清空字段,显示成功提示(通过内联横幅,绿色)""" + self.clear_fields() + # 改为绿色背景 + self.error_banner.setStyleSheet( + "QWidget#errorBanner { background: #10b981; border-radius: 6px; }" + ) + self.error_label.setText(message) + self.error_banner.setVisible(True) + # 恢复按钮状态 + self.register_btn.setEnabled(True) + self.register_btn.setText("注 册") + + def clear_fields(self) -> None: + """清空所有输入框并重置验证器""" + self.username_input.clear() + self.password_input.clear() + self.confirm_input.clear() + self.nickname_input.clear() + self.username_validator.clear() + self.password_validator.clear() + self.confirm_validator.clear() diff --git a/client/ui/search_window.py b/client/ui/search_window.py new file mode 100644 index 0000000..1b2c9d8 --- /dev/null +++ b/client/ui/search_window.py @@ -0,0 +1,244 @@ +""" +聊天记录搜索窗口(优化版 — 连接服务器搜索) +""" +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QListWidget, QListWidgetItem, + QTextEdit, QSplitter, QFrame +) +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QColor +import html + + +class SearchResultItem(QListWidgetItem): + def __init__(self, message_data, keyword=''): + super().__init__() + self.message_data = message_data + self._update_display(keyword) + + def _update_display(self, keyword=''): + sender = self.message_data.get('sender', '未知') + content = self.message_data.get('content', '') + timestamp = self.message_data.get('timestamp', '') + msg_type = self.message_data.get('msg_type', 'text') + if msg_type == 'image': + preview = '[图片]' + else: + preview = content[:60] + '...' if len(content) > 60 else content + display_text = f"{sender} {timestamp}\n{preview}" + self.setText(display_text) + self.setData(Qt.UserRole, self.message_data) + + def highlight_keyword(self, keyword): + """高亮关键词(未来可扩展富文本显示)""" + pass + + +class SearchWindow(QWidget): + search_requested = pyqtSignal(str, str, str) # chat_type, target_id, keyword + + def __init__(self, chat_type, target_id, target_name, parent=None): + super().__init__(parent) + self.chat_type = chat_type + self.target_id = target_id + self.target_name = target_name + self.search_results = [] + self._init_ui() + + def _init_ui(self): + self.setWindowTitle(f"搜索聊天记录 - {self.target_name}") + self.setGeometry(200, 200, 820, 600) + self.setMinimumSize(600, 400) + + layout = QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + # 搜索栏 + search_frame = QFrame() + search_frame.setStyleSheet(""" + QFrame { + background: #f8fafc; border-bottom: 1px solid #e2e8f0; + } + """) + search_layout = QHBoxLayout(search_frame) + search_layout.setContentsMargins(16, 10, 16, 10) + search_layout.setSpacing(8) + + search_icon = QLabel("🔍") + search_icon.setStyleSheet("font-size: 16px; border: none;") + search_layout.addWidget(search_icon) + + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("输入关键词搜索聊天记录,支持中英文…") + self.search_input.setStyleSheet(""" + QLineEdit { + border: 1px solid #ddd; border-radius: 8px; + background: white; font-size: 14px; padding: 8px 12px; + } + QLineEdit:focus { border: 1px solid #6366f1; } + """) + self.search_input.returnPressed.connect(self._perform_search) + search_layout.addWidget(self.search_input) + + search_btn = QPushButton("搜索") + search_btn.setStyleSheet(""" + QPushButton { + background: #6366f1; color: white; border: none; + border-radius: 8px; padding: 8px 20px; + font-size: 13px; font-weight: bold; + } + QPushButton:hover { background: #4f46e5; } + """) + search_btn.clicked.connect(self._perform_search) + search_layout.addWidget(search_btn) + + layout.addWidget(search_frame) + + # 信息栏 + info_frame = QFrame() + info_frame.setStyleSheet("background: #f1f5f9; border-bottom: 1px solid #e0e0e0;") + info_layout = QHBoxLayout(info_frame) + info_layout.setContentsMargins(16, 6, 16, 6) + chat_type_text = "私聊" if self.chat_type == 'private' else "群聊" + info_lbl = QLabel(f"搜索范围:{chat_type_text}「{self.target_name}」") + info_lbl.setStyleSheet("font-size: 12px; color: #888;") + info_layout.addWidget(info_lbl) + layout.addWidget(info_frame) + + # 分割器 + splitter = QSplitter(Qt.Horizontal) + self._build_results_panel(splitter) + self._build_detail_panel(splitter) + splitter.setSizes([320, 500]) + layout.addWidget(splitter) + + def _build_results_panel(self, parent_splitter): + results_frame = QFrame() + results_frame.setStyleSheet("QFrame { background: white; }") + + results_layout = QVBoxLayout(results_frame) + results_layout.setContentsMargins(0, 0, 0, 0) + results_layout.setSpacing(0) + + # 结果标题 + header = QWidget() + header.setFixedHeight(36) + header.setStyleSheet("background: #fafafa; border-bottom: 1px solid #eee;") + hl = QHBoxLayout(header) + hl.setContentsMargins(12, 0, 12, 0) + self.results_title = QLabel("搜索结果") + self.results_title.setStyleSheet("font-size: 13px; font-weight: bold; color: #555;") + hl.addWidget(self.results_title) + results_layout.addWidget(header) + + self.results_list = QListWidget() + self.results_list.setStyleSheet(""" + QListWidget { + border: none; background: white; outline: none; + } + QListWidget::item { + padding: 10px 14px; border-bottom: 1px solid #f1f5f9; + font-size: 12px; line-height: 1.4; + } + QListWidget::item:hover { background: #f8fafc; } + QListWidget::item:selected { background: #eef2ff; } + """) + self.results_list.itemClicked.connect(self._show_message_detail) + results_layout.addWidget(self.results_list) + + parent_splitter.addWidget(results_frame) + + def _build_detail_panel(self, parent_splitter): + detail_frame = QFrame() + detail_frame.setStyleSheet("QFrame { background: #f8fafc; }") + + detail_layout = QVBoxLayout(detail_frame) + detail_layout.setContentsMargins(0, 0, 0, 0) + detail_layout.setSpacing(0) + + # 详情标题 + header = QWidget() + header.setFixedHeight(36) + header.setStyleSheet("background: #fafafa; border-bottom: 1px solid #eee;") + hl = QHBoxLayout(header) + hl.setContentsMargins(12, 0, 12, 0) + detail_title = QLabel("消息详情") + detail_title.setStyleSheet("font-size: 13px; font-weight: bold; color: #555;") + hl.addWidget(detail_title) + detail_layout.addWidget(header) + + self.detail_text = QTextEdit() + self.detail_text.setReadOnly(True) + self.detail_text.setStyleSheet(""" + QTextEdit { + border: none; background: #f8fafc; + font-size: 13px; line-height: 1.6; + padding: 16px; + } + """) + self.detail_text.setPlaceholderText("选择左侧一条搜索结果查看消息详情…") + detail_layout.addWidget(self.detail_text) + + parent_splitter.addWidget(detail_frame) + + def _perform_search(self): + keyword = self.search_input.text().strip() + if not keyword: + return + + self.results_list.clear() + self.detail_text.clear() + self.results_title.setText("搜索中…") + self.search_requested.emit(self.chat_type, str(self.target_id), keyword) + + def _show_message_detail(self, item): + message_data = item.data(Qt.UserRole) + if not message_data: + return + + sender = message_data.get('sender', '未知') + content = message_data.get('content', '') + timestamp = message_data.get('timestamp', '') + msg_type = message_data.get('msg_type', 'text') + + if msg_type == 'image': + content_display = '
[图片消息]
' + else: + content_display = f'
{html.escape(content).replace(chr(92)+"n", "
")}
' + + detail_html = f""" +
+
+ 发送者:{html.escape(sender)}
+ 时间:{html.escape(timestamp)} +
+
+ 消息内容:

+ {content_display} +
+
+ """ + self.detail_text.setHtml(detail_html) + + def update_search_results(self, results, keyword): + self.search_results = results + self.results_list.clear() + + count = len(results) + self.results_title.setText(f"搜索结果({count} 条)") + + if not results: + no_result = QListWidgetItem("未找到包含关键词的消息") + no_result.setForeground(QColor('#94a3b8')) + self.results_list.addItem(no_result) + return + + for message in results: + item = SearchResultItem(message, keyword) + self.results_list.addItem(item) + + def closeEvent(self, event): + self.search_results.clear() + event.accept() diff --git a/client/ui/widgets.py b/client/ui/widgets.py new file mode 100644 index 0000000..62f3717 --- /dev/null +++ b/client/ui/widgets.py @@ -0,0 +1,407 @@ +""" +共享 UI 组件库 +""" +from PyQt5.QtWidgets import ( + QWidget, QLabel, QFrame, QPushButton, QVBoxLayout, QHBoxLayout, + QGridLayout, QDialog, QFormLayout, QLineEdit +) +from PyQt5.QtCore import Qt, pyqtSignal, QTimer + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. InlineValidator +# ───────────────────────────────────────────────────────────────────────────── + +class InlineValidator(QWidget): + """在输入框下方实时显示验证结果的提示组件""" + + def __init__(self, parent=None): + super().__init__(parent) + self._is_valid = None # None=未验证, True=有效, False=无效 + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self._label = QLabel() + self._label.setWordWrap(True) + self._label.setStyleSheet("font-size: 12px;") + layout.addWidget(self._label) + + self.setVisible(False) + + # ── 公开接口 ────────────────────────────────────────── + + def set_valid(self, message: str = "✓") -> None: + """显示绿色通过提示""" + self._is_valid = True + self._label.setText(message) + self._label.setStyleSheet("font-size: 12px; color: #27ae60;") + self.setVisible(True) + + def set_invalid(self, message: str) -> None: + """显示红色错误提示""" + self._is_valid = False + self._label.setText(message) + self._label.setStyleSheet("font-size: 12px; color: #e74c3c;") + self.setVisible(True) + + def clear(self) -> None: + """隐藏提示(初始/重置状态)""" + self._is_valid = None + self._label.setText("") + self.setVisible(False) + + @property + def is_valid(self): + """当前是否处于有效状态(None=未验证,True=有效,False=无效)""" + return self._is_valid + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. NotificationToast +# ───────────────────────────────────────────────────────────────────────────── + +class NotificationToast(QFrame): + """屏幕右下角弹出的非阻塞消息通知浮层""" + + clicked = pyqtSignal(str, object) # chat_type, target_id + + def __init__(self, parent: QWidget): + super().__init__(parent) + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.timeout.connect(self.dismiss) + self._chat_type = None + self._target_id = None + + self.setFixedWidth(280) + self.setFrameShape(QFrame.StyledPanel) + self.setCursor(Qt.PointingHandCursor) + self.setStyleSheet( + "NotificationToast {" + " background: white;" + " border-radius: 10px;" + " border: 1px solid #e0e0e0;" + "}" + ) + + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 10, 14, 10) + layout.setSpacing(4) + + self._sender_lbl = QLabel() + self._sender_lbl.setStyleSheet("font-weight: bold; font-size: 13px; color: #222;") + + self._preview_lbl = QLabel() + self._preview_lbl.setStyleSheet("font-size: 12px; color: #666;") + self._preview_lbl.setWordWrap(True) + + layout.addWidget(self._sender_lbl) + layout.addWidget(self._preview_lbl) + + self.setVisible(False) + + def show_message( + self, + sender: str, + preview: str, + chat_type: str, + target_id, + auto_dismiss_ms: int = 4000, + ) -> None: + self._chat_type = chat_type + self._target_id = target_id + + self._sender_lbl.setText(sender) + truncated = preview[:30] + ("…" if len(preview) > 30 else "") + self._preview_lbl.setText(truncated) + + self._reposition() + self.setVisible(True) + self.raise_() + + self._timer.stop() + self._timer.start(auto_dismiss_ms) + + def dismiss(self) -> None: + self._timer.stop() + self.setVisible(False) + + def _reposition(self) -> None: + """绝对定位于父窗口右下角""" + parent = self.parentWidget() + if parent is None: + return + margin = 16 + x = parent.width() - self.width() - margin + y = parent.height() - self.sizeHint().height() - margin + self.move(x, y) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self.clicked.emit(self._chat_type, self._target_id) + self.dismiss() + super().mousePressEvent(event) + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. EmojiPicker +# ───────────────────────────────────────────────────────────────────────────── + +_EMOJIS = [ + "😀", "😂", "😍", "😎", "😭", "😡", "😱", "🤔", + "👍", "👎", "👏", "🙏", "🤝", "💪", "🎉", "🔥", + "❤️", "💔", "💯", "✨", "🌟", "⭐", "🎵", "🎶", + "🍕", "🍔", "🍦", "☕", "🍺", "🎂", "🌈", "🌙", + "😊", "😇", "🥰", "😏", "😴", "🤣", "😅", "😬", + "🙄", "😤", "🤯", "🥳", "🤩", "😋", "🤗", "🤫", +] + + +class EmojiPicker(QFrame): + """简单 emoji 选择面板""" + + emoji_selected = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setFrameShape(QFrame.StyledPanel) + self.setStyleSheet( + "EmojiPicker {" + " background: white;" + " border-radius: 8px;" + " border: 1px solid #d0d7de;" + "}" + ) + + grid = QGridLayout(self) + grid.setContentsMargins(8, 8, 8, 8) + grid.setSpacing(2) + + cols = 8 + for idx, emoji in enumerate(_EMOJIS): + btn = QPushButton(emoji) + btn.setFixedSize(32, 32) + btn.setFlat(True) + btn.setCursor(Qt.PointingHandCursor) + btn.setStyleSheet( + "QPushButton { font-size: 18px; border: none; background: transparent; }" + "QPushButton:hover { background: #f0f0f0; border-radius: 4px; }" + ) + btn.clicked.connect(lambda checked, e=emoji: self._on_emoji_clicked(e)) + grid.addWidget(btn, idx // cols, idx % cols) + + self.setVisible(False) + + def _on_emoji_clicked(self, emoji: str) -> None: + self.emoji_selected.emit(emoji) + self.setVisible(False) + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. SettingsDialog +# ───────────────────────────────────────────────────────────────────────────── + +class SettingsDialog(QDialog): + """用户设置对话框:修改昵称、用户名、密码、通知设置""" + + profile_update_requested = pyqtSignal(str) + change_username_requested = pyqtSignal(str) + change_nickname_requested = pyqtSignal(str) + change_password_requested = pyqtSignal(str, str) + notify_toggled = pyqtSignal(bool) + + def __init__(self, username: str, nickname: str, parent=None): + super().__init__(parent) + self.username = username + self.setWindowTitle("个人设置") + self.setMinimumWidth(440) + self.setMaximumHeight(680) + self.setModal(True) + self._build_ui(username, nickname) + self._apply_style() + + def _build_ui(self, username: str, nickname: str) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(20, 16, 20, 16) + root.setSpacing(12) + root.setAlignment(Qt.AlignTop) + + from PyQt5.QtWidgets import QScrollArea + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setStyleSheet("QScrollArea{border:none;background:transparent;}") + + content = QWidget() + cl = QVBoxLayout(content) + cl.setSpacing(12) + + title = QLabel("个人设置") + title.setStyleSheet("font-size: 18px; font-weight: bold; color: #1a1a2e;") + cl.addWidget(title) + + section1 = QLabel("基本资料") + section1.setStyleSheet("font-size: 12px; font-weight: bold; color: #e94560; margin-top: 2px;") + cl.addWidget(section1) + + form = QFormLayout() + form.setSpacing(10) + form.setLabelAlignment(Qt.AlignRight) + + self._username_input = QLineEdit(username) + self._username_input.setPlaceholderText("修改用户名(4-20位字母/数字/下划线)") + form.addRow("用户名:", self._username_input) + + self._nickname_input = QLineEdit(nickname) + self._nickname_input.setPlaceholderText("修改昵称") + form.addRow("昵称:", self._nickname_input) + + cl.addLayout(form) + + section2 = QLabel("修改密码(留空则不修改)") + section2.setStyleSheet("font-size: 12px; font-weight: bold; color: #e94560; margin-top: 2px;") + cl.addWidget(section2) + + pw_form = QFormLayout() + pw_form.setSpacing(10) + pw_form.setLabelAlignment(Qt.AlignRight) + + self._old_password_input = QLineEdit() + self._old_password_input.setEchoMode(QLineEdit.Password) + self._old_password_input.setPlaceholderText("输入原密码") + pw_form.addRow("原密码:", self._old_password_input) + + self._new_password_input = QLineEdit() + self._new_password_input.setEchoMode(QLineEdit.Password) + self._new_password_input.setPlaceholderText("输入新密码(6-20位)") + pw_form.addRow("新密码:", self._new_password_input) + + self._confirm_password_input = QLineEdit() + self._confirm_password_input.setEchoMode(QLineEdit.Password) + self._confirm_password_input.setPlaceholderText("确认新密码") + pw_form.addRow("确认密码:", self._confirm_password_input) + + cl.addLayout(pw_form) + + from PyQt5.QtWidgets import QCheckBox + section3 = QLabel("通知设置") + section3.setStyleSheet("font-size: 12px; font-weight: bold; color: #e94560; margin-top: 2px;") + cl.addWidget(section3) + + self._notify_check = QCheckBox("启用桌面通知") + self._notify_check.setChecked(True) + self._notify_check.setStyleSheet("font-size: 13px; color: #555;") + self._notify_check.toggled.connect(self.notify_toggled.emit) + cl.addWidget(self._notify_check) + + cl.addStretch() + scroll.setWidget(content) + root.addWidget(scroll) + + self._status_label = QLabel("") + self._status_label.setWordWrap(True) + self._status_label.setVisible(False) + root.addWidget(self._status_label) + + btn_row = QHBoxLayout() + btn_row.addStretch() + + self._cancel_btn = QPushButton("取消") + self._cancel_btn.setCursor(Qt.PointingHandCursor) + self._cancel_btn.clicked.connect(self.reject) + + self._save_btn = QPushButton("保存修改") + self._save_btn.setCursor(Qt.PointingHandCursor) + self._save_btn.setDefault(True) + self._save_btn.clicked.connect(self._on_save) + + btn_row.addWidget(self._cancel_btn) + btn_row.addWidget(self._save_btn) + root.addLayout(btn_row) + + def _apply_style(self) -> None: + self.setStyleSheet(""" + QDialog { background: #fafbfc; } + QLineEdit { + padding: 8px 14px; + border: 1.5px solid #e0e0e0; + border-radius: 8px; + font-size: 13px; + color: #333; + background: white; + } + QLineEdit:focus { border: 1.5px solid #e94560; background: #fff5f5; } + QLabel { color: #444; } + QPushButton { padding: 8px 22px; border-radius: 8px; font-size: 13px; } + QPushButton#saveBtn { + background: qlineargradient(x1:0,y1:0,x2:1,y2:0,stop:0 #e94560,stop:1 #c23152); + color: white; border: none; font-weight: bold; + } + QPushButton#saveBtn:hover { background: #c23152; } + QPushButton#cancelBtn { + background: #f0f0f0; color: #555; border: 1px solid #ddd; + } + QPushButton#cancelBtn:hover { background: #e0e0e0; } + """) + self._save_btn.setObjectName("saveBtn") + self._cancel_btn.setObjectName("cancelBtn") + + def _on_save(self) -> None: + new_username = self._username_input.text().strip() + new_nickname = self._nickname_input.text().strip() + old_password = self._old_password_input.text() + new_password = self._new_password_input.text() + confirm_password = self._confirm_password_input.text() + + changed = False + + if new_nickname and new_nickname != self.username: + self.change_nickname_requested.emit(new_nickname) + changed = True + + if new_username and new_username != self.username: + if len(new_username) < 4 or len(new_username) > 20: + self._show_status("用户名需4-20位", "error") + return + if not all(c.isalnum() or c == '_' for c in new_username): + self._show_status("用户名只能包含字母、数字和下划线", "error") + return + self.change_username_requested.emit(new_username) + self.username = new_username + changed = True + + if old_password or new_password or confirm_password: + if not old_password: + self._show_status("请输入原密码", "error") + return + if not new_password: + self._show_status("请输入新密码", "error") + return + if len(new_password) < 6 or len(new_password) > 20: + self._show_status("新密码需6-20位", "error") + return + if new_password != confirm_password: + self._show_status("两次密码不一致", "error") + return + self.change_password_requested.emit(old_password, new_password) + changed = True + + if changed: + self._show_status("设置已保存", "success") + else: + self._show_status("没有需要修改的内容", "info") + + def _show_status(self, message, kind): + self._status_label.setText(message) + if kind == "error": + self._status_label.setStyleSheet( + "font-size: 12px; padding: 6px 12px; border-radius: 6px; background: #ffeaea; color: #c0392b;") + elif kind == "success": + self._status_label.setStyleSheet( + "font-size: 12px; padding: 6px 12px; border-radius: 6px; background: #eafaf1; color: #27ae60;") + else: + self._status_label.setStyleSheet( + "font-size: 12px; padding: 6px 12px; border-radius: 6px; background: #eaf2f8; color: #2980b9;") + self._status_label.setVisible(True) + diff --git a/client/utils.py b/client/utils.py new file mode 100644 index 0000000..37497f3 --- /dev/null +++ b/client/utils.py @@ -0,0 +1,46 @@ +""" +客户端工具函数 +""" +import json +import os +from datetime import datetime, timezone, timedelta + + +def beijing_now_str(fmt='%Y-%m-%d %H:%M:%S'): + """返回北京时间字符串""" + return datetime.now(timezone(timedelta(hours=8))).strftime(fmt) + + +def validate_username(username): + """验证用户名""" + if not username or len(username) < 4 or len(username) > 20: + return False, "用户名长度必须为4-20个字符" + if not username.replace('_', '').isalnum(): + return False, "用户名只能包含字母、数字和下划线" + return True, "" + + +def validate_password(password): + """验证密码""" + if not password or len(password) < 6 or len(password) > 20: + return False, "密码长度必须为6-20个字符" + return True, "" + + +def create_default_config(): + """创建默认配置文件(仅在不存在时创建)""" + if os.path.exists('config.json'): + return + default_config = { + 'server_host': '127.0.0.1', + 'server_port': 8888, + 'auto_login': False, + 'username': '', + 'save_password': False, + 'theme': 'light', + 'font_size': 12, + 'notify_new_message': True, + 'notify_sound': True + } + with open('config.json', 'w', encoding='utf-8') as f: + json.dump(default_config, f, ensure_ascii=False, indent=2) diff --git a/config.json b/config.json new file mode 100644 index 0000000..2d4e73e --- /dev/null +++ b/config.json @@ -0,0 +1,10 @@ +{ + "server_host": "127.0.0.1", + "server_port": 8888, + "buffer_size": 4096, + "timeout": 30, + "auto_login": false, + "username": "admin", + "password": "123456", + "save_password": true +} \ No newline at end of file diff --git a/database/images/20260514172153_1_test.png b/database/images/20260514172153_1_test.png new file mode 100644 index 0000000000000000000000000000000000000000..0f2de3749df299a6b84bf6ff1a0b393a1c1fd22b GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBTuKYyVd1A7xwz3mB) Q_dp2-Pgg&ebxsLQ0NDZ%=5.15.0 +pyqt5-tools \ No newline at end of file diff --git a/server/config.py b/server/config.py new file mode 100644 index 0000000..192e21d --- /dev/null +++ b/server/config.py @@ -0,0 +1,45 @@ +""" +服务器配置 +""" +import json +import os +import sys + + +def _resolve_path(rel_path): + """解析路径:打包后相对于可执行文件,开发时相对于脚本目录""" + if getattr(sys, 'frozen', False): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + return os.path.normpath(os.path.join(base, rel_path)) + + +class Config: + def __init__(self, config_file='config.json'): + self.config_file = _resolve_path(config_file) + # 数据库路径:开发环境在项目根目录,打包后在 exe 同级 + if getattr(sys, 'frozen', False): + db_path = _resolve_path('database/chat.db') + else: + db_path = _resolve_path('../database/chat.db') + self.config = { + 'host': '0.0.0.0', + 'port': 8888, + 'database': db_path, + } + self._load() + + def _load(self): + if os.path.exists(self.config_file): + try: + with open(self.config_file, 'r') as f: + self.config.update(json.load(f)) + except Exception: + pass + + def get(self, key, default=None): + return self.config.get(key, default) + + +config = Config() diff --git a/server/database.py b/server/database.py new file mode 100644 index 0000000..c8e6483 --- /dev/null +++ b/server/database.py @@ -0,0 +1,603 @@ +""" +数据库操作模块 +""" +import os +import sqlite3 +import hashlib +from datetime import datetime, timezone, timedelta +from config import config + + +def beijing_now_str(fmt='%Y-%m-%d %H:%M:%S'): + """返回北京时间字符串""" + return datetime.now(timezone(timedelta(hours=8))).strftime(fmt) + + +class Database: + def __init__(self): + self.db_path = config.get('database') + db_dir = os.path.dirname(self.db_path) + if db_dir and not os.path.exists(db_dir): + os.makedirs(db_dir, exist_ok=True) + self.init_database() + + def get_connection(self): + conn = sqlite3.connect(self.db_path, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + def init_database(self): + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(50) UNIQUE NOT NULL, + password VARCHAR(100) NOT NULL, + nickname VARCHAR(50), + avatar VARCHAR(200) DEFAULT 'default.png', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS friendships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + friend_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (friend_id) REFERENCES users(id), + UNIQUE(user_id, friend_id) + )''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + receiver_id INTEGER, + group_id INTEGER, + content TEXT NOT NULL, + msg_type VARCHAR(20) DEFAULT 'text', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (sender_id) REFERENCES users(id) + )''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL, + creator_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (creator_id) REFERENCES users(id) + )''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS group_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + join_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (group_id) REFERENCES groups(id), + FOREIGN KEY (user_id) REFERENCES users(id), + UNIQUE(group_id, user_id) + )''') + + # 默认管理员与测试用户 + default_users = [ + ('admin', '123456', '系统管理员'), + ('user1', '123456', '用户一'), + ('user2', '123456', '用户二'), + ('user3', '123456', '用户三'), + ] + for username, password, nickname in default_users: + cursor.execute("SELECT id FROM users WHERE username=?", (username,)) + if not cursor.fetchone(): + pw = hashlib.md5(password.encode()).hexdigest() + cursor.execute( + "INSERT INTO users (username, password, nickname) VALUES (?,?,?)", + (username, pw, nickname)) + + conn.commit() + + # 为新数据库填充测试数据:好友关系、群组、聊天记录 + self._seed_test_data(conn) + + conn.close() + + def _seed_test_data(self, conn): + """为新数据库填充测试数据(仅在数据为空时执行)""" + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM messages") + if cursor.fetchone()[0] > 0: + return # 已有数据,跳过 + + now = beijing_now_str() + cursor.execute("SELECT id, username FROM users") + users = {r[1]: r[0] for r in cursor.fetchall()} + uid = users.get + + # 好友关系 + friendships = [ + ('admin', 'user1'), ('admin', 'user2'), + ('user1', 'user2'), ('user1', 'user3'), ('user2', 'user3'), + ] + for a, b in friendships: + if a in users and b in users: + cursor.execute("INSERT OR IGNORE INTO friendships (user_id, friend_id) VALUES (?,?)", + (users[a], users[b])) + cursor.execute("INSERT OR IGNORE INTO friendships (user_id, friend_id) VALUES (?,?)", + (users[b], users[a])) + + # 群组 + cursor.execute("INSERT INTO groups (name, creator_id) VALUES (?,?)", ('技术交流群', users['admin'])) + gid = cursor.lastrowid + for name in ['admin', 'user1', 'user2', 'user3']: + if name in users: + cursor.execute("INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?,?)", + (gid, users[name])) + + # 私聊消息 + private_msgs = [ + ('admin', 'user1', '你好 user1,欢迎使用 SimpleChat!'), + ('user1', 'admin', '你好管理员,这个系统看起来很不错!'), + ('admin', 'user1', '是的,支持私聊、群聊、好友管理等功能'), + ('user1', 'admin', '太棒了,我试试看群聊功能'), + ('admin', 'user2', 'user2,最近在忙什么呢?'), + ('user2', 'admin', '在研究 Python 网络编程,这个聊天系统是用什么写的?'), + ('admin', 'user2', '用 Python + PyQt5 写的,TCP 通信'), + ('user2', 'admin', '厉害!我也想学习一下'), + ('user1', 'user2', 'hi user2,一起加个群聊吧'), + ('user2', 'user1', '好啊,我正想找人聊天呢'), + ('user1', 'user3', 'user3,好久不见'), + ('user3', 'user1', '是啊好久不见,最近怎么样?'), + ('user1', 'user3', '挺好的,这个聊天系统不错'), + ('user3', 'user1', '确实,界面也挺好看的'), + ] + for i, (sen, rec, content) in enumerate(private_msgs): + if sen in users and rec in users: + ts = f'2026-05-14 {9 + i // 6:02d}:{30 + i % 6 * 8:02d}:00' + cursor.execute( + "INSERT INTO messages (sender_id, receiver_id, content, created_at) VALUES (?,?,?,?)", + (users[sen], users[rec], content, ts)) + + # 群聊消息 + group_msgs = [ + ('admin', '欢迎大家加入技术交流群!'), + ('user1', '大家好,我是 user1,很高兴加入'), + ('user2', '大家好!我是 user2'), + ('user3', '各位好,我是 user3'), + ('admin', '大家可以在群里交流技术问题'), + ('user1', 'Python 的异步编程大家有研究吗?'), + ('user2', '我最近在看 asyncio,感觉挺有意思的'), + ('admin', 'asyncio 确实强大,适合高并发场景'), + ('user3', '我主要做前端,PyQt5 的界面开发也可以交流'), + ] + for i, (sen, content) in enumerate(group_msgs): + if sen in users: + ts = f'2026-05-14 {9 + i // 4:02d}:{40 + i % 4 * 8:02d}:00' + cursor.execute( + "INSERT INTO messages (sender_id, group_id, content, created_at) VALUES (?,?,?,?)", + (users[sen], gid, content, ts)) + + conn.commit() + + # ── 用户 ────────────────────────────────────────────── + def user_register(self, username, password, nickname=None): + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT id FROM users WHERE username=?", (username,)) + if cursor.fetchone(): + return False, "用户名已存在" + pw = hashlib.md5(password.encode()).hexdigest() + cursor.execute( + "INSERT INTO users (username, password, nickname) VALUES (?,?,?)", + (username, pw, nickname or username)) + conn.commit() + return True, "注册成功" + except Exception as e: + return False, f"注册失败: {e}" + finally: + conn.close() + + def user_login(self, username, password): + conn = self.get_connection() + cursor = conn.cursor() + try: + pw = hashlib.md5(password.encode()).hexdigest() + cursor.execute( + "SELECT id, username, nickname, avatar FROM users WHERE username=? AND password=?", + (username, pw)) + user = cursor.fetchone() + if user: + return True, "登录成功", dict(user) + return False, "用户名或密码错误", None + except Exception as e: + return False, f"登录失败: {e}", None + finally: + conn.close() + + def change_username(self, user_id, new_username): + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT id FROM users WHERE username=? AND id!=?", (new_username, user_id)) + if cursor.fetchone(): + return False, "用户名已被占用" + cursor.execute("UPDATE users SET username=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_username, user_id)) + conn.commit() + return True, "用户名修改成功" + except Exception as e: + return False, f"修改失败: {e}" + finally: + conn.close() + + def change_nickname(self, user_id, new_nickname): + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute("UPDATE users SET nickname=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_nickname, user_id)) + conn.commit() + return True, "昵称修改成功" + except Exception as e: + return False, f"修改失败: {e}" + finally: + conn.close() + + def change_password(self, user_id, old_password, new_password): + conn = self.get_connection() + cursor = conn.cursor() + try: + old_pw = hashlib.md5(old_password.encode()).hexdigest() + cursor.execute("SELECT id FROM users WHERE id=? AND password=?", (user_id, old_pw)) + if not cursor.fetchone(): + return False, "原密码错误" + new_pw = hashlib.md5(new_password.encode()).hexdigest() + cursor.execute("UPDATE users SET password=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_pw, user_id)) + conn.commit() + return True, "密码修改成功" + except Exception as e: + return False, f"修改失败: {e}" + finally: + conn.close() + + def get_user_by_id(self, user_id): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT id, username, nickname, avatar FROM users WHERE id=?", (user_id,)) + user = cursor.fetchone() + conn.close() + return dict(user) if user else None + + def get_user_by_username(self, username): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT id, username, nickname, avatar FROM users WHERE username=?", (username,)) + user = cursor.fetchone() + conn.close() + return dict(user) if user else None + + def get_all_users(self, exclude_id=None): + conn = self.get_connection() + cursor = conn.cursor() + if exclude_id: + cursor.execute( + "SELECT id, username, nickname, avatar FROM users WHERE id!=? ORDER BY username", + (exclude_id,)) + else: + cursor.execute( + "SELECT id, username, nickname, avatar FROM users ORDER BY username") + users = [dict(r) for r in cursor.fetchall()] + conn.close() + return users + + def search_users(self, keyword, exclude_id=None): + """搜索用户(用户名或昵称模糊匹配)""" + conn = self.get_connection() + cursor = conn.cursor() + like = f"%{keyword}%" + if exclude_id: + cursor.execute( + "SELECT id, username, nickname, avatar FROM users " + "WHERE id!=? AND (username LIKE ? OR nickname LIKE ?) ORDER BY username", + (exclude_id, like, like)) + else: + cursor.execute( + "SELECT id, username, nickname, avatar FROM users " + "WHERE username LIKE ? OR nickname LIKE ? ORDER BY username", + (like, like)) + users = [dict(r) for r in cursor.fetchall()] + conn.close() + return users + + # ── 好友 ────────────────────────────────────────────── + def add_friend(self, user_id, friend_username): + conn = self.get_connection() + cursor = conn.cursor() + try: + friend = self.get_user_by_username(friend_username) + if not friend: + return False, "用户不存在" + friend_id = friend['id'] + if user_id == friend_id: + return False, "不能添加自己为好友" + cursor.execute( + "SELECT id FROM friendships WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)", + (user_id, friend_id, friend_id, user_id)) + if cursor.fetchone(): + return False, "已经是好友" + cursor.execute("INSERT INTO friendships (user_id, friend_id) VALUES (?,?)", (user_id, friend_id)) + cursor.execute("INSERT INTO friendships (user_id, friend_id) VALUES (?,?)", (friend_id, user_id)) + conn.commit() + return True, "添加好友成功" + except Exception as e: + return False, f"添加好友失败: {e}" + finally: + conn.close() + + def get_friends(self, user_id): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT u.id, u.username, u.nickname, u.avatar + FROM users u JOIN friendships f ON u.id = f.friend_id + WHERE f.user_id=? ORDER BY u.username + ''', (user_id,)) + friends = [dict(r) for r in cursor.fetchall()] + conn.close() + return friends + + def remove_friend(self, user_id, friend_id): + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute( + "DELETE FROM friendships WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)", + (user_id, friend_id, friend_id, user_id)) + conn.commit() + return True, "已删除好友" + except Exception as e: + return False, str(e) + finally: + conn.close() + + # ── 消息 ────────────────────────────────────────────── + def save_message(self, sender_id, receiver_id, content, msg_type='text', group_id=None): + conn = self.get_connection() + cursor = conn.cursor() + try: + now = beijing_now_str() + cursor.execute( + "INSERT INTO messages (sender_id, receiver_id, group_id, content, msg_type, created_at) VALUES (?,?,?,?,?,?)", + (sender_id, receiver_id, group_id, content, msg_type, now)) + conn.commit() + return True, cursor.lastrowid + except Exception as e: + return False, str(e) + finally: + conn.close() + + def get_chat_history(self, user1_id, user2_id, limit=50): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT m.id, m.content, m.created_at, u.username, u.nickname + FROM messages m JOIN users u ON m.sender_id = u.id + WHERE ((m.sender_id=? AND m.receiver_id=?) OR (m.sender_id=? AND m.receiver_id=?)) + AND m.group_id IS NULL + ORDER BY m.created_at ASC LIMIT ? + ''', (user1_id, user2_id, user2_id, user1_id, limit)) + messages = [dict(r) for r in cursor.fetchall()] + conn.close() + return messages + + def get_group_history(self, group_id, limit=50): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT m.id, m.content, m.created_at, u.username, u.nickname + FROM messages m JOIN users u ON m.sender_id = u.id + WHERE m.group_id=? + ORDER BY m.created_at ASC LIMIT ? + ''', (group_id, limit)) + messages = [dict(r) for r in cursor.fetchall()] + conn.close() + return messages + + # ── 群组 ────────────────────────────────────────────── + def create_group(self, group_name, creator_id): + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute("INSERT INTO groups (name, creator_id) VALUES (?,?)", (group_name, creator_id)) + group_id = cursor.lastrowid + cursor.execute("INSERT INTO group_members (group_id, user_id) VALUES (?,?)", (group_id, creator_id)) + conn.commit() + return True, group_id + except Exception as e: + return False, str(e) + finally: + conn.close() + + def get_user_groups(self, user_id): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT g.id, g.name, g.creator_id + FROM groups g JOIN group_members gm ON g.id = gm.group_id + WHERE gm.user_id=? ORDER BY g.name + ''', (user_id,)) + groups = [dict(r) for r in cursor.fetchall()] + conn.close() + return groups + + def get_group_members(self, group_id): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT u.id, u.username, u.nickname, u.avatar + FROM users u JOIN group_members gm ON u.id = gm.user_id + WHERE gm.group_id=? ORDER BY u.username + ''', (group_id,)) + members = [dict(r) for r in cursor.fetchall()] + conn.close() + return members + + def join_group(self, group_id, user_id): + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT id FROM groups WHERE id=?", (group_id,)) + if not cursor.fetchone(): + return False, "群组不存在" + cursor.execute( + "INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?,?)", + (group_id, user_id)) + conn.commit() + return True, "加入成功" + except Exception as e: + return False, str(e) + finally: + conn.close() + + def get_all_groups(self): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute("SELECT id, name, creator_id FROM groups ORDER BY name") + groups = [dict(r) for r in cursor.fetchall()] + conn.close() + return groups + + def leave_group(self, group_id, user_id): + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT id FROM groups WHERE id=?", (group_id,)) + if not cursor.fetchone(): + return False, "群组不存在" + cursor.execute( + "DELETE FROM group_members WHERE group_id=? AND user_id=?", + (group_id, user_id)) + if cursor.rowcount == 0: + return False, "你不在该群组中" + conn.commit() + return True, "已退出群组" + except Exception as e: + return False, str(e) + finally: + conn.close() + + def invite_to_group(self, group_id, username): + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT id FROM groups WHERE id=?", (group_id,)) + if not cursor.fetchone(): + return False, "群组不存在" + user = self.get_user_by_username(username) + if not user: + return False, "用户不存在" + cursor.execute( + "SELECT id FROM group_members WHERE group_id=? AND user_id=?", + (group_id, user['id'])) + if cursor.fetchone(): + return False, "用户已在群组中" + cursor.execute( + "INSERT INTO group_members (group_id, user_id) VALUES (?,?)", + (group_id, user['id'])) + conn.commit() + return True, f"已邀请 {username} 加入群组" + except Exception as e: + return False, str(e) + finally: + conn.close() + + def get_all_user_history(self, user_id, limit=200): + """获取用户的所有聊天记录(私聊+群聊),用于全局历史浏览""" + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT m.id, m.content, m.created_at, m.msg_type, + u.username as sender_name, u.nickname as sender_nick, + CASE WHEN m.group_id IS NOT NULL THEN g.name ELSE ru.username END as target_name, + CASE WHEN m.group_id IS NOT NULL THEN 'group' ELSE 'private' END as chat_type, + m.group_id, m.receiver_id + FROM messages m + JOIN users u ON m.sender_id = u.id + LEFT JOIN groups g ON m.group_id = g.id + LEFT JOIN users ru ON m.receiver_id = ru.id + WHERE (m.sender_id = ? OR m.receiver_id = ? + OR m.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)) + ORDER BY m.created_at DESC LIMIT ? + ''', (user_id, user_id, user_id, limit)) + messages = [dict(r) for r in cursor.fetchall()] + conn.close() + return messages + + def get_recent_history(self, user_id, chat_type='private', target_id=None, limit=50): + """获取与特定对象或群组的聊天记录""" + conn = self.get_connection() + cursor = conn.cursor() + if chat_type == 'private': + friend = self.get_user_by_id(target_id) if target_id else None + if not friend: + return [] + cursor.execute(''' + SELECT m.id, m.content, m.created_at, u.username, u.nickname + FROM messages m JOIN users u ON m.sender_id = u.id + WHERE m.group_id IS NULL + AND ((m.sender_id=? AND m.receiver_id=?) OR (m.sender_id=? AND m.receiver_id=?)) + ORDER BY m.created_at DESC LIMIT ? + ''', (user_id, friend['id'], friend['id'], user_id, limit)) + else: + cursor.execute(''' + SELECT m.id, m.content, m.created_at, u.username, u.nickname + FROM messages m JOIN users u ON m.sender_id = u.id + WHERE m.group_id=? + ORDER BY m.created_at DESC LIMIT ? + ''', (target_id, limit)) + messages = [dict(r) for r in cursor.fetchall()] + conn.close() + return messages + + def search_messages(self, user_id, keyword, chat_type='private', target_id=None, limit=100): + conn = self.get_connection() + cursor = conn.cursor() + like = f"%{keyword}%" + if chat_type == 'private': + # target_id may be a username string (from client) or user_id int + friend = None + if isinstance(target_id, str) and not target_id.isdigit(): + friend = self.get_user_by_username(target_id) + elif target_id is not None: + friend = self.get_user_by_id(int(target_id)) + if not friend: + return [] + friend_id = friend['id'] + cursor.execute(''' + SELECT m.id, m.content, m.created_at, m.msg_type, u.username, u.nickname + FROM messages m JOIN users u ON m.sender_id = u.id + WHERE m.group_id IS NULL + AND ((m.sender_id=? AND m.receiver_id=?) OR (m.sender_id=? AND m.receiver_id=?)) + AND m.content LIKE ? + ORDER BY m.created_at DESC LIMIT ? + ''', (user_id, friend_id, friend_id, user_id, like, limit)) + else: + cursor.execute(''' + SELECT m.id, m.content, m.created_at, m.msg_type, u.username, u.nickname + FROM messages m JOIN users u ON m.sender_id = u.id + WHERE m.group_id=? AND m.content LIKE ? + ORDER BY m.created_at DESC LIMIT ? + ''', (target_id, like, limit)) + messages = [dict(r) for r in cursor.fetchall()] + conn.close() + return messages diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..3319bdc --- /dev/null +++ b/server/main.py @@ -0,0 +1,37 @@ +""" +服务器主程序 +""" +import argparse +import sys +from server_core import ChatServer +from config import config + +def main(): + """主函数""" + parser = argparse.ArgumentParser(description='SimpleChat 服务器') + parser.add_argument('--host', default=config.get('host'), help='服务器IP地址') + parser.add_argument('--port', type=int, default=config.get('port'), help='服务器端口') + parser.add_argument('--debug', action='store_true', help='调试模式') + + args = parser.parse_args() + + print("=" * 50) + print("SimpleChat 服务器") + print("=" * 50) + print(f"服务器地址: {args.host}:{args.port}") + print("按 Ctrl+C 停止服务器") + print("-" * 50) + + try: + server = ChatServer(args.host, args.port) + server.start() + except KeyboardInterrupt: + print("\n正在停止服务器...") + server.stop() + sys.exit(0) + except Exception as e: + print(f"服务器运行出错: {e}") + sys.exit(1) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/server/server_core.py b/server/server_core.py new file mode 100644 index 0000000..2ee945b --- /dev/null +++ b/server/server_core.py @@ -0,0 +1,624 @@ +""" +服务器核心逻辑 +""" +import socket +import struct +import threading +import json +import time +import logging +from datetime import datetime, timezone, timedelta + + +def beijing_now(): + """返回北京时间 datetime 对象""" + return datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None) + + +def beijing_now_str(fmt='%Y-%m-%d %H:%M:%S'): + """返回北京时间字符串""" + return beijing_now().strftime(fmt) +from database import Database + + +class ClientHandler(threading.Thread): + """每个客户端连接对应一个处理线程""" + + def __init__(self, client_socket, client_address, server): + super().__init__(daemon=True) + self.client_socket = client_socket + self.client_address = client_address + self.server = server + self.user_id = None + self.username = None + self.running = True + self.client_socket.settimeout(60) + + # ── 收发 ────────────────────────────────────────────── + def _recv_exact(self, n): + buf = b'' + while len(buf) < n: + if not self.running: + return None + try: + chunk = self.client_socket.recv(n - len(buf)) + if not chunk: + return None + buf += chunk + except socket.timeout: + continue + return buf + + def send_message(self, message): + try: + data = json.dumps(message, ensure_ascii=False).encode('utf-8') + self.client_socket.sendall(struct.pack('>I', len(data)) + data) + except Exception as e: + logging.error(f"发送消息失败 {self.client_address}: {e}") + self.disconnect() + + def send_error(self, msg): + self.send_message({'type': 'error', 'message': msg}) + + # ── 主循环 ──────────────────────────────────────────── + def run(self): + logging.info(f"客户端连接: {self.client_address}") + while self.running: + try: + header = self._recv_exact(4) + if not header: + break + msg_len = struct.unpack('>I', header)[0] + if msg_len > 10 * 1024 * 1024: # 拒绝超大包 + break + data = self._recv_exact(msg_len) + if not data: + break + try: + self.handle_message(json.loads(data.decode('utf-8'))) + except json.JSONDecodeError: + self.send_error("消息格式错误") + except socket.timeout: + # 超时发心跳 + self.send_message({'type': 'heartbeat', 'timestamp': time.time()}) + except Exception as e: + if self.running: + logging.error(f"处理消息出错: {e}") + break + self.disconnect() + + # ── 消息路由 ────────────────────────────────────────── + def handle_message(self, message): + t = message.get('type') + handlers = { + 'login': self.handle_login, + 'register': self.handle_register, + 'chat': self.handle_chat, + 'get_users': lambda m: self.handle_get_users(), + 'search_users': self.handle_search_users, + 'add_friend': self.handle_add_friend, + 'remove_friend': self.handle_remove_friend, + 'get_friends': lambda m: self.handle_get_friends(), + 'get_history': self.handle_get_history, + 'create_group': self.handle_create_group, + 'get_groups': lambda m: self.handle_get_groups(), + 'get_all_groups': lambda m: self.handle_get_all_groups(), + 'join_group': self.handle_join_group, + 'group_chat': self.handle_group_chat, + 'get_group_history': self.handle_get_group_history, + 'get_group_members': self.handle_get_group_members, + 'leave_group': self.handle_leave_group, + 'invite_to_group': self.handle_invite_to_group, + 'search_messages': self.handle_search_messages, + 'get_all_history': self.handle_get_all_history, + 'get_recent_history': self.handle_get_recent_history, + 'heartbeat': lambda m: self.send_message({'type': 'heartbeat_response', 'timestamp': time.time()}), + 'change_username': self.handle_change_username, + 'change_nickname': self.handle_change_nickname, + 'change_password': self.handle_change_password, + 'chat_image': self.handle_chat_image, + 'group_chat_image': self.handle_group_chat_image, + } + handler = handlers.get(t) + if handler: + handler(message) + else: + self.send_error(f"未知消息类型: {t}") + + # ── 登录 / 注册 ─────────────────────────────────────── + def handle_login(self, msg): + username = msg.get('username', '').strip() + password = msg.get('password', '').strip() + if not username or not password: + self.send_message({'type': 'login_response', 'success': False, 'message': '用户名和密码不能为空'}) + return + + # 踢掉同账号的旧连接 + with self.server._users_lock: + for uid, info in list(self.server.online_users.items()): + if info['username'] == username: + info['handler'].send_message({'type': 'error', 'message': '账号在其他地方登录'}) + info['handler'].disconnect() + break + + success, msg_text, user_info = self.server.db.user_login(username, password) + if success: + self.user_id = user_info['id'] + self.username = username + with self.server._users_lock: + self.server.online_users[self.user_id] = { + 'handler': self, + 'username': username, + 'nickname': user_info.get('nickname', username), + 'login_time': beijing_now_str() + } + online_count = len(self.server.online_users) + self.send_message({ + 'type': 'login_response', 'success': True, + 'message': '登录成功', 'user_info': user_info, + 'online_count': online_count + }) + self.broadcast_user_status(True) + logging.info(f"用户登录: {username}") + else: + self.send_message({'type': 'login_response', 'success': False, 'message': msg_text}) + + def handle_register(self, msg): + username = msg.get('username', '').strip() + password = msg.get('password', '').strip() + nickname = msg.get('nickname', '').strip() + if not username or not password: + self.send_message({'type': 'register_response', 'success': False, 'message': '用户名和密码不能为空'}) + return + success, text = self.server.db.user_register(username, password, nickname or username) + self.send_message({'type': 'register_response', 'success': success, 'message': text}) + if success: + logging.info(f"新用户注册: {username}") + + # ── 个人资料修改 ────────────────────────────────────── + def handle_change_username(self, msg): + if not self._require_login(): + return + new_username = msg.get('new_username', '').strip() + if not new_username: + self.send_message({'type': 'change_username_response', 'success': False, 'message': '新用户名不能为空'}) + return + if len(new_username) < 4 or len(new_username) > 20: + self.send_message({'type': 'change_username_response', 'success': False, 'message': '用户名长度需4-20位'}) + return + if not all(c.isalnum() or c == '_' for c in new_username): + self.send_message({'type': 'change_username_response', 'success': False, 'message': '用户名只能包含字母、数字和下划线'}) + return + success, msg_text = self.server.db.change_username(self.user_id, new_username) + if success: + old_username = self.username + self.username = new_username + with self.server._users_lock: + if self.user_id in self.server.online_users: + self.server.online_users[self.user_id]['username'] = new_username + self.send_message({'type': 'change_username_response', 'success': True, + 'message': msg_text, 'new_username': new_username}) + logging.info(f'用户改名: {old_username} -> {new_username}') + else: + self.send_message({'type': 'change_username_response', 'success': False, 'message': msg_text}) + + def handle_change_nickname(self, msg): + if not self._require_login(): + return + new_nickname = msg.get('new_nickname', '').strip() + if not new_nickname: + self.send_message({'type': 'change_nickname_response', 'success': False, 'message': '昵称不能为空'}) + return + success, msg_text = self.server.db.change_nickname(self.user_id, new_nickname) + if success: + with self.server._users_lock: + if self.user_id in self.server.online_users: + self.server.online_users[self.user_id]['nickname'] = new_nickname + self.send_message({'type': 'change_nickname_response', 'success': success, 'message': msg_text, + 'new_nickname': new_nickname}) + + def handle_change_password(self, msg): + if not self._require_login(): + return + old_password = msg.get('old_password', '').strip() + new_password = msg.get('new_password', '').strip() + if not old_password or not new_password: + self.send_message({'type': 'change_password_response', 'success': False, 'message': '密码不能为空'}) + return + if len(new_password) < 6 or len(new_password) > 20: + self.send_message({'type': 'change_password_response', 'success': False, 'message': '密码长度需6-20位'}) + return + success, msg_text = self.server.db.change_password(self.user_id, old_password, new_password) + self.send_message({'type': 'change_password_response', 'success': success, 'message': msg_text}) + + # ── 私聊 ────────────────────────────────────────────── + def handle_chat(self, msg): + if not self._require_login(): + return + receiver = msg.get('receiver', '').strip() + content = msg.get('content', '').strip() + if not receiver or not content: + self.send_error("接收方或消息内容不能为空") + return + receiver_info = self.server.db.get_user_by_username(receiver) + if not receiver_info: + self.send_error("用户不存在") + return + self.server.db.save_message(self.user_id, receiver_info['id'], content) + chat_msg = { + 'type': 'chat', + 'sender': self.username, + 'receiver': receiver, + 'content': content, + 'timestamp': beijing_now_str() + } + # 只转发给接收方(如在线),发送方由客户端本地显示 + rid = receiver_info['id'] + if rid in self.server.online_users: + self.server.online_users[rid]['handler'].send_message(chat_msg) + + # ── 用户列表 / 搜索 ─────────────────────────────────── + def handle_get_users(self): + if not self._require_login(): + return + users = self.server.db.get_all_users(self.user_id) + with self.server._users_lock: + online_ids = set(self.server.online_users.keys()) + for u in users: + u['is_online'] = u['id'] in online_ids + self.send_message({'type': 'users_list', 'users': users}) + + def handle_search_users(self, msg): + if not self._require_login(): + return + keyword = msg.get('keyword', '').strip() + users = self.server.db.search_users(keyword, self.user_id) if keyword else self.server.db.get_all_users(self.user_id) + with self.server._users_lock: + online_ids = set(self.server.online_users.keys()) + for u in users: + u['is_online'] = u['id'] in online_ids + self.send_message({'type': 'users_list', 'users': users}) + + # ── 好友管理 ────────────────────────────────────────── + def handle_add_friend(self, msg): + if not self._require_login(): + return + friend_username = msg.get('username', '').strip() + if not friend_username: + self.send_error("请输入用户名") + return + success, text = self.server.db.add_friend(self.user_id, friend_username) + self.send_message({'type': 'add_friend_response', 'success': success, 'message': text}) + + def handle_remove_friend(self, msg): + if not self._require_login(): + return + friend_id = msg.get('friend_id') + if not friend_id: + self.send_error("缺少好友ID") + return + success, text = self.server.db.remove_friend(self.user_id, friend_id) + self.send_message({'type': 'remove_friend_response', 'success': success, 'message': text}) + + def handle_get_friends(self): + if not self._require_login(): + return + friends = self.server.db.get_friends(self.user_id) + with self.server._users_lock: + online_ids = set(self.server.online_users.keys()) + for f in friends: + f['is_online'] = f['id'] in online_ids + self.send_message({'type': 'friends_list', 'friends': friends}) + + # ── 聊天记录 ────────────────────────────────────────── + def handle_get_history(self, msg): + if not self._require_login(): + return + friend_username = msg.get('username', '').strip() + limit = min(int(msg.get('limit', 50)), 200) + friend_info = self.server.db.get_user_by_username(friend_username) + if not friend_info: + self.send_error("用户不存在") + return + history = self.server.db.get_chat_history(self.user_id, friend_info['id'], limit) + formatted = [{'sender': m['username'], 'content': m['content'], 'timestamp': m['created_at']} + for m in history] + self.send_message({'type': 'chat_history', 'friend': friend_username, 'history': formatted}) + + def handle_get_group_history(self, msg): + if not self._require_login(): + return + group_id = msg.get('group_id') + limit = min(int(msg.get('limit', 50)), 200) + if not group_id: + self.send_error("缺少群组ID") + return + history = self.server.db.get_group_history(group_id, limit) + formatted = [{'sender': m['username'], 'content': m['content'], 'timestamp': m['created_at']} + for m in history] + self.send_message({'type': 'group_history', 'group_id': group_id, 'history': formatted}) + + # ── 群组 ────────────────────────────────────────────── + def handle_create_group(self, msg): + if not self._require_login(): + return + group_name = msg.get('group_name', '').strip() + if not group_name: + self.send_error("请输入群组名称") + return + success, result = self.server.db.create_group(group_name, self.user_id) + if success: + self.send_message({'type': 'create_group_response', 'success': True, + 'message': f"群组'{group_name}'创建成功", 'group_id': result}) + else: + self.send_message({'type': 'create_group_response', 'success': False, + 'message': f"创建失败: {result}"}) + + def handle_get_groups(self): + if not self._require_login(): + return + groups = self.server.db.get_user_groups(self.user_id) + self.send_message({'type': 'groups_list', 'groups': groups}) + + def handle_get_all_groups(self): + if not self._require_login(): + return + groups = self.server.db.get_all_groups() + self.send_message({'type': 'all_groups_list', 'groups': groups}) + + def handle_join_group(self, msg): + if not self._require_login(): + return + group_id = msg.get('group_id') + if not group_id: + self.send_error("缺少群组ID") + return + success, text = self.server.db.join_group(group_id, self.user_id) + self.send_message({'type': 'join_group_response', 'success': success, 'message': text, + 'group_id': group_id}) + + def handle_group_chat(self, msg): + if not self._require_login(): + return + group_id = msg.get('group_id') + content = msg.get('content', '').strip() + if not group_id or not content: + self.send_error("群组ID或消息内容不能为空") + return + self.server.db.save_message(self.user_id, None, content, group_id=group_id) + members = self.server.db.get_group_members(group_id) + group_msg = { + 'type': 'group_chat', + 'sender': self.username, + 'group_id': group_id, + 'content': content, + 'timestamp': beijing_now_str() + } + # 只转发给其他在线成员,发送方由客户端本地显示 + for member in members: + mid = member['id'] + if mid != self.user_id and mid in self.server.online_users: + self.server.online_users[mid]['handler'].send_message(group_msg) + + def handle_chat_image(self, msg): + if not self._require_login(): + return + receiver = msg.get('receiver', '').strip() + filename = msg.get('filename', 'image.png') + file_data_b64 = msg.get('data', '') + if not receiver or not file_data_b64: + self.send_error("接收方或图片数据不能为空") + return + receiver_info = self.server.db.get_user_by_username(receiver) + if not receiver_info: + self.send_error("用户不存在") + return + self.server.db.save_message(self.user_id, receiver_info['id'], filename, msg_type='image') + image_msg = { + 'type': 'chat_image', + 'sender': self.username, + 'receiver': receiver, + 'filename': filename, + 'data': file_data_b64, + 'timestamp': beijing_now_str() + } + rid = receiver_info['id'] + if rid in self.server.online_users: + self.server.online_users[rid]['handler'].send_message(image_msg) + + def handle_group_chat_image(self, msg): + if not self._require_login(): + return + group_id = msg.get('group_id') + filename = msg.get('filename', 'image.png') + file_data_b64 = msg.get('data', '') + if not group_id or not file_data_b64: + self.send_error("群组ID或图片数据不能为空") + return + self.server.db.save_message(self.user_id, None, filename, msg_type='image', group_id=group_id) + members = self.server.db.get_group_members(group_id) + image_msg = { + 'type': 'group_chat_image', + 'sender': self.username, + 'group_id': group_id, + 'filename': filename, + 'data': file_data_b64, + 'timestamp': beijing_now_str() + } + for member in members: + mid = member['id'] + if mid != self.user_id and mid in self.server.online_users: + self.server.online_users[mid]['handler'].send_message(image_msg) + + def handle_get_group_members(self, msg): + if not self._require_login(): + return + group_id = msg.get('group_id') + if not group_id: + self.send_error("缺少群组ID") + return + members = self.server.db.get_group_members(group_id) + with self.server._users_lock: + online_ids = set(self.server.online_users.keys()) + for m in members: + m['is_online'] = m['id'] in online_ids + self.send_message({'type': 'group_members', 'group_id': group_id, 'members': members}) + + # ── 群组管理(续)────────────────────────────────────── + def handle_leave_group(self, msg): + if not self._require_login(): + return + group_id = msg.get('group_id') + if not group_id: + self.send_error("缺少群组ID") + return + success, text = self.server.db.leave_group(group_id, self.user_id) + self.send_message({'type': 'leave_group_response', 'success': success, 'message': text, 'group_id': group_id}) + + def handle_invite_to_group(self, msg): + if not self._require_login(): + return + group_id = msg.get('group_id') + username = msg.get('username', '').strip() + if not group_id or not username: + self.send_error("缺少群组ID或用户名") + return + success, text = self.server.db.invite_to_group(group_id, username) + self.send_message({'type': 'invite_to_group_response', 'success': success, 'message': text, 'group_id': group_id}) + + # ── 消息搜索 ─────────────────────────────────────────── + def handle_search_messages(self, msg): + if not self._require_login(): + return + keyword = msg.get('keyword', '').strip() + chat_type = msg.get('chat_type', 'private') + target_id = msg.get('target_id') + if not keyword: + self.send_error("请输入搜索关键词") + return + results = self.server.db.search_messages(self.user_id, keyword, chat_type, target_id) + formatted = [{'sender': m['username'], 'content': m['content'], 'timestamp': m['created_at']} + for m in results] + self.send_message({'type': 'search_results', 'keyword': keyword, 'results': formatted, + 'chat_type': chat_type, 'target_id': target_id}) + + def handle_get_all_history(self, msg): + if not self._require_login(): + return + limit = min(int(msg.get('limit', 200)), 500) + results = self.server.db.get_all_user_history(self.user_id, limit) + formatted = [{ + 'sender': m['sender_name'], + 'content': m['content'], + 'timestamp': m['created_at'], + 'chat_type': m['chat_type'], + 'target_name': m['target_name'], + 'group_id': m['group_id'], + } for m in results] + self.send_message({'type': 'all_history', 'history': formatted}) + + def handle_get_recent_history(self, msg): + if not self._require_login(): + return + chat_type = msg.get('chat_type', 'private') + target_id = msg.get('target_id') + limit = min(int(msg.get('limit', 50)), 200) + results = self.server.db.get_recent_history(self.user_id, chat_type, target_id, limit) + formatted = [{'sender': m['username'], 'content': m['content'], 'timestamp': m['created_at']} + for m in results] + self.send_message({'type': 'recent_history', 'chat_type': chat_type, + 'target_id': target_id, 'history': formatted}) + + # ── 工具 ────────────────────────────────────────────── + def _require_login(self): + if not self.user_id: + self.send_error("请先登录") + return False + return True + + def broadcast_user_status(self, is_online): + with self.server._users_lock: + online_count = len(self.server.online_users) + targets = [(uid, info) for uid, info in self.server.online_users.items() + if uid != self.user_id] + msg = { + 'type': 'user_status', + 'user_id': self.user_id, + 'username': self.username, + 'is_online': is_online, + 'online_count': online_count + } + for uid, info in targets: + try: + info['handler'].send_message(msg) + except Exception: + pass + + def disconnect(self): + if not self.running: + return + self.running = False + with self.server._users_lock: + if self.user_id and self.user_id in self.server.online_users: + del self.server.online_users[self.user_id] + should_broadcast = bool(self.username) + else: + should_broadcast = False + if should_broadcast: + self.broadcast_user_status(False) + try: + self.client_socket.close() + except Exception: + pass + logging.info(f"客户端断开: {self.client_address} ({self.username})") + + +class ChatServer: + def __init__(self, host='0.0.0.0', port=8888): + self.host = host + self.port = port + self.server_socket = None + self.running = False + self.client_threads = [] + self.db = Database() + self.online_users = {} # {user_id: {handler, username, nickname, login_time}} + self._users_lock = threading.Lock() + + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s', + handlers=[logging.FileHandler('server.log', encoding='utf-8'), + logging.StreamHandler()]) + + def start(self): + self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.server_socket.bind((self.host, self.port)) + self.server_socket.listen(10) + self.server_socket.settimeout(1) + self.running = True + logging.info(f"服务器启动: {self.host}:{self.port}") + try: + while self.running: + try: + client_socket, addr = self.server_socket.accept() + handler = ClientHandler(client_socket, addr, self) + handler.start() + self.client_threads.append(handler) + # 清理已结束的线程 + self.client_threads = [t for t in self.client_threads if t.is_alive()] + except socket.timeout: + continue + except Exception as e: + if self.running: + logging.error(f"接受连接出错: {e}") + finally: + self.stop() + + def stop(self): + self.running = False + for t in self.client_threads: + t.disconnect() + if self.server_socket: + self.server_socket.close() + logging.info("服务器已停止") diff --git a/软件文档.md b/软件文档.md new file mode 100644 index 0000000..67c0b95 --- /dev/null +++ b/软件文档.md @@ -0,0 +1,432 @@ +# SimpleChat 即时通信系统 —— 软件文档 + +--- + +## 一、软件功能描述 + +SimpleChat 是一款基于 Python + PyQt5 开发的局域网即时通信系统,采用 C/S 架构,支持多用户同时在线的实时文本与图片通信。 + +### 1.1 核心功能 + +| 功能模块 | 说明 | +|---------|------| +| 用户注册/登录 | 支持新用户注册(用户名、密码、昵称),已有账户登录 | +| 个人资料修改 | 支持修改用户名、昵称、密码(入口:主窗口齿轮按钮) | +| 私聊 | 一对一实时消息,支持文本和图片发送 | +| 群聊 | 创建/加入/退出群组、群内广播消息、邀请好友入群 | +| 好友管理 | 添加/删除好友,好友在线状态实时更新 | +| 会话列表 | 最近聊天记录聚合,未读消息角标与消息预览 | +| 用户搜索与发现 | 按用户名或昵称模糊搜索,发现新朋友 | +| 表情选择器 | 内置 48 个常用 Emoji 表情,点击即插入 | +| 聊天记录浏览 | 全局历史记录浏览器,支持按私聊/群聊分类筛选 | +| 消息搜索 | 在指定会话内按关键词搜索历史消息 | +| 通知提醒 | 桌面通知浮层(右下角弹出)+ 任务栏闪烁 | +| 心跳保活 | 25 秒间隔心跳,自动检测连接状态 | +| 多账号互踢 | 同账号在新设备登录时,旧连接自动断开 | +| 北京时间同步 | 所有消息时间戳统一使用 UTC+8 | + +### 1.2 特色亮点 + +- **Indigo-Violet 紫蓝渐变主题**:现代化 UI 设计,侧边栏深色渐变背景,聊天区淡紫/白色气泡区分本人与对方消息 +- **响应式布局**:登录/注册窗口支持自由缩放,字体与控件随窗口尺寸自适应 +- **欢迎页**:紫蓝粉三色渐变卡片,展示功能引导提示 +- **离线消息持久化**:所有聊天记录存储于 SQLite 数据库,重启后数据不丢失 +- **开箱即用**:首次启动自动创建 4 个测试用户并填充示例聊天记录(14 条私聊 + 9 条群聊 + 5 组好友关系) + +--- + +## 二、软件设计 + +### 2.1 系统架构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Client (PyQt5) │ +│ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │ +│ │ UI 层 │ │ 协调层 │ │ 网络通信层 │ │ +│ │ main_ │ │ ChatAppli- │ │ ChatClient │ │ +│ │ window │──│ cation │──│ (TCP + JSON) │ │ +│ │ login_ │ │ (信号/槽) │ │ │ │ +│ │ window │ └──────────────┘ └───────────────────┘ │ +│ │ ... │ │ +│ └──────────┘ │ +└──────────────────────┬──────────────────────────────────┘ + │ TCP (4字节大端长度 + JSON) +┌──────────────────────┴──────────────────────────────────┐ +│ Server (多线程) │ +│ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │ +│ │ Main │ │ ClientHandler│ │ Database │ │ +│ │ 监听循环 │──│ (每连接一 │──│ (SQLite + WAL) │ │ +│ │ accept │ │ 线程) │ │ │ │ +│ └──────────┘ └──────────────┘ └───────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 2.2 模块划分 + +#### 服务端(server/) + +| 文件 | 职责 | +|------|------| +| [main.py](server/main.py) | 入口,命令行参数解析,启动/停止服务器 | +| [server_core.py](server/server_core.py) | 核心:多线程 TCP 监听、`ClientHandler` 消息路由(24 种消息类型)、在线用户管理、心跳机制 | +| [database.py](server/database.py) | SQLite 数据库封装,WAL 模式、外键约束;用户 CRUD、好友关系管理、消息存取、群组操作、全文搜索、测试数据填充 | +| [config.py](server/config.py) | 服务器配置管理,支持打包路径解析 | + +#### 客户端(client/) + +| 文件 | 职责 | +|------|------| +| [main.py](client/main.py) | `ChatApplication` 协调层:窗口切换、信号/槽连接、登录/注册流程、消息分发(30+ 种服务器消息类型) | +| [client_core.py](client/client_core.py) | TCP 通信核心:连接管理、收发循环、心跳(25s)、线程安全发送 | +| [config.py](client/config.py) | 客户端配置管理(服务器地址、自动登录、记住密码等) | +| [utils.py](client/utils.py) | 工具函数:输入校验、北京时间、默认配置创建 | + +#### UI 组件(client/ui/) + +| 文件 | 职责 | +|------|------| +| [login_window.py](client/ui/login_window.py) | 登录窗口:Indigo-Violet 渐变品牌头部,响应式布局,记住密码/自动登录 | +| [register_window.py](client/ui/register_window.py) | 注册窗口:实时字段校验(用户名/密码/确认密码),内联验证器 | +| [main_window.py](client/ui/main_window.py) | 主窗口:侧边栏(会话/好友/群组/发现四标签页)、聊天面板(ChatBubble/ChatPanel)、欢迎页、通知浮层、右键菜单、设置对话框 | +| [widgets.py](client/ui/widgets.py) | 共享组件:`InlineValidator`(实时校验)、`NotificationToast`(通知浮层)、`EmojiPicker`(48 表情)、`SettingsDialog`(个人设置) | +| [history_window.py](client/ui/history_window.py) | 全局聊天记录浏览器:分类筛选(全部/私聊/群聊)、消息详情面板 | +| [search_window.py](client/ui/search_window.py) | 消息搜索窗口:关键词搜索、结果列表与详情面板 | +| [group_detail_window.py](client/ui/group_detail_window.py) | 群组详情窗口:群信息卡片、成员列表(在线优先排序)、邀请/退出操作 | + +### 2.3 通信协议 + +**传输格式**:`[4 字节大端长度][JSON 数据]`,最大消息体 10MB。 + +**消息类型**(共 24 种): + +| type | 方向 | 说明 | +|------|------|------| +| `login` / `login_response` | C→S / S→C | 登录认证,含互踢逻辑 | +| `register` / `register_response` | C→S / S→C | 新用户注册 | +| `chat` | C→S / S→C | 私聊消息(转发给接收方) | +| `group_chat` | C→S / S→C | 群聊消息(广播给群内其他成员) | +| `chat_image` / `group_chat_image` | C→S / S→C | 图片消息(Base64 编码,≤5MB) | +| `get_users` / `users_list` | C→S / S→C | 获取用户列表(含在线状态) | +| `search_users` | C→S | 按关键词搜索用户 | +| `get_friends` / `friends_list` | C→S / S→C | 获取好友列表(含在线状态) | +| `add_friend` / `add_friend_response` | C→S / S→C | 添加好友 | +| `remove_friend` / `remove_friend_response` | C→S / S→C | 删除好友 | +| `get_history` / `chat_history` | C→S / S→C | 获取私聊历史记录 | +| `get_group_history` / `group_history` | C→S / S→C | 获取群聊历史记录 | +| `get_all_history` / `all_history` | C→S / S→C | 获取全部聊天记录(历史浏览器用) | +| `search_messages` / `search_results` | C→S / S→C | 按关键词搜索消息 | +| `create_group` / `create_group_response` | C→S / S→C | 创建群组 | +| `get_groups` / `groups_list` | C→S / S→C | 获取已加入的群组列表 | +| `get_all_groups` / `all_groups_list` | C→S / S→C | 获取所有群组列表 | +| `join_group` / `join_group_response` | C→S / S→C | 加入群组 | +| `leave_group` / `leave_group_response` | C→S / S→C | 退出群组 | +| `invite_to_group` / `invite_to_group_response` | C→S / S→C | 邀请用户加入群组 | +| `get_group_members` / `group_members` | C→S / S→C | 获取群组成员列表 | +| `user_status` | S→C(广播) | 用户上下线通知 | +| `change_username` / `change_nickname` / `change_password` | C→S / S→C | 个人资料修改 | +| `heartbeat` / `heartbeat_response` | C↔S | 心跳保活 | + +### 2.4 数据库设计 + +**引擎**:SQLite,启用 WAL 日志模式(支持并发读写)和外键约束。 + +**ER 图**: + +``` +users ──< friendships >── users + │ │ + │ ┌──────────────────────┘ + │ │ + ├──< messages >── groups + │ │ + │ └── group_members ──< users + │ + └── (sender_id, receiver_id) +``` + +**数据表结构**: + +- **users**:`id`, `username`(UNIQUE), `password`(MD5), `nickname`, `avatar`, `created_at`, `updated_at` +- **friendships**:`id`, `user_id`(FK), `friend_id`(FK), `created_at`,双向存储 +- **messages**:`id`, `sender_id`(FK), `receiver_id`, `group_id`, `content`, `msg_type`(text/image), `created_at` +- **groups**:`id`, `name`, `creator_id`(FK), `created_at` +- **group_members**:`id`, `group_id`(FK), `user_id`(FK), `join_time` + +### 2.5 关键技术选型 + +| 技术点 | 选型 | 理由 | +|--------|------|------| +| UI 框架 | PyQt5 | 跨平台桌面 GUI,组件丰富 | +| 通信协议 | TCP + JSON | 可靠传输,易于调试和扩展 | +| 并发模型 | 多线程(每连接一线程) | 适合中小规模局域网场景 | +| 数据库 | SQLite + WAL | 零配置、嵌入式,WAL 提升并发 | +| 密码存储 | MD5 哈希 | 简单场景下的密码保护 | +| 打包工具 | PyInstaller | 生成独立可执行文件 | +| 图片传输 | Base64 编码 | 与 JSON 协议兼容,≤5MB 限制 | + +--- + +## 三、功能演示(图片略) + +### 3.1 登录界面 + +登录窗口采用 Indigo-Violet 渐变品牌头部(`#6366f1` → `#8b5cf6`),包含 Logo(💬)、标题 "SimpleChat"、副标题 "简单 · 快速 · 安全"。表单区提供用户名/密码输入、记住密码/自动登录复选框、登录按钮和注册链接。底部可配置服务器地址与端口。界面支持响应式缩放。 + +### 3.2 注册界面 + +注册窗口延续品牌头部设计,提供用户名(4-20 位字母/数字/下划线)、密码(6-20 位)、确认密码、昵称(可选)四个输入字段。每个字段配备 InlineValidator 实时校验组件,输入完成后即时显示绿色通过或红色错误提示。 + +### 3.3 主窗口 + +左侧为深色渐变侧边栏(`#1e1b4b` → `#312e81`),从上至下依次为: + +- **个人资料栏**:紫蓝渐变背景,显示头像首字母、昵称、用户名、在线状态、在线人数、设置齿轮按钮 +- **四标签页**:💬 会话、👥 好友、👥 群组、🌍 发现 + +右侧为聊天区,初始显示三色渐变欢迎卡片,列出功能引导提示。打开聊天后变为 Tab 页,支持多会话同时开启。 + +### 3.4 聊天面板 + +聊天面板包含标题栏(图标 + 聊天对象名称 + 加载更多/历史记录/搜索按钮)、消息气泡区(本人淡紫色靠右、对方白色靠左,含头像首字母、发送者名、智能时间显示、日期分隔线)、输入区(文本输入框 + 表情按钮 + 图片发送按钮 + 字数统计 + 发送按钮)。消息气泡支持文本和图片两种类型,图片可点击查看大图。 + +### 3.5 好友管理 + +好友列表按在线状态排序(在线优先),每个好友项显示昵称、用户名、在线状态指示点(● 绿色在线 / ○ 灰色离线)。右键菜单支持发送消息、查看历史、删除好友。双击好友直接打开私聊面板。 + +### 3.6 群组功能 + +群组列表显示已加入的群组,提供创建群组和加入群组按钮。群组详情窗口显示群组信息卡片(渐变背景、群名、ID、成员数、创建时间)、成员列表(群主标识 👑、在线状态)、邀请好友和退出群组操作。 + +### 3.7 发现用户 + +发现标签页支持搜索用户(按用户名或昵称模糊匹配),未添加为好友的用户双击弹出添加好友确认对话框,已是好友则直接打开私聊。 + +### 3.8 会话列表 + +会话列表聚合所有最近聊天记录,按时间倒序排列。每项显示聊天类型图标(💬 私聊 / 👥 群聊)、对象名称、最新消息预览、时间和未读计数角标。搜索框支持实时过滤。 + +### 3.9 聊天记录浏览器 + +全局历史记录窗口采用左右分栏布局,左侧为消息列表(可按全部/私聊/群聊筛选),右侧为消息详情面板,展示发送者、消息类型、对象、时间和完整消息内容。 + +### 3.10 个人设置 + +设置对话框提供用户名修改、昵称修改、密码修改(需验证原密码)以及桌面通知开关。底部显示操作结果状态。 + +--- + +## 四、软件安装说明 + +### 4.1 环境要求 + +- 操作系统:Windows / macOS / Linux +- Python 版本:3.8 及以上 + +### 4.2 方式一:源码运行 + +**步骤 1:安装 Python 依赖** + +```bash +pip install PyQt5>=5.15.0 +``` + +**步骤 2:启动服务器** + +```bash +cd server +python main.py + +# 可选:指定 IP 和端口 +# python main.py --host 0.0.0.0 --port 8888 +``` + +服务器启动后将自动创建 `database/chat.db` 数据库并填充测试数据。 + +**步骤 3:启动客户端** + +新开一个终端窗口: + +```bash +cd client +python main.py +``` + +### 4.3 方式二:打包运行(无需 Python 环境) + +打包后的可执行文件位于 `dist/SimpleChat/` 目录,目录结构如下: + +``` +dist/SimpleChat/ +├── SimpleChat-Server.exe # 服务器可执行文件 +├── SimpleChat-Client.exe # 客户端可执行文件 +├── 启动服务器.bat # 一键启动服务器 +├── 使用说明.txt # 使用指南 +├── database/ # 数据自动创建于此 +├── avatars/ +├── messages/ +└── logs/ +``` + +**使用步骤**: + +1. 将整个 `dist/SimpleChat/` 文件夹复制到目标电脑 +2. 双击 `启动服务器.bat` 或 `SimpleChat-Server.exe` 启动服务器 +3. 双击 `SimpleChat-Client.exe` 启动客户端 + +### 4.4 自定义打包 + +如需自行打包,安装 PyInstaller 后执行: + +```bash +# 打包服务器 +pyinstaller --onefile --console --name SimpleChat-Server server/main.py + +# 打包客户端 +pyinstaller --onefile --noconsole --name SimpleChat-Client --hidden-import PyQt5.sip client/main.py +``` + +--- + +## 五、软件使用说明 + +### 5.1 首次使用 + +**启动服务器** + +首先在一台电脑上启动服务器程序。服务器默认监听 `0.0.0.0:8888`,控制台将显示启动信息和客户端连接日志。 + +**启动客户端** + +客户端启动后进入登录界面。 + +### 5.2 登录 + +系统内置 4 个测试账户: + +| 用户名 | 密码 | 昵称 | +|--------|--------|------------| +| admin | 123456 | 系统管理员 | +| user1 | 123456 | 用户一 | +| user2 | 123456 | 用户二 | +| user3 | 123456 | 用户三 | + +在登录界面输入用户名和密码,点击"登录"。如需自动登录,可勾选"记住密码"和"自动登录"。 + +如果服务器运行在其他电脑上,请在底部修改服务器 IP 地址(需确保网络互通)。 + +### 5.3 注册新用户 + +点击登录界面的"立即注册"进入注册页面。按提示填写: + +- **用户名**:4-20 位,仅允许字母、数字、下划线 +- **密码**:6-20 位 +- **确认密码**:需与密码一致 +- **昵称**:可选,留空则与用户名相同 + +每个字段输入完毕后会自动进行格式校验。全部通过后点击"注册"。 + +### 5.4 添加好友 + +1. 切换到左侧 **🌍 发现** 标签页,查看在线用户列表 +2. 双击目标用户,在弹出的确认对话框中点击"添加好友" +3. 或通过菜单栏 **聊天 → 添加好友**,输入用户名 + +好友请求为单向添加,无需对方确认。添加成功后好友将出现在 **👥 好友** 标签页。 + +### 5.5 发送私聊消息 + +- **双击好友** 或 **在好友上右键 → 发送消息**,右侧打开聊天面板 +- 在输入框输入消息(支持 `Ctrl+Enter` 快捷发送) +- 点击 😊 按钮可插入 Emoji 表情 +- 点击 🖼 按钮可发送图片(支持 png/jpg/gif/bmp/webp,最大 5MB) +- 点击"发送"按钮或按 `Ctrl+Enter` 发送 + +### 5.6 群组操作 + +**创建群组**: + +- 在 **👥 群组** 标签页点击"➕ 创建群组" +- 或菜单栏 **聊天 → 创建群组** + +**加入群组**: + +- 在 **👥 群组** 标签页点击"🔍 加入" +- 输入群组 ID 即可加入 + +**群聊消息**: + +- 双击群组进入群聊面板,发送方式与私聊相同 +- 消息将广播给群内所有在线成员 + +**群组管理**: + +- 右键群组 → **ℹ️ 群组详情**,可查看成员列表、邀请好友、退出群组 + +### 5.7 查看聊天记录 + +**会话内查看**: + +- 在聊天面板标题栏点击"📋 历史记录"加载最近 50 条消息 +- 点击"📜 加载更多"获取更早的历史记录 + +**全局浏览**: + +- 菜单栏 **聊天 → 📜 聊天记录**,打开全局历史记录浏览器 +- 可按"全部消息 / 私聊消息 / 群聊消息"分类筛选 +- 点击左侧条目查看右侧消息详情 + +### 5.8 搜索消息 + +- 在聊天面板标题栏点击"🔍 搜索" +- 输入关键词搜索该会话内的历史消息 +- 搜索结果按时间倒序排列,点击查看详情 + +### 5.9 修改个人资料 + +- 点击主窗口左上角个人资料栏右侧的 **⚙ 齿轮按钮** +- 可修改:用户名、昵称、密码(需验证原密码) +- 可开关桌面通知功能 + +### 5.10 退出 + +- 菜单栏 **文件 → 🚪 退出登录**,返回登录界面 +- 直接关闭窗口等同于退出程序 + +--- + +## 附录:项目结构总览 + +``` +simplechat/ +├── client/ # 客户端源码 +│ ├── main.py # 入口:ChatApplication 协调层 +│ ├── client_core.py # TCP 通信核心 +│ ├── config.py # 客户端配置管理 +│ ├── utils.py # 工具函数 +│ └── ui/ # UI 组件 +│ ├── login_window.py # 登录窗口(响应式) +│ ├── register_window.py # 注册窗口(含实时校验) +│ ├── main_window.py # 主窗口(会话/好友/群组/发现 + 聊天面板) +│ ├── widgets.py # 共享组件(校验器/通知/表情/设置) +│ ├── history_window.py # 聊天记录浏览器 +│ ├── search_window.py # 消息搜索窗口 +│ └── group_detail_window.py # 群组详情窗口 +├── server/ # 服务端源码 +│ ├── main.py # 入口:命令行参数解析 +│ ├── server_core.py # 核心:多线程 TCP 服务 + 24 种消息路由 +│ ├── database.py # SQLite 数据库封装(WAL 模式) +│ └── config.py # 服务器配置管理 +├── database/ +│ └── init_db.sql # 数据库初始化脚本 +├── messages/ # 本地消息缓存目录 +│ └── images/ # 图片缓存 +├── avatars/ # 头像文件目录 +├── logs/ # 日志目录 +├── dist/ # 打包输出目录 +│ └── SimpleChat/ # 分发包 +├── build/ # 构建中间文件 +├── config.json # 客户端配置文件 +├── requirements.txt # Python 依赖 +└── README.md # 项目说明 +```