feat: revamp contacts chat workspace

This commit is contained in:
liuwei
2026-04-13 11:47:34 +08:00
parent e20d57b291
commit 9698f9577f
3 changed files with 471 additions and 39 deletions

View File

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

View File

@@ -597,30 +597,90 @@
</el-descriptions>
</el-dialog>
<el-dialog title="聊天" :visible.sync="chatDialogVisible" width="60%" :close-on-click-modal="true">
<el-dialog title="聊天" :visible.sync="chatDialogVisible" width="72%" :close-on-click-modal="true">
<div class="chat-container">
<div class="message-list" ref="messageList">
{% raw %}
<div v-for="(msg, index) in chatMessages" :key="index" class="message-item" :class="{'message-self': msg.isSelf}">
<div class="message-content">
<div v-if="msg.type === 'text'" v-text="msg.content"></div>
<div v-else-if="msg.type === 'image'"><img :src="msg.content" style="max-width: 200px; max-height: 200px;"></div>
<div v-else-if="msg.type === 'voice'"><audio controls :src="msg.content"></audio></div>
<div v-else-if="msg.type === 'video'"><video controls :src="msg.content" style="max-width: 200px;"></video></div>
<div v-else-if="msg.type === 'link'"><a :href="msg.content.url" target="_blank" v-text="msg.content.title"></a><p v-text="msg.content.description"></p></div>
{% raw %}
<div class="chat-header-card" v-if="currentChatUser">
<div class="chat-header-main">
<div class="chat-header-title">{{ currentChatUser.name || currentChatUser.wxid }}</div>
<div class="chat-header-subtitle">
<span>{{ chatType === 'group' ? '群聊会话' : '私聊会话' }}</span>
<span>·</span>
<span>{{ currentChatUser.wxid }}</span>
</div>
</div>
<div class="chat-header-actions">
<div class="chat-history-tip">{{ chatHistoryTip }}</div>
<el-button size="mini" :loading="chatLoading" icon="el-icon-refresh" @click="loadRecentMessages">刷新</el-button>
</div>
</div>
{% endraw %}
<div v-loading="chatLoading" class="message-list" ref="messageList">
{% raw %}
<div v-if="!chatMessages.length" class="chat-empty-state">
<i class="el-icon-chat-line-square"></i>
<p>当前没有可展示的归档消息</p>
<span>你仍然可以直接使用下方工具发送文本、图片、语音、视频或链接。</span>
</div>
<div
v-for="(msg, index) in chatMessages"
:key="`${msg.messageId || 'local'}-${index}`"
class="message-item"
:class="{'message-self': msg.isSelf, 'message-system': msg.displayType === 'system'}"
>
<div v-if="msg.displayType !== 'system'" class="message-meta">
<span class="message-sender">{{ msg.senderName || msg.sender || '未知发送者' }}</span>
<span class="message-time">{{ msg.time }}</span>
</div>
<div class="message-content">
<div v-if="msg.displayType === 'text'" class="message-text" v-text="msg.content"></div>
<div v-else-if="msg.displayType === 'image'" class="message-media">
<img v-if="msg.mediaUrl" :src="msg.mediaUrl" class="message-image" />
<div v-else class="message-file-chip">图片消息</div>
<div v-if="msg.content && msg.content !== '[图片]'" class="message-caption" v-text="msg.content"></div>
</div>
<div v-else-if="msg.displayType === 'voice'" class="message-media">
<audio v-if="msg.mediaUrl" controls :src="msg.mediaUrl" class="message-audio"></audio>
<div v-else class="message-file-chip">语音消息</div>
<div class="message-caption" v-text="msg.content || '已通过 iPad 通道发送语音'"></div>
</div>
<div v-else-if="msg.displayType === 'video'" class="message-media">
<video v-if="msg.mediaUrl" controls :src="msg.mediaUrl" class="message-video"></video>
<div v-else class="message-file-chip">视频消息</div>
<div class="message-caption" v-text="msg.content || '已通过 iPad 通道发送视频'"></div>
</div>
<div v-else-if="msg.displayType === 'link'" class="message-link-card">
<a class="message-link-title" :href="msg.linkUrl || '#'" target="_blank" rel="noopener noreferrer">
{{ msg.linkTitle || msg.content || '链接消息' }}
</a>
<div v-if="msg.linkDescription" class="message-link-description" v-text="msg.linkDescription"></div>
<div v-if="msg.linkUrl" class="message-link-url" v-text="msg.linkUrl"></div>
</div>
<div v-else class="message-system-text" v-text="msg.content"></div>
</div>
<div class="message-time" v-text="msg.time"></div>
</div>
{% endraw %}
</div>
<div class="input-area">
<el-input type="textarea" :rows="3" placeholder="请输入消息..." v-model="messageInput" @keyup.enter.native="sendTextMessage"></el-input>
<el-input
type="textarea"
:rows="4"
placeholder="输入要发送的文本Enter 发送Shift + Enter 换行"
v-model="messageInput"
@keydown.native="handleChatInputKeydown"
></el-input>
<div class="toolbar">
<el-upload class="upload-demo" action="#" :http-request="uploadImage" :show-file-list="false"><el-button size="small" type="primary">图片</el-button></el-upload>
<el-upload class="upload-demo" action="#" :http-request="uploadVoice" :show-file-list="false"><el-button size="small" type="primary">语音</el-button></el-upload>
<el-upload class="upload-demo" action="#" :http-request="uploadVideo" :show-file-list="false"><el-button size="small" type="primary">视频</el-button></el-upload>
<el-button size="small" type="primary" @click="showLinkDialog">链接</el-button>
<el-button size="small" type="success" @click="sendTextMessage">发送</el-button>
<el-upload class="upload-demo" action="#" :http-request="uploadImage" :show-file-list="false" accept="image/*">
<el-button size="small" type="primary" plain icon="el-icon-picture-outline">图片</el-button>
</el-upload>
<el-upload class="upload-demo" action="#" :http-request="uploadVoice" :show-file-list="false" accept=".mp3,.wav,audio/*">
<el-button size="small" type="primary" plain icon="el-icon-microphone">语音</el-button>
</el-upload>
<el-upload class="upload-demo" action="#" :http-request="uploadVideo" :show-file-list="false" accept="video/*">
<el-button size="small" type="primary" plain icon="el-icon-video-camera">视频</el-button>
</el-upload>
<el-button size="small" type="primary" plain icon="el-icon-link" @click="showLinkDialog">链接</el-button>
<el-button size="small" type="success" icon="el-icon-position" :loading="chatSending" @click="sendTextMessage">发送文本</el-button>
</div>
</div>
</div>
@@ -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%; }
}
</style>
{% endblock %}

View File

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