diff --git a/admin/dashboard/blueprints/contacts.py b/admin/dashboard/blueprints/contacts.py index 4a513d9..ea67ad2 100644 --- a/admin/dashboard/blueprints/contacts.py +++ b/admin/dashboard/blueprints/contacts.py @@ -1,5 +1,6 @@ import asyncio import threading +import xml.etree.ElementTree as ET from concurrent.futures import ThreadPoolExecutor from flask import Blueprint, render_template, jsonify, request, current_app from .auth import login_required @@ -64,6 +65,96 @@ def run_member_context_refresh_in_thread(func, *args, **kwargs): message_thread_pool.submit(run) +def _safe_text(value): + return "" if value is None else str(value) + + +def _parse_app_message_payload(content: str): + payload = { + "title": "", + "description": "", + "url": "", + "app_type": "" + } + if not content: + return payload + + text = _safe_text(content).strip() + if not text.startswith("<"): + payload["description"] = text + return payload + + try: + root = ET.fromstring(text) + payload["title"] = _safe_text(root.findtext('.//title')).strip() + payload["description"] = _safe_text(root.findtext('.//des')).strip() + payload["url"] = _safe_text(root.findtext('.//url')).strip() + payload["app_type"] = _safe_text(root.findtext('.//type')).strip() + except Exception: + payload["description"] = text + return payload + + +def _normalize_recent_message(server, raw_message: dict, chat_type: str, target_wxid: str): + sender = _safe_text(raw_message.get("sender")).strip() + message_type = str(raw_message.get("message_type", "")) + content = _safe_text(raw_message.get("content")).strip() + image_path = _safe_text(raw_message.get("image_path")).strip() + attachment_url = _safe_text(raw_message.get("attachment_url")).strip() + message_thumb = _safe_text(raw_message.get("message_thumb")).strip() + self_wxid = _safe_text(getattr(server.robot, "wxid", "") or getattr(server.client, "wxid", "")).strip() + + sender_name = sender or "未知发送者" + if chat_type == "group": + sender_name = server.contact_manager.get_group_name(target_wxid, sender) or sender_name + elif sender: + sender_name = server.contact_manager.get_nickname(sender) or sender_name + + display_type = "text" + display_content = content + media_url = image_path or attachment_url or message_thumb + link_payload = None + + if message_type == "3": + display_type = "image" + display_content = content or "[图片]" + elif message_type == "34": + display_type = "voice" + display_content = content or "[语音]" + elif message_type == "43": + display_type = "video" + display_content = content or "[视频]" + elif message_type == "49": + app_payload = _parse_app_message_payload(content) + if app_payload.get("url") or app_payload.get("title"): + display_type = "link" + link_payload = app_payload + display_content = app_payload.get("title") or app_payload.get("description") or "[链接]" + else: + display_type = "text" + display_content = app_payload.get("description") or content or "[应用消息]" + elif message_type in {"10000", "10002"}: + display_type = "system" + display_content = content or "[系统消息]" + + return { + "timestamp": _safe_text(raw_message.get("timestamp")), + "sender": sender, + "sender_name": sender_name, + "content": content, + "message_type": message_type, + "display_type": display_type, + "display_content": display_content, + "image_path": image_path, + "attachment_url": attachment_url, + "media_url": media_url, + "message_thumb": message_thumb, + "message_id": raw_message.get("message_id"), + "link_payload": link_payload, + "is_self": bool(self_wxid and sender == self_wxid) + } + + # 联系人管理页面 @contacts_bp.route('/') @login_required @@ -320,6 +411,44 @@ def api_contacts_update(): return jsonify({"success": False, "message": f"更新通讯录失败: {str(e)}"}), 500 +@contacts_bp.route('/api/recent_messages', methods=['GET']) +@login_required +def api_recent_messages(): + """获取最近聊天消息""" + try: + server = current_app.dashboard_server + wxid = _safe_text(request.args.get("wxid")).strip() + chat_type = _safe_text(request.args.get("chat_type")).strip() or "personal" + limit = min(max(int(request.args.get("limit", 20)), 1), 50) + + if not wxid: + return jsonify({"success": False, "message": "缺少聊天对象"}), 400 + + if chat_type == "group": + raw_messages = server.message_storage.get_recent_group_chat_messages(wxid, limit=limit) + history_tip = f"最近 {limit} 条群消息" + else: + raw_messages = server.message_storage.get_recent_personal_messages(wxid, limit=limit) + history_tip = f"最近 {limit} 条已归档消息(私聊历史可能不完整)" + + messages = [ + _normalize_recent_message(server, item, chat_type, wxid) + for item in raw_messages + ] + + return jsonify({ + "success": True, + "data": { + "messages": messages, + "chat_type": chat_type, + "history_tip": history_tip + } + }) + except Exception as e: + logger.exception(f"获取最近聊天消息失败: {e}") + return jsonify({"success": False, "message": str(e)}), 500 + + @contacts_bp.route('/api/send_message', methods=['POST']) @login_required def api_send_message(): diff --git a/admin/dashboard/templates/contacts_management.html b/admin/dashboard/templates/contacts_management.html index 0ca0e02..2fa08a3 100644 --- a/admin/dashboard/templates/contacts_management.html +++ b/admin/dashboard/templates/contacts_management.html @@ -597,30 +597,90 @@ - +
-
- {% raw %} -
-
-
-
-
-
-

+ {% raw %} +
+
+
{{ currentChatUser.name || currentChatUser.wxid }}
+
+ {{ chatType === 'group' ? '群聊会话' : '私聊会话' }} + · + {{ currentChatUser.wxid }} +
+
+
+
{{ chatHistoryTip }}
+ 刷新 +
+
+ {% endraw %} +
+ {% raw %} +
+ +

当前没有可展示的归档消息

+ 你仍然可以直接使用下方工具发送文本、图片、语音、视频或链接。 +
+
+
+ {{ msg.senderName || msg.sender || '未知发送者' }} + {{ msg.time }} +
+
+
+
+ +
图片消息
+
+
+
+ +
语音消息
+
+
+
+ +
视频消息
+
+
+ +
-
{% endraw %}
- +
- 图片 - 语音 - 视频 - 链接 - 发送 + + 图片 + + + 语音 + + + 视频 + + 链接 + 发送文本
@@ -670,7 +730,8 @@ groupMembersList: [], groupMembersCurrentPage: 1, groupMembersPageSize: 10, groupMemberSearchQuery: '', groupMembersLoading: false, memberContextDialogVisible: false, memberContextLoading: false, memberContext: null, currentContextMember: {}, memberContextEnabled: false, - chatDialogVisible: false, currentChatUser: null, messageInput: '', chatMessages: [], + chatDialogVisible: false, currentChatUser: null, messageInput: '', chatMessages: [], chatLoading: false, chatSending: false, + chatType: 'personal', chatHistoryTip: '最近 20 条消息', linkDialogVisible: false, linkForm: { url: '', title: '', description: '' } }; @@ -1069,33 +1130,193 @@ const content = lastMessage.content || '[非文本消息]'; return `${sender} · ${time} · ${content}`; }, - openChatDialog(user) { this.currentChatUser = user; this.chatDialogVisible = true; this.chatMessages = []; }, - async sendTextMessage() { - if (!this.messageInput.trim()) return; + openChatDialog(user) { + this.currentChatUser = user; + this.chatType = user && user.wxid && user.wxid.endsWith('@chatroom') ? 'group' : 'personal'; + this.chatDialogVisible = true; + this.messageInput = ''; + this.chatMessages = []; + this.chatHistoryTip = this.chatType === 'group' ? '最近 20 条群消息' : '最近 20 条已归档消息(私聊历史可能不完整)'; + this.loadRecentMessages(); + }, + async loadRecentMessages() { + if (!this.currentChatUser || !this.currentChatUser.wxid) return; + this.chatLoading = true; try { - const response = await axios.post('/contacts/api/send_message', { wxid: this.currentChatUser.wxid, type: 'text', content: this.messageInput }); - if (response.data.success) { this.chatMessages.push({ type: 'text', content: this.messageInput, isSelf: true, time: new Date().toLocaleTimeString() }); this.messageInput = ''; this.$nextTick(() => { this.scrollToBottom(); }); } - } catch (error) { this.$message.error('发送消息失败'); } + const response = await axios.get('/contacts/api/recent_messages', { + params: { + wxid: this.currentChatUser.wxid, + chat_type: this.chatType, + limit: 20 + } + }); + if (response.data.success) { + const data = response.data.data || {}; + this.chatHistoryTip = data.history_tip || this.chatHistoryTip; + const messages = Array.isArray(data.messages) ? data.messages : []; + this.chatMessages = messages.map(item => this.normalizeChatMessage(item)); + this.$nextTick(() => { this.scrollToBottom(); }); + } else { + this.$message.error(response.data.message || '加载聊天记录失败'); + } + } catch (error) { + console.error('加载聊天记录失败:', error); + this.$message.error('加载聊天记录失败'); + } finally { + this.chatLoading = false; + } + }, + normalizeChatMessage(item) { + const linkPayload = item.link_payload || {}; + return { + messageId: item.message_id || '', + sender: item.sender || '', + senderName: item.sender_name || '', + displayType: item.display_type || 'text', + content: item.display_content || item.content || '', + rawContent: item.content || '', + time: item.timestamp || '', + isSelf: !!item.is_self, + mediaUrl: this.getChatMediaUrl(item.media_url || item.image_path || item.attachment_url || item.message_thumb || ''), + linkTitle: linkPayload.title || '', + linkDescription: linkPayload.description || '', + linkUrl: linkPayload.url || '' + }; + }, + getChatMediaUrl(url) { + if (!url) return ''; + if (url.startsWith('blob:') || url.startsWith('data:') || url.startsWith('/static/') || url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + if (url.startsWith('static/')) { + return `/${url}`; + } + if (url.includes('static/images') || url.includes('static\\images')) { + return `/${url.replace(/\\/g, '/').replace(/^\/+/, '')}`; + } + return `/api/messages/media_proxy?url=${encodeURIComponent(url)}`; + }, + createLocalChatMessage(payload = {}) { + return { + messageId: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + sender: '', + senderName: '我', + displayType: payload.displayType || 'text', + content: payload.content || '', + rawContent: payload.rawContent || payload.content || '', + time: new Date().toLocaleString(), + isSelf: true, + mediaUrl: payload.mediaUrl || '', + linkTitle: payload.linkTitle || '', + linkDescription: payload.linkDescription || '', + linkUrl: payload.linkUrl || '' + }; + }, + appendLocalChatMessage(payload) { + const localMessage = this.createLocalChatMessage(payload); + this.chatMessages.push(localMessage); + this.$nextTick(() => { this.scrollToBottom(); }); + return localMessage; + }, + async sendChatPayload(payload, successMessage, localMessageId = '') { + this.chatSending = true; + try { + const response = await axios.post('/contacts/api/send_message', payload); + if (!response.data.success) { + throw new Error(response.data.message || '发送失败'); + } + if (successMessage) { + this.$message.success(successMessage); + } + setTimeout(() => { this.loadRecentMessages(); }, 800); + return true; + } catch (error) { + console.error('发送消息失败:', error); + if (localMessageId) { + this.chatMessages = this.chatMessages.filter(item => item.messageId !== localMessageId); + } + this.$message.error(error.message || '发送消息失败'); + return false; + } finally { + this.chatSending = false; + } + }, + async sendTextMessage() { + if (!this.currentChatUser || !this.currentChatUser.wxid) return; + const text = this.messageInput.trim(); + if (!text) return; + const localMessage = this.appendLocalChatMessage({ displayType: 'text', content: text }); + this.messageInput = ''; + await this.sendChatPayload({ + wxid: this.currentChatUser.wxid, + type: 'text', + content: text + }, '文本消息已提交到 iPad 通道', localMessage.messageId); }, async uploadImage(options) { - const formData = new FormData(); formData.append('file', options.file); formData.append('wxid', this.currentChatUser.wxid); formData.append('type', 'image'); - try { const response = await axios.post('/contacts/api/send_message', formData); if (response.data.success) { this.chatMessages.push({ type: 'image', content: response.data.url, isSelf: true, time: new Date().toLocaleTimeString() }); this.$nextTick(() => { this.scrollToBottom(); }); } } catch (error) { this.$message.error('发送图片失败'); } + if (!this.currentChatUser || !this.currentChatUser.wxid) return; + const formData = new FormData(); + formData.append('file', options.file); + formData.append('wxid', this.currentChatUser.wxid); + formData.append('type', 'image'); + const localMessage = this.appendLocalChatMessage({ + displayType: 'image', + content: options.file.name || '[图片]', + mediaUrl: URL.createObjectURL(options.file) + }); + await this.sendChatPayload(formData, '图片消息已提交到 iPad 通道', localMessage.messageId); }, async uploadVoice(options) { - const formData = new FormData(); formData.append('file', options.file); formData.append('wxid', this.currentChatUser.wxid); formData.append('type', 'voice'); - try { const response = await axios.post('/contacts/api/send_message', formData); if (response.data.success) { this.chatMessages.push({ type: 'voice', content: response.data.url, isSelf: true, time: new Date().toLocaleTimeString() }); this.$nextTick(() => { this.scrollToBottom(); }); } } catch (error) { this.$message.error('发送语音失败'); } + if (!this.currentChatUser || !this.currentChatUser.wxid) return; + const formData = new FormData(); + formData.append('file', options.file); + formData.append('wxid', this.currentChatUser.wxid); + formData.append('type', 'voice'); + const localMessage = this.appendLocalChatMessage({ + displayType: 'voice', + content: options.file.name || '[语音]', + mediaUrl: URL.createObjectURL(options.file) + }); + await this.sendChatPayload(formData, '语音消息已提交到 iPad 通道', localMessage.messageId); }, async uploadVideo(options) { - const formData = new FormData(); formData.append('file', options.file); formData.append('wxid', this.currentChatUser.wxid); formData.append('type', 'video'); - try { const response = await axios.post('/contacts/api/send_message', formData); if (response.data.success) { this.chatMessages.push({ type: 'video', content: response.data.url, isSelf: true, time: new Date().toLocaleTimeString() }); this.$nextTick(() => { this.scrollToBottom(); }); } } catch (error) { this.$message.error('发送视频失败'); } + if (!this.currentChatUser || !this.currentChatUser.wxid) return; + const formData = new FormData(); + formData.append('file', options.file); + formData.append('wxid', this.currentChatUser.wxid); + formData.append('type', 'video'); + const localMessage = this.appendLocalChatMessage({ + displayType: 'video', + content: options.file.name || '[视频]', + mediaUrl: URL.createObjectURL(options.file) + }); + await this.sendChatPayload(formData, '视频消息已提交到 iPad 通道', localMessage.messageId); }, showLinkDialog() { this.linkForm = { url: '', title: '', description: '' }; this.linkDialogVisible = true; }, async sendLinkMessage() { + if (!this.currentChatUser || !this.currentChatUser.wxid) return; if (!this.linkForm.url) { this.$message.warning('请输入链接'); return; } - try { - const response = await axios.post('/contacts/api/send_message', { wxid: this.currentChatUser.wxid, type: 'link', content: this.linkForm }); - if (response.data.success) { this.chatMessages.push({ type: 'link', content: this.linkForm, isSelf: true, time: new Date().toLocaleTimeString() }); this.linkDialogVisible = false; this.$nextTick(() => { this.scrollToBottom(); }); } - } catch (error) { this.$message.error('发送链接失败'); } + const localMessage = this.appendLocalChatMessage({ + displayType: 'link', + content: this.linkForm.title || this.linkForm.url, + linkTitle: this.linkForm.title || this.linkForm.url, + linkDescription: this.linkForm.description || '', + linkUrl: this.linkForm.url + }); + const success = await this.sendChatPayload({ + wxid: this.currentChatUser.wxid, + type: 'link', + content: this.linkForm + }, '链接消息已提交到 iPad 通道', localMessage.messageId); + if (success) { + this.linkDialogVisible = false; + } + }, + handleChatInputKeydown(event) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.sendTextMessage(); + } }, scrollToBottom() { const messageList = this.$refs.messageList; if (messageList) messageList.scrollTop = messageList.scrollHeight; } } @@ -1194,21 +1415,69 @@ } .member-context-title { display: flex; flex-direction: column; gap: 4px; color: #475569; } .context-tag { margin-right: 8px; margin-bottom: 8px; } - .chat-container { display: flex; flex-direction: column; height: 500px; } + .chat-container { display: flex; flex-direction: column; gap: 14px; min-height: 620px; } + .chat-header-card { + display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 18px 20px; border-radius: 18px; + background: linear-gradient(135deg, rgba(14,165,233,0.10), rgba(16,185,129,0.08), rgba(255,255,255,0.96)); + border: 1px solid rgba(148,163,184,0.14); + } + .chat-header-main { min-width: 0; } + .chat-header-title { font-size: 20px; font-weight: 700; color: #0f172a; } + .chat-header-subtitle { + margin-top: 6px; font-size: 13px; color: #64748b; + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + } + .chat-header-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; } + .chat-history-tip { + padding: 8px 12px; border-radius: 999px; font-size: 12px; color: #0f766e; + background: rgba(20, 184, 166, 0.10); border: 1px solid rgba(20, 184, 166, 0.18); + } .message-list { flex: 1; overflow-y: auto; padding: 20px; background: rgba(248,250,252,0.82); border: 1px solid rgba(148,163,184,0.12); border-radius: 18px; } - .message-item { margin-bottom: 15px; display: flex; flex-direction: column; } + .chat-empty-state { + min-height: 280px; display: flex; flex-direction: column; align-items: center; justify-content: center; + color: #94a3b8; text-align: center; gap: 10px; + } + .chat-empty-state i { font-size: 34px; color: #38bdf8; } + .chat-empty-state p { font-size: 16px; color: #334155; margin: 0; } + .chat-empty-state span { max-width: 380px; line-height: 1.6; } + .message-item { margin-bottom: 18px; display: flex; flex-direction: column; gap: 6px; } .message-self { align-items: flex-end; } + .message-system { align-items: center; } + .message-meta { display: flex; align-items: center; gap: 8px; color: #94a3b8; font-size: 12px; } + .message-sender { color: #475569; font-weight: 600; } .message-content { - max-width: 70%; padding: 10px 12px; border-radius: 14px; background: rgba(255,255,255,0.92); color: #0f172a; + max-width: 75%; padding: 12px 14px; border-radius: 14px; background: rgba(255,255,255,0.92); color: #0f172a; border: 1px solid rgba(148,163,184,0.12); box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05); } .message-self .message-content { background: linear-gradient(135deg, #4f46e5, #6366f1); color: #ffffff; } - .message-time { font-size: 12px; color: #94a3b8; margin-top: 5px; } + .message-system .message-content { + max-width: 90%; background: rgba(241,245,249,0.92); color: #475569; border-style: dashed; + text-align: center; box-shadow: none; + } + .message-text, .message-system-text { white-space: pre-wrap; word-break: break-word; line-height: 1.7; } + .message-media { display: flex; flex-direction: column; gap: 10px; } + .message-image, .message-video { + max-width: 260px; max-height: 240px; border-radius: 14px; + background: rgba(15,23,42,0.06); object-fit: cover; + } + .message-audio { width: 260px; max-width: 100%; } + .message-caption { font-size: 12px; line-height: 1.6; opacity: 0.88; } + .message-file-chip { + display: inline-flex; align-items: center; width: fit-content; padding: 8px 12px; + border-radius: 999px; background: rgba(148,163,184,0.12); font-size: 12px; + } + .message-link-card { display: flex; flex-direction: column; gap: 8px; } + .message-link-title { font-size: 14px; font-weight: 700; color: inherit; text-decoration: none; } + .message-link-description { font-size: 12px; line-height: 1.6; opacity: 0.92; } + .message-link-url { font-size: 12px; opacity: 0.72; word-break: break-all; } + .message-self .message-meta { justify-content: flex-end; } + .message-self .message-sender, .message-self .message-time, .message-self .message-link-title { color: #ffffff; } .input-area { padding: 20px 0 0; } - .toolbar { margin-top: 10px; display: flex; gap: 10px; flex-wrap: wrap; } + .toolbar { margin-top: 12px; display: flex; gap: 10px; flex-wrap: wrap; } @media (max-width: 1200px) { .diagnosis-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -1217,6 +1486,10 @@ .page-hero-actions, .detail-tags { justify-content: flex-start; } .hero-search, .group-search { width: 100%; } .diagnosis-grid { grid-template-columns: 1fr; } + .chat-header-card { flex-direction: column; align-items: flex-start; } + .chat-header-actions { justify-content: flex-start; } + .message-content { max-width: 92%; } + .message-image, .message-video, .message-audio { max-width: 100%; width: 100%; } } {% endblock %} diff --git a/db/message_storage.py b/db/message_storage.py index 1f014ee..911d8b1 100644 --- a/db/message_storage.py +++ b/db/message_storage.py @@ -202,6 +202,36 @@ class MessageStorageDB(BaseDBOperator): """ return self.execute_query(sql, (target_date, group_id, limit)) or [] + def get_recent_group_chat_messages(self, group_id: str, limit: int = 20) -> List[Dict]: + """获取群聊最近消息""" + sql = """ + SELECT timestamp, sender, content, message_type, attachment_url, message_id, message_xml, message_thumb, image_path + FROM messages + WHERE group_id = %s + ORDER BY timestamp DESC + LIMIT %s + """ + results = self.execute_query(sql, (group_id, limit)) or [] + return list(reversed(results)) + + def get_recent_personal_messages(self, wxid: str, limit: int = 20) -> List[Dict]: + """获取私聊最近归档消息 + + 说明: + 当前消息表没有可靠的 to_user 会话维度,这里只返回目标联系人发来的、 + 且未归属到群聊的消息,用于通讯录内的“尽力模式”历史预览。 + """ + sql = """ + SELECT timestamp, sender, content, message_type, attachment_url, message_id, message_xml, message_thumb, image_path + FROM messages + WHERE (group_id IS NULL OR group_id = '') + AND sender = %s + ORDER BY timestamp DESC + LIMIT %s + """ + results = self.execute_query(sql, (wxid, limit)) or [] + return list(reversed(results)) + def get_message_count_by_date(self, date: str) -> List[Dict]: """获取指定日期的消息统计""" sql = """