Initial commit: SimpleChat 即时通信系统 v2.3

基于 Python + PyQt5 的局域网即时通信系统,支持私聊、群聊、好友管理和历史记录。
master
p6fbkl32v 2 weeks ago
commit b948b8c207

9
.gitignore vendored

@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.pyo
*.log
logs/
build/
dist/
=5.15.0
*.db

@ -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
```
## 数据库
- 引擎SQLiteWAL 日志模式,支持并发读写)
- 外键约束已启用
- 用户密码使用 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
- 初始版本:注册登录、私聊、群聊、好友管理、历史记录、消息搜索

@ -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})

@ -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
}

@ -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()

@ -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
}

@ -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()

@ -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

@ -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'<div style="font-size: 14px; color: #6366f1;">[图片消息]</div>'
else:
content_html = f'<div style="font-size: 14px; line-height: 1.7; color: #333;">{html.escape(content)}</div>'
detail_html = f"""
<div style="font-family: 'Microsoft YaHei', sans-serif;">
<div style="background: #eef2ff; padding: 10px 14px; border-radius: 8px; margin-bottom: 12px;">
<table style="width:100%; font-size: 13px;">
<tr><td style="color:#6366f1; width:60px;"><b>发送者</b></td><td>{html.escape(sender)}</td></tr>
<tr><td style="color:#6366f1;"><b>类型</b></td><td>{type_label}</td></tr>
<tr><td style="color:#6366f1;"><b>对象</b></td><td>{html.escape(target)}</td></tr>
<tr><td style="color:#6366f1;"><b>时间</b></td><td>{html.escape(timestamp)}</td></tr>
</table>
</div>
<div style="background: white; padding: 14px; border-radius: 8px; border: 1px solid #e0e0e0;">
<b style="color:#1e293b;">消息内容</b><br><br>
{content_html}
</div>
</div>
"""
self.detail_view.setHtml(detail_html)
def closeEvent(self, event):
self.all_messages.clear()
event.accept()

@ -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()

File diff suppressed because it is too large Load Diff

@ -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()

@ -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 = '<div style="font-size: 14px; color: #6366f1;">[图片消息]</div>'
else:
content_display = f'<div style="font-size: 14px; line-height: 1.7; color: #333;">{html.escape(content).replace(chr(92)+"n", "<br>")}</div>'
detail_html = f"""
<div style="font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif;">
<div style="background: #eef2ff; padding: 10px 14px; border-radius: 8px; margin-bottom: 14px;">
<strong style="color: #6366f1;">发送者</strong>{html.escape(sender)}<br>
<strong style="color: #6366f1;">时间</strong>{html.escape(timestamp)}
</div>
<div style="background: white; padding: 14px; border-radius: 8px; border: 1px solid #e0e0e0;">
<strong style="color: #2c3e50;">消息内容</strong><br><br>
{content_display}
</div>
</div>
"""
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()

@ -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)

@ -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)

@ -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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

@ -0,0 +1,101 @@
-- SimpleChat 数据库初始化脚本
-- 创建数据库: chat.db
-- 用户表
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
);
-- 好友关系表
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)
);
-- 消息表
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)
);
-- 群组表
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)
);
-- 群组成员表
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)
);
-- 创建默认管理员账户 (密码: 123456)
INSERT OR IGNORE INTO users (username, password, nickname)
VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e', '系统管理员');
-- 创建测试用户
INSERT OR IGNORE INTO users (username, password, nickname)
VALUES
('user1', 'e10adc3949ba59abbe56e057f20f883e', '用户一'),
('user2', 'e10adc3949ba59abbe56e057f20f883e', '用户二'),
('user3', 'e10adc3949ba59abbe56e057f20f883e', '用户三');
-- 创建测试好友关系
INSERT OR IGNORE INTO friendships (user_id, friend_id)
SELECT u1.id, u2.id
FROM users u1, users u2
WHERE u1.username = 'admin' AND u2.username = 'user1'
OR u1.username = 'admin' AND u2.username = 'user2'
OR u1.username = 'user1' AND u2.username = 'user2';
-- 创建测试群组
INSERT OR IGNORE INTO groups (name, creator_id)
SELECT '测试群组', id FROM users WHERE username = 'admin';
-- 添加群组成员
INSERT OR IGNORE INTO group_members (group_id, user_id)
SELECT g.id, u.id
FROM groups g, users u
WHERE g.name = '测试群组'
AND u.username IN ('admin', 'user1', 'user2', 'user3');
-- 创建测试消息
INSERT OR IGNORE INTO messages (sender_id, receiver_id, content)
SELECT u1.id, u2.id, '你好,这是测试消息!'
FROM users u1, users u2
WHERE u1.username = 'admin' AND u2.username = 'user1';
-- 创建索引以提高查询性能
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_id);
CREATE INDEX IF NOT EXISTS idx_messages_receiver ON messages(receiver_id);
CREATE INDEX IF NOT EXISTS idx_messages_group ON messages(group_id);
CREATE INDEX IF NOT EXISTS idx_friendships_user ON friendships(user_id);
CREATE INDEX IF NOT EXISTS idx_friendships_friend ON friendships(friend_id);
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);

@ -0,0 +1,2 @@
PyQt5>=5.15.0
pyqt5-tools

@ -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()

@ -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

@ -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()

@ -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("服务器已停止")

@ -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 # 项目说明
```
Loading…
Cancel
Save