commit 5ff0c575a1c51309818403f64bff9fdb272e9343 Author: theliu Date: Sun Apr 26 20:05:33 2026 +0800 v1.0 diff --git a/app.py b/app.py new file mode 100644 index 0000000..4cb13a5 --- /dev/null +++ b/app.py @@ -0,0 +1,176 @@ +from flask import Flask, render_template, request, jsonify, session, redirect, url_for, send_from_directory +import os +from flask_socketio import SocketIO +from flask_cors import CORS +import sys + +from config import Config +from auth import infi_login, login_required +from database import UserManager, ChatManager +from websocket_handler import WebSocketHandler +from file_handler import FileHandler + +app = Flask(__name__) +app.config.from_object(Config) +CORS(app) + +# 初始化SocketIO +# 在 Windows 上避免使用 eventlet 的 monkey_patch,改用 threading +use_eventlet = False +if not sys.platform.startswith('win'): + try: + import eventlet + eventlet.monkey_patch() + use_eventlet = True + except Exception: + use_eventlet = False + +socketio = SocketIO(app, cors_allowed_origins="*", async_mode='eventlet' if use_eventlet else 'threading') + +# 初始化管理器 +user_manager = UserManager() +chat_manager = ChatManager() + +# 初始化WebSocket处理器 +ws_handler = WebSocketHandler(socketio, user_manager) + +@app.route('/') +@login_required +def index(): + """主页面""" + user_account = session.get('user_account') + user_info = user_manager.get_user(user_account)['user_info'] + return render_template('index.html', user_info=user_info) + +@app.route('/login', methods=['GET', 'POST']) +def login(): + """登录页面""" + if request.method == 'GET': + return render_template('login.html') + + # POST请求处理登录 + data = request.json + account = data.get('account') + password = data.get('password') + + # 检查本地是否已保存用户 + if user_manager.verify_user(account, password): + session['user_account'] = account + user_manager.update_user_status(account, online=True) + return jsonify({'success': True, 'message': '登录成功'}) + + # 使用英飞API登录 + result, error = infi_login(account, password) + if error: + return jsonify({'success': False, 'message': error}) + + # 保存用户信息到本地 + user_manager.add_user(account, password, result['token'], result['user_info']) + + # 设置session + session['user_account'] = account + user_manager.update_user_status(account, online=True) + + return jsonify({'success': True, 'message': '登录成功'}) + +@app.route('/logout') +def logout(): + """登出""" + account = session.get('user_account') + if account: + user_manager.update_user_status(account, online=False) + session.clear() + return redirect(url_for('login')) + +@app.route('/api/users') +@login_required +def get_users(): + """获取用户列表""" + current_user = session.get('user_account') + users = [] + + for account, data in user_manager.users.items(): + if account != current_user: + users.append({ + 'account': account, + 'name': data['user_info'].get('name', '未知用户'), + 'online': data.get('online', False), + 'org': data['user_info'].get('orgFullList', [{}])[0].get('name', '') + }) + + return jsonify(users) + +@app.route('/api/upload', methods=['POST']) +@login_required +def upload_file(): + """文件上传""" + if 'file' not in request.files: + return jsonify({'success': False, 'message': '没有文件'}) + + # 检查是否可以上传新文件 + if not chat_manager.can_upload_file(): + return jsonify({'success': False, 'message': '同时最多只能有5个文件存在'}) + + file = request.files['file'] + uploader = session.get('user_account') + + # 检查文件大小 + file.seek(0, 2) # 移动到文件末尾 + file_size = file.tell() + file.seek(0) # 重置文件指针 + + if file_size > Config.MAX_CONTENT_LENGTH: + return jsonify({'success': False, 'message': '文件太大,最大100MB'}) + + # 保存文件 + file_info = FileHandler.save_file(file, uploader) + if not file_info: + return jsonify({'success': False, 'message': '不支持的文件类型'}) + + # 添加到活跃文件列表 + chat_manager.add_file(file_info) + + return jsonify({ + 'success': True, + 'filename': file_info.get('filename', file_info.get('original_name')), + 'url': file_info['url'] + }) + +@app.route('/api/files') +@login_required +def list_files(): + """获取文件列表""" + files = [] + for file_info in chat_manager.active_files: + files.append({ + 'filename': file_info.get('filename', file_info.get('original_name')), + 'uploader': file_info['uploader'], + 'upload_time': file_info['upload_time'].isoformat(), + 'size': file_info['size'], + 'url': file_info['url'] + }) + + return jsonify(files) + + +@app.route('/favicon.ico') +def favicon(): + # 从 static/favicon.ico 返回 favicon + favicon_path = os.path.join(app.root_path, 'static', 'favicon.ico') + if os.path.exists(favicon_path): + return send_from_directory(os.path.join(app.root_path, 'static'), 'favicon.ico') + return ('', 404) + +if __name__ == '__main__': + print("Starting Web WeChat server...") + print(f"Access at: http://localhost:5000") + socketio.run(app, debug=True, host='::', port=5000) + + +@app.route('/favicon.ico') +def favicon(): + # 从 static/favicon.ico 返回 favicon + favicon_path = os.path.join(app.root_path, 'static', 'favicon.ico') + if os.path.exists(favicon_path): + return send_from_directory(os.path.join(app.root_path, 'static'), 'favicon.ico') + return ('', 404) \ No newline at end of file diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..7be80de --- /dev/null +++ b/auth.py @@ -0,0 +1,114 @@ +import hashlib +import requests +from flask import session, redirect, url_for, request +from functools import wraps + +LOGIN_URL = "https://api.infi.cn/board/user/nc/accountLogin" +USER_API = "https://api.infi.cn/bsg/user/getMyInfo" + +def infi_login(account, password): + """使用英飞API登录""" + try: + login_data = { + "account": account, + "passwd": hashlib.md5(password.encode()).hexdigest(), + "loginOriginType": 1 + } + + login_response = requests.post( + LOGIN_URL, + json=login_data, + headers={"Content-Type": "application/json"}, + timeout=10 + ) + + print(f"[debug] infi_login: LOGIN_URL status={login_response.status_code}") + print(f"[debug] infi_login: LOGIN_URL body={login_response.text[:1000]}") + + if login_response.status_code != 200: + return None, f"API请求失败,HTTP {login_response.status_code}" + + try: + data = login_response.json() + except ValueError: + print("[debug] infi_login: 无法解析登录响应为 JSON", login_response.text[:1000]) + return None, "无法解析登录响应为 JSON" + + # 兼容不同的返回格式:优先从 obj.token 拿 token,其次尝试顶层 token 或 obj 为字符串的情况 + token = None + if isinstance(data, dict): + obj = data.get('obj') + if isinstance(obj, dict) and obj.get('token'): + token = obj.get('token') + elif isinstance(obj, str) and obj: + token = obj + elif data.get('token'): + token = data.get('token') + + if not token: + # 如果存在 code/msg,把它们返回以便排查 + code = data.get('code') if isinstance(data, dict) else None + msg = data.get('msg') if isinstance(data, dict) else None + detail = f" code={code}" if code is not None else "" + print(f"[debug] infi_login: 未找到 token,响应 data={data}") + if msg: + return None, f"登录失败: {msg}{detail}" + return None, f"登录失败:未在响应中找到 token。响应: {data}" + + # 获取用户信息 + user_response = requests.post( + USER_API, + headers={ + "access-token": token, + "Origin": "https://infi.cn", + "Content-Type": "application/json" + }, + timeout=10 + ) + + print(f"[debug] infi_login: USER_API status={user_response.status_code}") + print(f"[debug] infi_login: USER_API body={user_response.text[:1000]}") + + if user_response.status_code != 200: + body = user_response.text[:1000] + return None, f"获取用户信息失败,HTTP {user_response.status_code} 响应: {body}" + + try: + user_data = user_response.json() + except ValueError: + print("[debug] infi_login: 无法解析用户信息响应为 JSON", user_response.text[:1000]) + return None, "无法解析用户信息响应为 JSON" + + # 兼容不同返回格式:服务端有时使用 code==0 表示成功 + if isinstance(user_data, dict) and user_data.get('code') is not None and user_data.get('code') not in (0, 200): + print(f"[debug] infi_login: user_data indicates failure: {user_data}") + return None, f"获取用户信息失败: {user_data.get('msg', '')} 响应: {user_data}" + + user_obj = None + if isinstance(user_data, dict): + user_obj = user_data.get('obj') or user_data + + return { + 'token': token, + 'user_info': user_obj + }, None + + except Exception as e: + # 针对网络/DNS 解析类错误返回更有帮助的提示 + msg = str(e) + if 'NameResolutionError' in msg or 'Failed to resolve' in msg or 'Max retries exceeded' in msg: + return None, ( + "网络错误:无法解析 api.infi.cn(DNS)。请检查网络连接、DNS 或代理设置。" + " 可尝试命令:\n 1) `nslookup api.infi.cn`\n 2) `ping api.infi.cn`\n" + " 或切换网络/使用 VPN、设置正确的 `HTTP(S)_PROXY` 环境变量。" + ) + return None, f"登录异常: {msg}" + +def login_required(f): + """登录装饰器""" + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_account' not in session: + return redirect(url_for('login')) + return f(*args, **kwargs) + return decorated_function \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..1060e32 --- /dev/null +++ b/config.py @@ -0,0 +1,20 @@ +import os +from datetime import timedelta + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' + USERS_FILE = 'users.json' + UPLOAD_FOLDER = 'static/uploads' + MAX_CONTENT_LENGTH = 100 * 1024 * 1024 # 100MB + MAX_SIMULTANEOUS_FILES = 5 + SESSION_PERMANENT = True + PERMANENT_SESSION_LIFETIME = timedelta(days=7) + + # WebSocket配置 + SOCKETIO_ASYNC_MODE = 'eventlet' + + @staticmethod + def init_app(app): + # 确保上传目录存在 + if not os.path.exists(Config.UPLOAD_FOLDER): + os.makedirs(Config.UPLOAD_FOLDER) \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..a099d58 --- /dev/null +++ b/database.py @@ -0,0 +1,101 @@ +import json +import os +import hashlib +from base64 import urlsafe_b64encode +from datetime import datetime +from cryptography.fernet import Fernet +from config import Config + +class UserManager: + def __init__(self): + # 使用 Config.SECRET_KEY 派生一个可复现的 Fernet key,避免每次运行生成不同 key + digest = hashlib.sha256(Config.SECRET_KEY.encode()).digest() + self.key = urlsafe_b64encode(digest) + self.cipher = Fernet(self.key) + self.users_file = Config.USERS_FILE + self.load_users() + # 如果不存在内测账号,则创建之(账号: admin 密码: 123) + if 'admin' not in self.users: + try: + self.add_user('admin', '123', '', {'name': '内测管理员'}) + except Exception: + pass + + def encrypt(self, data): + return self.cipher.encrypt(data.encode()).decode() + + def decrypt(self, encrypted_data): + return self.cipher.decrypt(encrypted_data.encode()).decode() + + def load_users(self): + if os.path.exists(self.users_file): + with open(self.users_file, 'r', encoding='utf-8') as f: + self.users = json.load(f) + else: + self.users = {} + self.save_users() + + def save_users(self): + with open(self.users_file, 'w', encoding='utf-8') as f: + json.dump(self.users, f, ensure_ascii=False, indent=2) + + def add_user(self, account, password, token, user_info): + # 确保 user_info 是 dict 且包含必要的字段以防模版渲染错误 + if not isinstance(user_info, dict): + user_info = {} + user_info.setdefault('name', account) + user_info.setdefault('orgFullList', []) + + self.users[account] = { + 'password': self.encrypt(password), + 'token': token, + 'user_info': user_info, + 'last_login': datetime.now().isoformat(), + 'online': False, + 'socket_id': None + } + self.save_users() + + def get_user(self, account): + return self.users.get(account) + + def update_user_status(self, account, online=False, socket_id=None): + if account in self.users: + self.users[account]['online'] = online + if socket_id: + self.users[account]['socket_id'] = socket_id + self.save_users() + + def verify_user(self, account, password): + user = self.get_user(account) + if user: + try: + decrypted = self.decrypt(user['password']) + return decrypted == password + except: + return False + return False + +class ChatManager: + def __init__(self): + self.active_files = [] + self.file_lock = False + + def can_upload_file(self): + """检查是否可以上传文件(最多5个)""" + # 清理过期的文件(超过1小时) + current_time = datetime.now() + self.active_files = [ + file for file in self.active_files + if (current_time - file['upload_time']).seconds < 3600 + ] + return len(self.active_files) < Config.MAX_SIMULTANEOUS_FILES + + def add_file(self, file_info): + self.active_files.append({ + 'filename': file_info['filename'], + 'size': file_info['size'], + 'uploader': file_info['uploader'], + 'upload_time': datetime.now(), + 'url': file_info['url'] + }) \ No newline at end of file diff --git a/file_handler.py b/file_handler.py new file mode 100644 index 0000000..e6a33b2 --- /dev/null +++ b/file_handler.py @@ -0,0 +1,44 @@ +import os +import uuid +from datetime import datetime +from werkzeug.utils import secure_filename +from config import Config + +class FileHandler: + ALLOWED_EXTENSIONS = { + 'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', + 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'mp3', 'mp4', 'zip', 'rar' + } + + @staticmethod + def allowed_file(filename): + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in FileHandler.ALLOWED_EXTENSIONS + + @staticmethod + def save_file(file, uploader_account): + """保存上传的文件""" + if file and FileHandler.allowed_file(file.filename): + # 生成唯一文件名 + filename = secure_filename(file.filename) + unique_filename = f"{uuid.uuid4().hex}_{filename}" + + # 保存文件 + # 确保上传目录存在 + os.makedirs(Config.UPLOAD_FOLDER, exist_ok=True) + + file_path = os.path.join(Config.UPLOAD_FOLDER, unique_filename) + file.save(file_path) + + # 返回文件信息(包含兼容键名) + return { + 'filename': filename, + 'original_name': filename, + 'saved_name': unique_filename, + 'size': os.path.getsize(file_path), + 'uploader': uploader_account, + 'upload_time': datetime.now(), + 'url': f'/static/uploads/{unique_filename}' + } + return None \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9b46017 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +Flask==3.0.0 +Flask-SocketIO==5.3.6 +Flask-CORS==4.0.0 +requests==2.31.0 +python-socketio==5.10.0 +eventlet==0.33.3 +SQLAlchemy==2.0.25 +cryptography==42.0.5 +Pillow==10.2.0 \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..d3eb1fb --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,844 @@ +/* 苹果风格深色主题 */ +:root { + --bg-primary: #1c1c1e; + --bg-secondary: #2c2c2e; + --bg-tertiary: #3a3a3c; + --text-primary: #ffffff; + --text-secondary: #98989d; + --text-tertiary: #6c6c70; + --accent-blue: #0a84ff; + --accent-green: #30d158; + --accent-red: #ff453a; + --accent-orange: #ff9f0a; + --border-color: #38383a; + --shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + --radius-sm: 10px; + --radius-md: 14px; + --radius-lg: 20px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +body.dark-theme { + background-color: var(--bg-primary); + color: var(--text-primary); + height: 100vh; + overflow: hidden; +} + +.app-container { + display: flex; + height: 100vh; + max-width: 1400px; + margin: 0 auto; +} + +/* 侧边栏样式 */ +.sidebar { + width: 320px; + background-color: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.user-info { + display: flex; + align-items: center; + padding: 20px; + border-bottom: 1px solid var(--border-color); +} + +.avatar { + width: 50px; + height: 50px; + border-radius: 50%; + background-color: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + margin-right: 12px; + font-size: 28px; + color: var(--text-secondary); +} + +.user-details h3 { + font-size: 18px; + font-weight: 600; + margin-bottom: 4px; +} + +.user-details p { + font-size: 14px; + color: var(--text-secondary); +} + +.search-box { + padding: 16px 20px; + position: relative; +} + +.search-box input { + width: 100%; + padding: 10px 40px; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 14px; +} + +.search-box i { + position: absolute; + left: 36px; + top: 50%; + transform: translateY(-50%); + color: var(--text-tertiary); + z-index: 1; +} + +.nav-tabs { + display: flex; + padding: 0 20px; + margin-bottom: 10px; +} + +.nav-btn { + flex: 1; + padding: 12px 0; + background: none; + border: none; + color: var(--text-secondary); + font-size: 14px; + cursor: pointer; + border-radius: var(--radius-sm); + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.nav-btn.active { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.nav-btn:hover { + background-color: var(--bg-tertiary); +} + +.chat-list { + flex: 1; + overflow-y: auto; + padding: 0 10px; +} + +.chat-item { + display: flex; + align-items: center; + padding: 12px 16px; + border-radius: var(--radius-md); + cursor: pointer; + transition: background-color 0.2s; + margin-bottom: 4px; +} + +.chat-item:hover { + background-color: var(--bg-tertiary); +} + +.chat-item.active { + background-color: rgba(10, 132, 255, 0.1); +} + +.chat-item-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background-color: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + margin-right: 12px; + font-size: 22px; + color: var(--text-secondary); +} + +.chat-item-info { + flex: 1; + min-width: 0; +} + +.chat-item-name { + font-size: 16px; + font-weight: 500; + margin-bottom: 2px; + display: flex; + justify-content: space-between; +} + +.chat-item-last-message { + font-size: 14px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-item-time { + font-size: 12px; + color: var(--text-tertiary); +} + +.chat-item-unread { + background-color: var(--accent-blue); + color: white; + font-size: 12px; + padding: 2px 8px; + border-radius: 10px; + margin-left: 8px; +} + +/* 主聊天区域 */ +.main-chat { + flex: 1; + display: flex; + flex-direction: column; + background-color: var(--bg-primary); + position: relative; + overflow: hidden; +} + +.chat-header { + padding: 16px 24px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + background-color: var(--bg-secondary); + z-index: 10; +} + +.chat-partner { + display: flex; + align-items: center; +} + +.partner-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background-color: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + margin-right: 12px; + font-size: 24px; + color: var(--text-secondary); +} + +.partner-info h3 { + font-size: 18px; + font-weight: 600; + margin-bottom: 2px; +} + +.partner-info p { + font-size: 14px; + color: var(--text-secondary); +} + +.chat-actions { + display: flex; + gap: 12px; +} + +.action-btn { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: var(--bg-tertiary); + border: none; + color: var(--text-primary); + font-size: 18px; + cursor: pointer; + transition: background-color 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.action-btn:hover { + background-color: var(--accent-blue); +} + +.action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.message { + display: flex; + max-width: 70%; +} + +.message.received { + align-self: flex-start; +} + +.message.sent { + align-self: flex-end; + flex-direction: row-reverse; +} + +.message-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background-color: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: var(--text-secondary); + margin-top: 4px; +} + +.message.received .message-avatar { + margin-right: 12px; +} + +.message.sent .message-avatar { + margin-left: 12px; +} + +.message-content { + background-color: var(--bg-secondary); + padding: 12px 16px; + border-radius: var(--radius-lg); + position: relative; +} + +.message.sent .message-content { + background-color: var(--accent-blue); + color: white; + border-bottom-right-radius: var(--radius-sm); +} + +.message.received .message-content { + border-bottom-left-radius: var(--radius-sm); +} + +.message-text { + font-size: 15px; + line-height: 1.4; +} + +.message-time { + font-size: 12px; + color: var(--text-tertiary); + margin-top: 4px; + text-align: right; +} + +.message.sent .message-time { + color: rgba(255, 255, 255, 0.8); +} + +.chat-input-area { + padding: 20px 24px; + border-top: 1px solid var(--border-color); + background-color: var(--bg-secondary); +} + +.input-tools { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.tool-btn { + width: 36px; + height: 36px; + border-radius: 50%; + background: none; + border: 1px solid var(--border-color); + color: var(--text-primary); + font-size: 16px; + cursor: pointer; + transition: background-color 0.2s; +} + +.tool-btn:hover { + background-color: var(--bg-tertiary); +} + +.message-input-wrapper { + display: flex; + gap: 12px; + align-items: flex-end; +} + +.message-input-wrapper textarea { + flex: 1; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-primary); + padding: 12px 16px; + font-size: 15px; + resize: none; + max-height: 120px; + min-height: 44px; + line-height: 1.4; +} + +.message-input-wrapper textarea:focus { + outline: none; + border-color: var(--accent-blue); +} + +.send-btn { + width: 44px; + height: 44px; + border-radius: 50%; + background-color: var(--accent-blue); + border: none; + color: white; + font-size: 18px; + cursor: pointer; + transition: background-color 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.send-btn:hover { + background-color: #007aff; +} + +/* 语音通话界面 */ +.call-interface { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.call-container { + text-align: center; + max-width: 400px; + padding: 40px; +} + +.call-avatar { + width: 120px; + height: 120px; + border-radius: 50%; + background-color: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 24px; + font-size: 60px; + color: var(--text-secondary); +} + +.call-container h3 { + font-size: 24px; + margin-bottom: 8px; +} + +.call-status { + font-size: 16px; + color: var(--text-secondary); + margin-bottom: 24px; +} + +.call-timer { + font-size: 32px; + font-weight: 600; + margin: 24px 0; + color: var(--accent-green); +} + +.call-controls { + display: flex; + justify-content: center; + gap: 40px; + margin-top: 40px; +} + +.call-control-btn { + width: 64px; + height: 64px; + border-radius: 50%; + border: none; + font-size: 24px; + cursor: pointer; + transition: transform 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.call-control-btn:hover { + transform: scale(1.1); +} + +.call-control-btn.decline { + background-color: var(--accent-red); + color: white; +} + +.call-control-btn.accept { + background-color: var(--accent-green); + color: white; +} + +/* 侧边栏 */ +.contacts-sidebar, +.files-sidebar { + width: 320px; + background-color: var(--bg-secondary); + border-left: 1px solid var(--border-color); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + right: -320px; + bottom: 0; + transition: right 0.3s; + z-index: 100; +} + +.contacts-sidebar.active, +.files-sidebar.active { + right: 0; +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar-header h3 { + font-size: 20px; + font-weight: 600; +} + +.close-sidebar { + width: 36px; + height: 36px; + border-radius: 50%; + background: none; + border: none; + color: var(--text-primary); + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.close-sidebar:hover { + background-color: var(--bg-tertiary); +} + +.contacts-list, +.files-list { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.contact-item { + display: flex; + align-items: center; + padding: 12px 16px; + border-radius: var(--radius-md); + cursor: pointer; + transition: background-color 0.2s; + margin-bottom: 8px; +} + +.contact-item:hover { + background-color: var(--bg-tertiary); +} + +.contact-status { + width: 8px; + height: 8px; + border-radius: 50%; + margin-left: auto; +} + +.contact-status.online { + background-color: var(--accent-green); +} + +.contact-status.offline { + background-color: var(--text-tertiary); +} + +/* 移动端样式:显示顶部的联系人按钮并调整布局 */ +@media (max-width: 768px) { + .sidebar { display: none; } + .action-btn#openContactsBtn { display: flex; } + .main-chat { padding-top: 0; } + .app-container { max-width: 100%; } +} + +/* 文件上传模态框 */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal.active { + display: flex; +} + +.modal-content { + background-color: var(--bg-secondary); + border-radius: var(--radius-lg); + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.modal-header { + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-header h3 { + font-size: 20px; + font-weight: 600; +} + +.close-modal { + width: 36px; + height: 36px; + border-radius: 50%; + background: none; + border: none; + color: var(--text-primary); + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.close-modal:hover { + background-color: var(--bg-tertiary); +} + +.modal-body { + padding: 24px; + flex: 1; + overflow-y: auto; +} + +.upload-area { + border: 2px dashed var(--border-color); + border-radius: var(--radius-md); + padding: 40px 20px; + text-align: center; + cursor: pointer; + transition: border-color 0.2s; + margin-bottom: 24px; +} + +.upload-area:hover { + border-color: var(--accent-blue); +} + +.upload-area i { + font-size: 48px; + color: var(--text-secondary); + margin-bottom: 16px; +} + +.upload-area p { + font-size: 16px; + margin-bottom: 8px; +} + +.upload-hint { + font-size: 14px; + color: var(--text-tertiary); + margin-bottom: 20px; +} + +.upload-btn { + background-color: var(--accent-blue); + color: white; + border: none; + padding: 10px 24px; + border-radius: var(--radius-md); + font-size: 16px; + cursor: pointer; + transition: background-color 0.2s; +} + +.upload-btn:hover { + background-color: #007aff; +} + +.file-preview { + margin-top: 20px; +} + +.file-item { + display: flex; + align-items: center; + padding: 12px; + background-color: var(--bg-tertiary); + border-radius: var(--radius-md); + margin-bottom: 8px; +} + +.file-icon { + width: 40px; + height: 40px; + background-color: var(--accent-blue); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 12px; + color: white; +} + +.file-info { + flex: 1; +} + +.file-name { + font-size: 14px; + font-weight: 500; + margin-bottom: 2px; +} + +.file-size { + font-size: 12px; + color: var(--text-secondary); +} + +/* 欢迎消息 */ +.welcome-message { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); +} + +.welcome-message i { + font-size: 64px; + margin-bottom: 24px; + opacity: 0.5; +} + +.welcome-message h3 { + font-size: 24px; + margin-bottom: 12px; + color: var(--text-primary); +} + +.welcome-message p { + font-size: 16px; +} + +/* 滚动条样式 */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .sidebar { + width: 100%; + position: fixed; + top: 0; + left: -100%; + bottom: 0; + transition: left 0.3s; + z-index: 100; + } + + .sidebar.active { + left: 0; + } + + .contacts-sidebar, + .files-sidebar { + width: 100%; + right: -100%; + } + + .contacts-sidebar.active, + .files-sidebar.active { + right: 0; + } + + .app-container { + flex-direction: column; + } + + .message { + max-width: 85%; + } +} \ No newline at end of file diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..967909f Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..44ef1ba --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,851 @@ +class WebWeChat { + constructor() { + this.socket = null; + this.currentUser = null; + self.currentContact = null; + this.contacts = []; + this.activeCall = null; + this.callTimer = null; + this.callStartTime = null; + this.peerConnection = null; + this.localStream = null; + + this.init(); + } + + init() { + this.initSocket(); + this.bindEvents(); + this.loadContacts(); + this.setupWebRTC(); + // 如果是移动设备(窄屏),默认打开联系人侧栏以便选择对话 + if (window.innerWidth <= 768) { + this.showContactsSidebar(); + } + } + + initSocket() { + // 连接WebSocket服务器 + this.socket = io({ + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: 5 + }); + + // Socket事件监听 + this.socket.on('connect', () => { + console.log('WebSocket连接成功'); + this.authenticate(); + }); + + this.socket.on('disconnect', () => { + console.log('WebSocket断开连接'); + }); + + this.socket.on('user_status', (data) => { + this.updateUserStatus(data.account, data.online); + }); + + this.socket.on('private_message', (data) => { + this.receiveMessage(data); + }); + + this.socket.on('private_message_sent', (data) => { + console.log('消息已发送:', data); + }); + + this.socket.on('incoming_call', (data) => { + this.handleIncomingCall(data); + }); + + this.socket.on('call_accepted', (data) => { + this.handleCallAccepted(data); + }); + + this.socket.on('call_ended', (data) => { + this.handleCallEnded(data); + }); + + this.socket.on('webrtc_signal', (signal) => { + this.handleWebRTCSignal(signal); + }); + + this.socket.on('file_received', (data) => { + this.showFileNotification(data); + }); + } + + authenticate() { + // 从localStorage获取用户信息 + const account = localStorage.getItem('user_account'); + if (account) { + this.socket.emit('authenticate', { account: account }); + this.currentUser = account; + } + } + + bindEvents() { + // 发送消息 + document.getElementById('sendBtn').addEventListener('click', () => { + this.sendMessage(); + }); + + document.getElementById('messageInput').addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.sendMessage(); + } + }); + + // 语音通话 + document.getElementById('voiceCallBtn').addEventListener('click', () => { + this.startVoiceCall(); + }); + + document.getElementById('endCallBtn').addEventListener('click', () => { + this.endCall(); + }); + + document.getElementById('answerCallBtn').addEventListener('click', () => { + this.answerCall(); + }); + + // 文件上传 + document.getElementById('fileUploadBtn').addEventListener('click', () => { + this.showFileUploadModal(); + }); + + document.getElementById('selectFileBtn').addEventListener('click', () => { + document.getElementById('fileInput').click(); + }); + + document.getElementById('fileInput').addEventListener('change', (e) => { + this.handleFileSelect(e.target.files[0]); + }); + + // 联系人列表 + document.querySelectorAll('[data-tab="contacts"]').forEach(btn => { + btn.addEventListener('click', () => { + this.showContactsSidebar(); + }); + }); + + document.getElementById('closeContactsBtn').addEventListener('click', () => { + this.hideContactsSidebar(); + }); + + // 手机端顶部打开联系人按钮 + const openContactsBtn = document.getElementById('openContactsBtn'); + if (openContactsBtn) { + openContactsBtn.addEventListener('click', () => { + this.showContactsSidebar(); + }); + } + + // 文件列表 + document.querySelectorAll('[data-tab="files"]').forEach(btn => { + btn.addEventListener('click', () => { + this.showFilesSidebar(); + }); + }); + + document.getElementById('closeFilesBtn').addEventListener('click', () => { + this.hideFilesSidebar(); + }); + + // 关闭文件上传模态框 + document.getElementById('closeFileModal').addEventListener('click', () => { + this.hideFileUploadModal(); + }); + + // 拖放文件上传 + const uploadArea = document.getElementById('uploadArea'); + uploadArea.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadArea.style.borderColor = '#0a84ff'; + }); + + uploadArea.addEventListener('dragleave', () => { + uploadArea.style.borderColor = '#38383a'; + }); + + uploadArea.addEventListener('drop', (e) => { + e.preventDefault(); + uploadArea.style.borderColor = '#38383a'; + const file = e.dataTransfer.files[0]; + if (file) { + this.handleFileSelect(file); + } + }); + } + + loadContacts() { + // 从API加载联系人 + fetch('/api/users') + .then(response => response.json()) + .then(contacts => { + this.contacts = contacts; + this.renderContacts(); + }) + .catch(error => { + console.error('加载联系人失败:', error); + }); + } + + renderContacts() { + const contactsList = document.getElementById('contactsList'); + const chatList = document.getElementById('chatList'); + + contactsList.innerHTML = ''; + chatList.innerHTML = ''; + + this.contacts.forEach(contact => { + // 联系人侧边栏 + const contactItem = document.createElement('div'); + contactItem.className = 'contact-item'; + contactItem.innerHTML = ` +
+ +
+
+
+ ${contact.name} + +
+
${contact.org}
+
+ `; + contactItem.addEventListener('click', () => { + this.selectContact(contact); + this.hideContactsSidebar(); + }); + contactsList.appendChild(contactItem); + + // 聊天列表 + const chatItem = document.createElement('div'); + chatItem.className = 'chat-item'; + chatItem.innerHTML = ` +
+ +
+
+
+ ${contact.name} + +
+
${contact.org}
+
+ `; + chatItem.addEventListener('click', () => { + this.selectContact(contact); + }); + chatList.appendChild(chatItem); + }); + } + + selectContact(contact) { + this.currentContact = contact; + + // 更新UI + document.getElementById('partnerName').textContent = contact.name; + document.getElementById('partnerStatus').textContent = + contact.online ? '在线' : '离线'; + document.getElementById('partnerStatus').style.color = + contact.online ? '#30d158' : '#6c6c70'; + + // 启用通话按钮 + document.getElementById('voiceCallBtn').disabled = !contact.online; + + // 显示聊天区域 + this.showChatArea(); + + // 加载聊天历史(这里简化处理) + this.loadChatHistory(contact.account); + } + + showChatArea() { + // 隐藏欢迎消息,显示聊天消息区域 + const welcomeMsg = document.querySelector('.welcome-message'); + if (welcomeMsg) { + welcomeMsg.style.display = 'none'; + } + } + + sendMessage() { + const input = document.getElementById('messageInput'); + const message = input.value.trim(); + + if (!message || !this.currentContact) return; + + // 发送消息 + this.socket.emit('private_message', { + to: this.currentContact.account, + from: this.currentUser, + message: message + }); + + // 在界面上显示消息 + this.addMessage({ + from: this.currentUser, + message: message, + timestamp: Date.now() / 1000, + isOwn: true + }); + + // 清空输入框 + input.value = ''; + this.adjustTextareaHeight(input); + } + + receiveMessage(data) { + // 如果是当前联系人的消息,直接显示 + if (data.from === this.currentContact?.account) { + this.addMessage({ + from: data.from, + message: data.message, + timestamp: data.timestamp, + isOwn: false + }); + } else { + // 显示通知 + this.showNotification(`收到来自 ${data.from} 的新消息`); + } + } + + addMessage(data) { + const messagesContainer = document.getElementById('chatMessages'); + const messageDiv = document.createElement('div'); + messageDiv.className = `message ${data.isOwn ? 'sent' : 'received'}`; + + const contact = this.contacts.find(c => c.account === data.from); + const contactName = contact ? contact.name : data.from; + + const time = new Date(data.timestamp * 1000); + const timeStr = time.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit' + }); + + messageDiv.innerHTML = ` +
+ +
+
+
${this.escapeHtml(data.message)}
+
${timeStr}
+
+ `; + + messagesContainer.appendChild(messageDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + adjustTextareaHeight(textarea) { + textarea.style.height = 'auto'; + textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; + } + + startVoiceCall() { + if (!this.currentContact || !this.currentContact.online) return; + + const callId = 'call_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + + this.activeCall = { + id: callId, + partner: this.currentContact.account, + type: 'voice' + }; + + // 显示通话界面 + this.showCallInterface(); + document.getElementById('callPartnerName').textContent = this.currentContact.name; + document.getElementById('callStatus').textContent = '正在呼叫...'; + document.getElementById('answerCallBtn').style.display = 'none'; + + // 通知对方 + this.socket.emit('start_call', { + call_id: callId, + to: this.currentContact.account, + from: this.currentUser + }); + + // 开始获取本地音频流 + this.startLocalStream(); + } + + handleIncomingCall(data) { + // 找到联系人 + const contact = this.contacts.find(c => c.account === data.from); + if (!contact) return; + + this.activeCall = { + id: data.call_id, + partner: data.from, + type: 'incoming' + }; + + // 显示来电界面 + this.showCallInterface(); + document.getElementById('callPartnerName').textContent = contact.name; + document.getElementById('callStatus').textContent = '来电中...'; + document.getElementById('answerCallBtn').style.display = 'flex'; + + // 播放铃声 + this.playRingtone(); + } + + answerCall() { + if (!this.activeCall) return; + + this.stopRingtone(); + + // 通知对方已接听 + this.socket.emit('answer_call', { + call_id: this.activeCall.id + }); + + // 开始通话计时 + this.startCallTimer(); + document.getElementById('callStatus').textContent = '通话中...'; + document.getElementById('answerCallBtn').style.display = 'none'; + + // 开始获取本地音频流并在就绪后建立WebRTC连接 + this.startLocalStream() + .then(() => { + this.setupWebRTCCall(); + }) + .catch(() => { + console.error('无法获取本地音频,无法建立通话'); + }); + } + + endCall() { + if (!this.activeCall) return; + + this.stopRingtone(); + this.stopCallTimer(); + + // 通知对方结束通话 + this.socket.emit('end_call', { + call_id: this.activeCall.id + }); + + // 关闭本地流 + if (this.localStream) { + this.localStream.getTracks().forEach(track => track.stop()); + this.localStream = null; + } + + // 关闭PeerConnection + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection = null; + } + + // 隐藏通话界面 + this.hideCallInterface(); + this.activeCall = null; + } + + handleCallAccepted(data) { + if (!this.activeCall || this.activeCall.id !== data.call_id) return; + + // 开始通话计时 + this.startCallTimer(); + document.getElementById('callStatus').textContent = '通话中...'; + + // 确保本地音频就绪后建立WebRTC连接并创建offer + this.startLocalStream() + .then(() => { + this.setupWebRTCCall(); + }) + .catch(() => { + console.error('无法获取本地音频,无法建立通话'); + }); + } + + handleCallEnded(data) { + if (this.activeCall && this.activeCall.id === data.call_id) { + this.endCall(); + } + } + + showCallInterface() { + document.getElementById('callInterface').style.display = 'flex'; + } + + hideCallInterface() { + document.getElementById('callInterface').style.display = 'none'; + } + + startCallTimer() { + this.callStartTime = Date.now(); + this.callTimer = setInterval(() => { + const elapsed = Date.now() - this.callStartTime; + const minutes = Math.floor(elapsed / 60000); + const seconds = Math.floor((elapsed % 60000) / 1000); + document.getElementById('callTimer').textContent = + `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + }, 1000); + } + + stopCallTimer() { + if (this.callTimer) { + clearInterval(this.callTimer); + this.callTimer = null; + } + } + + playRingtone() { + // 这里可以添加铃声播放逻辑 + console.log('播放铃声'); + } + + stopRingtone() { + // 停止铃声 + console.log('停止铃声'); + } + + setupWebRTC() { + // 简单的WebRTC配置 + this.peerConnectionConfig = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' } + ] + }; + } + + startLocalStream() { + return navigator.mediaDevices.getUserMedia({ + audio: true, + video: false + }) + .then(stream => { + this.localStream = stream; + const localAudio = document.getElementById('localAudio'); + if (localAudio) { + localAudio.srcObject = stream; + } + return stream; + }) + .catch(error => { + console.error('获取音频流失败:', error); + throw error; + }); + } + + setupWebRTCCall() { + this.peerConnection = new RTCPeerConnection(this.peerConnectionConfig); + + // 添加本地音频流 + if (this.localStream) { + this.localStream.getTracks().forEach(track => { + this.peerConnection.addTrack(track, this.localStream); + }); + } + + // 处理远程流 + this.peerConnection.ontrack = (event) => { + const remoteAudio = document.getElementById('remoteAudio'); + if (remoteAudio) { + remoteAudio.srcObject = event.streams[0]; + } + }; + + // 处理ICE候选 + this.peerConnection.onicecandidate = (event) => { + if (event.candidate) { + this.socket.emit('webrtc_signal', { + to: this.activeCall.partner, + signal: { + type: 'candidate', + candidate: event.candidate + } + }); + } + }; + + // 如果是发起方,创建offer + if (this.activeCall.type === 'voice') { + this.peerConnection.createOffer() + .then(offer => this.peerConnection.setLocalDescription(offer)) + .then(() => { + this.socket.emit('webrtc_signal', { + to: this.activeCall.partner, + signal: { + type: 'offer', + sdp: this.peerConnection.localDescription + } + }); + }) + .catch(error => { + console.error('创建offer失败:', error); + }); + } + } + + handleWebRTCSignal(signal) { + if (!this.peerConnection) return; + + switch (signal.type) { + case 'offer': { + const desc = signal.sdp || signal; + this.peerConnection.setRemoteDescription(new RTCSessionDescription(desc)) + .then(() => this.peerConnection.createAnswer()) + .then(answer => this.peerConnection.setLocalDescription(answer)) + .then(() => { + this.socket.emit('webrtc_signal', { + to: this.activeCall.partner, + signal: { + type: 'answer', + sdp: this.peerConnection.localDescription + } + }); + }) + .catch(error => { + console.error('处理offer失败:', error); + }); + break; + } + case 'answer': { + const desc = signal.sdp || signal; + this.peerConnection.setRemoteDescription(new RTCSessionDescription(desc)) + .catch(error => { + console.error('处理answer失败:', error); + }); + break; + } + case 'candidate': { + const candidate = signal.candidate || signal; + this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) + .catch(error => { + console.error('添加ICE候选失败:', error); + }); + break; + } + } + } + + showFileUploadModal() { + document.getElementById('fileUploadModal').style.display = 'flex'; + } + + hideFileUploadModal() { + document.getElementById('fileUploadModal').style.display = 'none'; + document.getElementById('filePreview').innerHTML = ''; + document.getElementById('fileInput').value = ''; + } + + async handleFileSelect(file) { + if (!file) return; + + // 显示文件预览 + const filePreview = document.getElementById('filePreview'); + filePreview.innerHTML = ` +
+
+ +
+
+
${file.name}
+
${this.formatFileSize(file.size)}
+
+ +
+ `; + + // 添加发送按钮事件 + document.getElementById('uploadFileBtn').addEventListener('click', async () => { + await this.uploadFile(file); + }); + } + + async uploadFile(file) { + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (result.success) { + // 发送文件消息 + this.socket.emit('file_uploaded', { + to: this.currentContact.account, + from: this.currentUser, + filename: result.filename, + url: result.url + }); + + // 显示文件消息 + this.addMessage({ + from: this.currentUser, + message: `[文件] ${result.filename}`, + timestamp: Date.now() / 1000, + isOwn: true + }); + + this.hideFileUploadModal(); + } else { + alert(result.message); + } + } catch (error) { + console.error('文件上传失败:', error); + alert('文件上传失败'); + } + } + + showFileNotification(data) { + // 如果是当前联系人的文件,显示消息 + if (data.from === this.currentContact?.account) { + this.addMessage({ + from: data.from, + message: `[文件] ${data.filename}`, + timestamp: data.timestamp, + isOwn: false + }); + + // 添加下载链接 + const messagesContainer = document.getElementById('chatMessages'); + const fileLink = document.createElement('div'); + fileLink.className = 'message received'; + fileLink.innerHTML = ` +
+ +
+ `; + messagesContainer.appendChild(fileLink); + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } else { + this.showNotification(`收到来自 ${data.from} 的文件: ${data.filename}`); + } + } + + showContactsSidebar() { + document.getElementById('contactsSidebar').classList.add('active'); + } + + hideContactsSidebar() { + document.getElementById('contactsSidebar').classList.remove('active'); + } + + showFilesSidebar() { + this.loadFiles(); + document.getElementById('filesSidebar').classList.add('active'); + } + + hideFilesSidebar() { + document.getElementById('filesSidebar').classList.remove('active'); + } + + async loadFiles() { + try { + const response = await fetch('/api/files'); + const files = await response.json(); + this.renderFiles(files); + } catch (error) { + console.error('加载文件失败:', error); + } + } + + renderFiles(files) { + const filesList = document.getElementById('filesList'); + filesList.innerHTML = ''; + + files.forEach(file => { + const fileItem = document.createElement('div'); + fileItem.className = 'file-item'; + fileItem.innerHTML = ` +
+ +
+
+
${file.filename}
+
${this.formatFileSize(file.size)}
+
上传者: ${file.uploader}
+
+ + + + `; + filesList.appendChild(fileItem); + }); + } + + updateUserStatus(account, online) { + // 更新联系人状态 + const contact = this.contacts.find(c => c.account === account); + if (contact) { + contact.online = online; + this.renderContacts(); + + // 如果是当前联系人,更新状态显示 + if (this.currentContact && this.currentContact.account === account) { + document.getElementById('partnerStatus').textContent = online ? '在线' : '离线'; + document.getElementById('partnerStatus').style.color = + online ? '#30d158' : '#6c6c70'; + document.getElementById('voiceCallBtn').disabled = !online; + } + } + } + + formatFileSize(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + showNotification(message) { + // 简单的浏览器通知 + if ('Notification' in window && Notification.permission === 'granted') { + new Notification('Web WeChat', { + body: message, + icon: '/static/favicon.ico' + }); + } + } + + loadChatHistory(account) { + // 这里可以加载聊天历史 + const messagesContainer = document.getElementById('chatMessages'); + messagesContainer.innerHTML = ''; + + // 暂时显示一条欢迎消息 + if (messagesContainer.children.length === 0) { + const welcomeMsg = document.createElement('div'); + welcomeMsg.className = 'welcome-message'; + welcomeMsg.innerHTML = ` + +

开始与 ${this.currentContact.name} 聊天

+

输入消息开始对话

+ `; + messagesContainer.appendChild(welcomeMsg); + } + } +} + +// 页面加载完成后初始化应用 +document.addEventListener('DOMContentLoaded', () => { + window.wechatApp = new WebWeChat(); + + // 请求通知权限 + if ('Notification' in window) { + Notification.requestPermission(); + } +}); \ No newline at end of file diff --git a/static/uploads/5a1b62e19c2948af978b048901587189_Screenshot_20260207_224831.jpg b/static/uploads/5a1b62e19c2948af978b048901587189_Screenshot_20260207_224831.jpg new file mode 100644 index 0000000..23c7b34 Binary files /dev/null and b/static/uploads/5a1b62e19c2948af978b048901587189_Screenshot_20260207_224831.jpg differ diff --git a/static/uploads/868b43453ab949aeb26f8ea37c60d85a_Screenshot_20260207_224831.jpg b/static/uploads/868b43453ab949aeb26f8ea37c60d85a_Screenshot_20260207_224831.jpg new file mode 100644 index 0000000..23c7b34 Binary files /dev/null and b/static/uploads/868b43453ab949aeb26f8ea37c60d85a_Screenshot_20260207_224831.jpg differ diff --git a/static/uploads/9308e261c86847b6896c64e6db9854d4_20260124142149_11_75.jpg b/static/uploads/9308e261c86847b6896c64e6db9854d4_20260124142149_11_75.jpg new file mode 100644 index 0000000..954de96 Binary files /dev/null and b/static/uploads/9308e261c86847b6896c64e6db9854d4_20260124142149_11_75.jpg differ diff --git a/static/uploads/991708c5a9534c9fab277b8f377e273a_config.txt b/static/uploads/991708c5a9534c9fab277b8f377e273a_config.txt new file mode 100644 index 0000000..5297bf7 --- /dev/null +++ b/static/uploads/991708c5a9534c9fab277b8f377e273a_config.txt @@ -0,0 +1,2 @@ +//端口卸载下一行(第二行) +8080 \ No newline at end of file diff --git a/static/uploads/9ba85bc37d664f2d89650fc8446b47d4_config.txt b/static/uploads/9ba85bc37d664f2d89650fc8446b47d4_config.txt new file mode 100644 index 0000000..5297bf7 --- /dev/null +++ b/static/uploads/9ba85bc37d664f2d89650fc8446b47d4_config.txt @@ -0,0 +1,2 @@ +//端口卸载下一行(第二行) +8080 \ No newline at end of file diff --git a/static/uploads/a2b9580b340a4be79f03999b95be3ff7_config.txt b/static/uploads/a2b9580b340a4be79f03999b95be3ff7_config.txt new file mode 100644 index 0000000..5297bf7 --- /dev/null +++ b/static/uploads/a2b9580b340a4be79f03999b95be3ff7_config.txt @@ -0,0 +1,2 @@ +//端口卸载下一行(第二行) +8080 \ No newline at end of file diff --git a/static/uploads/a49d964a951f40e19f6165f6aa2eed45_20260124142149_11_75.jpg b/static/uploads/a49d964a951f40e19f6165f6aa2eed45_20260124142149_11_75.jpg new file mode 100644 index 0000000..954de96 Binary files /dev/null and b/static/uploads/a49d964a951f40e19f6165f6aa2eed45_20260124142149_11_75.jpg differ diff --git a/static/uploads/c850a7e3a1d04fd09bdf5e111cf0b0f4_20260124142149_11_75.jpg b/static/uploads/c850a7e3a1d04fd09bdf5e111cf0b0f4_20260124142149_11_75.jpg new file mode 100644 index 0000000..954de96 Binary files /dev/null and b/static/uploads/c850a7e3a1d04fd09bdf5e111cf0b0f4_20260124142149_11_75.jpg differ diff --git a/static/uploads/ce0a8dcb42e944e4bb064ed642f7682c_20260124142149_11_75.jpg b/static/uploads/ce0a8dcb42e944e4bb064ed642f7682c_20260124142149_11_75.jpg new file mode 100644 index 0000000..954de96 Binary files /dev/null and b/static/uploads/ce0a8dcb42e944e4bb064ed642f7682c_20260124142149_11_75.jpg differ diff --git a/templates b/templates new file mode 160000 index 0000000..34d6f8a --- /dev/null +++ b/templates @@ -0,0 +1 @@ +Subproject commit 34d6f8a3dcf8c75ee63c6106feb0b2921cc2e7df diff --git a/users.json b/users.json new file mode 100644 index 0000000..c5d141c --- /dev/null +++ b/users.json @@ -0,0 +1,174 @@ +{ + "15817238733": { + "password": "gAAAAABpiBywHugIuIWc38y4w9uXM1Z78ocmFjwcp2Iulms81PCaMz9gTdvUrWij15lfc2OY8BanOswA1YyWSdj42FLPkvs-DA==", + "token": "BOARD-66ed8680d46d115558532813-1770527922080-0.665604332710812-1770527922082258054", + "user_info": { + "isPasswdTag": 1, + "registTags": [], + "_id": "66ed8680d46d115558532813", + "name": "刘浚哲", + "mCode": "86", + "mobile": "15817238733", + "profession": "student", + "avatarUrl": "https://file.infi.cn/bsg/head/user/default/2025-03-22_13%3A20%3A21_66ed8680d46d115558532813_default_avatar.png", + "isDefaultAvatar": 1, + "myId": "66ed8680d46d115558532813", + "bindList": [ + { + "type": "mobile", + "isBind": 1, + "bindData": "15817238733" + }, + { + "type": "mail", + "isBind": 0 + }, + { + "type": "passwd", + "isBind": 1 + }, + { + "type": "wx", + "isBind": 0 + } + ], + "action": [], + "orgFullList": [ + { + "currentUserRoleName": "orgRestrictedMember", + "memberCanInvite": 1, + "name": "深圳市云端学校", + "orgId": "64fc0f1e821d24bef457bc7f", + "avatarUrl": "https://pub.infi.cn/board/head/64fc0f1e821d24bef457bc7f_org_.jpg?t=1701913752011", + "logoUrl": "https://pub.infi.cn/board/head/64fc0f1e821d24bef457bc7f_orgLogo_.jpg?t=1702364444661", + "userId": "66ed8680d46d115558532813", + "packageExpire": 1797350399000, + "packageType": 3, + "packageSeat": 1000, + "lastAccess": 1766815725222, + "orgGuideSettingInit": 0 + } + ], + "preferences": { + "boardViewGuidanceTask": 0, + "gridType": 1, + "showCursors": 0, + "colors": [], + "toolBar": [ + "template", + "sticker", + "text", + "shapes", + "connect_line", + "comment", + "pen", + "frame", + "upload", + "mind_map" + ], + "toolBox": [ + "card", + "kanban", + "bigdoc", + "table", + "webpage", + "customized_sticker", + "timer", + "ai_instruction" + ], + "colorPalettes": {}, + "openState": {}, + "boardAccessCount": 372, + "disableOpenTemplate": 0, + "silentAddUser": false, + "haveCreatedBoard": 0, + "svgIconConfig": {}, + "presentationHasShownOnce": true + } + }, + "last_login": "2026-02-08T13:18:40.201893", + "online": false, + "socket_id": "9KLqTFd3j5GNjeCoAAAF" + }, + "admin": { + "password": "gAAAAABpiBu_0CYFxos5fr5iZaudMVaBA9lFdIXFLYe7u_7aiIlihLQ3BKJiW2rHV6DYbvLuy7YcBPp6Pb5V_mtW6xhNMzz8VA==", + "token": "", + "user_info": { + "name": "内测管理员" + }, + "last_login": "2026-02-08T13:14:39.081841", + "online": false, + "socket_id": "zpynU_-CgB_gLKT1AAAf" + }, + "13827495788": { + "password": "gAAAAABpiECWr-rFHZNtOEgWcyAeXpH5tRt-yYaMgGCXwKkkpPVLX4v-GuetfF4cBgKFYPEglJlCmXmki-rn07DM8hyeEh0coA==", + "token": "BOARD-66e57daba386b71dcd5b3ce3-1770537112707-0.513617461315166-1770537112710073811", + "user_info": { + "isPasswdTag": 1, + "registTags": [], + "_id": "66e57daba386b71dcd5b3ce3", + "name": "臧铭炜", + "mCode": "86", + "mobile": "13827495788", + "profession": "engineer", + "avatarUrl": "https://file.infi.cn/bsg/head/user/default/2024-11-09_22%3A50%3A39_66e57daba386b71dcd5b3ce3_default_avatar.png", + "isDefaultAvatar": 1, + "myId": "66e57daba386b71dcd5b3ce3", + "bindList": [ + { + "type": "mobile", + "isBind": 1, + "bindData": "13827495788" + }, + { + "type": "mail", + "isBind": 0 + }, + { + "type": "passwd", + "isBind": 1 + }, + { + "type": "wx", + "isBind": 1 + } + ], + "action": [], + "orgFullList": [ + { + "currentUserRoleName": "orgRestrictedMember", + "memberCanInvite": 1, + "name": "深圳市云端学校", + "orgId": "64fc0f1e821d24bef457bc7f", + "avatarUrl": "https://pub.infi.cn/board/head/64fc0f1e821d24bef457bc7f_org_.jpg?t=1701913752011", + "logoUrl": "https://pub.infi.cn/board/head/64fc0f1e821d24bef457bc7f_orgLogo_.jpg?t=1702364444661", + "userId": "66e57daba386b71dcd5b3ce3", + "packageExpire": 1797350399000, + "packageType": 3, + "packageSeat": 1000, + "lastAccess": 0, + "orgGuideSettingInit": 0 + } + ], + "preferences": { + "boardViewGuidanceTask": 0, + "gridType": 1, + "showCursors": 0, + "colors": [], + "toolBar": [], + "toolBox": [], + "colorPalettes": {}, + "openState": {}, + "boardAccessCount": 513, + "disableOpenTemplate": 0, + "silentAddUser": false, + "haveCreatedBoard": 0, + "svgIconConfig": {}, + "presentationHasShownOnce": true + } + }, + "last_login": "2026-02-08T15:51:50.750746", + "online": false, + "socket_id": "k8yao3QqDWm_IV0eAAAd" + } +} \ No newline at end of file diff --git a/websocket_handler.py b/websocket_handler.py new file mode 100644 index 0000000..9994372 --- /dev/null +++ b/websocket_handler.py @@ -0,0 +1,167 @@ +from flask_socketio import emit, join_room, leave_room +from flask import request +import time +import json + +class WebSocketHandler: + def __init__(self, socketio, user_manager): + self.socketio = socketio + self.user_manager = user_manager + self.active_calls = {} # 正在进行的语音通话 + self.setup_handlers() + + def setup_handlers(self): + @self.socketio.on('connect') + def handle_connect(auth=None): + print(f'Client connected: {request.sid}') + + @self.socketio.on('disconnect') + def handle_disconnect(): + account = None + # 找到断开连接的用户 + for acc, user in self.user_manager.users.items(): + if user.get('socket_id') == request.sid: + account = acc + break + + if account: + self.user_manager.update_user_status(account, online=False) + + # 通知好友用户离线 + emit('user_status', { + 'account': account, + 'online': False + }, broadcast=True) + + @self.socketio.on('authenticate') + def handle_authenticate(data): + account = data.get('account') + if account in self.user_manager.users: + self.user_manager.update_user_status( + account, + online=True, + socket_id=request.sid + ) + + # 通知所有用户此用户上线 + emit('user_status', { + 'account': account, + 'online': True + }, broadcast=True) + + @self.socketio.on('private_message') + def handle_private_message(data): + to_account = data.get('to') + message = data.get('message') + from_account = data.get('from') + + # 获取接收者的socket_id + recipient = self.user_manager.get_user(to_account) + if recipient and recipient.get('socket_id'): + emit('private_message', { + 'from': from_account, + 'message': message, + 'timestamp': time.time() + }, room=recipient['socket_id']) + + # 同时发送给自己(用于确认) + emit('private_message_sent', { + 'to': to_account, + 'message': message, + 'timestamp': time.time() + }) + + @self.socketio.on('group_message') + def handle_group_message(data): + group_id = data.get('group_id') + message = data.get('message') + from_account = data.get('from') + + emit('group_message', { + 'group_id': group_id, + 'from': from_account, + 'message': message, + 'timestamp': time.time() + }, room=group_id) + + @self.socketio.on('join_group') + def handle_join_group(data): + group_id = data.get('group_id') + join_room(group_id) + + @self.socketio.on('leave_group') + def handle_leave_group(data): + group_id = data.get('group_id') + leave_room(group_id) + + @self.socketio.on('start_call') + def handle_start_call(data): + """开始语音通话""" + to_account = data.get('to') + from_account = data.get('from') + call_id = data.get('call_id') + + self.active_calls[call_id] = { + 'participants': [from_account, to_account], + 'start_time': time.time() + } + + # 通知对方 + recipient = self.user_manager.get_user(to_account) + if recipient and recipient.get('socket_id'): + emit('incoming_call', { + 'from': from_account, + 'call_id': call_id + }, room=recipient['socket_id']) + + @self.socketio.on('answer_call') + def handle_answer_call(data): + """接听电话""" + call_id = data.get('call_id') + if call_id in self.active_calls: + emit('call_accepted', { + 'call_id': call_id + }, room=request.sid) + + @self.socketio.on('end_call') + def handle_end_call(data): + """结束通话""" + call_id = data.get('call_id') + if call_id in self.active_calls: + # 通知所有参与者 + for participant in self.active_calls[call_id]['participants']: + user = self.user_manager.get_user(participant) + if user and user.get('socket_id'): + emit('call_ended', { + 'call_id': call_id + }, room=user['socket_id']) + + del self.active_calls[call_id] + + @self.socketio.on('webrtc_signal') + def handle_webrtc_signal(data): + """WebRTC信令转发""" + to_account = data.get('to') + signal = data.get('signal') + + recipient = self.user_manager.get_user(to_account) + if recipient and recipient.get('socket_id'): + emit('webrtc_signal', signal, room=recipient['socket_id']) + + @self.socketio.on('file_uploaded') + def handle_file_uploaded(data): + """文件上传完成通知""" + filename = data.get('filename') + url = data.get('url') + to_account = data.get('to') + from_account = data.get('from') + + # 通知接收者 + recipient = self.user_manager.get_user(to_account) + if recipient and recipient.get('socket_id'): + emit('file_received', { + 'from': from_account, + 'filename': filename, + 'url': url, + 'timestamp': time.time() + }, room=recipient['socket_id']) \ No newline at end of file