feat: revamp contacts chat workspace
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
Reference in New Issue
Block a user